Skip to content

spatial_graph_algorithms.plot

Visualisation helpers for SpatialGraph objects.

Function Output Use when
plot_network 2-D graph layout Inspect a simulated graph; false edges shown in red
plot_network_3d 3-D graph layout Graphs with dim=3 positions
plot_edge_length_histogram Length distribution Check connectivity density and false-edge length
plot_comparison Side-by-side original vs reconstructed Visually judge reconstruction quality
plot_provenance_comparison Side-by-side with spatial pattern overlay Visually diagnose reconstruction quality with a user-chosen color pattern
render_simulation_visualization_bundle Saves all relevant plots One-call output for a run

API Reference

spatial_graph_algorithms.plot.plot_network(sn, *, figsize=(6.0, 4.5), node_size=None, edge_alpha=None, true_edge_color='#777777', false_edge_color='#d62728', false_edge_linewidth=None, true_edge_linewidth=None, false_edge_alpha=None, node_alpha=None, edge_display='auto', max_edges=None, edge_sample_seed=0, save=False, output_dir=DEFAULT_VISUALIZATION_DIR, filename='network.png')

Plot a 2-D spatial graph with optional false-edge highlighting.

True edges are drawn in grey; false (injected noise) edges are drawn as solid red lines when edge_metadata["is_false"] is present. When edge_display is "auto", dense graphs use adaptive styling and edge sampling to preserve readability.

Parameters:

Name Type Description Default
sn SpatialGraph

Graph to plot. Must have 2-D positions.

required
figsize tuple of float

Figure size (width, height) in inches.

(6.0, 4.5)
node_size float

Scatter marker size. If None (default), chosen from graph size.

None
edge_alpha float

Opacity of true edges. If None (default), chosen from graph size.

None
true_edge_color str

Hex colour for true edges.

'#777777'
false_edge_color str

Hex colour for false edges.

'#d62728'
false_edge_linewidth float

Line width for false edges. If None (default), chosen from graph size.

None
true_edge_linewidth float

Line width for true edges. If None (default), chosen from graph size.

None
false_edge_alpha float

Opacity of false edges. Default is high enough to keep false edges visible above true edges, with automatic reduction when many false edges are drawn.

None
node_alpha float

Opacity of nodes. If None (default), chosen from graph size.

None
edge_display ('auto', 'all', 'sample', 'none')

Edge rendering mode. "auto" draws all edges for small/medium graphs and samples edges for large graphs. "sample" draws up to max_edges, preserving false edges before sampling true edges. "none" draws nodes only.

"auto"
max_edges int

Maximum number of edges to draw for "auto" or "sample". If omitted, a size-dependent budget is used.

None
edge_sample_seed int

Random seed used when sampling edges. Default 0.

0
save bool

If True, save the figure to output_dir / filename.

False
output_dir str or Path

Directory for saved output.

DEFAULT_VISUALIZATION_DIR
filename str

Filename for saved output.

'network.png'

Returns:

Type Description
Figure

The rendered figure.

Raises:

Type Description
ValueError

If positions is None or not 2-D.

