Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented the Moran process on graphs #799

Merged
merged 7 commits into from
Jan 5, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion axelrod/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

# The order of imports matters!
from .version import __version__
from . import graph
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have thought this line wasn't needed? Am I wrong?

Copy link
Member Author

@marcharper marcharper Jan 1, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It depends on how you access the graph class in code, axelrod.graph.Graph will fail without this line.

We could use from .graph import Graph, complete_graph, ...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It depends on how you access the graph class in code, axelrod.graph.Graph will fail

I thought it wouldn't fail but happy as it is :)

from .actions import Actions, flip_action
from .random_ import random_choice, seed
from .plot import Plot
Expand All @@ -13,7 +14,7 @@
obey_axelrod, update_history, update_state_distribution, Player
from .mock_player import MockPlayer, simulate_play
from .match import Match
from .moran import MoranProcess
from .moran import MoranProcess, MoranProcessGraph
from .strategies import *
from .deterministic_cache import DeterministicCache
from .match_generator import *
Expand Down
128 changes: 128 additions & 0 deletions axelrod/graph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""
Weighted undirected sparse graphs.

Original source:
https://github.com/marcharper/stationary/blob/master/stationary/utils/graph.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not against this. A couple of initial thoughts:

  1. As you have built this for your stationary library you no doubt have a reason but why we wouldn't just use networkx (which would have the quick lookups we want here)? (1 negative side is adding another dependency to the library)
  2. Can we add some tests please?
  3. The spatial tournament does not necessarily need a graph object (it just loops through the edges once via the match generator) but if we're going to have an axelrod.graph class we should also use that there (for consistency). I see two approaches:
    1. We allow the spatial tournament to take both a list of edges of one of these graph objects (ensures backwards compatibility);
    2. We let the Moran process take a list of edges and obfuscate the building of this underlying graph object there. I think this would be my preferred approach.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear: If we go with my suggestion of 3.i then that would be work for a different PR.

Copy link
Member Author

@marcharper marcharper Jan 1, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figured this would come up which is why I didn't add tests yet. We can switch this out for another library, I just used what I had laying around. It's a bit overkill since we don't need the weights (yet?), but it optimized in the right ways -- sparse implementation, precomputed neighbors. So we could also just simplify it to parts needed (relatively easy).

I'm not opposed to networkX but I'm not familiar with its API, what internal representation it uses (adjacency matrix? sparse matrix?) etc. Thoughts?

I agree re: consistent API with spatial tournaments -- we could also use isinstance to be flexible on the input, and also have the graph's __repr__ method just output the list of edges. There are cases however that a list of edges doesn't cover, like some types of random graphs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are cases however that a list of edges doesn't cover, like some types of random graphs.

That's the killer point right there! I guess that also includes the possibility of graphs that change over time etc which also rules out networkX (which really is a data representation library coupled with graph theoretic algorithms as opposed to the flexibility we might need here).

I suggest going for flexibility on the input (so we can open an issue for my suggestion of 3.i).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In order to get the __repr__ method to output the list of edges I expect we could use an edges attribute or otherwise? That could be a useful attribute to have anyway?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we can save the input edges, or output a sorted list.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are cases in the literature where the graph changes, it's called "active linking".

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turns out we need the graph to be weighted for some applications in the literature. If the reproduction graph is weighted then it changes the death probabilities in the birth-death case.

"""

from collections import defaultdict


class Graph(object):
"""Weighted and directed graph object intended for the graph associated to a
Markov process. Gives easy access to the neighbors of a particular state
needed for various calculations.

Vertices can be any hashable / immutable python object. Initialize with a
list of edges:
[[node1, node2, weights], ...]
Weights can be omitted for an undirected graph.

For efficiency, neighbors are cached in dictionaries. Undirected graphs
are implemented as directed graphs in which every edge (s, t) has the
opposite edge (t, s).
"""

def __init__(self, edges=None, directed=False):
self.directed = directed
self.original_edges = edges
self.out_mapping = defaultdict(lambda: defaultdict(float))
self.in_mapping = defaultdict(lambda: defaultdict(float))
self._edges = []
if edges:
self.add_edges(edges)

def add_edge(self, source, target, weight=None):
if (source, target) not in self._edges:
self._edges.append((source, target))
self.out_mapping[source][target] = weight
self.in_mapping[target][source] = weight
if not self.directed and (source != target) and \
(target, source) not in self._edges:
self._edges.append((target, source))
self.out_mapping[target][source] = weight
self.in_mapping[source][target] = weight

def add_edges(self, edges):
for edge in edges:
self.add_edge(*edge)

def edges(self):
return self._edges

def vertices(self):
"""Returns the set of vertices of the graph."""
return list(self.out_mapping.keys())

def out_dict(self, source):
"""Returns a dictionary of the outgoing edges of source with weights."""
return self.out_mapping[source]

def out_vertices(self, source):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If undirected, why not just have a adjacent_vertices method? (To replace both the in and out ones?).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to allow the graphs to be directed, see e.g. here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might some of these methods be better as properties?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Getters would make sense for e.g vertices not sure about the others. Maybe edges makes sense with a setter, essentially rewriting the graph. I'm open to suggestions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's leave it for now. If we fancy refactoring the graph, we can do so later.

"""Returns a list of the outgoing vertices."""
return list(self.out_mapping[source].keys())

def in_dict(self, target):
"""Returns a dictionary of the incoming edges of source with weights."""
return self.in_mapping[target]

def in_vertices(self, source):
"""Returns a list of the outgoing vertices."""
return list(self.in_mapping[source].keys())

def __repr__(self):
s = "<Graph: {}>".format(repr(self.original_edges))
return s


## Example Graphs


def cycle(length, directed=False):
"""
Produces a cycle of length `length`.
Parameters
----------
length: int
Number of vertices in the cycle
directed: bool, False
Is the cycle directed?
Returns
-------
a Graph object
"""

graph = Graph(directed=directed)
edges = []
for i in range(length - 1):
edges.append((i, i+1))
edges.append((length - 1, 0))
graph.add_edges(edges)
return graph


def complete_graph(length, loops=True):
"""
Produces a complete graph of size `length`, with loops.
https://en.wikipedia.org/wiki/Complete_graph

Parameters
----------
length: int
Number of vertices in the cycle
directed: bool, False
Is the graph directed?
Returns
-------
a Graph object
"""
offset = 1
if loops:
offset = 0
graph = Graph(directed=False)
edges = []
for i in range(length):
for j in range(i + offset, length):
edges.append((i, j))
graph.add_edges(edges)
return graph
Loading