refactor: 将子模块转为普通目录,移除外部 git 依赖
Sync to site1 / sync (push) Has been cancelled

- 移除 JEPA/lejepa-identifiability 子模块 gitlink
- 移除 research/multiply/MultiPLY 子模块 gitlink
- 删除 .gitmodules(不再有外部 URL 依赖)
- 两个目录内容作为普通文件纳入主仓库追踪
- 删除各自内部 .git 目录,消除嵌套 git 仓库
This commit is contained in:
gaojie
2026-06-05 17:14:01 +08:00
parent cb629f18a1
commit c66855adfc
208 changed files with 23296 additions and 9 deletions
@@ -0,0 +1,63 @@
"""
Aggregate results from any experiment into a flat CSV.
Usage:
python analysis/aggregate.py --results_dir results/2d/ --out results/2d/summary.csv
python analysis/aggregate.py --results_dir results/ --recursive --out results/all.csv
"""
import argparse, glob, json, os
import pandas as pd
SCALAR_KEYS = [
"experiment", "run_name", "mixing", "encoder", "mode", "source_dist",
"seed", "N", "lamb", "rho", "lr", "steps", "batch_size", "n_layers", "hidden",
"r2_zx", "r2_xz", "r2_zh", "r2_hz",
"orth_err", "orth_err_normalized",
"epsilon", "delta", "D_bound", "approx_bound",
"procrustes_mse", "L_h", "trace_cov",
"final_align", "final_sigreg", "final_whiten", "final_loss",
]
def load_results(results_dir, recursive=False):
pattern = os.path.join(results_dir, "**/*.json") if recursive else os.path.join(results_dir, "*.json")
files = sorted(glob.glob(pattern, recursive=recursive))
print(f"Found {len(files)} .json files")
rows = []
for path in files:
try:
with open(path) as f:
r = json.load(f)
row = {k: r.get(k) for k in SCALAR_KEYS}
row["file"] = os.path.relpath(path, results_dir)
rows.append(row)
except Exception as e:
print(f" SKIP {path}: {e}")
return pd.DataFrame(rows)
def main():
p = argparse.ArgumentParser()
p.add_argument("--results_dir", type=str, required=True)
p.add_argument("--out", type=str, default=None)
p.add_argument("--recursive", action="store_true")
args = p.parse_args()
df = load_results(args.results_dir, recursive=args.recursive)
if len(df) == 0:
print("No results found.")
return
print(f"\n{len(df)} runs loaded")
print(df.to_string(index=False))
out = args.out or os.path.join(args.results_dir, "summary.csv")
df.to_csv(out, index=False)
print(f"\nSaved {out}")
if __name__ == "__main__":
main()
@@ -0,0 +1,371 @@
"""
Reacher trajectory distribution analysis figures.
Produces two figures for the paper:
1. Scatter grid: stationary marginal + per-delta 2D transition differences
and per-dim (z_t, z_{t+delta}) scatters, annotated with R² and rho.
2. rho-vs-SIGReg scatter: three panels (z_0, z_1, joint), colored by R²,
showing the dual constraint that identifiability requires both
rho off from 1 and approximately-Gaussian transition shape.
Usage:
python -m analysis.make_reacher_distributions \
--results_dir results/reacher \
--data_path data/reacher.h5 \
--out_dir figures/reacher
"""
import argparse
import json
from collections import defaultdict
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import torch
from scipy.stats import pearsonr
DELTAS = [1, 2, 4, 8, 16, 32, 64]
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
N_MAX = 100_000
# ═════════════════════════════════════════════════════════════════════════════
# SIGReg (EppsPulley) — matches LeJEPA Algorithm 1
# ═════════════════════════════════════════════════════════════════════════════
def _sigreg_nd(x, num_slices=64, n_knots=17, seed=0, device=DEVICE):
"""EP via random slicing on (N, K)."""
x = torch.as_tensor(np.asarray(x), dtype=torch.float32, device=device)
if x.dim() == 1:
x = x[:, None]
N, K = x.shape
g = torch.Generator(device=device).manual_seed(seed)
A = torch.randn(K, num_slices, generator=g, device=device)
A = A / A.norm(p=2, dim=0)
t = torch.linspace(-5, 5, n_knots, device=device)
phi = torch.exp(-0.5 * t ** 2)
zt = (x @ A).unsqueeze(2) * t
cm, sm = torch.cos(zt).mean(0), torch.sin(zt).mean(0)
err = ((cm - phi) ** 2 + sm ** 2) * phi
return (torch.trapz(err, t, dim=1) * N).mean().item()
def _sigreg_1d(x, n_knots=17, device=DEVICE):
"""EP directly on 1D (no slicing)."""
x = torch.as_tensor(np.asarray(x).reshape(-1), dtype=torch.float32, device=device)
N = x.shape[0]
t = torch.linspace(-5, 5, n_knots, device=device)
phi = torch.exp(-0.5 * t ** 2)
zt = x.unsqueeze(1) * t
cm, sm = torch.cos(zt).mean(0), torch.sin(zt).mean(0)
err = ((cm - phi) ** 2 + sm ** 2) * phi
return (torch.trapz(err, t) * N).item()
def _zscore(x):
return (x - x.mean(0, keepdims=True)) / (x.std(0, keepdims=True) + 1e-8)
def measure(x, n_draws=20, N_max=N_MAX, num_slices=64):
"""
SIGReg raw + zscored for joint (K-d) and per-dim marginals.
Averages over n_draws random subsamples / projections.
Returns dict mapping key -> (mean, std) over draws.
"""
x = np.asarray(x)
if x.ndim == 1:
x = x[:, None]
N, K = x.shape
rng = np.random.default_rng(0)
keys = ["joint_raw", "joint_zs"]
keys += [f"marg_{k}_raw" for k in range(K)]
keys += [f"marg_{k}_zs" for k in range(K)]
buf = {k: [] for k in keys}
for s in range(n_draws):
xs = x if N <= N_max else x[rng.choice(N, N_max, replace=False)]
xz = _zscore(xs)
buf["joint_raw"].append(_sigreg_nd(xs, num_slices, seed=s))
buf["joint_zs"].append(_sigreg_nd(xz, num_slices, seed=s))
for k in range(K):
buf[f"marg_{k}_raw"].append(_sigreg_1d(xs[:, k]))
buf[f"marg_{k}_zs"].append(_sigreg_1d(xz[:, k]))
return {k: (np.mean(v), np.std(v)) for k, v in buf.items()}
# ═════════════════════════════════════════════════════════════════════════════
# R² loading — per-seed best-lambda tuning, median across seeds
# ═════════════════════════════════════════════════════════════════════════════
def get_best_r2_per_delta(results_dir, agg="median"):
"""
For each (delta, seed), pick the lambda with best R²; aggregate seeds
with median (robust to outliers) or mean.
"""
by_dls = defaultdict(list)
for p in Path(results_dir).rglob("result.json"):
r = json.load(open(p))
if "delta" not in r or r.get("rho") is not None:
continue
by_dls[(r["delta"], r["lamb"], r.get("seed", 0))].append(r)
deltas = sorted({k[0] for k in by_dls})
lambs = sorted({k[1] for k in by_dls})
seeds = sorted({k[2] for k in by_dls})
agg_fn = np.median if agg == "median" else np.mean
best = {}
for delta in deltas:
per_seed = {"r2": [], "d0": [], "d1": [], "lamb": []}
for seed in seeds:
best_lamb, best_r2 = None, -np.inf
for lamb in lambs:
runs = by_dls.get((delta, lamb, seed), [])
if not runs:
continue
r2 = np.mean([r["r2_hz"] for r in runs])
if r2 > best_r2:
best_r2, best_lamb = r2, lamb
if best_lamb is None:
continue
runs = by_dls[(delta, best_lamb, seed)]
per_seed["r2"].append(np.mean([r["r2_hz"] for r in runs]))
per_seed["d0"].append(np.mean([r["r2_hz_per_dim"][0] for r in runs]))
per_seed["d1"].append(np.mean([r["r2_hz_per_dim"][1] for r in runs]))
per_seed["lamb"].append(best_lamb)
best[delta] = {
"r2": agg_fn(per_seed["r2"]),
"r2_dim0": agg_fn(per_seed["d0"]),
"r2_dim1": agg_fn(per_seed["d1"]),
"lamb": np.median(per_seed["lamb"]),
"n_seeds": len(per_seed["r2"]),
}
return best
def best_ou_rho(results_dir):
"""Return the rho of the OU run with highest mean R² across seeds/lambdas."""
grouped = defaultdict(list)
for p in Path(results_dir).rglob("result.json"):
r = json.load(open(p))
if "rho" not in r or r.get("rho") is None:
continue
grouped[(r["rho"], r["lamb"])].append(r)
if not grouped:
return None
best_rho, best_mean = None, -np.inf
for (rho, lamb), runs in grouped.items():
m = np.mean([r["r2_hz"] for r in runs])
if m > best_mean:
best_mean, best_rho = m, rho
return best_rho
# ═════════════════════════════════════════════════════════════════════════════
# Figure 1: scatter grid
# ═════════════════════════════════════════════════════════════════════════════
def make_scatter_grid(episodes, r2_dict, save_path, sub=1, s=1e-4):
"""
Left column: stationary marginal scatter of (z_0, z_1).
Top row, remaining columns: 2D transition-difference scatter per delta.
Bottom row, remaining columns: per-dim (z_t, z_{t+delta}) scatter.
Titles show R² and rho.
"""
fig = plt.figure(figsize=0.85 * np.array((1 + 3 * len(DELTAS), 5)))
gs = fig.add_gridspec(2, 2 + len(DELTAS))
# stationary marginal (spans both rows, first two cols)
ax = fig.add_subplot(gs[:2, :2])
ax.scatter(*episodes.reshape(-1, 2)[::sub].T, s=s * 10)
ax.set_title("Marginal")
ax.grid()
ax.set_xlabel(r"$z_0$ (shoulder)")
ax.set_ylabel(r"$z_1$ (wrist)")
for i, delta in enumerate(DELTAS):
# top row: 2D transition differences
ax = fig.add_subplot(gs[0, 2 + i])
transitions = episodes[:, delta:] - episodes[:, :-delta]
ax.scatter(*transitions.reshape(-1, 2)[::sub].T, s=s)
r2_d0 = r2_dict[delta]["r2_dim0"]
r2_d1 = r2_dict[delta]["r2_dim1"]
ax.set_title(r"$\Delta=$" + f"{delta}" + "\n"
r"$R^2=(%.2f, %.2f)$" % (r2_d0, r2_d1))
ax.grid()
# bottom row: per-dim (z_t, z_{t+delta}) with rho
ax = fig.add_subplot(gs[1, 2 + i])
a = episodes[:, delta:, 0].flatten()[::sub]
b = episodes[:, :-delta, 0].flatten()[::sub]
rho0 = pearsonr(a, b)[0]
ax.scatter(a, b, s=s)
c = episodes[:, delta:, 1].flatten()[::sub]
d = episodes[:, :-delta, 1].flatten()[::sub]
rho1 = pearsonr(c, d)[0]
ax.scatter(c, d, s=s)
ax.set_title(r"$\rho=(%.2f, %.2f)$" % (rho0, rho1))
ax.grid()
if i == 0:
ax.legend([r"$z_0$ (shoulder)", r"$z_1$ (wrist)"], loc="upper left")
plt.tight_layout()
plt.savefig(save_path, dpi=500, bbox_inches="tight")
plt.close()
print(f"Saved {save_path}")
# ═════════════════════════════════════════════════════════════════════════════
# Figure 2: rho vs SIGReg scatter (3 panels)
# ═════════════════════════════════════════════════════════════════════════════
def make_rho_vs_sigreg(trans, r2_dict, save_path, best_ou_rho_val=None,
gaussian_floor=1.2):
"""
Three panels (z_0, z_1, joint) showing rho vs SIGReg(zscored),
colored by R². Vertical line marks the best OU rho for reference.
"""
fig, axes = plt.subplots(1, 3, figsize=(16, 4.5))
names = [r"$z_0$ (shoulder)", r"$z_1$ (wrist)", "joint (avg across dims)"]
all_r2 = []
for d in DELTAS:
all_r2.append(r2_dict[d]["r2_dim0"])
all_r2.append(r2_dict[d]["r2_dim1"])
all_r2.append(r2_dict[d]["r2"])
vmin, vmax = min(all_r2), max(all_r2)
def _one(ax, rhos, sigs, errs, r2s, xlabel, ylabel, title):
if best_ou_rho_val is not None:
ax.axvline(best_ou_rho_val, color="crimson", lw=1.4, ls="--",
alpha=0.8, label=fr"best OU $\rho={best_ou_rho_val:.2f}$",
zorder=1)
sc = ax.scatter(rhos, sigs, c=r2s, cmap="viridis", s=140,
vmin=vmin, vmax=vmax,
edgecolors="black", linewidths=0.8, zorder=3)
ax.errorbar(rhos, sigs, yerr=errs, fmt="none", ecolor="gray",
alpha=0.5, zorder=2)
for d, r, s in zip(DELTAS, rhos, sigs):
ax.annotate(f"Δ={d}", (r, s), xytext=(6, 6),
textcoords="offset points", fontsize=9)
ax.axhline(gaussian_floor, color="red", lw=1, ls=":", alpha=0.6,
label="Gaussian floor")
ax.set_yscale("log")
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
ax.set_title(title)
ax.grid(alpha=0.3, which="both")
ax.legend(loc="lower left", fontsize=8)
return sc
# per-dim panels
for k in range(2):
rhos = np.array([trans[d]["rho"][k] for d in DELTAS])
sigs = np.array([trans[d]["sig"][f"marg_{k}_zs"][0] for d in DELTAS])
errs = np.array([trans[d]["sig"][f"marg_{k}_zs"][1] for d in DELTAS])
r2s = np.array([r2_dict[d][f"r2_dim{k}"] for d in DELTAS])
sc = _one(axes[k], rhos, sigs, errs, r2s,
xlabel=r"auto-correlation $\rho$",
ylabel="SIGReg (zscored, marginal)",
title=names[k])
plt.colorbar(sc, ax=axes[k], label=r"$R^2$")
# joint panel
rhos_avg = np.array([np.mean(trans[d]["rho"]) for d in DELTAS])
sigs_j = np.array([trans[d]["sig"]["joint_zs"][0] for d in DELTAS])
errs_j = np.array([trans[d]["sig"]["joint_zs"][1] for d in DELTAS])
r2s_avg = np.array([r2_dict[d]["r2"] for d in DELTAS])
sc = _one(axes[2], rhos_avg, sigs_j, errs_j, r2s_avg,
xlabel=r"avg auto-correlation $\bar{\rho}$",
ylabel="SIGReg (zscored, 2D joint)",
title=names[2])
plt.colorbar(sc, ax=axes[2], label=r"$R^2$ (avg)")
plt.tight_layout()
plt.savefig(save_path, dpi=300, bbox_inches="tight")
plt.close()
print(f"Saved {save_path}")
# ═════════════════════════════════════════════════════════════════════════════
# Measurement pipeline
# ═════════════════════════════════════════════════════════════════════════════
def compute_all_transitions(episodes, n_draws=20):
"""
For each delta, compute SIGReg stats and per-dim rho on the
transition-difference distribution z(t+delta) - z(t).
"""
trans = {}
for d in DELTAS:
diffs = (episodes[:, d:] - episodes[:, :-d]).reshape(-1, 2)
rho = np.array([
pearsonr(episodes[:, d:, k].flatten(),
episodes[:, :-d, k].flatten())[0]
for k in range(2)
])
trans[d] = {"rho": rho, "sig": measure(diffs, n_draws=n_draws)}
return trans
# ═════════════════════════════════════════════════════════════════════════════
# Main
# ═════════════════════════════════════════════════════════════════════════════
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--results_dir", type=str, default="results/reacher",
help="Directory with result.json files")
parser.add_argument("--data_path", type=str,
default="data/reacher.h5",
help="HDF5 file with 'qpos' and 'ep_len' datasets "
"(reshaped to (n_episodes, T, 2))")
parser.add_argument("--out_dir", type=str, default="figures/reacher")
parser.add_argument("--n_draws", type=int, default=20,
help="Random subsamples for SIGReg stats")
parser.add_argument("--agg", type=str, default="median",
choices=["median", "mean"],
help="How to aggregate R² across seeds")
args = parser.parse_args()
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
# load episodes (HDF5 with qpos + ep_len, matching notebook convention)
import h5py
with h5py.File(args.data_path, "r") as f:
qpos = np.array(f["qpos"])
ep_len = np.array(f["ep_len"])
T = int(ep_len[0])
episodes = qpos.reshape(-1, T, 2)
print(f"Loaded {len(episodes)} episodes of length {T} from {args.data_path}")
r2_dict = get_best_r2_per_delta(args.results_dir, agg=args.agg)
print(f"Loaded R² for deltas: {sorted(r2_dict.keys())}")
for d in DELTAS:
v = r2_dict[d]
print(f" Δ={d:2d} λ={v['lamb']:.0e} n={v['n_seeds']} "
f"R²={v['r2']:.3f} dim0={v['r2_dim0']:.3f} "
f"dim1={v['r2_dim1']:.3f}")
ou_rho = best_ou_rho(args.results_dir)
if ou_rho is not None:
print(f"Best OU rho: {ou_rho}")
# measure transitions
print("Computing SIGReg on transitions...")
trans = compute_all_transitions(episodes, n_draws=args.n_draws)
# figures
make_scatter_grid(episodes, r2_dict,
save_path=out_dir / "distribution.png")
make_rho_vs_sigreg(trans, r2_dict,
save_path=out_dir / "rho_vs_sigreg.png",
best_ou_rho_val=ou_rho)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,98 @@
"""
Ablation table: 4 mixings × 3 source distributions × {SIGReg, Whitening}.
Reports R²(h→z). The winner per (mixing, α) is bolded when the difference
is statistically significant (Welch's t-test, p < 0.05).
Usage:
python analysis/make_table_ablation.py --out figures/tab_ablation.tex
"""
import argparse, glob, json, os
import numpy as np
from scipy import stats
from collections import defaultdict
MIXINGS = [("spiral", "Spiral"), ("banana", "Banana"),
("sinusoid", "Sinusoid"), ("nvp", "NVP")]
ALPHAS = [
(None, r"Gaussian ($\alpha = 2$)"),
(0.25, r"Heavy tail / sparse ($\alpha = 1/4$)"),
(16.0, r"Light tail / uniform ($\alpha = 16$)"),
]
# METRIC = "r2_hz" # marginal
METRIC = "r2_hz_grid" # grid
P_THRESH = 0.05
def alpha_key(r):
if r.get("source_dist") == "gennorm":
return r.get("source_alpha")
return None
def fmt(vals, bold=False):
if not vals:
return r"$-$"
s = f"{np.mean(vals):.3f} \\pm {np.std(vals):.3f}"
return rf"$\mathbf{{{s}}}$" if bold else f"${s}$"
def main():
p = argparse.ArgumentParser()
p.add_argument("--dirs", nargs="+",
default=["results/2d",
"results/ablation_alpha_0.25",
"results/ablation_alpha_16"])
p.add_argument("--out", default="figures/tab_ablation.tex")
args = p.parse_args()
groups = defaultdict(list)
for d in args.dirs:
for path in sorted(glob.glob(os.path.join(d, "*.json"))):
with open(path) as f:
r = json.load(f)
if METRIC not in r:
continue
key = (r["mixing"], alpha_key(r), r["mode"])
groups[key].append(r[METRIC])
col_spec = "l" + " cc" * len(ALPHAS)
multicol = " & ".join(rf"\multicolumn{{2}}{{c}}{{{lab}}}" for _, lab in ALPHAS)
cmidrules = "".join(rf"\cmidrule(lr){{{2*i+2}-{2*i+3}}}" for i in range(len(ALPHAS)))
method_hdr = " & ".join(["SIGReg & Whitening"] * len(ALPHAS))
lines = [
rf"\begin{{tabular}}{{{col_spec}}}",
r"\toprule",
rf" & {multicol} \\",
cmidrules,
rf"Mixing & {method_hdr} \\",
r"\midrule",
]
for mix_key, mix_name in MIXINGS:
row = [mix_name]
for alpha_k, _ in ALPHAS:
v_lej = groups.get((mix_key, alpha_k, "lejepa"), [])
v_wht = groups.get((mix_key, alpha_k, "whiten"), [])
bold_lej = bold_wht = False
if len(v_lej) >= 2 and len(v_wht) >= 2:
_, pval = stats.ttest_ind(v_lej, v_wht, equal_var=False)
if pval < P_THRESH:
if np.mean(v_lej) > np.mean(v_wht):
bold_lej = True
else:
bold_wht = True
row.append(fmt(v_lej, bold=bold_lej))
row.append(fmt(v_wht, bold=bold_wht))
lines.append(" & ".join(row) + r" \\")
lines += [r"\bottomrule", r"\end{tabular}"]
out = "\n".join(lines)
print(out)
os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True)
with open(args.out, "w") as f:
f.write(out + "\n")
print(f"\nSaved {args.out}")
if __name__ == "__main__":
main()
@@ -0,0 +1,236 @@
"""
Generate LaTeX tables for the paper, matching scaling table style.
Usage:
python analysis/make_table_reacher.py --results_dir results/reacher
"""
import json
import argparse
import numpy as np
from pathlib import Path
from collections import defaultdict
def load_all_results(results_dir):
ou, traj = [], []
for p in Path(results_dir).rglob("result.json"):
r = json.load(open(p))
if "delta" in r and r.get("rho") is None:
traj.append(r)
elif "rho" in r:
ou.append(r)
return ou, traj
def best_lambda_per_x(results, x_key):
grouped = defaultdict(list)
for r in results:
grouped[(r[x_key], r["lamb"])].append(r)
best = {}
for x in sorted(set(k[0] for k in grouped)):
best_mean, best_lamb = -np.inf, None
for lamb in set(k[1] for k in grouped if k[0] == x):
m = np.mean([r["r2_hz"] for r in grouped[(x, lamb)]])
if m > best_mean:
best_mean, best_lamb = m, lamb
best[x] = {
"lamb": best_lamb,
"runs": grouped[(x, best_lamb)],
}
return best
def pm(vals, fmt=".2f"):
"""Format as value\\tiny{±std} matching paper style."""
m, s = np.mean(vals), np.std(vals)
return f"{m:{fmt}}\\tiny{{$\\pm${s:.0e}}}"
def make_combined_table(ou_results, traj_results):
ou_best = best_lambda_per_x(ou_results, "rho")
traj_best = best_lambda_per_x(traj_results, "delta")
lines = []
lines.append(r"\begin{table}[t]")
lines.append(r"\centering")
lines.append(r"\caption{")
lines.append(r" \textbf{Pixel-observation identifiability on DMC Reacher} "
r"(mean $\pm$ std over 3 seeds, best $\lambda$ per condition).")
lines.append(r" \textbf{Left:} OU process with Gaussian marginals. "
r"$R^2$ increases monotonically with $\rho$, reaching $0.95$ "
r"at $\rho = 0.99$, confirming linear identifiability from pixels.")
lines.append(r" \textbf{Right:} Real SAC trajectories with non-Gaussian marginals. "
r"The two joints have different autocorrelation timescales ($\rho_0 \neq \rho_1$) "
r"and the wrist has a near-uniform marginal distribution, "
r"leading to anisotropic and reduced identifiability.")
lines.append(r"}")
lines.append(r"\label{tab:reacher}")
lines.append(r"\resizebox{\textwidth}{!}{%")
lines.append(r"\begin{tabular}{r cc | r cc ccc}")
lines.append(r" \multicolumn{3}{c}{\textbf{OU (Gaussian)}} & "
r"\multicolumn{6}{c}{\textbf{Trajectory (non-Gaussian)}} \\")
lines.append(r"\cmidrule(lr){1-3} \cmidrule(lr){4-9}")
lines.append(r"$\rho$ & $R^2(z \to h)$ & $R^2(h \to z)$ & "
r"$\delta$ & $\rho_0$ & $\rho_1$ & "
r"$R^2(z \to h)$ & $R^2(h \to z_0)$ & $R^2(h \to z_1)$ \\")
lines.append(r"\midrule")
ou_rhos = sorted(ou_best.keys())
traj_deltas = sorted(traj_best.keys())
n_rows = max(len(ou_rhos), len(traj_deltas))
for i in range(n_rows):
# OU columns
if i < len(ou_rhos):
rho = ou_rhos[i]
runs = ou_best[rho]["runs"]
r2_zh = pm([r["r2_zh"] for r in runs])
r2_hz = pm([r["r2_hz"] for r in runs])
ou_str = f" {rho:.2f} & {r2_zh} & {r2_hz}"
else:
ou_str = r" & &"
# Traj columns
if i < len(traj_deltas):
delta = traj_deltas[i]
runs = traj_best[delta]["runs"]
rho0 = runs[0].get("rho_shoulder", None)
rho1 = runs[0].get("rho_wrist", None)
rho0_s = f"{rho0:.3f}" if rho0 is not None else "---"
rho1_s = f"{rho1:.3f}" if rho1 is not None else "---"
r2_zh = pm([r["r2_zh"] for r in runs])
r2_d0 = pm([r["r2_hz_per_dim"][0] for r in runs])
r2_d1 = pm([r["r2_hz_per_dim"][1] for r in runs])
traj_str = f"{delta} & {rho0_s} & {rho1_s} & {r2_zh} & {r2_d0} & {r2_d1}"
else:
traj_str = r"& & & & &"
lines.append(f"{ou_str} & {traj_str} \\\\")
lines.append(r"\bottomrule")
lines.append(r"\end{tabular}}")
lines.append(r"\vspace{5pt}")
lines.append(r"\vspace{-20pt}")
lines.append(r"\end{table}")
return "\n".join(lines)
def make_ou_table_standalone(ou_results):
"""Standalone OU table for appendix if needed."""
ou_best = best_lambda_per_x(ou_results, "rho")
lines = []
lines.append(r"\begin{table}[t]")
lines.append(r"\centering")
lines.append(r"\begin{tabular}{r c cc}")
lines.append(r"\toprule")
lines.append(r" \multicolumn{1}{c}{\textbf{Correlation}} & "
r"\multicolumn{1}{c}{\textbf{Regularizer}} & "
r"\multicolumn{2}{c}{\textbf{Linear identifiability}} \\")
lines.append(r"\cmidrule(lr){1-1} \cmidrule(lr){2-2} \cmidrule(lr){3-4}")
lines.append(r"$\rho$ & $\lambda$ & $R^2(z \to h)$ & $R^2(h \to z)$ \\")
lines.append(r"\midrule")
for rho in sorted(ou_best.keys()):
runs = ou_best[rho]["runs"]
lamb = ou_best[rho]["lamb"]
r2_zh = pm([r["r2_zh"] for r in runs])
r2_hz = pm([r["r2_hz"] for r in runs])
lines.append(f" {rho:.2f} & {lamb:.0e} & {r2_zh} & {r2_hz} \\\\")
lines.append(r"\bottomrule")
lines.append(r"\end{tabular}")
lines.append(r"\vspace{5pt}")
lines.append(r"\caption{")
lines.append(r" \textbf{OU (Gaussian) identifiability from pixels} "
r"(mean $\pm$ std over 3 seeds).")
lines.append(r" $R^2$ increases monotonically with temporal correlation $\rho$, "
r"reaching $0.95$ at $\rho = 0.99$.")
lines.append(r"}")
lines.append(r"\label{tab:reacher_ou}")
lines.append(r"\end{table}")
return "\n".join(lines)
def make_traj_table_standalone(traj_results):
"""Standalone traj table for appendix if needed."""
traj_best = best_lambda_per_x(traj_results, "delta")
lines = []
lines.append(r"\begin{table}[t]")
lines.append(r"\centering")
lines.append(r"\resizebox{\textwidth}{!}{%")
lines.append(r"\begin{tabular}{r cc c c cc}")
lines.append(r"\toprule")
lines.append(r" \multicolumn{1}{c}{\textbf{Stride}} & "
r"\multicolumn{2}{c}{\textbf{Autocorrelation}} & "
r"\multicolumn{1}{c}{\textbf{Regularizer}} & "
r"\multicolumn{1}{c}{\textbf{Identifiability}} & "
r"\multicolumn{2}{c}{\textbf{Per-dimension}} \\")
lines.append(r"\cmidrule(lr){1-1} \cmidrule(lr){2-3} \cmidrule(lr){4-4} "
r"\cmidrule(lr){5-5} \cmidrule(lr){6-7}")
lines.append(r"$\delta$ & $\rho_0$ & $\rho_1$ & $\lambda$ & "
r"$R^2(z \to h)$ & $R^2(h \to z_0)$ & $R^2(h \to z_1)$ \\")
lines.append(r"\midrule")
for delta in sorted(traj_best.keys()):
runs = traj_best[delta]["runs"]
lamb = traj_best[delta]["lamb"]
rho0 = runs[0].get("rho_shoulder", None)
rho1 = runs[0].get("rho_wrist", None)
rho0_s = f"{rho0:.3f}" if rho0 is not None else "---"
rho1_s = f"{rho1:.3f}" if rho1 is not None else "---"
r2_zh = pm([r["r2_zh"] for r in runs])
r2_d0 = pm([r["r2_hz_per_dim"][0] for r in runs])
r2_d1 = pm([r["r2_hz_per_dim"][1] for r in runs])
lines.append(f" {delta} & {rho0_s} & {rho1_s} & {lamb:.0e} "
f"& {r2_zh} & {r2_d0} & {r2_d1} \\\\")
lines.append(r"\bottomrule")
lines.append(r"\end{tabular}}")
lines.append(r"\vspace{5pt}")
lines.append(r"\caption{")
lines.append(r" \textbf{Trajectory (non-Gaussian) identifiability from pixels} "
r"(mean $\pm$ std over 3 seeds).")
lines.append(r" The shoulder ($z_0$) and wrist ($z_1$) have different "
r"autocorrelation timescales and marginal distributions, "
r"leading to anisotropic identifiability.")
lines.append(r"}")
lines.append(r"\label{tab:reacher_traj}")
lines.append(r"\end{table}")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--results_dir", type=str, default="results/reacher")
args = parser.parse_args()
ou, traj = load_all_results(args.results_dir)
print(f"Loaded {len(ou)} OU runs, {len(traj)} traj runs\n")
if ou and traj:
print("=" * 70)
print("COMBINED TABLE (for main text)")
print("=" * 70)
print(make_combined_table(ou, traj))
print()
if ou:
print("=" * 70)
print("OU TABLE (standalone, for appendix)")
print("=" * 70)
print(make_ou_table_standalone(ou))
print()
if traj:
print("=" * 70)
print("TRAJ TABLE (standalone, for appendix)")
print("=" * 70)
print(make_traj_table_standalone(traj))
if __name__ == "__main__":
main()
@@ -0,0 +1,272 @@
"""
Generate LaTeX tables from scaling results.
Emits four tables:
1. tab:scaling-comparison (main text)
Three-way R^2(h -> z) comparison: SIGReg, VICReg, InfoNCE.
2. tab:scaling-sigreg (appendix)
Detailed per-method table for SIGReg: mixing difficulty,
linear identifiability (both directions), orthogonality error,
alignment loss, SIGReg loss.
3. tab:scaling-vicreg (appendix)
Same structure as SIGReg, but with whitening loss column.
4. tab:scaling-infonce (appendix)
Same structure as SIGReg, but with InfoNCE loss column.
The three appendix tables let each method tell its own failure-mode story:
- SIGReg / VICReg: orthogonality error grows gradually with N
- InfoNCE: regularizer loss explodes / fails to converge at high N
Usage:
python analysis/make_table_scaling.py --results_dir results/scaling/
"""
import argparse, glob, json, os
import numpy as np
from collections import defaultdict
import math
# ──────────────────────────────────────────────────────────────────────────
# Number formatting helpers (shared)
# ──────────────────────────────────────────────────────────────────────────
def column_scale(stds, threshold=0.05):
valid = [s for s in stds if s > 0 and not np.isnan(s)]
if not valid:
return 0
m = max(valid)
if m >= threshold:
return 0
return int(np.floor(np.log10(m))) - 1
def fmt_std(s, k):
if np.isnan(s):
return "---"
scaled = s if k == 0 else s / (10 ** k)
if scaled >= 10:
return f"{math.floor(scaled):.0f}"
elif scaled >= 1:
return f"{math.floor(scaled * 10) / 10:.1f}"
else:
return f"{math.floor(scaled * 100) / 100:.2f}"
def scale_header(k):
if k == 0:
return r"{\scriptsize $\pm$std}"
return rf"{{\scriptsize $\pm$std\,$\times 10^{{{k}}}$}}"
def fmt_cell(m, s, dec, k):
if np.isnan(m):
return "---"
factor = 10 ** dec
m_floored = math.floor(m * factor) / factor
return rf"{m_floored:.{dec}f}\tiny{{$\pm${fmt_std(s, k)}}}"
# ──────────────────────────────────────────────────────────────────────────
# Main-text: three-way comparison on R^2(h -> z)
# ──────────────────────────────────────────────────────────────────────────
def render_table_comparison(by_key, dims):
modes = ("lejepa", "whiten", "infonce")
def agg(N, mode, key):
rs = by_key.get((N, mode), [])
vals = [r[key] for r in rs if r.get(key) is not None]
if not vals:
return float("nan"), float("nan")
return float(np.mean(vals)), float(np.std(vals))
def agg_mixing(N, key):
all_rs = sum((by_key.get((N, m), []) for m in modes), [])
vals = [r[key] for r in all_rs if r.get(key) is not None]
if not vals:
return float("nan"), float("nan")
return float(np.mean(vals)), float(np.std(vals))
mix_scale = column_scale([agg_mixing(N, "r2_xz")[1] for N in dims])
r2_scales = {m: column_scale([agg(N, m, "r2_hz")[1] for N in dims]) for m in modes}
print(r"\begin{table}[t]")
print(r"\centering")
print(r"\caption{\textbf{Scaling Comparison Across Regularizers} (mean $\pm$ std, 5 seeds). "
r"All three Gaussianity-enforcing objectives are tested on the same RealNVP mixing "
r"with matched encoder. SIGReg and VICReg (batch-statistic estimators) maintain "
r"$R^2 > 0.999$ up to $N{=}1024$, consistent with Thm.~\ref{thm:approx}. "
r"InfoNCE (pair-based) matches at low $N$ but degrades at scale under fixed kernel "
r"width $\sigma{=}1$, illustrating the per-dimension tuning required by pair-based estimators. "
r"Per-method details (orthogonality, regularizer loss) in App.~\ref{app:scaling}, "
r"Tabs.~\ref{tab:scaling-sigreg}--\ref{tab:scaling-infonce}.}")
print(r"\label{tab:scaling-comparison}")
print(r"\begin{tabular}{r c ccc}")
print(r"\toprule")
print(r" & \textbf{Mixing} & \multicolumn{3}{c}{\textbf{Linear identifiability} $R^2(h \to z)$} \\")
print(r"\cmidrule(lr){3-5}")
print(r"$N$ & $R^2(x \to z)$ & SIGReg & VICReg & InfoNCE \\")
sub_cells = [
scale_header(mix_scale),
scale_header(r2_scales["lejepa"]),
scale_header(r2_scales["whiten"]),
scale_header(r2_scales["infonce"]),
]
print(" & " + " & ".join(sub_cells) + r" \\")
print(r"\midrule")
for N in dims:
cells = [rf"{N}"]
m, s = agg_mixing(N, "r2_xz")
cells.append(fmt_cell(m, s, 3, mix_scale))
for mode in modes:
m_, s_ = agg(N, mode, "r2_hz")
cells.append(fmt_cell(m_, s_, 6, r2_scales[mode]))
print(" " + " & ".join(cells) + r" \\")
print(r"\bottomrule")
print(r"\end{tabular}")
print(r"\end{table}")
print()
# ──────────────────────────────────────────────────────────────────────────
# Appendix: detailed per-method table
# ──────────────────────────────────────────────────────────────────────────
# Each method has its own native regularizer loss key.
METHOD_SPECS = {
"lejepa": {
"name": "SIGReg",
"label": "tab:scaling-sigreg",
"reg_loss_key": "final_sigreg",
"reg_loss_label": "SIGReg",
"reg_loss_dec": 2,
"caption_tail": (
"The RealNVP mixing is consistently nonlinear across dimensions "
r"($R^2(x \to z) < 1$). The learned model nonetheless recovers the "
"true latents at all dimensions. Training losses are stable; "
r"orthogonality error grows gradually with $N$."
),
},
"whiten": {
"name": "VICReg",
"label": "tab:scaling-vicreg",
"reg_loss_key": "final_whiten",
"reg_loss_label": "Whitening",
"reg_loss_dec": 4,
"caption_tail": (
"VICReg matches SIGReg on linear identifiability across all dimensions; "
r"orthogonality error grows similarly with $N$. The whitening loss is "
"stable and small throughout."
),
},
"infonce": {
"name": "InfoNCE",
"label": "tab:scaling-infonce",
"reg_loss_key": "final_loss",
"reg_loss_label": "InfoNCE",
"reg_loss_dec": 3,
"caption_tail": (
r"InfoNCE matches the batch-statistic methods at low $N$ but degrades "
"at scale under a fixed Gaussian kernel width. The InfoNCE column "
"shows the total contrastive loss (not decomposable into alignment "
"plus regularizer), which inflates with $N$ as the per-dimension "
"kernel-width assumption breaks down."
),
},
}
def render_table_per_method(by_key, dims, mode):
spec = METHOD_SPECS[mode]
rows = sum((by_key.get((N, mode), []) for N in dims), [])
if not rows:
print(f"% No rows for mode={mode}, skipping {spec['label']}")
return
cols = [
("r2_zx", 3, r"$R^2(z \to x)$"),
("r2_xz", 3, r"$R^2(x \to z)$"),
("r2_zh", 5, r"$R^2(z \to h)$"),
("r2_hz", 5, r"$R^2(h \to z)$"),
("orth_err_normalized", 3, r"$\|\hat Q^\top \hat Q - I\|_F / \sqrt{N}$"),
("final_align", 4, "Align"),
(spec["reg_loss_key"], spec["reg_loss_dec"], spec["reg_loss_label"]),
]
agg = {}
for N in dims:
rs = [r for r in rows if r["N"] == N]
for key, _, _ in cols:
vals = [r[key] for r in rs if r.get(key) is not None]
agg[(N, key)] = ((float(np.mean(vals)), float(np.std(vals)))
if vals else (float("nan"), float("nan")))
scales = {key: column_scale([agg[(N, key)][1] for N in dims])
for key, _, _ in cols}
print(r"\begin{table}[t]")
print(r"\centering")
print(rf"\caption{{\textbf{{Scaling Experiment ({spec['name']})}} "
r"(mean $\pm$ std, 5 seeds). " + spec["caption_tail"] + "}")
print(rf"\label{{{spec['label']}}}")
print(r"\resizebox{\textwidth}{!}{%")
print(r"\begin{tabular}{r cc cc c cc}")
print(r"\toprule")
print(r" \multicolumn{1}{c}{\textbf{Latents}} "
r"& \multicolumn{2}{c}{\textbf{Mixing difficulty}} "
r"& \multicolumn{2}{c}{\textbf{Linear identifiability}} "
r"& \multicolumn{1}{c}{\textbf{Orthogonality}} "
rf"& \multicolumn{{2}}{{c}}{{\textbf{{{spec['name']} losses}}}} \\")
print(r"\cmidrule(lr){1-1} \cmidrule(lr){2-3} \cmidrule(lr){4-5} "
r"\cmidrule(lr){6-6} \cmidrule(lr){7-8}")
print("$N$ & " + " & ".join(label for _, _, label in cols) + r" \\")
print(" & " + " & ".join(scale_header(scales[key]) for key, _, _ in cols) + r" \\")
print(r"\midrule")
for N in dims:
log2N = int(np.log2(N))
cells = [rf"$2^{{{log2N}}}$"]
for key, dec, _ in cols:
m, s = agg[(N, key)]
cells.append(fmt_cell(m, s, dec, scales[key]))
print(" " + " & ".join(cells) + r" \\")
print(r"\bottomrule")
print(r"\end{tabular}}")
print(r"\end{table}")
print()
# ──────────────────────────────────────────────────────────────────────────
# Driver
# ──────────────────────────────────────────────────────────────────────────
def main():
p = argparse.ArgumentParser()
p.add_argument("--results_dir", default="results/scaling/")
args = p.parse_args()
by_key = defaultdict(list)
for path in sorted(glob.glob(os.path.join(args.results_dir, "*.json"))):
with open(path) as f:
r = json.load(f)
mode = r.get("mode", "lejepa")
by_key[(r["N"], mode)].append(r)
dims = sorted({N for (N, _) in by_key})
modes_present = sorted({mode for (_, mode) in by_key})
print(f"% Found modes: {modes_present}, dims: {dims}\n")
print(r"% ── Main text: three-way comparison ──")
render_table_comparison(by_key, dims)
print(r"% ── Appendix: detailed per-method tables ──")
for mode in ("lejepa", "whiten", "infonce"):
render_table_per_method(by_key, dims, mode)
if __name__ == "__main__":
main()
@@ -0,0 +1,82 @@
"""
2D illustration figure: one row per mixing (z, g(z), h(z)).
Picks best seed per mixing by final_loss.
Usage:
python analysis/plot_2d.py --results_dir results/2d/ --out figures/
"""
import argparse, os, glob
import torch
import numpy as np
import matplotlib.pyplot as plt
import colorsys
MIXING_ORDER = ["spiral", "banana", "sinusoid", "nvp"]
def make_colors(z):
x, y = z[:, 0], z[:, 1]
angles = np.arctan2(y, x)
radii = np.sqrt(x**2 + y**2)
hue = (angles + np.pi) / (2 * np.pi)
lightness = 0.3 + 0.4 * (radii / (radii.max() + 1e-8))
saturation = np.full_like(hue, 0.85)
return [colorsys.hls_to_rgb(h, l, s) for h, l, s in zip(hue, lightness, saturation)]
def load_best_per_mixing(results_dir):
"""Load all results, pick best lejepa run per mixing by final_loss."""
files = sorted(glob.glob(os.path.join(results_dir, "*.pt")))
by_mix = {}
for path in files:
r = torch.load(path, map_location="cpu", weights_only=False)
if r.get("mode") != "lejepa":
continue
mix = r["mixing"]
if mix not in by_mix or r["final_loss"] < by_mix[mix]["final_loss"]:
by_mix[mix] = r
return by_mix
def main():
p = argparse.ArgumentParser()
p.add_argument("--results_dir", default="results/2d/")
p.add_argument("--out", default="figures/")
args = p.parse_args()
os.makedirs(args.out, exist_ok=True)
best = load_best_per_mixing(args.results_dir)
s, lim = 5, 4
for mix_name in MIXING_ORDER:
if mix_name not in best:
print(f"Missing {mix_name}"); continue
res = best[mix_name]
z, x, h = res["z"], res["x"], res["h"]
colors = make_colors(z)
fig, axes = plt.subplots(1, 3, figsize=(9, 3))
for i, (ax, data, labels) in enumerate(zip(
axes, [z, x, h],
[("True Latent 0", "True Latent 1"),
("Observation 0", "Observation 1"),
("Learned Latent 0", "Learned Latent 1")],
)):
ax.scatter(data[:, 0], data[:, 1], c=colors, s=s, linewidths=0)
ax.set_xlabel(labels[0])
ax.set_ylabel(labels[1])
ax.grid(alpha=0.3)
if i == 0 or i == 2:
ax.set_xlim(-4, 4)
ax.set_ylim(-4, 4)
fig.tight_layout()
out_path = os.path.join(args.out, f"fig_2d_{mix_name}.jpg")
fig.savefig(out_path, bbox_inches="tight", dpi=500)
print(f"Saved {out_path}")
plt.close()
if __name__ == "__main__":
main()
@@ -0,0 +1,91 @@
"""
Laplace ablation: 4 rows (mixings) x 4 cols (z, g(z), h_lejepa, h_whiten).
Picks best seed per (mixing, mode) by final_loss.
Usage:
python analysis/plot_ablation.py --results_dir results/ablation_alpha_0.25/ --prefix ablation_alpha_0.25 --out figures/
python analysis/plot_ablation.py --results_dir results/ablation_alpha_16/ --prefix ablation_alpha_16 --out figures/
"""
import argparse, os, glob
import torch
import numpy as np
import matplotlib.pyplot as plt
import colorsys
MIXING_ORDER = ["spiral", "banana", "sinusoid", "nvp"]
def make_colors(z):
x, y = z[:, 0], z[:, 1]
angles = np.arctan2(y, x)
radii = np.sqrt(x**2 + y**2)
hue = (angles + np.pi) / (2 * np.pi)
lightness = 0.3 + 0.4 * (radii / (radii.max() + 1e-8))
saturation = np.full_like(hue, 0.85)
return [colorsys.hls_to_rgb(h, l, s) for h, l, s in zip(hue, lightness, saturation)]
def load_best(results_dir):
files = sorted(glob.glob(os.path.join(results_dir, "*.pt")))
by_key = {}
for path in files:
r = torch.load(path, map_location="cpu", weights_only=False)
key = r["run_name"] # e.g. "spiral_lejepa"
if key not in by_key or r["final_loss"] < by_key[key]["final_loss"]:
by_key[key] = r
return by_key
def main():
p = argparse.ArgumentParser()
p.add_argument("--results_dir", default="results/ablation/")
p.add_argument("--out", default="figures/")
p.add_argument("--prefix", default="ablation",
help="Output filename prefix (e.g. ablation_alpha_0.25)")
args = p.parse_args()
os.makedirs(args.out, exist_ok=True)
best = load_best(args.results_dir)
s, lim = 5, 4
for mix in MIXING_ORDER:
lej_key = f"{mix}_lejepa"
wht_key = f"{mix}_whiten"
if lej_key not in best or wht_key not in best:
print(f"Missing {mix}"); continue
lej, wht = best[lej_key], best[wht_key]
z = lej["z"]
colors = make_colors(z)
fig, axes = plt.subplots(1, 4, figsize=(12, 3))
col_labels = [
("True Latent 0", "True Latent 1"),
("Observation 0", "Observation 1"),
("Learned (LeJEPA) 0", "Learned (LeJEPA) 1"),
("Learned (Whiten) 0", "Learned (Whiten) 1"),
]
panels = [z, lej["x"], lej["h"], wht["h"]]
# r2s = [None, None, lej["r2_hz"], wht["r2_hz"]]
r2s = [None, None, lej["r2_hz_grid"], wht["r2_hz_grid"]]
for i, (ax, data, labels, r2) in enumerate(zip(axes, panels, col_labels, r2s)):
ax.scatter(data[:, 0], data[:, 1], c=colors, s=s, linewidths=0)
ax.set_xlabel(labels[0])
ax.set_ylabel(labels[1])
ax.grid(alpha=0.3)
if r2 is not None:
ax.text(0.95, 0.05, f"$R^2$={r2:.3f}", transform=ax.transAxes,
ha="right", va="bottom", fontsize=9,
bbox=dict(boxstyle="round,pad=0.2", fc="white", alpha=0.8))
fig.tight_layout()
out_path = os.path.join(args.out, f"fig_{args.prefix}_{mix}.jpg")
fig.savefig(out_path, bbox_inches="tight", dpi=500)
print(f"Saved {out_path}")
plt.close()
if __name__ == "__main__":
main()
@@ -0,0 +1,237 @@
"""
Bound verification and grid search plots.
bound_verification.pdf — pooled across experiments (main paper)
bound_decomposition.pdf — grid search only (appendix)
heatmap_*.pdf — grid search only (appendix)
Usage:
python analysis/plot_bound.py \
--results_dirs results/grid results/2d results/scaling results/gennorm \
--out figures/
"""
import argparse, os, glob, json
import numpy as np
import matplotlib.pyplot as plt
import matplotlib as mpl
# mpl.rcParams.update({
# "font.size": 10, "axes.titlesize": 11, "axes.labelsize": 10,
# "figure.dpi": 200, "font.family": "serif",
# })
EXPERIMENT_COLORS = {
"2d": "tab:blue",
"grid": "tab:red",
"scaling": "tab:green",
"reacher": "tab:purple",
"gennorm": "tab:orange",
}
EXPERIMENT_MARKERS = {
"2d": "o",
"grid": "D",
"scaling": "s",
"reacher": "v",
"gennorm": "^",
}
EXPERIMENT_ORDER = ["grid", "scaling", "2d", "reacher", "gennorm"]
def is_valid_run(r, path=""):
"""SIGReg + Gaussian source + non-degenerate (encoder actually learned)."""
if r.get("mode") != "lejepa":
return False
sd = r.get("source_dist", "gaussian")
if sd == "gennorm" and abs(r.get("source_alpha", 0) - 2.0) > 1e-6:
return False
if sd not in ("gaussian", "gennorm"):
return False
# Drop degenerate runs where the encoder failed to learn
if r.get("r2_hz", 0) < 0.5:
return False
return True
def load_all(dirs):
data = []
for d in dirs:
for path in sorted(glob.glob(os.path.join(d, "**", "*.json"), recursive=True)):
with open(path) as f:
r = json.load(f)
if not isinstance(r, dict):
continue
if r.get("approx_bound") is None or r.get("procrustes_mse") is None:
continue
if not is_valid_run(r, path):
continue
data.append(r)
print(f"Loaded {len(data)} Gaussian-source runs")
return data
def main():
p = argparse.ArgumentParser()
p.add_argument("--results_dirs", nargs="+", required=True)
p.add_argument("--out", default="figures/")
args = p.parse_args()
os.makedirs(args.out, exist_ok=True)
data = load_all(args.results_dirs)
if not data:
print("No results."); return
# ══════════════════════════════════════════════════════════════════════
# Bound verification (main paper, single panel)
# ══════════════════════════════════════════════════════════════════════
errors = np.array([d["procrustes_mse"] for d in data])
bounds = np.array([d["approx_bound"] for d in data])
experiments = [d["experiment"] for d in data]
fig, ax = plt.subplots(figsize=0.8 * np.array((3, 3)))
pos = (errors > 0) & (bounds > 0)
lo = min(errors[pos].min(), bounds[pos].min()) * 0.3
hi = max(errors.max(), bounds.max()) * 3
pts = np.logspace(np.log10(lo), np.log10(hi), 200)
ax.fill_between(pts, lo * 0.1, pts, color='#c8e6c9', alpha=0.35, zorder=0)
ax.fill_between(pts, pts, hi * 10, color='#ffcdd2', alpha=0.35, zorder=0)
for exp in EXPERIMENT_ORDER:
mask = np.array([e == exp for e in experiments])
if not mask.any():
continue
ax.scatter(bounds[mask], errors[mask],
c=EXPERIMENT_COLORS[exp],
marker=EXPERIMENT_MARKERS[exp],
s=32, alpha=0.7, edgecolors='k', linewidths=0.3,
label=exp, zorder=3)
ax.plot([lo, hi], [lo, hi], 'k--', alpha=0.5, linewidth=0.8)
ax.set_xscale('log'); ax.set_yscale('log')
ax.set_xlim(lo, hi)
ax.set_ylim(lo, hi)
ax.set_xlabel("Recovery error bound")
ax.set_ylabel("Recovery error")
ax.legend(fontsize=7, loc='upper left', framealpha=0.9)
ax.grid(alpha=0.3)
ax.set_aspect("equal")
fig.tight_layout()
fig.savefig(os.path.join(args.out, "bound_verification.pdf"), bbox_inches="tight")
print("Saved bound_verification.pdf")
plt.close()
# ══════════════════════════════════════════════════════════════════════
# Grid-specific plots (appendix)
# ══════════════════════════════════════════════════════════════════════
grid_dir = None
for d in args.results_dirs:
if "grid" in d:
grid_dir = d
break
if grid_dir is None:
print("No grid dir found, skipping decomposition and heatmaps.")
return
grid_data = []
for path in sorted(glob.glob(os.path.join(grid_dir, "*.json"))):
with open(path) as f:
grid_data.append(json.load(f))
if not grid_data:
print("No grid results."); return
errors_g = np.array([d["procrustes_mse"] for d in grid_data])
epsilons = np.array([d["epsilon"] for d in grid_data])
deltas = np.array([d["delta"] for d in grid_data])
rhos = np.array([d["rho"] for d in grid_data])
lambs = np.array([d["lamb"] for d in grid_data])
LAMB_MARKERS = {1e-6: 'h', 1e-5: 'H', 1e-4: 'p',
1e-3: 'o', 5e-3: 's', 1e-2: 'D', 5e-2: '^', 1e-1: 'v', 5e-1: 'P'}
RHO_MARKERS = {0.3: 'o', 0.5: 's', 0.7: 'D', 0.8: '^', 0.9: 'v', 0.95: 'P', 0.99: 'X'}
def scatter_by_lamb(ax, xvals, yvals):
cmap = plt.cm.viridis
norm = mpl.colors.Normalize(vmin=min(rhos), vmax=max(rhos))
for lamb in sorted(set(lambs)):
mask = lambs == lamb
ax.scatter(xvals[mask], yvals[mask], c=rhos[mask], cmap=cmap, norm=norm,
marker=LAMB_MARKERS.get(lamb, 'o'), s=30, alpha=0.8,
edgecolors='k', linewidths=0.3, label=f"$\\lambda$={lamb:.0e}")
return cmap, norm, r"Correlation [$\rho$]"
def scatter_by_rho(ax, xvals, yvals):
log_lambs = np.log10(lambs)
cmap = plt.cm.plasma
norm = mpl.colors.Normalize(vmin=log_lambs.min(), vmax=log_lambs.max())
for rho_val in sorted(set(rhos)):
mask = rhos == rho_val
ax.scatter(xvals[mask], yvals[mask], c=log_lambs[mask], cmap=cmap, norm=norm,
marker=RHO_MARKERS.get(rho_val, 'o'), s=30, alpha=0.8,
edgecolors='k', linewidths=0.3, label=f"$\\rho$={rho_val:.2f}")
return cmap, norm, r"Regularization [$\log_{10}\lambda$]"
# ── Decomposition ──
ylabel = r"$\min_{Q \in O(n)} \mathbb{E}[\|h(z) - Qz\|^2]$"
fig, axes = plt.subplots(2, 2, figsize=(8, 6))
x_configs = [
(epsilons, r"$\varepsilon = \|\mathrm{Cov}(h(z)) - I\|_F$", r"Error vs $\varepsilon$"),
(deltas, r"$\delta = \mathcal{L}(h) - 2(1{-}\rho)\,\mathrm{tr}(\Sigma)$", r"Error vs $\delta$"),
]
for row, scatter_fn in enumerate([scatter_by_lamb, scatter_by_rho]):
for col, (xvals, xlabel, title) in enumerate(x_configs):
ax = axes[row, col]
cmap, norm, cbar_label = scatter_fn(ax, xvals, errors_g)
ax.set_xlabel(xlabel); ax.set_ylabel(ylabel); ax.set_title(title)
sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm); sm.set_array([])
plt.colorbar(sm, ax=ax, label=cbar_label, shrink=0.85)
ax.legend(fontsize=5.5, loc='upper right', framealpha=0.9)
ax.grid()
fig.tight_layout()
fig.savefig(os.path.join(args.out, "bound_decomposition.pdf"), bbox_inches="tight")
print("Saved bound_decomposition.pdf")
plt.close()
# ── Heatmaps ──
unique_lambs = sorted(set(lambs))
unique_rhos = sorted(set(rhos))
for metric_key, title, cmap_name in [
("r2_hz", "Linear $R^2$ (h -> z)", "viridis"),
("orth_err_normalized", "Orth. error normalized", "viridis_r"),
]:
grid = np.full((len(unique_lambs), len(unique_rhos)), np.nan)
counts = np.zeros_like(grid)
for r in grid_data:
li = unique_lambs.index(r["lamb"])
ri = unique_rhos.index(r["rho"])
val = r.get(metric_key)
if val is not None:
if np.isnan(grid[li, ri]):
grid[li, ri] = 0
grid[li, ri] += val
counts[li, ri] += 1
grid = np.where(counts > 0, grid / counts, np.nan)
fig, ax = plt.subplots(figsize=(7, 5))
im = ax.imshow(grid, aspect="auto", origin="lower", cmap=cmap_name)
plt.colorbar(im, ax=ax, label=title)
ax.set_xticks(range(len(unique_rhos)))
ax.set_xticklabels([f"{r:.2f}" for r in unique_rhos])
ax.set_yticks(range(len(unique_lambs)))
ax.set_yticklabels([f"{l:.0e}" for l in unique_lambs])
ax.set_xlabel(r"$\rho$"); ax.set_ylabel(r"$\lambda$")
ax.set_title(title)
for i in range(len(unique_lambs)):
for j in range(len(unique_rhos)):
if not np.isnan(grid[i, j]):
ax.text(j, i, f"{grid[i,j]:.3f}", ha="center", va="center", fontsize=6)
fig.tight_layout()
safe = metric_key.replace(".", "_")
fig.savefig(os.path.join(args.out, f"heatmap_{safe}.pdf"), bbox_inches="tight")
print(f"Saved heatmap_{safe}.pdf")
plt.close()
if __name__ == "__main__":
main()
@@ -0,0 +1,202 @@
"""
Generalized normal sweep across 4 mixings.
Emits three figures:
1. fig_gennorm.pdf — 4-panel R^2(h -> z) vs alpha for SIGReg/VICReg/InfoNCE
2. fig_gennorm_orth.pdf — 4-panel orthogonality error vs alpha (unconstrained ylim
to show InfoNCE excursions off the chart)
3. fig_gennorm_main.pdf — single-panel spiral-only headline figure for main text,
matching the Fig.~4b style of the paper
Usage:
python analysis/plot_gennorm.py --results_dir results/gennorm/ --out figures/
"""
import argparse, glob, json, os, re
import numpy as np
import matplotlib.pyplot as plt
from collections import defaultdict
MIXINGS = [("spiral", "Spiral"), ("banana", "Banana"),
("sinusoid", "Sinusoid"), ("nvp", "NVP")]
MODES = ("lejepa", "whiten", "infonce")
COLORS = {"lejepa": "#d62728", "whiten": "#1f77b4", "infonce": "#2ca02c"}
LABELS = {"lejepa": "SIGReg", "whiten": "VICReg", "infonce": "InfoNCE"}
YLABELS = {
"r2_hz_grid": r"Linear identifiability $R^2(h \to z)$",
"r2_hz": r"Linear identifiability $R^2(h \to z)$",
"orth_err_normalized_grid": r"Orthogonality error $\|\hat Q^\top \hat Q - I\|_F / \sqrt{n}$",
"orth_err_normalized": r"Orthogonality error $\|\hat Q^\top \hat Q - I\|_F / \sqrt{n}$",
}
# Sensible y-limits per metric. R^2 is bounded in [0,1] so we clip there.
# Orthogonality error is unbounded above (Whitening/InfoNCE off-Gaussian can spike
# into the tens), so we use log scale and let matplotlib autoscale.
YLIMS = {
"r2_hz_grid": (-0.05, 1.05),
"r2_hz": (-0.05, 1.05),
"orth_err_normalized_grid": None, # autoscale; log scale (see YSCALES) handles outliers
"orth_err_normalized": None,
}
# Y-axis scale per metric. Linear by default; log for orth error to compress
# off-Gaussian excursions while still showing structure near zero.
YSCALES = {
"r2_hz_grid": "linear",
"r2_hz": "linear",
"orth_err_normalized_grid": "log",
"orth_err_normalized": "log",
}
# ──────────────────────────────────────────────────────────────────────────
# Data loading
# ──────────────────────────────────────────────────────────────────────────
def load_groups(results_dir, metric):
"""groups[(mixing, mode, alpha)] -> list of seed values for `metric`."""
groups = defaultdict(list)
for path in sorted(glob.glob(os.path.join(results_dir, "*.json"))):
with open(path) as f:
r = json.load(f)
if r.get("experiment") != "gennorm":
continue
alpha = r.get("source_alpha")
if alpha is None:
m = re.search(r"alpha=([\d.]+)", r.get("run_name", ""))
if m:
alpha = float(m.group(1))
if alpha is None or metric not in r:
continue
groups[(r["mixing"], r["mode"], alpha)].append(r[metric])
return groups
def curve(groups, mixing, mode):
alphas = sorted({a for (mx, m, a) in groups if mx == mixing and m == mode})
mu = np.array([np.mean(groups[(mixing, mode, a)]) for a in alphas])
sd = np.array([np.std (groups[(mixing, mode, a)]) for a in alphas])
return np.array(alphas), mu, sd
# ──────────────────────────────────────────────────────────────────────────
# Figure 1 & 2: 4-panel grids (one per metric)
# ──────────────────────────────────────────────────────────────────────────
def plot_grid(groups, metric, out_path):
fig, axes = plt.subplots(1, 4, figsize=(13, 3.0), sharey=True)
use_log = YSCALES[metric] == "log"
for ax, (mix_key, mix_name) in zip(axes, MIXINGS):
for mode in MODES:
alphas, mu, sd = curve(groups, mix_key, mode)
if len(alphas) == 0:
continue
ax.plot(alphas, mu, marker="o", ms=5, lw=1.8,
color=COLORS[mode], label=LABELS[mode], zorder=3)
# On log axes, clip the lower edge of the band away from zero
# so fill_between doesn't disappear / warn.
lower = mu - sd
if use_log:
lower = np.maximum(lower, 1e-3)
ax.fill_between(alphas, lower, mu + sd,
color=COLORS[mode], alpha=0.2, zorder=2)
ax.set_xscale("log", base=2)
if use_log:
ax.set_yscale("log")
ax.axvline(2.0, color="black", lw=0.7, ls="--", alpha=0.6, zorder=1)
ax.set_xlabel(r"Source shape $\alpha$")
ax.set_title(mix_name)
if YLIMS[metric] is not None:
ax.set_ylim(*YLIMS[metric])
ax.grid(alpha=0.3, which="both" if use_log else "major")
axes[0].set_ylabel(YLABELS[metric])
axes[-1].legend(frameon=False, loc="best")
fig.tight_layout()
fig.savefig(out_path, bbox_inches="tight")
print(f"Saved {out_path}")
plt.close(fig)
# ──────────────────────────────────────────────────────────────────────────
# Figure 3: main-text single-panel headline (spiral mixing, all three methods)
# ──────────────────────────────────────────────────────────────────────────
def plot_main_panel(groups, out_path):
"""Single-panel spiral-only figure to sit next to the bound-verification panel
in the main-text composite figure (Fig.~4b in the paper)."""
FIGSIZE = 0.8 * np.array((4.0, 3.0))
LW = 2.0
MS = 6
FONTSIZE = 11
rc_saved = plt.rcParams.copy()
plt.rcParams.update({
"font.size": FONTSIZE,
"axes.labelsize": FONTSIZE,
"xtick.labelsize": FONTSIZE - 1,
"ytick.labelsize": FONTSIZE - 1,
"legend.fontsize": FONTSIZE - 1,
"axes.spines.top": False,
"axes.spines.right": False,
})
fig, ax = plt.subplots(figsize=FIGSIZE)
for mode in MODES:
alphas, mu, sd = curve(groups, "spiral", mode)
if len(alphas) == 0:
continue
ax.plot(alphas, mu, marker="o", ms=MS, lw=LW,
color=COLORS[mode], label=LABELS[mode], zorder=3)
ax.fill_between(alphas, mu - sd, mu + sd,
color=COLORS[mode], alpha=0.2, zorder=2)
# Reference lines for canonical distributions
ax.axvline(1.0, 0, 0.95, color="gray", lw=0.8, ls=":", alpha=0.7, zorder=1)
ax.axvline(2.0, 0, 0.90, color="black", lw=0.8, ls="--", alpha=0.7, zorder=1)
ax.text(1.0, 1.04, "Laplace", ha="center", va="bottom",
fontsize=FONTSIZE - 1, color="gray")
ax.set_xscale("log", base=2)
ax.set_ylabel(r"Linearity")
ax.set_ylim(-0.05, 1.12)
ax.grid(alpha=0.3, which="both")
ax.legend(frameon=False, loc="lower right")
ax.set_xticks([2**(-2), 2, 16])
ax.set_xticklabels([r"$\leftarrow$ sparse", "Gaussian", r"uniform $\rightarrow$"])
fig.tight_layout()
fig.savefig(out_path, bbox_inches="tight", dpi=500)
print(f"Saved {out_path}")
plt.close(fig)
plt.rcParams.update(rc_saved)
# ──────────────────────────────────────────────────────────────────────────
# Driver
# ──────────────────────────────────────────────────────────────────────────
def main():
p = argparse.ArgumentParser()
p.add_argument("--results_dir", default="results/gennorm/")
p.add_argument("--out", default="figures/")
args = p.parse_args()
os.makedirs(args.out, exist_ok=True)
# 4-panel R^2 grid (appendix)
groups_r2 = load_groups(args.results_dir, "r2_hz_grid")
plot_grid(groups_r2, "r2_hz_grid",
os.path.join(args.out, "fig_gennorm.pdf"))
# 4-panel orthogonality grid (appendix, autoscaled)
groups_orth = load_groups(args.results_dir, "orth_err_normalized_grid")
plot_grid(groups_orth, "orth_err_normalized_grid",
os.path.join(args.out, "fig_gennorm_orth.pdf"))
# Single-panel main-text headline (spiral, all three methods)
plot_main_panel(groups_r2,
os.path.join(args.out, "fig_gennorm_main.pdf"))
if __name__ == "__main__":
main()
@@ -0,0 +1,503 @@
"""
Aggregate and plot Reacher sweep results.
Generates:
1. OU: R² vs ρ (per lambda)
2. OU: R² vs ρ (per dimension)
3. Traj: per-dim R² vs δ (with per-dim ρ annotations)
4. OU vs Traj on same axes (ρ on x-axis, traj uses measured ρ_mean)
5. Lambda robustness panel (OU, R² vs λ for each ρ)
6. Traj: R² vs measured ρ
7. Orthogonality error plots
Usage:
python analysis/plot_reacher.py --results_dir results/reacher
"""
import os
import json
import argparse
import numpy as np
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from collections import defaultdict
# ═════════════════════════════════════════════════════════════════════════════
# DATA LOADING
# ═════════════════════════════════════════════════════════════════════════════
def load_summaries(results_dir):
"""Load all summary_*.json files, split into OU and traj."""
ou_results, traj_results = [], []
for p in sorted(Path(results_dir).glob("summary_*.json")):
with open(p) as f:
summary = json.load(f)
for run_name, r in summary.items():
if r.get("r2_hz", -999) <= -1:
continue
if "delta" in r:
traj_results.append(r)
else:
ou_results.append(r)
return ou_results, traj_results
def _group_by(results, x_key):
"""Group results by (x_key, lamb) → list of result dicts."""
grouped = defaultdict(list)
for r in results:
grouped[(r[x_key], r["lamb"])].append(r)
return grouped
def _get_sorted(results, key):
return sorted(set(r[key] for r in results))
# ═════════════════════════════════════════════════════════════════════════════
# 1. OU: R² vs ρ (one curve per lambda)
# ═════════════════════════════════════════════════════════════════════════════
def plot_ou_r2_vs_rho(results, save_path):
grouped = _group_by(results, "rho")
lambs = _get_sorted(results, "lamb")
rhos = _get_sorted(results, "rho")
fig, ax = plt.subplots(figsize=(7, 4.5))
for lamb in lambs:
medians, q25, q75, xs = [], [], [], []
for rho in rhos:
vals = [r["r2_hz"] for r in grouped.get((rho, lamb), [])]
if vals:
medians.append(np.median(vals))
q25.append(np.percentile(vals, 25))
q75.append(np.percentile(vals, 75))
xs.append(rho)
medians, q25, q75 = np.array(medians), np.array(q25), np.array(q75)
ax.plot(xs, medians, "o-", label=f"λ={lamb:.0e}", markersize=5)
ax.fill_between(xs, q25, q75, alpha=0.15)
ax.set_xlabel("ρ (OU autocorrelation)", fontsize=12)
ax.set_ylabel("R² (embed → true state)", fontsize=12)
ax.set_title("OU: Linear identifiability vs. ρ", fontsize=13)
ax.legend(fontsize=9)
ax.set_ylim(-0.05, 1.05)
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(save_path, dpi=200, bbox_inches="tight")
plt.close()
print(f"Saved {save_path}")
# ═════════════════════════════════════════════════════════════════════════════
# 2. OU: per-dimension R² vs ρ
# ═════════════════════════════════════════════════════════════════════════════
def plot_ou_perdim_r2(results, save_path):
"""Two curves: shoulder vs wrist, averaged over lambda and seeds."""
by_rho = defaultdict(list)
for r in results:
if "r2_hz_per_dim" in r:
by_rho[r["rho"]].append(r["r2_hz_per_dim"])
rhos = sorted(by_rho.keys())
dim0_med, dim1_med = [], []
for rho in rhos:
vals = np.array(by_rho[rho])
dim0_med.append(np.median(vals[:, 0]))
dim1_med.append(np.median(vals[:, 1]))
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(rhos, dim0_med, "o-", label="Shoulder (dim 0)", markersize=5)
ax.plot(rhos, dim1_med, "s-", label="Wrist (dim 1)", markersize=5)
ax.set_xlabel("ρ (OU autocorrelation)", fontsize=12)
ax.set_ylabel("R² per dimension", fontsize=12)
ax.set_title("OU: Per-dimension identifiability", fontsize=13)
ax.legend(fontsize=10)
ax.set_ylim(-0.05, 1.05)
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(save_path, dpi=200, bbox_inches="tight")
plt.close()
print(f"Saved {save_path}")
# ═════════════════════════════════════════════════════════════════════════════
# 3. Traj: per-dimension R² vs δ (with ρ annotations)
# ═════════════════════════════════════════════════════════════════════════════
def plot_traj_perdim_r2(results, save_path):
"""Two curves: shoulder vs wrist, annotated with per-dim ρ."""
by_delta = defaultdict(list)
for r in results:
if "r2_hz_per_dim" in r:
by_delta[r["delta"]].append(r)
deltas = sorted(by_delta.keys())
dim0_med, dim1_med = [], []
rho0_vals, rho1_vals = [], []
for delta in deltas:
runs = by_delta[delta]
vals = np.array([r["r2_hz_per_dim"] for r in runs])
dim0_med.append(np.median(vals[:, 0]))
dim1_med.append(np.median(vals[:, 1]))
rho0 = [r.get("rho_shoulder", None) for r in runs]
rho1 = [r.get("rho_wrist", None) for r in runs]
rho0_vals.append(rho0[0] if rho0[0] is not None else None)
rho1_vals.append(rho1[0] if rho1[0] is not None else None)
fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(deltas, dim0_med, "o-", label="Shoulder (dim 0)",
markersize=5, color="C0")
ax.plot(deltas, dim1_med, "s-", label="Wrist (dim 1)",
markersize=5, color="C1")
# Annotate with ρ values
for i, delta in enumerate(deltas):
if rho0_vals[i] is not None:
y_pos = max(dim0_med[i], dim1_med[i]) + 0.03
ax.annotate(f"ρ₀={rho0_vals[i]:.3f}\nρ₁={rho1_vals[i]:.3f}",
(delta, y_pos),
fontsize=7, ha="center", alpha=0.7)
ax.set_xlabel("δ (temporal stride)", fontsize=12)
ax.set_ylabel("R² per dimension", fontsize=12)
ax.set_title("Trajectory: Per-dimension identifiability vs. δ", fontsize=13)
ax.legend(fontsize=10)
ax.set_ylim(-0.15, 1.15)
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(save_path, dpi=200, bbox_inches="tight")
plt.close()
print(f"Saved {save_path}")
# ═════════════════════════════════════════════════════════════════════════════
# 4. OU vs Traj on same axes (ρ on x-axis)
# ═════════════════════════════════════════════════════════════════════════════
def plot_ou_vs_traj(ou_results, traj_results, save_path):
"""Both conditions on one plot, using ρ as common x-axis."""
# OU: group by rho, average over lambda and seeds
ou_by_rho = defaultdict(list)
for r in ou_results:
ou_by_rho[r["rho"]].append(r["r2_hz"])
# Traj: use rho_mean, group by delta
traj_by_rho = defaultdict(list)
for r in traj_results:
rho_mean = r.get("rho_mean", None)
if rho_mean is not None:
traj_by_rho[rho_mean].append(r["r2_hz"])
fig, ax = plt.subplots(figsize=(7, 4.5))
# OU
rhos_ou = sorted(ou_by_rho.keys())
med_ou = [np.median(ou_by_rho[rho]) for rho in rhos_ou]
q25_ou = [np.percentile(ou_by_rho[rho], 25) for rho in rhos_ou]
q75_ou = [np.percentile(ou_by_rho[rho], 75) for rho in rhos_ou]
ax.plot(rhos_ou, med_ou, "o-", label="OU (Gaussian)", markersize=6,
color="C0", linewidth=2)
ax.fill_between(rhos_ou, q25_ou, q75_ou, alpha=0.15, color="C0")
# Traj
rhos_traj = sorted(traj_by_rho.keys())
med_traj = [np.median(traj_by_rho[rho]) for rho in rhos_traj]
q25_traj = [np.percentile(traj_by_rho[rho], 25) for rho in rhos_traj]
q75_traj = [np.percentile(traj_by_rho[rho], 75) for rho in rhos_traj]
ax.plot(rhos_traj, med_traj, "s-", label="Trajectory (non-Gaussian)",
markersize=6, color="C3", linewidth=2)
ax.fill_between(rhos_traj, q25_traj, q75_traj, alpha=0.15, color="C3")
ax.set_xlabel("ρ (autocorrelation)", fontsize=12)
ax.set_ylabel("R² (embed → true state)", fontsize=12)
ax.set_title("Gaussian vs. non-Gaussian latents", fontsize=13)
ax.legend(fontsize=10)
ax.set_ylim(-0.05, 1.05)
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(save_path, dpi=200, bbox_inches="tight")
plt.close()
print(f"Saved {save_path}")
# ═════════════════════════════════════════════════════════════════════════════
# 5. Lambda robustness (OU)
# ═════════════════════════════════════════════════════════════════════════════
def plot_lambda_robustness(results, save_path):
"""R² vs lambda for each rho."""
grouped = _group_by(results, "rho")
rhos = _get_sorted(results, "rho")
lambs = _get_sorted(results, "lamb")
fig, ax = plt.subplots(figsize=(7, 4.5))
for rho in rhos:
medians, xs = [], []
for lamb in lambs:
vals = [r["r2_hz"] for r in grouped.get((rho, lamb), [])]
if vals:
medians.append(np.median(vals))
xs.append(lamb)
if medians:
ax.plot(xs, medians, "o-", label=f"ρ={rho}", markersize=4)
ax.set_xscale("log")
ax.set_xlabel("λ (SIGReg weight)", fontsize=12)
ax.set_ylabel("R² (embed → true state)", fontsize=12)
ax.set_title("OU: Robustness to λ", fontsize=13)
ax.legend(fontsize=8, ncol=2)
ax.set_ylim(-0.05, 1.05)
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(save_path, dpi=200, bbox_inches="tight")
plt.close()
print(f"Saved {save_path}")
# ═════════════════════════════════════════════════════════════════════════════
# 6. Traj: R² vs measured ρ (per lambda)
# ═════════════════════════════════════════════════════════════════════════════
def plot_traj_r2_vs_rho(results, save_path):
"""Traj R² plotted against measured autocorrelation, per lambda."""
grouped = defaultdict(list)
for r in results:
rho_mean = r.get("rho_mean", None)
if rho_mean is not None:
grouped[(rho_mean, r["lamb"])].append(r["r2_hz"])
lambs = _get_sorted(results, "lamb")
rhos = sorted(set(k[0] for k in grouped.keys()))
fig, ax = plt.subplots(figsize=(7, 4.5))
for lamb in lambs:
medians, xs = [], []
for rho in rhos:
vals = grouped.get((rho, lamb), [])
if vals:
medians.append(np.median(vals))
xs.append(rho)
if medians:
ax.plot(xs, medians, "o-", label=f"λ={lamb:.0e}", markersize=5)
ax.set_xlabel("ρ (measured autocorrelation)", fontsize=12)
ax.set_ylabel("R² (embed → true state)", fontsize=12)
ax.set_title("Trajectory: Identifiability vs. measured ρ", fontsize=13)
ax.legend(fontsize=9)
ax.set_ylim(-0.05, 1.05)
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(save_path, dpi=200, bbox_inches="tight")
plt.close()
print(f"Saved {save_path}")
# ═════════════════════════════════════════════════════════════════════════════
# 7. Orthogonality error
# ═════════════════════════════════════════════════════════════════════════════
def plot_orth_err(results, x_key, x_label, title, save_path):
grouped = _group_by(results, x_key)
lambs = _get_sorted(results, "lamb")
x_vals = _get_sorted(results, x_key)
fig, ax = plt.subplots(figsize=(7, 4.5))
for lamb in lambs:
medians, xs = [], []
for x in x_vals:
vals = [r.get("orth_error", r.get("procrustes_error", 1.0))
for r in grouped.get((x, lamb), [])]
if vals:
medians.append(np.median(vals))
xs.append(x)
ax.plot(xs, medians, "o-", label=f"λ={lamb:.0e}", markersize=4)
ax.set_xlabel(x_label, fontsize=12)
ax.set_ylabel("Orthogonality error", fontsize=12)
ax.set_title(title, fontsize=13)
ax.legend(fontsize=9)
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(save_path, dpi=200, bbox_inches="tight")
plt.close()
print(f"Saved {save_path}")
# ═════════════════════════════════════════════════════════════════════════════
# TABLE
# ═════════════════════════════════════════════════════════════════════════════
def print_table(results, x_key, label):
grouped = defaultdict(list)
for r in results:
grouped[(r[x_key], r["lamb"])].append(r)
print(f"\n=== {label} ===")
print(f"{x_key:>6s} {'lamb':>8s} {'R²(h→z)':>10s} "
f"{'R²(dim0)':>10s} {'R²(dim1)':>10s} "
f"{'R²(sincos)':>10s} {'orth_err':>10s} {'n':>4s}")
print("-" * 76)
for (x, lamb), runs in sorted(grouped.items()):
r2s = [r["r2_hz"] for r in runs]
errs = [r.get("orth_error", r.get("procrustes_error", 1.0))
for r in runs]
dim0 = [r["r2_hz_per_dim"][0] for r in runs
if "r2_hz_per_dim" in r]
dim1 = [r["r2_hz_per_dim"][1] for r in runs
if "r2_hz_per_dim" in r]
sincos = [r["r2_sincos"] for r in runs if "r2_sincos" in r]
d0 = f"{np.median(dim0):10.4f}" if dim0 else f"{'n/a':>10s}"
d1 = f"{np.median(dim1):10.4f}" if dim1 else f"{'n/a':>10s}"
sc = f"{np.median(sincos):10.4f}" if sincos else f"{'n/a':>10s}"
print(f"{x:6g} {lamb:8.1e} "
f"{np.median(r2s):10.4f} "
f"{d0} {d1} "
f"{sc} "
f"{np.median(errs):10.4f} "
f"{len(runs):4d}")
def plot_traj_distributions(h5_path, results_dir, save_path):
"""Marginal + transition distributions for trajectory data, annotated with R²."""
import h5py
from scipy.stats import pearsonr
with h5py.File(h5_path, "r") as f:
qpos = np.array(f["qpos"])
ep_len = np.array(f["ep_len"])
T = ep_len[0]
episodes = qpos.reshape(-1, T, 2)
# Load R² per delta
grouped = defaultdict(list)
for p in Path(results_dir).rglob("result.json"):
r = json.load(open(p))
if "delta" not in r or r.get("rho") is not None:
continue
grouped[(r["delta"], r["lamb"])].append(r)
r2_dict = {}
for delta in set(k[0] for k in grouped):
best_mean, best_lamb = -np.inf, None
for lamb in set(k[1] for k in grouped if k[0] == delta):
m = np.mean([r["r2_hz"] for r in grouped[(delta, lamb)]])
if m > best_mean:
best_mean, best_lamb = m, lamb
runs = grouped[(delta, best_lamb)]
r2_dict[delta] = {
"r2_dim0": np.mean([r["r2_hz_per_dim"][0] for r in runs]),
"r2_dim1": np.mean([r["r2_hz_per_dim"][1] for r in runs]),
}
DELTAS = [1, 2, 4, 8, 16, 32, 64]
s = 0.0001
sub = 1
fig = plt.figure(figsize=0.85 * np.array((1 + 3 * len(DELTAS), 5)))
gs = fig.add_gridspec(2, 2 + len(DELTAS))
ax = fig.add_subplot(gs[:2, :2])
ax.scatter(*episodes.reshape(-1, 2)[::sub].T, s=s * 10)
ax.set_title("Marginal")
ax.grid()
ax.set_xlabel(r"$z_0$ (shoulder)")
ax.set_ylabel(r"$z_1$ (wrist)")
for i, delta in enumerate(DELTAS):
# Transition scatter
ax = fig.add_subplot(gs[0, 2 + i])
transitions = (episodes[:, delta:] - episodes[:, :-delta]).reshape(-1, 2)
ax.scatter(*transitions[::sub].T, s=s)
ax.set_title(
r"$\Delta=%d$" % delta + "\n"
+ r"$R^2=(%.2f,\,%.2f)$"
% (r2_dict[delta]["r2_dim0"], r2_dict[delta]["r2_dim1"])
)
ax.grid()
# Autocorrelation scatter
ax = fig.add_subplot(gs[1, 2 + i])
a = episodes[:, delta:, 0].flatten()[::sub]
b = episodes[:, :-delta, 0].flatten()[::sub]
rho0 = pearsonr(a, b)[0]
ax.scatter(a, b, s=s)
c = episodes[:, delta:, 1].flatten()[::sub]
d = episodes[:, :-delta, 1].flatten()[::sub]
rho1 = pearsonr(c, d)[0]
ax.scatter(c, d, s=s)
ax.set_title(r"$\rho=(%.2f,\,%.2f)$" % (rho0, rho1))
ax.grid()
if i == 0:
ax.legend([r"$z_0$ (shoulder)", r"$z_1$ (wrist)"], loc="upper left")
plt.tight_layout()
plt.savefig(save_path, dpi=500, bbox_inches="tight", format="jpg")
plt.close()
print(f"Saved {save_path}")
# ═════════════════════════════════════════════════════════════════════════════
# MAIN
# ═════════════════════════════════════════════════════════════════════════════
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--results_dir", type=str, default="results/reacher")
parser.add_argument("--out_dir", type=str, default="figures/reacher")
parser.add_argument("--h5_path", type=str, default="data/reacher.h5")
args = parser.parse_args()
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
ou_results, traj_results = load_summaries(args.results_dir)
print(f"Loaded {len(ou_results)} OU runs, {len(traj_results)} traj runs")
# Tables
if ou_results:
print_table(ou_results, "rho", "OU")
if traj_results:
print_table(traj_results, "delta", "Trajectory")
# OU plots
if ou_results:
plot_ou_r2_vs_rho(ou_results, out_dir / "ou_r2_vs_rho.png")
plot_ou_perdim_r2(ou_results, out_dir / "ou_perdim_r2.png")
plot_lambda_robustness(ou_results, out_dir / "ou_lambda_robustness.png")
plot_orth_err(ou_results, "rho", "ρ",
"OU: Orthogonality error vs. ρ",
out_dir / "ou_orth_err.png")
# Traj plots
if traj_results:
plot_traj_perdim_r2(traj_results, out_dir / "traj_perdim_r2.png")
plot_traj_r2_vs_rho(traj_results, out_dir / "traj_r2_vs_rho.png")
plot_orth_err(traj_results, "delta", "δ",
"Trajectory: Orthogonality error vs. δ",
out_dir / "traj_orth_err.png")
h5_path = os.path.join(args.h5_path)
if os.path.exists(h5_path):
plot_traj_distributions(h5_path, args.results_dir,
out_dir / "traj_distributions.png")
# Combined
if ou_results and traj_results:
plot_ou_vs_traj(ou_results, traj_results,
out_dir / "ou_vs_traj.png")
if __name__ == "__main__":
main()
@@ -0,0 +1,73 @@
"""
Scaling plots: R² and orthogonality vs latent dimension N.
Usage:
python analysis/plot_scaling.py --results_dir results/scaling/ --out figures/
"""
import argparse, os, glob, json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
def load_results(results_dir):
rows = []
for path in sorted(glob.glob(os.path.join(results_dir, "*.json"))):
with open(path) as f:
r = json.load(f)
rows.append({k: r.get(k) for k in [
"N", "seed", "r2_zx", "r2_xz", "r2_zh", "r2_hz",
"orth_err", "orth_err_normalized", "final_loss",
"final_align", "final_sigreg", "final_whiten",
"epsilon", "delta", "approx_bound", "procrustes_mse",
]})
return pd.DataFrame(rows)
def main():
p = argparse.ArgumentParser()
p.add_argument("--results_dir", default="results/scaling/")
p.add_argument("--out", default="figures/")
args = p.parse_args()
os.makedirs(args.out, exist_ok=True)
df = load_results(args.results_dir)
if len(df) == 0:
print("No results."); return
summary = df.groupby("N").agg(
r2_xz_mean=("r2_xz", "mean"), r2_xz_std=("r2_xz", "std"),
r2_hz_mean=("r2_hz", "mean"), r2_hz_std=("r2_hz", "std"),
orth_mean=("orth_err_normalized", "mean"), orth_std=("orth_err_normalized", "std"),
).reset_index()
dims = summary["N"].values
fig, axes = plt.subplots(1, 2, figsize=(8, 3.5))
ax = axes[0]
ax.errorbar(dims, summary["r2_xz_mean"], yerr=summary["r2_xz_std"],
fmt="o-", capsize=3, color="gray", label=r"Probe: $g(z) \to z$")
ax.errorbar(dims, summary["r2_hz_mean"], yerr=summary["r2_hz_std"],
fmt="s-", capsize=3, label=r"Probe: $f \circ g(z) \to z$")
ax.set_xscale("log", base=2)
ax.set_xlabel("Latent dimension $N$"); ax.set_ylabel(r"Linearity [$R^2$]")
ax.set_title("Latent Recovery"); ax.set_ylim(-0.05, 1.05)
ax.set_xticks(dims); ax.legend(); ax.grid(alpha=0.3)
ax = axes[1]
ax.errorbar(dims, summary["orth_mean"], yerr=summary["orth_std"],
fmt="D-", capsize=3, color="tab:green")
ax.set_xscale("log", base=2)
ax.set_xlabel("Latent dimension $N$")
ax.set_ylabel(r"$\|A^\top A - I\|_F / \sqrt{N}$")
ax.set_title("Orthogonality Error"); ax.set_xticks(dims); ax.grid(alpha=0.3)
fig.tight_layout()
fig.savefig(os.path.join(args.out, "fig_scaling.pdf"), bbox_inches="tight")
print("Saved fig_scaling.pdf")
plt.close()
if __name__ == "__main__":
main()
@@ -0,0 +1,103 @@
"""
Cross-experiment scatter plots (2x2 panel).
Usage:
python analysis/plot_scatter.py --results_dirs results/2d results/scaling results/grid results/ablation --out figures/
"""
import argparse, os, glob, json
import numpy as np
import matplotlib.pyplot as plt
EXPERIMENT_COLORS = {"2d": "tab:blue", "grid": "tab:red", "scaling": "tab:green", "ablation": "tab:orange"}
EXPERIMENT_ORDER = ["grid", "scaling", "2d", "ablation"]
def load_all(dirs):
rows = []
for d in dirs:
for path in sorted(glob.glob(os.path.join(d, "*.json"))):
try:
with open(path) as f:
rows.append(json.load(f))
except Exception:
pass
return rows
def scatter_by_experiment(ax, rows, x_key, y_key):
for exp in EXPERIMENT_ORDER:
pts = [r for r in rows if r.get("experiment") == exp
and r.get(x_key) is not None and r.get(y_key) is not None]
if not pts:
continue
ax.scatter([r[x_key] for r in pts],
[r[y_key] for r in pts],
c=EXPERIMENT_COLORS[exp],
s=25, alpha=0.7, edgecolors='k', linewidths=0.3,
label=exp, zorder=3)
def main():
p = argparse.ArgumentParser()
p.add_argument("--results_dirs", nargs="+", required=True)
p.add_argument("--out", default="figures/")
args = p.parse_args()
os.makedirs(args.out, exist_ok=True)
rows = load_all(args.results_dirs)
if not rows:
print("No results."); return
xlim = (5e-3, 1e0)
ylim = (0.9, 1.01)
fig = plt.figure(figsize=0.65 * np.array((8, 7)))
# ── Total loss vs R² ──
ax = plt.subplot(2, 2, 1)
scatter_by_experiment(ax, rows, "final_loss", "r2_hz")
ax.set_xlabel("Total loss")
ax.set_ylabel("Linear Identifiability")
ax.legend(fontsize=8)
ax.grid(alpha=0.3)
ax.set_xlim(*xlim)
ax.set_ylim(*ylim)
ax.set_xscale("log")
# ── Alignment vs R² ──
ax = plt.subplot(2, 2, 2)
scatter_by_experiment(ax, rows, "final_align", "r2_hz")
ax.set_xlabel("Alignment loss")
ax.set_ylabel("Linear Identifiability")
ax.grid(alpha=0.3)
ax.set_xlim(*xlim)
ax.set_ylim(*ylim)
ax.set_xscale("log")
# ── SIGReg vs R² ──
ax = plt.subplot(2, 2, 3)
scatter_by_experiment(ax, rows, "final_sigreg", "r2_hz")
ax.set_xlabel("SIGReg loss")
ax.set_ylabel("Linear Identifiability")
ax.grid(alpha=0.3)
ax.set_ylim(*ylim)
ax.set_xscale("log")
# ── SIGReg vs whitening ──
ax = plt.subplot(2, 2, 4)
scatter_by_experiment(ax, rows, "final_sigreg", "final_whiten")
ax.set_xlabel("SIGReg loss")
ax.set_ylabel("Whitening loss")
ax.grid(alpha=0.3)
ax.set_xscale("log")
ax.set_yscale("log")
fig.tight_layout()
fig.savefig(os.path.join(args.out, "scatter_plots.pdf"), bbox_inches="tight")
print("Saved scatter_plots.pdf")
plt.close()
if __name__ == "__main__":
main()
@@ -0,0 +1,10 @@
#!/bin/bash
python analysis/aggregate.py --results_dir results/2d/ --out results/2d/summary.csv
python analysis/aggregate.py --results_dir results/ --recursive --out results/all.csv
python analysis/plot_2d.py --results_dir results/2d/ --out figures/
python analysis/plot_bound.py --results_dirs results/grid results/2d results/scaling results/ablation --out figures/
python analysis/plot_ablation.py --results_dir results/ablation/ --out figures/
python analysis/plot_scaling.py --results_dir results/scaling/ --out figures/
python analysis/plot_scatter.py --results_dirs results/2d results/scaling results/grid results/ablation --out figures/
python analysis/make_table_scaling.py --results_dir results/scaling/