Source code in src/spatial_graph_algorithms/plot/network.py
def plot_network(
    sn: SpatialGraph,
    *,
    figsize: tuple[float, float] = (6.0, 4.5),
    node_size: float | None = None,
    edge_alpha: float | None = None,
    true_edge_color: str = "#777777",
    false_edge_color: str = "#d62728",
    false_edge_linewidth: float | None = None,
    true_edge_linewidth: float | None = None,
    false_edge_alpha: float | None = None,
    node_alpha: float | None = None,
    edge_display: EdgeDisplay = "auto",
    max_edges: int | None = None,
    edge_sample_seed: int | None = 0,
    save: bool = False,
    output_dir: str | Path = DEFAULT_VISUALIZATION_DIR,
    filename: str = "network.png",
) -> Figure:
    """Plot a 2-D spatial graph with optional false-edge highlighting.

    True edges are drawn in grey; false (injected noise) edges are drawn as
    solid red lines when ``edge_metadata["is_false"]`` is present.  When
    *edge_display* is ``"auto"``, dense graphs use adaptive styling and edge
    sampling to preserve readability.

    Parameters
    ----------
    sn : SpatialGraph
        Graph to plot.  Must have 2-D *positions*.
    figsize : tuple of float
        Figure size ``(width, height)`` in inches.
    node_size : float
        Scatter marker size.  If ``None`` (default), chosen from graph size.
    edge_alpha : float
        Opacity of true edges.  If ``None`` (default), chosen from graph size.
    true_edge_color : str
        Hex colour for true edges.
    false_edge_color : str
        Hex colour for false edges.
    false_edge_linewidth : float
        Line width for false edges.  If ``None`` (default), chosen from graph size.
    true_edge_linewidth : float
        Line width for true edges.  If ``None`` (default), chosen from graph size.
    false_edge_alpha : float, optional
        Opacity of false edges.  Default is high enough to keep false edges
        visible above true edges, with automatic reduction when many false
        edges are drawn.
    node_alpha : float, optional
        Opacity of nodes.  If ``None`` (default), chosen from graph size.
    edge_display : {"auto", "all", "sample", "none"}
        Edge rendering mode.  ``"auto"`` draws all edges for small/medium
        graphs and samples edges for large graphs.  ``"sample"`` draws up to
        *max_edges*, preserving false edges before sampling true edges.
        ``"none"`` draws nodes only.
    max_edges : int, optional
        Maximum number of edges to draw for ``"auto"`` or ``"sample"``.
        If omitted, a size-dependent budget is used.
    edge_sample_seed : int, optional
        Random seed used when sampling edges.  Default 0.
    save : bool
        If ``True``, save the figure to *output_dir* / *filename*.
    output_dir : str or Path
        Directory for saved output.
    filename : str
        Filename for saved output.

    Returns
    -------
    matplotlib.figure.Figure
        The rendered figure.

    Raises
    ------
    ValueError
        If *positions* is ``None`` or not 2-D.
    """
    if sn.positions is None:
        raise ValueError("SpatialGraph.positions is None — cannot plot network")
    if sn.positions.shape[1] != 2:
        raise ValueError("plot_network currently supports only 2D positions")

    edges = _extract_unique_edges(sn)
    pos = sn.positions

    fig, ax = plt.subplots(figsize=figsize, dpi=300)
    false_edge_set = _false_edge_set(sn)
    style = _resolve_network_plot_style(
        sn.n_nodes,
        len(edges),
        edge_display=edge_display,
        max_edges=max_edges,
        node_size=node_size,
        edge_alpha=edge_alpha,
        true_edge_linewidth=true_edge_linewidth,
        false_edge_linewidth=false_edge_linewidth,
        false_edge_alpha=false_edge_alpha,
        node_alpha=node_alpha,
    )
    true_edges, false_edges = _split_true_false_edges(edges, false_edge_set)
    true_edges, false_edges = _sample_edges_for_display(
        true_edges,
        false_edges,
        max_edges=style.max_edges,
        seed=edge_sample_seed,
    )

    true_segments = _edge_segments(pos, true_edges)
    if len(true_segments):
        true_collection = LineCollection(
            true_segments,
            colors=true_edge_color,
            linewidths=style.true_edge_linewidth,
            alpha=style.true_edge_alpha,
            zorder=1,
        )
        true_collection.set_rasterized(style.rasterized)
        ax.add_collection(true_collection)

    false_segments = _edge_segments(pos, false_edges)
    if len(false_segments):
        resolved_false_alpha = _resolve_false_edge_alpha(len(false_edges), false_edge_alpha)
        false_collection = LineCollection(
            false_segments,
            colors=false_edge_color,
            linewidths=style.false_edge_linewidth,
            alpha=resolved_false_alpha,
            linestyles="solid",
            zorder=2,
        )
        false_collection.set_rasterized(style.rasterized)
        ax.add_collection(false_collection)

    scatter = ax.scatter(
        pos[:, 0],
        pos[:, 1],
        s=style.node_size,
        c="#1f77b4",
        alpha=style.node_alpha,
        edgecolors="none",
        zorder=3,
    )
    scatter.set_rasterized(style.rasterized)
    ax.set_title("Simulated Network")
    ax.set_xlabel("x")
    ax.set_ylabel("y")
    ax.set_aspect("equal", adjustable="box")

    if len(false_segments):
        from matplotlib.lines import Line2D

        handles = [
            Line2D(
                [0], [0], color=true_edge_color,
                lw=style.true_edge_linewidth, label="true edges",
            ),
            Line2D(
                [0], [0], color=false_edge_color, lw=style.false_edge_linewidth,
                alpha=_resolve_false_edge_alpha(len(false_edges), false_edge_alpha),
                linestyle="-", label="false edges",
            ),
        ]
        ax.legend(handles=handles, loc="best", frameon=False)

    if save:
        _maybe_save(fig, Path(output_dir) / filename)

    return fig

spatial_graph_algorithms.plot.plot_network_3d(sn, *, figsize=(6.0, 4.5), edge_display='auto', max_edges=None, edge_sample_seed=0, save=False, output_dir=DEFAULT_VISUALIZATION_DIR, filename='network_3d.png')

Plot a 3-D spatial graph using matplotlib's 3-D projection.

Parameters:

Name Type Description Default
sn SpatialGraph

Graph to plot. Must have 3-D positions.

required
figsize tuple of float

Figure size in inches.

(6.0, 4.5)
edge_display ('auto', 'all', 'sample', 'none')

Edge rendering mode. Large graphs are sampled in "auto" mode.

"auto"
max_edges int

Maximum number of edges to draw for "auto" or "sample".

None
edge_sample_seed int

Random seed used when sampling edges. Default 0.

0
save bool

Save figure to output_dir / filename when True.

False
output_dir str or Path

Directory for saved output.

DEFAULT_VISUALIZATION_DIR
filename str

Filename for saved output.

'network_3d.png'

Returns:

Type Description
Figure

The rendered figure.

Raises:

Type Description
ValueError

If positions is None or not 3-D.

