Source code for att.viz.plotting

"""Publication-quality plotting utilities."""

import json
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
import matplotlib


[docs] def plot_persistence_diagram( diagrams: list[np.ndarray], ax: matplotlib.axes.Axes | None = None, colormap: str = "viridis", ) -> matplotlib.figure.Figure: """Plot persistence diagrams for all homology dimensions.""" if ax is None: fig, ax = plt.subplots(figsize=(6, 6)) else: fig = ax.get_figure() cmap = plt.get_cmap(colormap) colors = [cmap(i / max(len(diagrams) - 1, 1)) for i in range(len(diagrams))] all_vals = [] for dgm in diagrams: if len(dgm) > 0: all_vals.extend(dgm.ravel()) if all_vals: vmin, vmax = min(all_vals), max(all_vals) else: vmin, vmax = 0, 1 # Diagonal ax.plot([vmin, vmax], [vmin, vmax], "k--", alpha=0.3, linewidth=1) for dim, dgm in enumerate(diagrams): if len(dgm) > 0: ax.scatter( dgm[:, 0], dgm[:, 1], c=[colors[dim]] * len(dgm), label=f"H{dim}", s=20, alpha=0.7, edgecolors="k", linewidths=0.3, ) ax.set_xlabel("Birth") ax.set_ylabel("Death") ax.set_title("Persistence Diagram") ax.legend() ax.set_aspect("equal") return fig
[docs] def plot_persistence_image( images: list[np.ndarray], ax: matplotlib.axes.Axes | None = None, colormap: str = "hot", ) -> matplotlib.figure.Figure: """Plot persistence images for all homology dimensions.""" n = len(images) if ax is not None: fig = ax.get_figure() axes = [ax] else: fig, axes = plt.subplots(1, n, figsize=(5 * n, 4)) if n == 1: axes = [axes] for i, (img, ax_) in enumerate(zip(images, axes)): im = ax_.imshow(img, cmap=colormap, origin="lower", aspect="auto") ax_.set_title(f"H{i} Persistence Image") fig.colorbar(im, ax=ax_, fraction=0.046, pad=0.04) fig.tight_layout() return fig
[docs] def plot_barcode( diagrams: list[np.ndarray], ax: matplotlib.axes.Axes | None = None, ) -> matplotlib.figure.Figure: """Plot persistence barcodes.""" if ax is None: fig, ax = plt.subplots(figsize=(8, 5)) else: fig = ax.get_figure() colors = ["tab:blue", "tab:orange", "tab:green", "tab:red"] y_offset = 0 for dim, dgm in enumerate(diagrams): if len(dgm) == 0: continue # Sort by persistence (longest first) lifetimes = dgm[:, 1] - dgm[:, 0] order = np.argsort(-lifetimes) color = colors[dim % len(colors)] for idx in order: birth, death = dgm[idx] ax.plot([birth, death], [y_offset, y_offset], color=color, linewidth=1.5) y_offset += 1 ax.set_xlabel("Filtration Parameter") ax.set_ylabel("Feature") ax.set_title("Persistence Barcode") # Legend handles = [] for dim in range(len(diagrams)): if len(diagrams[dim]) > 0: handles.append(plt.Line2D([0], [0], color=colors[dim % len(colors)], label=f"H{dim}")) ax.legend(handles=handles) return fig
[docs] def plot_betti_curve( betti_curves: list[np.ndarray], ax: matplotlib.axes.Axes | None = None, ) -> matplotlib.figure.Figure: """Plot Betti curves.""" if ax is None: fig, ax = plt.subplots(figsize=(8, 4)) else: fig = ax.get_figure() colors = ["tab:blue", "tab:orange", "tab:green", "tab:red"] for dim, curve in enumerate(betti_curves): ax.plot(curve, color=colors[dim % len(colors)], label=f{dim}") ax.set_xlabel("Filtration Index") ax.set_ylabel("Betti Number") ax.set_title("Betti Curves") ax.legend() return fig
[docs] def plot_attractor_3d( cloud: np.ndarray, color_by: str = "time", backend: str = "plotly", ): """3D scatter/line plot of an attractor point cloud. Parameters ---------- cloud : (n_points, 3+) array — uses first 3 columns color_by : "time" (color by index) backend : "plotly" or "matplotlib" """ cloud = np.asarray(cloud)[:, :3] if backend == "plotly": import plotly.graph_objects as go colors = np.arange(len(cloud)) fig = go.Figure( data=[go.Scatter3d( x=cloud[:, 0], y=cloud[:, 1], z=cloud[:, 2], mode="lines", line=dict(color=colors, colorscale="Viridis", width=2), )] ) fig.update_layout( title="Attractor", scene=dict(xaxis_title="x", yaxis_title="y", zaxis_title="z"), width=700, height=600, ) return fig else: fig = plt.figure(figsize=(8, 6)) ax = fig.add_subplot(111, projection="3d") colors = np.arange(len(cloud)) ax.scatter( cloud[:, 0], cloud[:, 1], cloud[:, 2], c=colors, cmap="viridis", s=0.5, alpha=0.5, ) ax.set_xlabel("x") ax.set_ylabel("y") ax.set_zlabel("z") ax.set_title("Attractor") return fig
[docs] def plot_surrogate_distribution( observed: float, surrogates: np.ndarray, ax: matplotlib.axes.Axes | None = None, ) -> matplotlib.figure.Figure: """Histogram of surrogate scores with observed score marked.""" if ax is None: fig, ax = plt.subplots(figsize=(7, 4)) else: fig = ax.get_figure() ax.hist(surrogates, bins=30, alpha=0.7, color="steelblue", edgecolor="white") ax.axvline(observed, color="red", linewidth=2, label=f"Observed = {observed:.4f}") p95 = np.percentile(surrogates, 95) ax.axvline(p95, color="orange", linewidth=1.5, linestyle="--", label=f"95th pctile = {p95:.4f}") ax.set_xlabel("Binding Score") ax.set_ylabel("Count") ax.set_title("Surrogate Distribution") ax.legend() return fig
[docs] def plot_benchmark_sweep(results, ax=None) -> matplotlib.figure.Figure: """Plot benchmark sweep with all methods overlaid. Parameters ---------- results : pd.DataFrame with columns coupling, method, score, score_normalized """ if ax is None: fig, ax = plt.subplots(figsize=(8, 5)) else: fig = ax.get_figure() methods = results["method"].unique() colors = plt.cm.tab10(np.linspace(0, 1, len(methods))) for method, color in zip(methods, colors): subset = results[results["method"] == method].sort_values("coupling") col = "score_normalized" if "score_normalized" in results.columns else "score" ax.plot(subset["coupling"], subset[col], "o-", color=color, label=method, markersize=4) ax.set_xlabel("Coupling Strength") ax.set_ylabel("Score (normalized)") ax.set_title("Coupling Benchmark Sweep") ax.legend() return fig
[docs] def plot_binding_comparison(detector) -> matplotlib.figure.Figure: """3-panel comparison: marginal X | joint (excess highlighted) | marginal Y. Parameters ---------- detector : BindingDetector with fitted state Returns ------- matplotlib Figure """ diagrams_x = detector._result_x["diagrams"] diagrams_joint = detector._result_joint["diagrams"] diagrams_y = detector._result_y["diagrams"] fig, axes = plt.subplots(1, 3, figsize=(18, 5)) titles = ["Marginal X", "Joint (excess highlighted)", "Marginal Y"] all_diagrams = [diagrams_x, diagrams_joint, diagrams_y] colors = ["tab:blue", "tab:orange", "tab:green"] # Find global axis range all_vals = [] for diag_set in all_diagrams: for dgm in diag_set: if len(dgm) > 0: all_vals.extend(dgm.ravel()) if all_vals: vmin, vmax = min(all_vals), max(all_vals) else: vmin, vmax = 0, 1 for panel_idx, (diags, ax, title) in enumerate(zip(all_diagrams, axes, titles)): ax.plot([vmin, vmax], [vmin, vmax], "k--", alpha=0.3, linewidth=1) for dim, dgm in enumerate(diags): if len(dgm) > 0: ax.scatter( dgm[:, 0], dgm[:, 1], c=colors[dim % len(colors)], label=f"H{dim}", s=20, alpha=0.7, edgecolors="k", linewidths=0.3, ) ax.set_xlabel("Birth") ax.set_ylabel("Death") ax.set_title(title) ax.legend() ax.set_aspect("equal") ax.set_xlim(vmin - 0.5, vmax + 0.5) ax.set_ylim(vmin - 0.5, vmax + 0.5) fig.tight_layout() return fig
[docs] def plot_binding_image( images: list[np.ndarray], colormap: str = "RdBu_r", ) -> matplotlib.figure.Figure: """Heatmap of residual persistence images. Parameters ---------- images : list of (resolution, resolution) residual images, one per dimension colormap : diverging colormap (red=emergent, blue=deficit) Returns ------- matplotlib Figure """ n = len(images) fig, axes = plt.subplots(1, n, figsize=(5 * n, 4)) if n == 1: axes = [axes] for i, (img, ax) in enumerate(zip(images, axes)): vmax = max(abs(img.min()), abs(img.max())) or 1.0 im = ax.imshow( img, cmap=colormap, origin="lower", aspect="auto", vmin=-vmax, vmax=vmax, ) ax.set_title(f"H{i} Binding Image") ax.set_xlabel("Birth") ax.set_ylabel("Persistence") fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) fig.tight_layout() return fig
[docs] def export_to_json(results: dict, path: str) -> None: """Export computed results as JSON.""" Path(path).parent.mkdir(parents=True, exist_ok=True) def _convert(obj): if isinstance(obj, np.ndarray): return obj.tolist() if isinstance(obj, np.integer): return int(obj) if isinstance(obj, np.floating): return float(obj) if isinstance(obj, dict): return {k: _convert(v) for k, v in obj.items()} if isinstance(obj, list): return [_convert(v) for v in obj] return obj with open(path, "w") as f: json.dump(_convert(results), f, indent=2)
[docs] def load_from_json(path: str) -> dict: """Load results from JSON.""" with open(path, "r") as f: return json.load(f)
[docs] def plot_transition_timeline(detector, ground_truth=None, figsize=(12, 6)): """Plot topology transition timeline from a fitted TransitionDetector. Parameters ---------- detector : TransitionDetector Must have been fit_transform()'d. ground_truth : list of int or None True transition sample indices (plotted as green dotted lines). figsize : tuple Figure size. Returns ------- matplotlib Figure """ result = detector._result if result is None: raise RuntimeError("TransitionDetector must be fitted first.") window_centers = result["window_centers"] image_distances = result["image_distances"] # image_distances has len = len(window_centers) - 1 # Use midpoints between consecutive window centers dist_x = (window_centers[:-1] + window_centers[1:]) / 2 # Compute H1 persistence entropy per window h1_entropy = [] for topo in result["topology_timeseries"]: # persistence_entropy is a list per dim if len(topo["persistence_entropy"]) > 1: h1_entropy.append(topo["persistence_entropy"][1]) else: h1_entropy.append(0.0) fig, axes = plt.subplots(2, 1, figsize=figsize, sharex=True) # Top panel: image distances + changepoints ax = axes[0] ax.plot(dist_x, image_distances, 'k-', linewidth=1.5, label='PI distance') ax.set_ylabel('Image distance (L2)') ax.set_title('Topological Transition Timeline') # Detected changepoints try: changepoints = detector.detect_changepoints() for cp in changepoints: if cp < len(dist_x): ax.axvline(dist_x[cp], color='red', linestyle='--', alpha=0.8, label='Detected' if cp == changepoints[0] else None) except Exception: pass # Ground truth if ground_truth is not None: for i, gt in enumerate(ground_truth): ax.axvline(gt, color='green', linestyle=':', alpha=0.7, linewidth=2, label='Ground truth' if i == 0 else None) ax.legend(loc='upper right') ax.grid(True, alpha=0.3) # Bottom panel: H1 persistence entropy ax = axes[1] ax.plot(window_centers, h1_entropy, 'b-', linewidth=1.5) ax.set_xlabel('Sample index') ax.set_ylabel('H1 persistence entropy') ax.set_title('Loop Complexity Over Time') if ground_truth is not None: for gt in ground_truth: ax.axvline(gt, color='green', linestyle=':', alpha=0.7, linewidth=2) ax.grid(True, alpha=0.3) plt.tight_layout() return fig
# ============================================================ # LLM hidden-state analysis plots (Wave 1) # ============================================================
[docs] def plot_zscore_profile( z_scores: np.ndarray, p_values: np.ndarray | None = None, per_dim_z_scores: dict | None = None, ax: matplotlib.axes.Axes | None = None, significance_threshold: float = 0.05, ) -> matplotlib.figure.Figure: """Layer-indexed z-score profile with significance shading. Parameters ---------- z_scores : (n_layers,) aggregate z-scores. p_values : (n_layers,) p-values for significance shading. per_dim_z_scores : dict mapping dim (int) -> (n_layers,) z-scores. ax : optional axes. significance_threshold : p-value below which to shade. """ if per_dim_z_scores is not None and ax is None: fig, ax = plt.subplots(figsize=(12, 5)) elif ax is None: fig, ax = plt.subplots(figsize=(10, 5)) else: fig = ax.get_figure() layers = np.arange(len(z_scores)) if per_dim_z_scores is not None: dim_colors = {0: "tab:blue", 1: "tab:red", 2: "tab:green"} dim_labels = {0: "H0", 1: "H1", 2: "H2"} for dim, zs in per_dim_z_scores.items(): ax.plot( layers, zs, "o-", color=dim_colors.get(dim, "gray"), linewidth=1.5, markersize=3, alpha=0.7, label=dim_labels.get(dim, f"H{dim}"), ) ax.plot( layers, z_scores, "s-", color="black", linewidth=2.5, markersize=5, label="Aggregate", zorder=10, ) # Significance shading if p_values is not None: sig_mask = p_values < significance_threshold for i in range(len(layers)): if sig_mask[i]: ax.axvspan( layers[i] - 0.5, layers[i] + 0.5, alpha=0.08, color="gold", zorder=0, ) # Terminal-layer shading n = len(z_scores) if n > 6: ax.axvspan(n - 5.5, n - 0.5, alpha=0.08, color="gray", label="Terminal 5 layers") ax.axhline(1.96, color="gray", linestyle="--", alpha=0.5, label="z=1.96") ax.set_xlabel("Layer Index") ax.set_ylabel("z-score") ax.set_title("Per-Layer Topological Discriminability Profile") ax.legend(loc="upper left", fontsize=8) ax.grid(True, alpha=0.3) fig.tight_layout() return fig
[docs] def plot_crocker( betti_matrix: np.ndarray, parameter_labels: list[str] | None = None, filtration_range: tuple[float, float] | None = None, ax: matplotlib.axes.Axes | None = None, colormap: str = "viridis", title: str | None = None, ) -> matplotlib.figure.Figure: """2D heatmap of Betti numbers (filtration scale × parameter). Parameters ---------- betti_matrix : (n_filtration_steps, n_parameters) Betti number matrix. parameter_labels : labels for the parameter axis. filtration_range : (min, max) of filtration values. """ if ax is None: fig, ax = plt.subplots(figsize=(max(8, betti_matrix.shape[1] * 0.5), 6)) else: fig = ax.get_figure() extent = None if filtration_range is not None: extent = [ -0.5, betti_matrix.shape[1] - 0.5, filtration_range[0], filtration_range[1], ] im = ax.imshow( betti_matrix, aspect="auto", origin="lower", cmap=colormap, extent=extent, interpolation="nearest", ) fig.colorbar(im, ax=ax, label="Betti number", fraction=0.046, pad=0.04) if parameter_labels is not None: ax.set_xticks(range(len(parameter_labels))) ax.set_xticklabels(parameter_labels, rotation=45, ha="right", fontsize=8) ax.set_ylabel("Filtration scale (ε)") ax.set_xlabel("Parameter") ax.set_title(title or "CROCKER Plot") fig.tight_layout() return fig
[docs] def plot_compression_decomposition( levels: list[int], total_persistence: list[float], n_features: list[float], mean_lifetime: list[float], ax: matplotlib.axes.Axes | None = None, title: str = "H1 Persistence Decomposition", ) -> matplotlib.figure.Figure: """Dual-axis plot of feature count vs mean lifetime by difficulty. Parameters ---------- levels : difficulty level labels. total_persistence : total persistence per level. n_features : feature count per level. mean_lifetime : mean lifetime per level. """ if ax is None: fig, ax1 = plt.subplots(figsize=(8, 5)) else: ax1 = ax fig = ax.get_figure() ax2 = ax1.twinx() x = np.arange(len(levels)) width = 0.3 bars1 = ax1.bar( x - width / 2, n_features, width, color="steelblue", alpha=0.8, label="Feature Count", ) bars2 = ax2.bar( x + width / 2, mean_lifetime, width, color="coral", alpha=0.8, label="Mean Lifetime", ) ax1.set_xlabel("Difficulty Level") ax1.set_ylabel("Feature Count", color="steelblue") ax2.set_ylabel("Mean Lifetime", color="coral") ax1.set_xticks(x) ax1.set_xticklabels([str(lv) for lv in levels]) ax1.set_title(title) lines1, labels1 = ax1.get_legend_handles_labels() lines2, labels2 = ax2.get_legend_handles_labels() ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper left") ax1.grid(True, alpha=0.3, axis="y") fig.tight_layout() return fig
# ============================================================ # LLM hidden-state analysis plots (Wave 2) # ============================================================
[docs] def plot_roc_curves( roc_data: dict[str, tuple[np.ndarray, np.ndarray, float]], ax: matplotlib.axes.Axes | None = None, title: str = "ROC Curves: Correctness Prediction", ) -> matplotlib.figure.Figure: """Plot ROC curves for correctness prediction. Parameters ---------- roc_data : dict mapping label -> (fpr, tpr, auroc). ax : optional axes. title : plot title. """ if ax is None: fig, ax = plt.subplots(figsize=(7, 7)) else: fig = ax.get_figure() for label, (fpr, tpr, auroc) in roc_data.items(): ax.plot(fpr, tpr, linewidth=2, label=f"{label} (AUROC={auroc:.3f})") ax.plot([0, 1], [0, 1], "k--", alpha=0.5, label="Random") ax.set_xlabel("False Positive Rate") ax.set_ylabel("True Positive Rate") ax.set_title(title) ax.legend(loc="lower right") ax.grid(True, alpha=0.3) ax.set_xlim([-0.02, 1.02]) ax.set_ylim([-0.02, 1.02]) fig.tight_layout() return fig
[docs] def plot_id_profile( profiles: dict[int, np.ndarray], ax: matplotlib.axes.Axes | None = None, title: str = "Intrinsic Dimension by Layer", method_label: str = "TwoNN", ) -> matplotlib.figure.Figure: """Plot intrinsic dimension profiles across layers by difficulty level. Parameters ---------- profiles : dict mapping level -> (n_layers,) array of ID estimates. ax : optional axes. title : plot title. method_label : label for the ID method. """ if ax is None: fig, ax = plt.subplots(figsize=(12, 5)) else: fig = ax.get_figure() levels = sorted(profiles.keys()) colors = plt.cm.viridis(np.linspace(0, 1, len(levels))) for level, color in zip(levels, colors): ids = profiles[level] layers = np.arange(len(ids)) ax.plot(layers, ids, "-o", color=color, label=f"Level {level}", markersize=3, linewidth=1.5) ax.set_xlabel("Layer Index") ax.set_ylabel(f"Intrinsic Dimension ({method_label})") ax.set_title(title) ax.legend() ax.grid(True, alpha=0.3) # Shade terminal layers n_layers = len(next(iter(profiles.values()))) terminal_start = max(0, n_layers - 5) ax.axvspan(terminal_start, n_layers - 1, alpha=0.08, color="red") fig.tight_layout() return fig
[docs] def plot_spectral_comparison( euclidean_entropy: dict[int, list[float]], spectral_entropy: dict[int, list[float]], layer_indices: list[int], ax: matplotlib.axes.Axes | None = None, title: str = "Euclidean vs Spectral PH Entropy", ) -> matplotlib.figure.Figure: """Side-by-side comparison of Euclidean and spectral persistence entropy. Parameters ---------- euclidean_entropy : dict mapping H-dim -> list of entropies per layer. spectral_entropy : dict mapping H-dim -> list of entropies per layer. layer_indices : list of layer index labels. ax : optional axes. title : plot title. """ n_dims = len(euclidean_entropy) if ax is None: fig, axes = plt.subplots(1, n_dims, figsize=(7 * n_dims, 5)) if n_dims == 1: axes = [axes] else: fig = ax.get_figure() axes = [ax] for dim_idx, dim in enumerate(sorted(euclidean_entropy.keys())): if dim_idx >= len(axes): break a = axes[dim_idx] a.plot(layer_indices, euclidean_entropy[dim], "b-o", label="Euclidean", markersize=4, linewidth=1.5) a.plot(layer_indices, spectral_entropy[dim], "r-s", label="Spectral", markersize=4, linewidth=1.5) a.set_xlabel("Layer") a.set_ylabel("Persistence Entropy") a.set_title(f"H{dim}") a.legend() a.grid(True, alpha=0.3) fig.suptitle(title, fontsize=13) fig.tight_layout() return fig
# --------------------------------------------------------------------------- # Wave 3: Zigzag persistence + token-region plots # ---------------------------------------------------------------------------
[docs] def plot_zigzag_barcode( barcodes: np.ndarray, dim: int = 1, level: int | None = None, ax=None, title: str | None = None, colormap: str = "viridis", ): """Plot zigzag persistence barcode as horizontal lifetime bars. Parameters ---------- barcodes : (n, 2) array of (birth_layer, death_layer). dim : homology dimension (for labeling). level : difficulty level (for labeling). ax : optional matplotlib axes. title : plot title override. colormap : matplotlib colormap name. """ if ax is None: fig, ax = plt.subplots(figsize=(12, 5)) else: fig = ax.get_figure() if len(barcodes) == 0: ax.text(0.5, 0.5, f"No H{dim} features", ha="center", va="center", transform=ax.transAxes, fontsize=12, color="gray") if title: ax.set_title(title) return fig order = np.argsort(barcodes[:, 0]) barcodes = barcodes[order] lifetimes = barcodes[:, 1] - barcodes[:, 0] max_lt = max(lifetimes.max(), 1e-10) cmap = plt.get_cmap(colormap) colors = cmap(lifetimes / max_lt) for i, (bar, color) in enumerate(zip(barcodes, colors)): ax.barh(i, bar[1] - bar[0], left=bar[0], height=0.8, color=color, alpha=0.8) ax.set_xlabel("Layer (zigzag time)") ax.set_ylabel("Feature index") default_title = f"H{dim} Zigzag Barcode" if level is not None: default_title += f" — Level {level}" ax.set_title(title or default_title) ax.grid(True, alpha=0.2, axis="x") return fig
[docs] def plot_zigzag_comparison( results: dict, dim: int = 1, metric: str = "mean_lifetime", ax=None, title: str = "Zigzag Feature Statistics by Difficulty", ): """Bar chart comparing zigzag statistics across difficulty levels. Parameters ---------- results : dict mapping level (int) -> stats dict from zigzag_feature_lifetime_stats. dim : homology dimension (for labeling). metric : which stat to plot ('mean_lifetime', 'n_features', 'max_lifetime', 'n_long_lived'). ax : optional axes. title : plot title. """ if ax is None: fig, ax = plt.subplots(figsize=(8, 5)) else: fig = ax.get_figure() levels = sorted(results.keys()) values = [results[l].get(metric, 0) for l in levels] colors = plt.cm.viridis(np.linspace(0, 1, len(levels))) ax.bar(levels, values, color=colors, alpha=0.8) ax.set_xlabel("Difficulty Level") ax.set_ylabel(metric.replace("_", " ").title()) ax.set_title(title) ax.grid(True, alpha=0.3, axis="y") return fig
[docs] def plot_token_partition_topology( region_entropy: dict, levels: list[int] | None = None, ax=None, title: str = "Persistence Entropy by Token Region", ): """Grouped bar chart of persistence entropy across token regions and difficulty. Parameters ---------- region_entropy : dict mapping region_name -> {level: list_of_entropy_values}. levels : which levels to include (default: all). ax : optional axes. title : plot title. """ if ax is None: fig, ax = plt.subplots(figsize=(10, 5)) else: fig = ax.get_figure() regions = sorted(region_entropy.keys()) if levels is None: all_levels = set() for r_data in region_entropy.values(): all_levels.update(r_data.keys()) levels = sorted(all_levels) n_regions = len(regions) n_levels = len(levels) x = np.arange(n_regions) width = 0.8 / max(n_levels, 1) colors = plt.cm.viridis(np.linspace(0, 1, n_levels)) for i, (level, color) in enumerate(zip(levels, colors)): means = [] errs = [] for region in regions: vals = region_entropy[region].get(level, []) if vals: means.append(np.mean(vals)) errs.append(np.std(vals)) else: means.append(0) errs.append(0) offset = (i - n_levels / 2 + 0.5) * width ax.bar(x + offset, means, width, yerr=errs, label=f"Level {level}", color=color, alpha=0.8, capsize=2) ax.set_xticks(x) ax.set_xticklabels([r.replace("_", "\n") for r in regions], fontsize=9) ax.set_ylabel("Persistence Entropy") ax.set_title(title) ax.legend() ax.grid(True, alpha=0.3, axis="y") return fig
[docs] def plot_cross_model_zscore( zscore_results: dict, model_labels: dict | None = None, model_colors: dict | None = None, ax=None, title: str = "Cross-Model Z-Score Profiles", ): """Overlay z-score profiles from multiple models. Parameters ---------- zscore_results : dict mapping model_key -> {"z_scores": array, "n_layers": int}. model_labels : optional mapping model_key -> display name. model_colors : optional mapping model_key -> color. ax : optional axes. title : plot title. """ if ax is None: fig, ax = plt.subplots(figsize=(12, 6)) else: fig = ax.get_figure() default_colors = plt.cm.tab10(np.linspace(0, 1, len(zscore_results))) for i, (key, data) in enumerate(zscore_results.items()): z = np.asarray(data["z_scores"]) x = np.linspace(0, 1, len(z)) label = (model_labels or {}).get(key, key) color = (model_colors or {}).get(key, default_colors[i]) ax.plot(x, z, label=label, color=color, linewidth=2, alpha=0.8) ax.axhline(y=1.96, color="gray", linestyle="--", alpha=0.5, label="p<0.05") ax.axhline(y=2.58, color="gray", linestyle=":", alpha=0.5, label="p<0.01") ax.set_xlabel("Normalized Layer Position (0=embedding, 1=final)") ax.set_ylabel("Z-Score") ax.set_title(title) ax.legend() ax.grid(True, alpha=0.3) return fig
[docs] def plot_attention_binding_heatmap( scores: dict, levels: list[int], layer_indices: list[int], ax=None, title: str = "Attention-Hidden Binding Score", cmap: str = "RdYlBu_r", ): """Heatmap of binding scores (difficulty × layer). Parameters ---------- scores : dict mapping (level, layer) -> float or {"mean": float}. levels : list of difficulty levels. layer_indices : list of layer indices. ax : optional axes. title : plot title. cmap : colormap name. """ n_levels = len(levels) n_layers = len(layer_indices) matrix = np.zeros((n_levels, n_layers)) for i, level in enumerate(levels): for j, layer in enumerate(layer_indices): val = scores.get((level, layer), 0.0) if isinstance(val, dict): val = val.get("mean", 0.0) matrix[i, j] = val if ax is None: fig, ax = plt.subplots(figsize=(max(8, n_layers), max(4, n_levels * 0.8))) else: fig = ax.get_figure() im = ax.imshow(matrix, aspect="auto", cmap=cmap, interpolation="nearest") ax.set_xticks(range(n_layers)) ax.set_xticklabels([str(l) for l in layer_indices]) ax.set_yticks(range(n_levels)) ax.set_yticklabels([f"Level {l}" for l in levels]) ax.set_xlabel("Layer Index") ax.set_ylabel("Difficulty Level") ax.set_title(title) for i in range(n_levels): for j in range(n_layers): ax.text(j, i, f"{matrix[i, j]:.3f}", ha="center", va="center", fontsize=8, color="white" if matrix[i, j] > matrix.mean() else "black") fig.colorbar(im, ax=ax, label="Binding Score") return fig