"""
Network visualisation utilities for EGTtools.
Provides six plot types commonly used when analysing evolutionary games on
structured populations:
1. :func:`plot_network_state` — snapshot of the network, nodes coloured by strategy
2. :func:`plot_strategy_evolution` — time-series of strategy frequencies
3. :func:`animate_network_evolution` — GIF/MP4 animation of the evolving network
4. :func:`plot_edge_homophily` — homophily (fraction same-strategy edges) over time
5. :func:`plot_parameter_sweep` — cooperation level vs two parameters (heatmap)
6. :func:`plot_strategy_by_degree` — dominant strategy per degree bin (scale-free nets)
"""
from __future__ import annotations
import warnings
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
import matplotlib.pyplot as plt
import numpy as np
__all__ = [
"plot_network_state",
"plot_strategy_evolution",
"animate_network_evolution",
"plot_edge_homophily",
"plot_parameter_sweep",
"plot_strategy_by_degree",
]
# Default colour cycle — maximally distinguishable for up to 8 strategies.
_DEFAULT_COLORS = [
"#4C72B0", # blue
"#DD8452", # orange
"#55A868", # green
"#C44E52", # red
"#8172B3", # purple
"#937860", # brown
"#DA8BC3", # pink
"#8C8C8C", # grey
]
def _strategy_colors(nb_strategies: int, strategy_colors: Optional[List] = None) -> list:
if strategy_colors is not None:
return list(strategy_colors)
colors = _DEFAULT_COLORS * (nb_strategies // len(_DEFAULT_COLORS) + 1)
return colors[:nb_strategies]
# ---------------------------------------------------------------------------
# 1. Network state snapshot
# ---------------------------------------------------------------------------
[docs]
def plot_network_state(
G,
population: Sequence[int],
strategy_names: Optional[Sequence[str]] = None,
strategy_colors: Optional[Sequence] = None,
ax: Optional[plt.Axes] = None,
layout: Optional[Dict[int, Tuple[float, float]]] = None,
node_size: int = 80,
edge_color: str = "#aaaaaa",
edge_alpha: float = 0.5,
legend: bool = True,
) -> plt.Axes:
"""
Draw a network snapshot with nodes coloured by their current strategy.
Parameters
----------
G : networkx.Graph (or any object with an ``.adjacency()`` method)
The network topology.
population : sequence of int
Strategy index of each node (same order as ``list(G.nodes())``).
strategy_names : sequence of str, optional
Labels for each strategy. Defaults to ``["Strategy 0", ...]``.
strategy_colors : sequence, optional
Colours for each strategy.
ax : matplotlib.axes.Axes, optional
Existing axes to draw on.
layout : dict, optional
``{node: (x, y)}`` positions. Computed via NetworkX spring layout if omitted.
node_size : int
edge_color : str
edge_alpha : float
legend : bool
Whether to draw a strategy legend.
Returns
-------
matplotlib.axes.Axes
"""
import networkx as nx
population = list(population)
nb_strategies = max(population) + 1
colors = _strategy_colors(nb_strategies, strategy_colors)
names = list(strategy_names) if strategy_names else [f"Strategy {s}" for s in range(nb_strategies)]
if ax is None:
_, ax = plt.subplots(figsize=(6, 6))
if layout is None:
layout = nx.spring_layout(G, seed=42)
nodes = list(G.nodes())
node_colors = [colors[population[i]] for i in range(len(nodes))]
nx.draw_networkx_edges(G, layout, ax=ax, edge_color=edge_color, alpha=edge_alpha, width=0.8)
nx.draw_networkx_nodes(G, layout, ax=ax,
node_color=node_colors, node_size=node_size, linewidths=0.5,
edgecolors="white")
if legend:
present = sorted(set(population))
handles = [
plt.Line2D([0], [0], marker="o", color="w",
markerfacecolor=colors[s], markersize=9, label=names[s])
for s in present
]
ax.legend(handles=handles, loc="best", fontsize=8, framealpha=0.7)
ax.set_axis_off()
return ax
# ---------------------------------------------------------------------------
# 2. Strategy frequency evolution
# ---------------------------------------------------------------------------
[docs]
def plot_strategy_evolution(
trajectory: np.ndarray,
strategy_names: Optional[Sequence[str]] = None,
strategy_colors: Optional[Sequence] = None,
ax: Optional[plt.Axes] = None,
xlabel: str = "Generation",
ylabel: str = "Strategy frequency",
title: str = "",
) -> plt.Axes:
"""
Plot time-series of strategy frequencies.
Parameters
----------
trajectory : numpy.ndarray, shape (T, nb_strategies)
Output from ``NetworkMCEstimator.run()``; rows are generations, columns are
strategy counts. Counts are normalised internally to frequencies in [0, 1].
strategy_names : sequence of str, optional
strategy_colors : sequence, optional
ax : matplotlib.axes.Axes, optional
Returns
-------
matplotlib.axes.Axes
"""
trajectory = np.asarray(trajectory, dtype=float)
T, nb_strategies = trajectory.shape
row_sums = trajectory.sum(axis=1, keepdims=True)
row_sums[row_sums == 0] = 1.0
freqs = trajectory / row_sums
colors = _strategy_colors(nb_strategies, strategy_colors)
names = list(strategy_names) if strategy_names else [f"Strategy {s}" for s in range(nb_strategies)]
if ax is None:
_, ax = plt.subplots(figsize=(8, 3))
t = np.arange(T)
for s in range(nb_strategies):
ax.plot(t, freqs[:, s], color=colors[s], label=names[s], linewidth=1.2)
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
if title:
ax.set_title(title)
ax.set_xlim(0, T - 1)
ax.set_ylim(0, 1)
ax.legend(loc="best", fontsize=8, framealpha=0.7)
return ax
# ---------------------------------------------------------------------------
# 3. Network evolution animation
# ---------------------------------------------------------------------------
[docs]
def animate_network_evolution(
G,
snapshot_matrix: np.ndarray,
strategy_names: Optional[Sequence[str]] = None,
strategy_colors: Optional[Sequence] = None,
layout: Optional[Dict] = None,
interval_ms: int = 200,
node_size: int = 80,
save_path: Optional[str] = None,
fps: int = 5,
) -> "matplotlib.animation.FuncAnimation":
"""
Animate the evolution of a network, colouring nodes by strategy.
Parameters
----------
G : networkx.Graph
snapshot_matrix : numpy.ndarray, shape (nb_snapshots, N)
Each row is a per-node strategy vector at one snapshot.
Produced by ``NetworkMCEstimator.run_snapshots(return_full=True)`` or by
accumulating the per-snapshot callback into a 2-D array.
strategy_names : sequence of str, optional
strategy_colors : sequence, optional
layout : dict, optional
``{node: (x, y)}``. Computed once via spring layout if omitted.
interval_ms : int
Milliseconds between frames.
node_size : int
save_path : str, optional
If given, save the animation to this path. Extension determines format:
``.gif`` uses Pillow; ``.mp4`` uses ffmpeg.
fps : int
Frames per second (only used when saving).
Returns
-------
matplotlib.animation.FuncAnimation
"""
import networkx as nx
from matplotlib.animation import FuncAnimation
snapshots = np.asarray(snapshot_matrix, dtype=int)
nb_snapshots, N = snapshots.shape
nb_strategies = int(snapshots.max()) + 1
colors = _strategy_colors(nb_strategies, strategy_colors)
names = list(strategy_names) if strategy_names else [f"Strategy {s}" for s in range(nb_strategies)]
if layout is None:
layout = nx.spring_layout(G, seed=42)
pos_array = np.array([layout[n] for n in G.nodes()])
fig, ax = plt.subplots(figsize=(6, 6))
ax.set_axis_off()
nx.draw_networkx_edges(G, layout, ax=ax, edge_color="#aaaaaa", alpha=0.4, width=0.8)
scatter = ax.scatter(pos_array[:, 0], pos_array[:, 1],
c=[colors[snapshots[0, i]] for i in range(N)],
s=node_size, zorder=5, linewidths=0.5, edgecolors="white")
title_obj = ax.set_title("t = 0", fontsize=10)
present = sorted(set(snapshots.flatten()))
legend_handles = [
plt.Line2D([0], [0], marker="o", color="w",
markerfacecolor=colors[s], markersize=9, label=names[s])
for s in present
]
ax.legend(handles=legend_handles, loc="best", fontsize=8, framealpha=0.7)
def update(frame: int):
node_colors = [colors[snapshots[frame, i]] for i in range(N)]
scatter.set_facecolor(node_colors)
title_obj.set_text(f"t = {frame}")
return scatter, title_obj
anim = FuncAnimation(fig, update, frames=nb_snapshots,
interval=interval_ms, blit=True)
if save_path is not None:
if save_path.endswith(".gif"):
try:
anim.save(save_path, writer="pillow", fps=fps)
except Exception as e:
warnings.warn(f"Could not save GIF (Pillow required): {e}")
elif save_path.endswith(".mp4"):
try:
anim.save(save_path, writer="ffmpeg", fps=fps)
except Exception as e:
warnings.warn(f"Could not save MP4 (ffmpeg required): {e}")
else:
warnings.warn(f"Unknown extension for save_path '{save_path}'. Use .gif or .mp4.")
return anim
# ---------------------------------------------------------------------------
# 4. Edge homophily evolution
# ---------------------------------------------------------------------------
[docs]
def plot_edge_homophily(
homophily: Sequence[float],
ax: Optional[plt.Axes] = None,
color: str = "#4C72B0",
label: str = "Edge homophily",
xlabel: str = "Generation",
ylabel: str = "Fraction same-strategy edges",
title: str = "",
) -> plt.Axes:
"""
Plot the evolution of edge homophily over time.
Useful for co-evolutionary networks where the topology adapts alongside
strategy dynamics.
Parameters
----------
homophily : sequence of float
Per-generation fraction of edges connecting same-strategy nodes.
Typically computed from the topology callback of
``NetworkCoEvolutionary.run_snapshots()``.
ax : matplotlib.axes.Axes, optional
color : str
label : str
xlabel : str
ylabel : str
title : str
Returns
-------
matplotlib.axes.Axes
"""
h = np.asarray(homophily, dtype=float)
if ax is None:
_, ax = plt.subplots(figsize=(8, 3))
ax.plot(np.arange(len(h)), h, color=color, label=label, linewidth=1.2)
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
ax.set_ylim(0, 1)
ax.set_xlim(0, max(len(h) - 1, 1))
if title:
ax.set_title(title)
ax.legend(loc="best", fontsize=8, framealpha=0.7)
return ax
# ---------------------------------------------------------------------------
# 5. Parameter sweep heatmap
# ---------------------------------------------------------------------------
[docs]
def plot_parameter_sweep(
cooperation_matrix: np.ndarray,
x_values: Sequence[float],
y_values: Sequence[float],
xlabel: str = "Parameter X",
ylabel: str = "Parameter Y",
title: str = "Cooperation level",
cmap: str = "RdBu_r",
vmin: float = 0.0,
vmax: float = 1.0,
ax: Optional[plt.Axes] = None,
colorbar: bool = True,
) -> plt.Axes:
"""
Plot a 2-D parameter sweep as a heatmap.
Parameters
----------
cooperation_matrix : numpy.ndarray, shape (len(y_values), len(x_values))
Mean cooperation level (or any scalar metric) for each parameter combination.
Rows correspond to ``y_values``, columns to ``x_values``.
x_values : sequence of float
Values swept along the x-axis.
y_values : sequence of float
Values swept along the y-axis.
xlabel : str
ylabel : str
title : str
cmap : str
Matplotlib colormap name.
vmin, vmax : float
Colour scale limits.
ax : matplotlib.axes.Axes, optional
colorbar : bool
Returns
-------
matplotlib.axes.Axes
Example
-------
>>> import numpy as np
>>> betas = np.linspace(0, 5, 20)
>>> rewire_probs = np.linspace(0, 1, 20)
>>> coop = np.zeros((len(rewire_probs), len(betas)))
>>> # ... fill coop via nested loops calling estimate_strategy_distribution ...
>>> plot_parameter_sweep(coop, betas, rewire_probs,
... xlabel="beta", ylabel="rewiring probability")
"""
mat = np.asarray(cooperation_matrix, dtype=float)
x = list(x_values)
y = list(y_values)
if ax is None:
_, ax = plt.subplots(figsize=(6, 5))
im = ax.imshow(mat, origin="lower", aspect="auto",
extent=[x[0], x[-1], y[0], y[-1]],
cmap=cmap, vmin=vmin, vmax=vmax)
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
ax.set_title(title)
if colorbar:
plt.colorbar(im, ax=ax, label=title)
return ax
# ---------------------------------------------------------------------------
# 6. Degree-stratified strategy distribution
# ---------------------------------------------------------------------------
[docs]
def plot_strategy_by_degree(
degrees: Sequence[int],
population: Sequence[int],
nb_strategies: int,
strategy_names: Optional[Sequence[str]] = None,
strategy_colors: Optional[Sequence] = None,
ax: Optional[plt.Axes] = None,
bins: Union[int, str] = "auto",
xlabel: str = "Node degree",
ylabel: str = "Strategy frequency",
title: str = "Strategy distribution by degree",
) -> plt.Axes:
"""
Plot the frequency of each strategy stratified by node degree.
Useful for heterogeneous networks (e.g. Barabási–Albert scale-free graphs) to
reveal whether hub nodes and peripheral nodes adopt different strategies.
Parameters
----------
degrees : sequence of int
Degree of each node (length N).
population : sequence of int
Current strategy of each node (length N).
nb_strategies : int
strategy_names : sequence of str, optional
strategy_colors : sequence, optional
ax : matplotlib.axes.Axes, optional
bins : int or str
Number of degree bins, or ``'auto'`` to use unique degree values.
xlabel : str
ylabel : str
title : str
Returns
-------
matplotlib.axes.Axes
"""
degrees = np.asarray(degrees, dtype=int)
population = np.asarray(population, dtype=int)
colors = _strategy_colors(nb_strategies, strategy_colors)
names = list(strategy_names) if strategy_names else [f"Strategy {s}" for s in range(nb_strategies)]
if ax is None:
_, ax = plt.subplots(figsize=(7, 4))
if bins == "auto":
unique_degrees = np.sort(np.unique(degrees))
bin_centers = unique_degrees
bin_freqs = np.zeros((len(unique_degrees), nb_strategies))
for i, d in enumerate(unique_degrees):
mask = degrees == d
if mask.sum() == 0:
continue
for s in range(nb_strategies):
bin_freqs[i, s] = (population[mask] == s).sum() / mask.sum()
else:
counts, bin_edges = np.histogram(degrees, bins=int(bins))
bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:])
bin_freqs = np.zeros((len(bin_centers), nb_strategies))
for i in range(len(bin_centers)):
mask = (degrees >= bin_edges[i]) & (degrees < bin_edges[i + 1])
if i == len(bin_centers) - 1:
mask = (degrees >= bin_edges[i]) & (degrees <= bin_edges[i + 1])
if mask.sum() == 0:
continue
for s in range(nb_strategies):
bin_freqs[i, s] = (population[mask] == s).sum() / mask.sum()
bottom = np.zeros(len(bin_centers))
for s in range(nb_strategies):
ax.bar(bin_centers, bin_freqs[:, s], bottom=bottom,
color=colors[s], label=names[s], alpha=0.85,
width=(bin_centers[1] - bin_centers[0]) * 0.8 if len(bin_centers) > 1 else 0.5)
bottom += bin_freqs[:, s]
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
ax.set_title(title)
ax.set_ylim(0, 1)
ax.legend(loc="best", fontsize=8, framealpha=0.7)
return ax