Source code in src/spatial_graph_algorithms/plot/network.py
def plot_network_3d(
    sn: SpatialGraph,
    *,
    figsize: tuple[float, float] = (6.0, 4.5),
    edge_display: EdgeDisplay = "auto",
    max_edges: int | None = None,
    edge_sample_seed: int | None = 0,
    save: bool = False,
    output_dir: str | Path = DEFAULT_VISUALIZATION_DIR,
    filename: str = "network_3d.png",
) -> Figure:
    """Plot a 3-D spatial graph using matplotlib's 3-D projection.

    Parameters
    ----------
    sn : SpatialGraph
        Graph to plot.  Must have 3-D *positions*.
    figsize : tuple of float
        Figure size in inches.
    edge_display : {"auto", "all", "sample", "none"}
        Edge rendering mode.  Large graphs are sampled in ``"auto"`` mode.
    max_edges : int, optional
        Maximum number of edges to draw for ``"auto"`` or ``"sample"``.
    edge_sample_seed : int, optional
        Random seed used when sampling edges.  Default 0.
    save : bool
        Save figure to *output_dir* / *filename* when ``True``.
    output_dir : str or Path
        Directory for saved output.
    filename : str
        Filename for saved output.

    Returns
    -------
    matplotlib.figure.Figure
        The rendered figure.

    Raises
    ------
    ValueError
        If *positions* is ``None`` or not 3-D.
    """
    if sn.positions is None:
        raise ValueError("SpatialGraph.positions is None — cannot plot 3D network")
    if sn.positions.shape[1] != 3:
        raise ValueError("plot_network_3d requires 3D positions")

    edges = _extract_unique_edges(sn)
    pos = sn.positions
    false_edge_set = _false_edge_set(sn)
    style = _resolve_network_plot_style(
        sn.n_nodes,
        len(edges),
        edge_display=edge_display,
        max_edges=max_edges,
    )
    true_edges, false_edges = _split_true_false_edges(edges, false_edge_set)
    true_edges, false_edges = _sample_edges_for_display(
        true_edges,
        false_edges,
        max_edges=style.max_edges,
        seed=edge_sample_seed,
    )

    fig = plt.figure(figsize=figsize, dpi=300)
    ax = fig.add_subplot(111, projection='3d')

    for a, b in true_edges:
        xs = [pos[a, 0], pos[b, 0]]
        ys = [pos[a, 1], pos[b, 1]]
        zs = [pos[a, 2], pos[b, 2]]
        ax.plot(
            xs, ys, zs, color="#777777",
            linewidth=style.true_edge_linewidth, alpha=style.true_edge_alpha,
        )

    for a, b in false_edges:
        xs = [pos[a, 0], pos[b, 0]]
        ys = [pos[a, 1], pos[b, 1]]
        zs = [pos[a, 2], pos[b, 2]]
        ax.plot(
            xs, ys, zs, color="#d62728",
            linestyle="-", linewidth=style.false_edge_linewidth,
            alpha=_resolve_false_edge_alpha(len(false_edges), None),
        )

    ax.scatter(
        pos[:, 0], pos[:, 1], pos[:, 2],
        s=style.node_size, c='#1f77b4', alpha=style.node_alpha,
    )
    ax.set_title('Simulated 3D Network')
    ax.set_xlabel('x')
    ax.set_ylabel('y')
    ax.set_zlabel('z')

    if save:
        _maybe_save(fig, Path(output_dir) / filename)

    return fig

spatial_graph_algorithms.plot.plot_edge_length_histogram(sn, *, bins=30, figsize=(4.5, 3.0), density=False, with_all_pairs=False, max_edge_sample=50000, max_pair_sample=200000, seed=None, save=False, output_dir=DEFAULT_VISUALIZATION_DIR, filename='edge_length_histogram.png')

Plot the distribution of Euclidean edge lengths.

Parameters:

Name Type Description Default
sn SpatialGraph

Graph to analyse. Requires positions.

required
bins int

Number of histogram bins.

30
figsize tuple of float

Figure size in inches.

(4.5, 3.0)
density bool

If True, normalise the histogram to a probability density.

False
with_all_pairs bool

If True, overlay a step-line showing the distance distribution for all node pairs (not just connected ones), using the same bin edges. Both curves are normalised by the total number of possible pairs C(n, 2), so the connected-pairs bars are always ≤ the reference line (they are a subset), and their sum equals the graph density E / C(n, 2). The density parameter is ignored in this mode.

False
max_edge_sample int or None

Maximum number of edges to use when computing edge lengths. When the graph has more edges than this, a random subset is drawn. None disables sampling (use all edges).

50000
max_pair_sample int

Maximum number of node-pair distances to sample when computing the all-pairs reference. For a graph with n nodes there are n(n-1)/2 pairs; above max_pair_sample a random subset is drawn. Only used when with_all_pairs is True.

200000
seed int or None

Random seed for reproducible sampling.

None
save bool

Save figure to output_dir / filename when True.

False
output_dir str or Path

Directory for saved output.

DEFAULT_VISUALIZATION_DIR
filename str

Filename for saved output.

'edge_length_histogram.png'

Returns:

Type Description
Figure

The rendered figure.

Raises:

Type Description
ValueError

If positions is None or the graph has no edges.

Source code in src/spatial_graph_algorithms/plot/network.py
def plot_edge_length_histogram(
    sn: SpatialGraph,
    *,
    bins: int = 30,
    figsize: tuple[float, float] = (4.5, 3.0),
    density: bool = False,
    with_all_pairs: bool = False,
    max_edge_sample: int | None = 50_000,
    max_pair_sample: int = 200_000,
    seed: int | None = None,
    save: bool = False,
    output_dir: str | Path = DEFAULT_VISUALIZATION_DIR,
    filename: str = "edge_length_histogram.png",
) -> Figure:
    """Plot the distribution of Euclidean edge lengths.

    Parameters
    ----------
    sn : SpatialGraph
        Graph to analyse.  Requires *positions*.
    bins : int
        Number of histogram bins.
    figsize : tuple of float
        Figure size in inches.
    density : bool
        If ``True``, normalise the histogram to a probability density.
    with_all_pairs : bool
        If ``True``, overlay a step-line showing the distance distribution
        for *all* node pairs (not just connected ones), using the same bin
        edges.  Both curves are normalised by the total number of possible
        pairs C(n, 2), so the connected-pairs bars are always ≤ the
        reference line (they are a subset), and their sum equals the graph
        density E / C(n, 2).  The *density* parameter is ignored in this
        mode.
    max_edge_sample : int or None
        Maximum number of edges to use when computing edge lengths.  When the
        graph has more edges than this, a random subset is drawn.  ``None``
        disables sampling (use all edges).
    max_pair_sample : int
        Maximum number of node-pair distances to sample when computing the
        all-pairs reference.  For a graph with *n* nodes there are
        *n(n-1)/2* pairs; above *max_pair_sample* a random subset is drawn.
        Only used when *with_all_pairs* is ``True``.
    seed : int or None
        Random seed for reproducible sampling.
    save : bool
        Save figure to *output_dir* / *filename* when ``True``.
    output_dir : str or Path
        Directory for saved output.
    filename : str
        Filename for saved output.

    Returns
    -------
    matplotlib.figure.Figure
        The rendered figure.

    Raises
    ------
    ValueError
        If *positions* is ``None`` or the graph has no edges.
    """
    if sn.positions is None:
        raise ValueError("SpatialGraph.positions is None — cannot compute edge lengths")

    edges = _extract_unique_edges(sn)
    if len(edges) == 0:
        raise ValueError("No edges available for edge-length histogram")

    rng = np.random.default_rng(seed)
    p = sn.positions

    if max_edge_sample is not None and len(edges) > max_edge_sample:
        idx = rng.choice(len(edges), size=max_edge_sample, replace=False)
        edges = edges[idx]

    diffs = p[edges[:, 0]] - p[edges[:, 1]]
    edge_lengths = np.linalg.norm(diffs, axis=1)

    fig, ax = plt.subplots(figsize=figsize, dpi=300)

    if with_all_pairs:
        n = p.shape[0]
        total_pairs = n * (n - 1) // 2

        # Normalize both histograms by total_pairs so that connected pairs
        # are always ≤ the all-pairs reference (they are a subset), and the
        # sum of the connected bars equals the graph density E / C(n,2).
        edge_weights = np.ones(len(edge_lengths)) / total_pairs
        _, bin_edges, _ = ax.hist(
            edge_lengths,
            bins=bins,
            weights=edge_weights,
            color="#2ca02c",
            alpha=0.75,
            label="connected pairs",
        )

        if total_pairs <= max_pair_sample:
            i_idx, j_idx = np.triu_indices(n, k=1)
            n_sampled = total_pairs
        else:
            chosen = rng.choice(total_pairs, size=max_pair_sample, replace=False)
            i_idx, j_idx = np.triu_indices(n, k=1)
            i_idx = i_idx[chosen]
            j_idx = j_idx[chosen]
            n_sampled = max_pair_sample

        all_diffs = p[i_idx] - p[j_idx]
        all_lengths = np.linalg.norm(all_diffs, axis=1)

        # Each sampled pair represents total_pairs/n_sampled actual pairs,
        # so weight = (total_pairs/n_sampled) / total_pairs = 1/n_sampled.
        # This estimates count_in_bin / total_pairs for each bin.
        ref_counts, _ = np.histogram(
            all_lengths, bins=bin_edges,
            weights=np.ones(n_sampled) / n_sampled,
        )
        bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
        ax.step(
            bin_centers,
            ref_counts,
            where="mid",
            color="#d62728",
            linewidth=1.5,
            label="all pairs (reference)",
        )
        ax.legend(frameon=False)
        ax.set_ylabel("fraction of all possible pairs per bin")
    else:
        _, bin_edges, _ = ax.hist(
            edge_lengths,
            bins=bins,
            density=density,
            color="#2ca02c",
            alpha=0.75,
        )
        ax.set_ylabel("density" if density else "count")

    ax.set_title("Edge Length Distribution")
    ax.set_xlabel("edge length")

    if save:
        _maybe_save(fig, Path(output_dir) / filename)

    return fig

spatial_graph_algorithms.plot.plot_comparison(sn, *, figsize=(12.0, 4.5), node_size=8.0, cmap='viridis', save=False, output_dir=DEFAULT_VISUALIZATION_DIR, filename='reconstruction_comparison.png')

Plot original and reconstructed positions side by side.

Nodes are coloured by their angle from the centroid in the original space, using the same colour map in both panels. Consistent colouring makes it easy to judge whether the spatial structure was recovered.

Procrustes alignment (scipy.spatial.procrustes — standardizes both arrays to unit Frobenius norm, then finds optimal rotation + reflection) is applied to the reconstructed positions before plotting so that orientation differences do not obscure reconstruction quality.

Parameters:

Name Type Description Default
sn SpatialGraph

Graph with both positions and reconstructed_positions set.

required
figsize tuple of float

Total figure size (width, height) in inches. Default is wide enough for two side-by-side panels.

(12.0, 4.5)
node_size float

Scatter marker size.

8.0
cmap str

Matplotlib colour map name for node colouring.

'viridis'
save bool

Save figure to output_dir / filename when True.

False
output_dir str or Path

Directory for saved output.

DEFAULT_VISUALIZATION_DIR
filename str

Filename for saved output.

'reconstruction_comparison.png'

Returns:

Type Description
Figure

Figure containing two subplots: original (left) and reconstructed (right).

Raises:

Type Description
ValueError

If positions or reconstructed_positions is None, or if either is not 2-D.

Source code in src/spatial_graph_algorithms/plot/network.py
def plot_comparison(
    sn: SpatialGraph,
    *,
    figsize: tuple[float, float] = (12.0, 4.5),
    node_size: float = 8.0,
    cmap: str = "viridis",
    save: bool = False,
    output_dir: str | Path = DEFAULT_VISUALIZATION_DIR,
    filename: str = "reconstruction_comparison.png",
) -> Figure:
    """Plot original and reconstructed positions side by side.

    Nodes are coloured by their angle from the centroid in the original space,
    using the same colour map in both panels.  Consistent colouring makes it
    easy to judge whether the spatial structure was recovered.

    Procrustes alignment (``scipy.spatial.procrustes`` — standardizes both arrays to
    unit Frobenius norm, then finds optimal rotation + reflection) is applied
    to the reconstructed positions before plotting so that orientation
    differences do not obscure reconstruction quality.

    Parameters
    ----------
    sn : SpatialGraph
        Graph with both *positions* and *reconstructed_positions* set.
    figsize : tuple of float
        Total figure size ``(width, height)`` in inches.  Default is wide enough
        for two side-by-side panels.
    node_size : float
        Scatter marker size.
    cmap : str
        Matplotlib colour map name for node colouring.
    save : bool
        Save figure to *output_dir* / *filename* when ``True``.
    output_dir : str or Path
        Directory for saved output.
    filename : str
        Filename for saved output.

    Returns
    -------
    matplotlib.figure.Figure
        Figure containing two subplots: original (left) and reconstructed
        (right).

    Raises
    ------
    ValueError
        If *positions* or *reconstructed_positions* is ``None``, or if either
        is not 2-D.
    """
    from scipy.spatial import procrustes as scipy_procrustes

    if sn.positions is None:
        raise ValueError("SpatialGraph.positions is None")
    if sn.reconstructed_positions is None:
        raise ValueError("SpatialGraph.reconstructed_positions is None — run reconstruct() first")
    if sn.positions.shape[1] > 2 or sn.reconstructed_positions.shape[1] > 2:
        raise ValueError("plot_comparison currently supports only 2D positions")

    orig = sn.positions.copy().astype(float)
    recon = sn.reconstructed_positions.copy().astype(float)

    _, recon_aligned, _ = scipy_procrustes(orig, recon)

    centroid = orig.mean(axis=0)
    angles = np.arctan2(orig[:, 1] - centroid[1], orig[:, 0] - centroid[0])
    colors = (angles - angles.min()) / ((angles.max() - angles.min()) + 1e-12)

    fig, axes = plt.subplots(1, 2, figsize=figsize, dpi=150)

    sc0 = axes[0].scatter(
        orig[:, 0], orig[:, 1], c=colors, cmap=cmap, s=node_size, alpha=0.85, edgecolors="none"
    )
    axes[0].set_title("Original positions")
    axes[0].set_xlabel("x")
    axes[0].set_ylabel("y")
    axes[0].set_aspect("equal", adjustable="box")

    axes[1].scatter(
        recon_aligned[:, 0], recon_aligned[:, 1],
        c=colors, cmap=cmap, s=node_size, alpha=0.85, edgecolors="none",
    )
    axes[1].set_title("Reconstructed positions")
    axes[1].set_xlabel("x")
    axes[1].set_ylabel("y")
    axes[1].set_aspect("equal", adjustable="box")

    fig.colorbar(sc0, ax=axes, label="angle from centroid (normalized)", shrink=0.8)
    fig.suptitle("Spatial Reconstruction Comparison", fontsize=12)

    if save:
        _maybe_save(fig, Path(output_dir) / filename)

    return fig

spatial_graph_algorithms.plot.plot_provenance_comparison(sn, *, pattern='grid', figsize=(12.0, 4.5), node_size=8.0, save=False, output_dir=DEFAULT_VISUALIZATION_DIR, filename='provenance_comparison.png', **pattern_kwargs)

Plot original and reconstructed positions side by side with a spatial color pattern.

A color pattern is derived from ground-truth positions and applied identically to both panels. If reconstruction is good, the pattern appears intact on the right; distortion or scrambling signals poor quality.

Procrustes alignment (scipy.spatial.procrustes — standardizes both arrays to unit Frobenius norm, then finds optimal rotation + reflection) is applied to the reconstructed positions before plotting so that orientation differences do not obscure quality. The alignment is for display only and does not mutate sn.reconstructed_positions.

Parameters:

Name Type Description Default
sn SpatialGraph

Graph with both positions and reconstructed_positions set. Both must be exactly 2-D.

required
pattern str

Pattern kind passed to :func:spatial_graph_algorithms.plot.patterns.apply_pattern. One of "checkerboard", "grid", "rings", "quadrants", "gradient", "image". Default is "grid".

'grid'
figsize tuple of float

Total figure size (width, height) in inches.

(12.0, 4.5)
node_size float

Scatter marker size. Default 8.0.

8.0
save bool

If True, save the figure to output_dir / filename.

False
output_dir str or Path

Directory for saved output.

DEFAULT_VISUALIZATION_DIR
filename str

Filename for saved output.

'provenance_comparison.png'
**pattern_kwargs

Additional keyword arguments forwarded to :func:~spatial_graph_algorithms.plot.patterns.apply_pattern (e.g. grid_size=(4, 4), cmap="plasma").

{}

Returns:

Type Description
Figure

Figure with two subplots: original (left) and reconstructed (right).

Raises:

Type Description
ValueError

If positions or reconstructed_positions is None or not exactly 2-D.

Examples:

>>> from spatial_graph_algorithms.simulate import generate
>>> from spatial_graph_algorithms.reconstruct import reconstruct
>>> sn = generate(n=200, seed=0)
>>> sn_rec = reconstruct(sn, method="mds", seed=0)
>>> fig = plot_provenance_comparison(sn_rec, pattern="grid", grid_size=(4, 4))
Source code in src/spatial_graph_algorithms/plot/network.py
def plot_provenance_comparison(
    sn: SpatialGraph,
    *,
    pattern: str = "grid",
    figsize: tuple[float, float] = (12.0, 4.5),
    node_size: float = 8.0,
    save: bool = False,
    output_dir: str | Path = DEFAULT_VISUALIZATION_DIR,
    filename: str = "provenance_comparison.png",
    **pattern_kwargs,
) -> Figure:
    """Plot original and reconstructed positions side by side with a spatial color pattern.

    A color pattern is derived from ground-truth positions and applied identically
    to both panels.  If reconstruction is good, the pattern appears intact on the
    right; distortion or scrambling signals poor quality.

    Procrustes alignment (``scipy.spatial.procrustes`` — standardizes both arrays
    to unit Frobenius norm, then finds optimal rotation + reflection) is applied to
    the reconstructed positions before plotting so that orientation differences do
    not obscure quality.  The alignment is for display only and does not mutate
    ``sn.reconstructed_positions``.

    Parameters
    ----------
    sn : SpatialGraph
        Graph with both *positions* and *reconstructed_positions* set.
        Both must be exactly 2-D.
    pattern : str
        Pattern kind passed to :func:`spatial_graph_algorithms.plot.patterns.apply_pattern`.
        One of ``"checkerboard"``, ``"grid"``, ``"rings"``, ``"quadrants"``,
        ``"gradient"``, ``"image"``.  Default is ``"grid"``.
    figsize : tuple of float
        Total figure size ``(width, height)`` in inches.
    node_size : float
        Scatter marker size.  Default 8.0.
    save : bool
        If ``True``, save the figure to *output_dir* / *filename*.
    output_dir : str or Path
        Directory for saved output.
    filename : str
        Filename for saved output.
    **pattern_kwargs
        Additional keyword arguments forwarded to
        :func:`~spatial_graph_algorithms.plot.patterns.apply_pattern`
        (e.g. ``grid_size=(4, 4)``, ``cmap="plasma"``).

    Returns
    -------
    matplotlib.figure.Figure
        Figure with two subplots: original (left) and reconstructed (right).

    Raises
    ------
    ValueError
        If *positions* or *reconstructed_positions* is ``None`` or not exactly 2-D.

    Examples
    --------
    >>> from spatial_graph_algorithms.simulate import generate
    >>> from spatial_graph_algorithms.reconstruct import reconstruct
    >>> sn = generate(n=200, seed=0)
    >>> sn_rec = reconstruct(sn, method="mds", seed=0)
    >>> fig = plot_provenance_comparison(sn_rec, pattern="grid", grid_size=(4, 4))
    """
    from scipy.spatial import procrustes as scipy_procrustes

    from .patterns import apply_pattern

    if sn.positions is None:
        raise ValueError("SpatialGraph.positions is None — cannot plot provenance comparison")
    if sn.positions.shape[1] != 2:
        raise ValueError("plot_provenance_comparison requires exactly 2-D positions")
    if sn.reconstructed_positions is None:
        raise ValueError(
            "SpatialGraph.reconstructed_positions is None — run reconstruct() first"
        )
    if sn.reconstructed_positions.shape[1] != 2:
        raise ValueError(
            "plot_provenance_comparison requires exactly 2-D reconstructed_positions"
        )

    orig = sn.positions.copy().astype(float)
    recon = sn.reconstructed_positions.copy().astype(float)
    _, recon_aligned, _ = scipy_procrustes(orig, recon)

    colors = apply_pattern(orig, kind=pattern, **pattern_kwargs)

    fig, axes = plt.subplots(1, 2, figsize=figsize, dpi=150)

    axes[0].scatter(
        orig[:, 0], orig[:, 1], c=colors, s=node_size, alpha=0.85, edgecolors="none"
    )
    axes[0].set_title("Original positions")
    axes[0].set_xlabel("x")
    axes[0].set_ylabel("y")
    axes[0].set_aspect("equal", adjustable="box")

    axes[1].scatter(
        recon_aligned[:, 0], recon_aligned[:, 1],
        c=colors, s=node_size, alpha=0.85, edgecolors="none",
    )
    axes[1].set_title("Reconstructed positions")
    axes[1].set_xlabel("x")
    axes[1].set_ylabel("y")
    axes[1].set_aspect("equal", adjustable="box")

    fig.suptitle(f"Provenance Comparison — pattern: {pattern}", fontsize=12)

    if save:
        _maybe_save(fig, Path(output_dir) / filename)

    return fig

spatial_graph_algorithms.plot.plot_denoising_evaluation(sg, scores, *, method, figsize=(15.0, 5.0), output_path=None)

Three-panel evaluation plot for a denoising scoring run.

Panels
  1. Score vs edge length scatter. Points coloured red for false edges and blue for true edges when is_false labels are present; grey otherwise. Pearson and Spearman correlations are annotated.
  2. ROC curve with AUC (requires is_false labels).
  3. Precision-Recall curve with AUC and optimal-F1 threshold marker (requires is_false labels).

Panels 2 and 3 degrade gracefully when ground-truth is unavailable, showing an explanatory message instead.

Parameters:

Name Type Description Default
sg SpatialGraph

Graph used for scoring. Must have positions for panel 1; must have edge_metadata["is_false"] for panels 2 and 3.

required
scores dict[tuple[int, int], float]

Edge scores from :class:~spatial_graph_algorithms.denoise.EdgeScorer.

required
method str

Scoring method that produced scores. Score polarity is looked up in :data:spatial_graph_algorithms.denoise.SCORE_POLARITY.

required
figsize tuple of float

Figure dimensions (width, height) in inches. Default (15, 5).

(15.0, 5.0)
output_path str or Path

If given, saves the figure at 300 DPI with tight layout.

None

Returns:

Type Description
Figure

The rendered figure.

Examples:

>>> from spatial_graph_algorithms.simulate import generate
>>> from spatial_graph_algorithms.denoise import score_edges
>>> from spatial_graph_algorithms.plot.denoise import plot_denoising_evaluation
>>> sg = generate(n=200, false_edges_fraction=0.10, seed=42)
>>> scores = score_edges(sg, method="jaccard")
>>> fig = plot_denoising_evaluation(sg, scores, method="jaccard")
Source code in src/spatial_graph_algorithms/plot/denoise.py
def plot_denoising_evaluation(
    sg: SpatialGraph,
    scores: dict[tuple[int, int], float],
    *,
    method: str,
    figsize: tuple[float, float] = (15.0, 5.0),
    output_path: str | Path | None = None,
) -> Figure:
    """Three-panel evaluation plot for a denoising scoring run.

    Panels
    ------
    1. **Score vs edge length** scatter.  Points coloured red for false edges
       and blue for true edges when ``is_false`` labels are present; grey
       otherwise.  Pearson and Spearman correlations are annotated.
    2. **ROC curve** with AUC (requires ``is_false`` labels).
    3. **Precision-Recall curve** with AUC and optimal-F1 threshold marker
       (requires ``is_false`` labels).

    Panels 2 and 3 degrade gracefully when ground-truth is unavailable,
    showing an explanatory message instead.

    Parameters
    ----------
    sg : SpatialGraph
        Graph used for scoring.  Must have ``positions`` for panel 1;
        must have ``edge_metadata["is_false"]`` for panels 2 and 3.
    scores : dict[tuple[int, int], float]
        Edge scores from :class:`~spatial_graph_algorithms.denoise.EdgeScorer`.
    method : str
        Scoring method that produced *scores*.  Score polarity is looked up
        in :data:`spatial_graph_algorithms.denoise.SCORE_POLARITY`.
    figsize : tuple of float
        Figure dimensions ``(width, height)`` in inches.  Default ``(15, 5)``.
    output_path : str or Path, optional
        If given, saves the figure at 300 DPI with tight layout.

    Returns
    -------
    matplotlib.figure.Figure
        The rendered figure.

    Examples
    --------
    >>> from spatial_graph_algorithms.simulate import generate
    >>> from spatial_graph_algorithms.denoise import score_edges
    >>> from spatial_graph_algorithms.plot.denoise import plot_denoising_evaluation
    >>> sg = generate(n=200, false_edges_fraction=0.10, seed=42)
    >>> scores = score_edges(sg, method="jaccard")
    >>> fig = plot_denoising_evaluation(sg, scores, method="jaccard")
    """
    fig, axes = plt.subplots(1, 3, figsize=figsize, constrained_layout=True)
    suffix = f" — {method}" if method else ""

    edges = list(scores.keys())
    score_vals = np.array([scores[e] for e in edges])
    # Flip so that higher always means "more suspicious" for ROC/PR orientation.
    suspicious = score_vals if _score_direction(method) else -score_vals

    has_positions = sg.positions is not None
    has_gt = (
        sg.edge_metadata is not None
        and "is_false" in sg.edge_metadata.columns
        and "source" in sg.edge_metadata.columns
    )

    # ── panel 1: score vs edge length ─────────────────────────────────────
    if has_positions:
        _plot_score_vs_length(axes[0], sg, edges, score_vals, suffix, color_by_gt=has_gt)
    else:
        axes[0].text(
            0.5, 0.5, "positions not available",
            ha="center", va="center", transform=axes[0].transAxes, fontsize=11,
        )
        axes[0].set_title(f"Score vs Edge Length{suffix}")

    # ── panels 2 & 3: ROC and PR ──────────────────────────────────────────
    if has_gt:
        n64 = np.int64(sg.adjacency_matrix.shape[0])
        meta = sg.edge_metadata
        lo = np.minimum(meta["source"].values, meta["target"].values).astype(np.int64)
        hi = np.maximum(meta["source"].values, meta["target"].values).astype(np.int64)
        encoded_meta = lo * n64 + hi

        suspicious_by_code = {
            np.int64(u) * n64 + np.int64(v): s
            for (u, v), s in zip(edges, suspicious)
        }
        y_score_full = np.array(
            [suspicious_by_code.get(int(e), float("nan")) for e in encoded_meta]
        )
        y_true_full = meta["is_false"].astype(int).to_numpy()
        valid = ~np.isnan(y_score_full)
        y_true = y_true_full[valid]
        y_score = y_score_full[valid]

        if y_true.sum() > 0:
            _plot_roc(axes[1], y_true, y_score, suffix)
            _plot_pr(axes[2], y_true, y_score, suffix)
        else:
            for ax in axes[1:]:
                ax.text(0.5, 0.5, "no false edges in graph",
                        ha="center", va="center", transform=ax.transAxes, fontsize=11)
    else:
        msg = "is_false labels not available\n(real-data graph)"
        for ax, title in zip(axes[1:], [f"ROC Curve{suffix}", f"Precision-Recall{suffix}"]):
            ax.text(0.5, 0.5, msg, ha="center", va="center",
                    transform=ax.transAxes, fontsize=11)
            ax.set_title(title)

    if output_path is not None:
        out = Path(output_path)
        out.parent.mkdir(parents=True, exist_ok=True)
        fig.savefig(out, dpi=300, bbox_inches="tight")

    return fig

spatial_graph_algorithms.plot.render_simulation_visualization_bundle(sn, *, output_dir=DEFAULT_VISUALIZATION_DIR, prefix='simulation')

Save the standard visualisation bundle (network + edge-length histogram) to disk.

Parameters:

Name Type Description Default
sn SpatialGraph

Graph to visualise.

required
output_dir str or Path

Directory where plots are written.

DEFAULT_VISUALIZATION_DIR
prefix str

Filename prefix for all saved plots.

'simulation'

Returns:

Type Description
dict

Mapping of "network" (or "network_3d" for 3-D graphs) and "edge_length_histogram" to their :class:pathlib.Path on disk.

Source code in src/spatial_graph_algorithms/plot/network.py
def render_simulation_visualization_bundle(
    sn: SpatialGraph,
    *,
    output_dir: str | Path = DEFAULT_VISUALIZATION_DIR,
    prefix: str = "simulation",
) -> dict[str, Path]:
    """Save the standard visualisation bundle (network + edge-length histogram) to disk.

    Parameters
    ----------
    sn : SpatialGraph
        Graph to visualise.
    output_dir : str or Path
        Directory where plots are written.
    prefix : str
        Filename prefix for all saved plots.

    Returns
    -------
    dict
        Mapping of ``"network"`` (or ``"network_3d"`` for 3-D graphs) and
        ``"edge_length_histogram"`` to their :class:`pathlib.Path` on disk.
    """
    out = Path(output_dir)
    out.mkdir(parents=True, exist_ok=True)

    network_path = out / f"{prefix}_network.png"
    hist_path = out / f"{prefix}_edge_length_histogram.png"

    result = {"edge_length_histogram": hist_path}

    if sn.positions is not None and sn.positions.shape[1] == 3:
        p3 = out / f"{prefix}_network_3d.png"
        fig3 = plot_network_3d(sn, save=True, output_dir=out, filename=p3.name)
        fig2 = plot_edge_length_histogram(sn, save=True, output_dir=out, filename=hist_path.name)
        plt.close(fig3)
        plt.close(fig2)
        result['network_3d'] = p3
    else:
        fig1 = plot_network(sn, save=True, output_dir=out, filename=network_path.name)
        fig2 = plot_edge_length_histogram(sn, save=True, output_dir=out, filename=hist_path.name)
        plt.close(fig1)
        plt.close(fig2)
        result['network'] = network_path

    return result