summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYurenHao0426 <Blackhao0426@gmail.com>2026-06-01 17:48:25 -0500
committerYurenHao0426 <Blackhao0426@gmail.com>2026-06-01 17:48:25 -0500
commit826a8f85384eeab02b98daad28a3ace885d53dfc (patch)
tree6cf99c5327b07263d0d89b09e1af2e2b194c0d89
parent78bb8016e4a779bfeddcfe1402c1bb9a79108a30 (diff)
Add downstream capacity threshold validation
-rw-r--r--notes/04_downstream_capacity_design.md250
-rw-r--r--notes/05_downstream_random_capacity_results.md78
-rw-r--r--requirements.txt1
-rw-r--r--scripts/downstream_capacity_sweep.py914
4 files changed, 1243 insertions, 0 deletions
diff --git a/notes/04_downstream_capacity_design.md b/notes/04_downstream_capacity_design.md
new file mode 100644
index 0000000..c2b9704
--- /dev/null
+++ b/notes/04_downstream_capacity_design.md
@@ -0,0 +1,250 @@
+# Downstream Capacity Experiment Design
+
+## Goal
+
+Validate the core downstream claim:
+
+> FA and BP should behave similarly while redundant parameter capacity can absorb the FA alignment burden, and FA should degrade faster once redundancy is exhausted.
+
+For random-label memorization, the primary performance metric is train MSE. The
+experiment should produce two performance curves:
+
+\[
+L_{\mathrm{BP}}(P)
+\quad \text{and} \quad
+L_{\mathrm{FA}}(P),
+\]
+
+where \(P\) is model parameter count. The target signature is a widening FA/BP
+train gap as \(P\) is reduced, with the widening centered near the predicted FA
+capacity margin.
+
+## Main Task Choice: Random-Label Memorization
+
+Use:
+
+\[
+x_i\sim \mathcal N(0,I_{n_0}),
+\qquad
+y_i\sim \mathcal N(0,I_{n_L}).
+\]
+
+The target is train-set memorization, not test generalization. This makes the
+task dimension directly measurable:
+
+\[
+d_{\mathrm{task}}\approx N n_L.
+\]
+
+For a two-hidden-layer MLP with no bias,
+
+\[
+P(n)=n_0 n+n^2+n n_L.
+\]
+
+For ordinary FA, the hard alignment-burden proxy is:
+
+\[
+K_{\mathrm{FA}}(n)
+=
+(n^2-1)+(n n_L-1).
+\]
+
+The predicted thresholds are:
+
+\[
+P(n)\gtrsim Nn_L
+\qquad \text{for BP},
+\]
+
+and
+
+\[
+P(n)-K_{\mathrm{FA}}(n)\gtrsim Nn_L
+\qquad \text{for FA}.
+\]
+
+This is the cleanest task for testing the redundancy-exhaustion story because
+the breakpoint is predicted before training.
+
+## Auxiliary Task Choice: Low-Rank Linear Teacher
+
+Use:
+
+\[
+x\sim \mathcal N(0,I_{n_0}),
+\]
+
+\[
+y=A x + \epsilon,
+\]
+
+where:
+
+\[
+A=U\Sigma V^\top,\qquad \operatorname{rank}(A)=r.
+\]
+
+Reasons:
+
+- Input/output dimensions are explicit.
+- Task rank is controlled.
+- Test loss is easy to measure.
+- It is simple enough that BP should establish a clear capacity baseline.
+
+Open risk:
+
+- The task may be too easy for ReLU MLPs, and FA may not show a clean transition unless width is very small.
+
+## Architecture
+
+Use a two-hidden-layer ReLU MLP:
+
+\[
+n_0 \to n \to n \to n_L.
+\]
+
+Sweep hidden width:
+
+\[
+n\in\{2,3,4,6,8,12,16,24,32,48,64\}.
+\]
+
+Parameter count without bias:
+
+\[
+P(n)=n_0 n+n^2+n n_L.
+\]
+
+Depth is fixed so that the main knob is capacity, not feedback path length.
+
+## Training
+
+For each width:
+
+- Train BP baseline.
+- Train FA with fixed random feedback matrices.
+- Use same initial forward weights for matched BP/FA comparisons.
+- Use multiple data/init seeds and multiple feedback seeds.
+
+Initial smoke sweep:
+
+- widths: \(\{2,4,8,16,32\}\)
+- init seeds: `3`
+- feedback seeds per init: `5`
+
+Scale-up sweep:
+
+- widths: `2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64`
+- init seeds: `10`
+- feedback seeds per init: `20`
+
+## Theoretical Quantities to Estimate
+
+### Parameter Count
+
+\[
+P=\sum_l n_l n_{l-1}.
+\]
+
+### Task Effective Dimension
+
+At the BP-trained endpoint, estimate:
+
+\[
+J=\frac{\partial f_\theta(X)}{\partial \theta}
+\]
+
+on a probe batch. Let:
+
+\[
+K=JJ^\top.
+\]
+
+Effective dimension:
+
+\[
+d_{\mathrm{eff}}(\lambda)
+=
+\operatorname{tr}\left[K(K+\lambda I)^{-1}\right].
+\]
+
+This estimates how many functional directions the task uses locally.
+
+### Hard FA Burden
+
+For the random-label memorization task, use the hard alignment-burden proxy:
+
+\[
+K_{\mathrm{FA}}=\sum_l(D_l-1).
+\]
+
+For ordinary FA in a two-hidden-layer MLP:
+
+\[
+D_1=n^2,\qquad D_2=n n_L,
+\]
+
+so:
+
+\[
+K_{\mathrm{FA}}=(n^2-1)+(n n_L-1).
+\]
+
+### Log-Volume FA Burden
+
+For matrix-level capacity validation, keep the log-volume quantity:
+
+\[
+k_{\mathrm{FA}}^{\mathrm{proxy}}
+=
+\sum_l C_l(q),
+\]
+
+where:
+
+\[
+C_l(q)=
+-\log P(Q_l\ge q),
+\qquad
+Q_l\sim \mathrm{Beta}\left(\frac12,\frac{D_l-1}{2}\right).
+\]
+
+Use a fixed threshold \(q\) for the first sweep. This is not a hard constraint
+count; it is the matrix-level log-volume cost used in the direct capacity
+distribution experiments.
+
+### Redundancy Score
+
+Define:
+
+\[
+R_{\mathrm{eff}}
+=
+P-d_{\mathrm{eff}}-K_{\mathrm{FA}}.
+\]
+
+For random-label memorization, also use the direct theoretical margin:
+
+\[
+M_{\mathrm{FA}}=P-K_{\mathrm{FA}}-Nn_L.
+\]
+
+## Main Plots
+
+1. BP and FA train MSE vs hidden width or parameter count.
+2. FA/BP train MSE gap vs hidden width or parameter count.
+3. FA/BP train MSE gap vs predicted FA margin \(P-K_{\mathrm{FA}}-Nn_L\).
+4. Estimated \(d_{\mathrm{eff}}\), \(k_{\mathrm{FA}}^{\mathrm{proxy}}\), and \(P\) vs width.
+
+## Success Criterion
+
+The experiment supports the contribution if:
+
+- BP reaches low train MSE near the predicted \(P\gtrsim Nn_L\) threshold.
+- FA reaches low train MSE near the predicted \(P-K_{\mathrm{FA}}\gtrsim Nn_L\) threshold.
+- The FA/BP train gap is largest where BP has positive margin but FA has negative margin.
+- The transition aligns better with \(P-K_{\mathrm{FA}}-Nn_L\) than with arbitrary width.
+
+Teacher-regression tasks are useful only as auxiliary downstream checks because
+their task dimension is not known before training.
diff --git a/notes/05_downstream_random_capacity_results.md b/notes/05_downstream_random_capacity_results.md
new file mode 100644
index 0000000..b6b0e92
--- /dev/null
+++ b/notes/05_downstream_random_capacity_results.md
@@ -0,0 +1,78 @@
+# Downstream Random-Capacity Results
+
+## Main Run
+
+Output directory:
+
+`outputs/downstream_capacity_random_main_fast`
+
+Task:
+
+\[
+x_i\sim \mathcal N(0,I_{16}),
+\qquad
+y_i\sim \mathcal N(0,I_4),
+\qquad
+N=128.
+\]
+
+Task dimension:
+
+\[
+N n_L=512.
+\]
+
+Model:
+
+\[
+16\to n\to n\to 4.
+\]
+
+Theory:
+
+\[
+P(n)=16n+n^2+4n,
+\]
+
+\[
+K_{\mathrm{FA}}(n)=(n^2-1)+(4n-1),
+\]
+
+\[
+M_{\mathrm{BP}}=P-Nn_L,
+\qquad
+M_{\mathrm{FA}}=P-K_{\mathrm{FA}}-Nn_L.
+\]
+
+Prediction:
+
+- BP threshold at width \(16\).
+- FA threshold at width \(32\).
+
+Trajectory count:
+
+- BP: \(4\) seeds per width.
+- FA: \(4\times 12=48\) trajectories per width.
+- Widths: \(8,12,16,24,32,48,64,96\).
+
+## Summary
+
+| width | BP train | FA train | gap | BP margin | FA margin |
+|---:|---:|---:|---:|---:|---:|
+| 8 | 0.7088 | 1.064 | 0.3555 | -288 | -382 |
+| 12 | 0.2252 | 0.6141 | 0.3889 | -128 | -318 |
+| 16 | 0.00743 | 0.2948 | 0.2873 | 64 | -254 |
+| 24 | 0.000129 | 0.0346 | 0.0345 | 544 | -126 |
+| 32 | 0.000007 | 0.0108 | 0.0108 | 1152 | 2 |
+| 48 | 0.000007 | 0.00392 | 0.00391 | 2752 | 258 |
+| 64 | 0.000046 | 0.00189 | 0.00185 | 4864 | 514 |
+| 96 | 0.000250 | 0.00231 | 0.00206 | 10624 | 1026 |
+
+## Main Figures
+
+- `outputs/downstream_capacity_random_main_fast/train_mse_trajectory_distribution.png`
+- `outputs/downstream_capacity_random_main_fast/train_gap_trajectory_distribution_vs_fa_margin.png`
+- `outputs/downstream_capacity_random_main_fast/bp_fa_train_mse_vs_width.png`
+- `outputs/downstream_capacity_random_main_fast/train_gap_vs_fa_capacity_margin.png`
+
+The cleanest figure is `train_gap_trajectory_distribution_vs_fa_margin.png`: the FA/BP train gap is large when \(M_{\mathrm{FA}}<0\), drops sharply near \(M_{\mathrm{FA}}=0\), and remains small for positive FA margin.
diff --git a/requirements.txt b/requirements.txt
index 74fa65e..0c44687 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
matplotlib
numpy
scipy
+torch
diff --git a/scripts/downstream_capacity_sweep.py b/scripts/downstream_capacity_sweep.py
new file mode 100644
index 0000000..6e42550
--- /dev/null
+++ b/scripts/downstream_capacity_sweep.py
@@ -0,0 +1,914 @@
+#!/usr/bin/env python3
+"""Downstream BP/FA capacity sweep on controlled capacity tasks."""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import math
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+import matplotlib.pyplot as plt
+import numpy as np
+import torch
+from scipy import stats
+
+
+Tensor = torch.Tensor
+
+
+@dataclass(frozen=True)
+class RunConfig:
+ task: str
+ input_dim: int
+ output_dim: int
+ teacher_rank: int
+ teacher_width: int
+ teacher_hidden_layers: int
+ normalize_targets: bool
+ widths: list[int]
+ train_samples: int
+ test_samples: int
+ probe_samples: int
+ steps: int
+ lr: float
+ optimizer: str
+ init_seeds: int
+ feedback_seeds: int
+ data_seed: int
+ noise_std: float
+ feedback_scale: str
+ capacity_q: float
+ jacobian_lambda_rel: float
+ device: str
+ torch_threads: int
+ outdir: str
+ plot: bool
+
+
+@dataclass(frozen=True)
+class RunRow:
+ width: int
+ parameter_count: int
+ init_seed: int
+ feedback_seed: int
+ run_type: str
+ train_mse: float
+ test_mse: float
+ bp_train_mse: float
+ bp_test_mse: float
+ train_gap_to_bp: float
+ test_gap_to_bp: float
+ task_dimension: int
+ fa_constraint_rank: int
+ bp_capacity_margin: int
+ fa_capacity_margin: int
+ d_eff: float
+ hard_rank: int
+ jacobian_lambda: float
+ fa_burden_nats: float
+ redundancy_score: float
+
+
+@dataclass(frozen=True)
+class WidthSummaryRow:
+ width: int
+ parameter_count: int
+ runs_bp: int
+ runs_fa: int
+ bp_test_mean: float
+ bp_test_std: float
+ bp_train_mean: float
+ bp_train_std: float
+ fa_test_mean: float
+ fa_test_std: float
+ fa_train_mean: float
+ fa_train_std: float
+ gap_mean: float
+ gap_std: float
+ train_gap_mean: float
+ train_gap_std: float
+ task_dimension: int
+ fa_constraint_rank: int
+ bp_capacity_margin: int
+ fa_capacity_margin: int
+ d_eff_mean: float
+ hard_rank_mean: float
+ fa_burden_nats: float
+ redundancy_score_mean: float
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Capacity shrinkage experiment: BP vs FA on controlled tasks."
+ )
+ parser.add_argument("--input-dim", type=int, default=16)
+ parser.add_argument("--output-dim", type=int, default=4)
+ parser.add_argument("--task", choices=["linear", "mlp", "random"], default="linear")
+ parser.add_argument("--teacher-rank", type=int, default=4)
+ parser.add_argument("--teacher-width", type=int, default=64)
+ parser.add_argument("--teacher-hidden-layers", type=int, default=2)
+ parser.add_argument("--normalize-targets", action="store_true")
+ parser.add_argument("--widths", type=int, nargs="+", default=[2, 4, 8, 16, 32])
+ parser.add_argument("--train-samples", type=int, default=256)
+ parser.add_argument("--test-samples", type=int, default=1024)
+ parser.add_argument("--probe-samples", type=int, default=64)
+ parser.add_argument("--steps", type=int, default=800)
+ parser.add_argument("--lr", type=float, default=0.03)
+ parser.add_argument("--optimizer", choices=["sgd", "adam"], default="sgd")
+ parser.add_argument("--init-seeds", type=int, default=3)
+ parser.add_argument("--feedback-seeds", type=int, default=5)
+ parser.add_argument("--data-seed", type=int, default=0)
+ parser.add_argument("--noise-std", type=float, default=0.0)
+ parser.add_argument(
+ "--feedback-scale",
+ choices=["relu", "fan-in", "unit"],
+ default="relu",
+ )
+ parser.add_argument("--capacity-q", type=float, default=0.01)
+ parser.add_argument("--jacobian-lambda-rel", type=float, default=1e-3)
+ parser.add_argument("--device", choices=["cpu", "cuda"], default="cpu")
+ parser.add_argument("--torch-threads", type=int, default=0)
+ parser.add_argument(
+ "--outdir",
+ type=Path,
+ default=Path("outputs/downstream_capacity_sweep"),
+ )
+ parser.add_argument("--plot", action="store_true")
+ return parser.parse_args()
+
+
+def parse_config(args: argparse.Namespace) -> RunConfig:
+ return RunConfig(
+ task=args.task,
+ input_dim=args.input_dim,
+ output_dim=args.output_dim,
+ teacher_rank=args.teacher_rank,
+ teacher_width=args.teacher_width,
+ teacher_hidden_layers=args.teacher_hidden_layers,
+ normalize_targets=args.normalize_targets,
+ widths=args.widths,
+ train_samples=args.train_samples,
+ test_samples=args.test_samples,
+ probe_samples=args.probe_samples,
+ steps=args.steps,
+ lr=args.lr,
+ optimizer=args.optimizer,
+ init_seeds=args.init_seeds,
+ feedback_seeds=args.feedback_seeds,
+ data_seed=args.data_seed,
+ noise_std=args.noise_std,
+ feedback_scale=args.feedback_scale,
+ capacity_q=args.capacity_q,
+ jacobian_lambda_rel=args.jacobian_lambda_rel,
+ device=args.device,
+ torch_threads=args.torch_threads,
+ outdir=str(args.outdir),
+ plot=args.plot,
+ )
+
+
+def validate_config(config: RunConfig) -> None:
+ if config.input_dim < 1 or config.output_dim < 1:
+ raise ValueError("Input and output dimensions must be positive.")
+ if not 1 <= config.teacher_rank <= min(config.input_dim, config.output_dim):
+ raise ValueError("teacher-rank must be in [1, min(input_dim, output_dim)].")
+ if config.teacher_width < 1 or config.teacher_hidden_layers < 1:
+ raise ValueError("Teacher MLP dimensions must be positive.")
+ if any(width < 1 for width in config.widths):
+ raise ValueError("All widths must be positive.")
+ if config.train_samples < 1 or config.test_samples < 1 or config.probe_samples < 1:
+ raise ValueError("Sample counts must be positive.")
+ if config.steps < 1:
+ raise ValueError("--steps must be positive.")
+ if config.lr <= 0:
+ raise ValueError("--lr must be positive.")
+ if config.init_seeds < 1 or config.feedback_seeds < 1:
+ raise ValueError("Seed counts must be positive.")
+ if not 0 <= config.capacity_q <= 1:
+ raise ValueError("--capacity-q must be in [0, 1].")
+ if config.jacobian_lambda_rel <= 0:
+ raise ValueError("--jacobian-lambda-rel must be positive.")
+
+
+def make_low_rank_teacher(config: RunConfig, rng: np.random.Generator) -> np.ndarray:
+ u, _ = np.linalg.qr(rng.standard_normal((config.output_dim, config.teacher_rank)))
+ v, _ = np.linalg.qr(rng.standard_normal((config.input_dim, config.teacher_rank)))
+ singular = np.linspace(1.0, 0.4, config.teacher_rank)
+ return (u * singular) @ v.T
+
+
+def numpy_teacher_mlp(config: RunConfig, rng: np.random.Generator) -> list[np.ndarray]:
+ dims = [
+ config.input_dim,
+ *([config.teacher_width] * config.teacher_hidden_layers),
+ config.output_dim,
+ ]
+ weights: list[np.ndarray] = []
+ for layer, (fan_in, fan_out) in enumerate(zip(dims[:-1], dims[1:])):
+ scale = math.sqrt(2.0 / fan_in) if layer < len(dims) - 2 else 1.0 / math.sqrt(fan_in)
+ weights.append(rng.standard_normal((fan_out, fan_in)) * scale)
+ return weights
+
+
+def numpy_teacher_predict(weights: list[np.ndarray], x: np.ndarray) -> np.ndarray:
+ current = x
+ for layer, weight in enumerate(weights):
+ current = current @ weight.T
+ if layer < len(weights) - 1:
+ current = np.maximum(current, 0.0)
+ return current
+
+
+def make_data(config: RunConfig) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor, object]:
+ rng = np.random.default_rng(config.data_seed)
+ teacher: object
+ if config.task == "linear":
+ teacher = make_low_rank_teacher(config, rng)
+ elif config.task == "mlp":
+ teacher = numpy_teacher_mlp(config, rng)
+ else:
+ teacher = None
+
+ def sample_x(count: int) -> np.ndarray:
+ x = rng.standard_normal((count, config.input_dim))
+ return x.astype(np.float64)
+
+ def teacher_y(x: np.ndarray) -> np.ndarray:
+ if config.task == "linear":
+ y = x @ teacher.T # type: ignore[union-attr]
+ elif config.task == "mlp":
+ y = numpy_teacher_predict(teacher, x) # type: ignore[arg-type]
+ else:
+ y = rng.standard_normal((x.shape[0], config.output_dim))
+ if config.noise_std > 0:
+ y = y + rng.standard_normal(y.shape) * config.noise_std
+ return y.astype(np.float64)
+
+ x_train = sample_x(config.train_samples)
+ y_train = teacher_y(x_train)
+ x_test = sample_x(config.test_samples)
+ y_test = teacher_y(x_test)
+ x_probe = sample_x(config.probe_samples)
+
+ if config.normalize_targets:
+ mean = y_train.mean(axis=0, keepdims=True)
+ std = y_train.std(axis=0, keepdims=True) + 1e-8
+ y_train = (y_train - mean) / std
+ y_test = (y_test - mean) / std
+
+ device = torch.device(config.device)
+ return (
+ torch.tensor(x_train, dtype=torch.float64, device=device),
+ torch.tensor(y_train, dtype=torch.float64, device=device),
+ torch.tensor(x_test, dtype=torch.float64, device=device),
+ torch.tensor(y_test, dtype=torch.float64, device=device),
+ torch.tensor(x_probe, dtype=torch.float64, device=device),
+ teacher,
+ )
+
+
+def initialize_weights(config: RunConfig, width: int, seed: int) -> list[Tensor]:
+ generator = torch.Generator(device=config.device)
+ generator.manual_seed(seed)
+ dims = [config.input_dim, width, width, config.output_dim]
+ weights: list[Tensor] = []
+ for layer, (fan_in, fan_out) in enumerate(zip(dims[:-1], dims[1:])):
+ scale = math.sqrt(2.0 / fan_in) if layer < len(dims) - 2 else 1.0 / math.sqrt(fan_in)
+ weight = torch.randn(
+ fan_out,
+ fan_in,
+ generator=generator,
+ dtype=torch.float64,
+ device=config.device,
+ ) * scale
+ weights.append(weight)
+ return weights
+
+
+def clone_weights(weights: list[Tensor]) -> list[Tensor]:
+ return [weight.clone() for weight in weights]
+
+
+def forward(weights: list[Tensor], x: Tensor) -> tuple[list[Tensor], list[Tensor]]:
+ activations = [x]
+ preacts: list[Tensor] = []
+ current = x
+ for layer, weight in enumerate(weights):
+ preact = current @ weight.T
+ preacts.append(preact)
+ current = torch.relu(preact) if layer < len(weights) - 1 else preact
+ activations.append(current)
+ return activations, preacts
+
+
+def predict(weights: list[Tensor], x: Tensor) -> Tensor:
+ return forward(weights, x)[0][-1]
+
+
+def mse(weights: list[Tensor], x: Tensor, y: Tensor) -> float:
+ with torch.no_grad():
+ error = predict(weights, x) - y
+ return float(0.5 * torch.mean(torch.sum(error * error, dim=1)).cpu())
+
+
+def gradients(weights: list[Tensor], x: Tensor, y: Tensor, feedback: list[Tensor] | None) -> list[Tensor]:
+ activations, preacts = forward(weights, x)
+ pred = activations[-1]
+ batch = x.shape[0]
+ deltas: list[Tensor] = [torch.empty(0, dtype=torch.float64, device=x.device) for _ in weights]
+ deltas[-1] = (pred - y) / batch
+ for layer in range(len(weights) - 2, -1, -1):
+ if feedback is None:
+ back = deltas[layer + 1] @ weights[layer + 1]
+ else:
+ back = deltas[layer + 1] @ feedback[layer].T
+ deltas[layer] = back * (preacts[layer] > 0)
+ return [delta.T @ activations[layer] for layer, delta in enumerate(deltas)]
+
+
+def train(
+ weights_init: list[Tensor],
+ x: Tensor,
+ y: Tensor,
+ lr: float,
+ steps: int,
+ feedback: list[Tensor] | None,
+ optimizer: str,
+) -> list[Tensor]:
+ weights = clone_weights(weights_init)
+ first_moment = [torch.zeros_like(weight) for weight in weights]
+ second_moment = [torch.zeros_like(weight) for weight in weights]
+ beta1 = 0.9
+ beta2 = 0.999
+ eps = 1e-8
+ for step in range(1, steps + 1):
+ grads = gradients(weights, x, y, feedback)
+ for index, grad in enumerate(grads):
+ if optimizer == "sgd":
+ update = grad
+ elif optimizer == "adam":
+ first_moment[index] = beta1 * first_moment[index] + (1.0 - beta1) * grad
+ second_moment[index] = beta2 * second_moment[index] + (1.0 - beta2) * (grad * grad)
+ first_hat = first_moment[index] / (1.0 - beta1**step)
+ second_hat = second_moment[index] / (1.0 - beta2**step)
+ update = first_hat / (torch.sqrt(second_hat) + eps)
+ else:
+ raise ValueError(f"Unknown optimizer: {optimizer}")
+ weights[index] = weights[index] - lr * update
+ return weights
+
+
+def feedback_scale(rows: int, mode: str) -> float:
+ if mode == "relu":
+ return math.sqrt(2.0 / rows)
+ if mode == "fan-in":
+ return math.sqrt(1.0 / rows)
+ if mode == "unit":
+ return 1.0
+ raise ValueError(f"Unknown feedback scale: {mode}")
+
+
+def init_feedback(config: RunConfig, width: int, seed: int) -> list[Tensor]:
+ generator = torch.Generator(device=config.device)
+ generator.manual_seed(seed)
+ shapes = [(width, width), (width, config.output_dim)]
+ feedback: list[Tensor] = []
+ for rows, cols in shapes:
+ matrix = torch.randn(
+ rows,
+ cols,
+ generator=generator,
+ dtype=torch.float64,
+ device=config.device,
+ ) * feedback_scale(rows, config.feedback_scale)
+ feedback.append(matrix)
+ return feedback
+
+
+def parameter_count(config: RunConfig, width: int) -> int:
+ dims = [config.input_dim, width, width, config.output_dim]
+ return sum(fan_in * fan_out for fan_in, fan_out in zip(dims[:-1], dims[1:]))
+
+
+def task_dimension(config: RunConfig) -> int:
+ return config.train_samples * config.output_dim
+
+
+def fa_constraint_rank(config: RunConfig, width: int) -> int:
+ feedback_target_dims = [width * width, width * config.output_dim]
+ return sum(max(dim - 1, 0) for dim in feedback_target_dims)
+
+
+def fa_burden_nats(config: RunConfig, width: int) -> float:
+ dims = [width * width, width * config.output_dim]
+ total = 0.0
+ for dim in dims:
+ total += float(-stats.beta(0.5, (dim - 1) / 2).logsf(config.capacity_q))
+ return total
+
+
+def flatten_params(params: list[Tensor]) -> Tensor:
+ return torch.cat([param.reshape(-1) for param in params])
+
+
+def jacobian_effective_dimension(
+ weights: list[Tensor],
+ x_probe: Tensor,
+ lambda_rel: float,
+) -> tuple[float, int, float]:
+ params = [weight.clone().detach().requires_grad_(True) for weight in weights]
+ outputs = predict(params, x_probe).reshape(-1)
+ rows: list[Tensor] = []
+ for output in outputs:
+ grads = torch.autograd.grad(output, params, retain_graph=True)
+ rows.append(flatten_params(list(grads)).detach())
+ jacobian = torch.stack(rows, dim=0)
+ kernel = jacobian @ jacobian.T
+ eigenvalues = torch.linalg.eigvalsh(kernel).clamp_min(0.0)
+ max_eval = float(torch.max(eigenvalues).cpu())
+ lam = max(lambda_rel * max_eval, 1e-12)
+ d_eff = float(torch.sum(eigenvalues / (eigenvalues + lam)).cpu())
+ hard_rank = int(torch.count_nonzero(eigenvalues > (1e-8 * max_eval)).cpu()) if max_eval > 0 else 0
+ return d_eff, hard_rank, lam
+
+
+def write_csv(path: Path, rows: list[object]) -> None:
+ if not rows:
+ return
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", newline="") as handle:
+ first = asdict(rows[0]) # type: ignore[arg-type]
+ writer = csv.DictWriter(handle, fieldnames=list(first.keys()))
+ writer.writeheader()
+ for row in rows:
+ writer.writerow(asdict(row)) # type: ignore[arg-type]
+
+
+def summarize_widths(rows: list[RunRow]) -> list[WidthSummaryRow]:
+ summaries: list[WidthSummaryRow] = []
+ for width in sorted({row.width for row in rows}):
+ width_rows = [row for row in rows if row.width == width]
+ bp_rows = [row for row in width_rows if row.run_type == "bp"]
+ fa_rows = [row for row in width_rows if row.run_type == "fa"]
+ bp_test = np.array([row.test_mse for row in bp_rows], dtype=np.float64)
+ bp_train = np.array([row.train_mse for row in bp_rows], dtype=np.float64)
+ fa_test = np.array([row.test_mse for row in fa_rows], dtype=np.float64)
+ fa_train = np.array([row.train_mse for row in fa_rows], dtype=np.float64)
+ gaps = np.array([row.test_gap_to_bp for row in fa_rows], dtype=np.float64)
+ train_gaps = np.array([row.train_gap_to_bp for row in fa_rows], dtype=np.float64)
+ d_eff = np.array([row.d_eff for row in bp_rows], dtype=np.float64)
+ hard_rank = np.array([row.hard_rank for row in bp_rows], dtype=np.float64)
+ burden = fa_rows[0].fa_burden_nats if fa_rows else bp_rows[0].fa_burden_nats
+ redundancy = np.array([row.redundancy_score for row in bp_rows], dtype=np.float64)
+ first = width_rows[0]
+ summaries.append(
+ WidthSummaryRow(
+ width=width,
+ parameter_count=first.parameter_count,
+ runs_bp=len(bp_rows),
+ runs_fa=len(fa_rows),
+ bp_test_mean=float(np.mean(bp_test)),
+ bp_test_std=float(np.std(bp_test, ddof=1)) if len(bp_test) > 1 else 0.0,
+ bp_train_mean=float(np.mean(bp_train)),
+ bp_train_std=float(np.std(bp_train, ddof=1)) if len(bp_train) > 1 else 0.0,
+ fa_test_mean=float(np.mean(fa_test)),
+ fa_test_std=float(np.std(fa_test, ddof=1)) if len(fa_test) > 1 else 0.0,
+ fa_train_mean=float(np.mean(fa_train)),
+ fa_train_std=float(np.std(fa_train, ddof=1)) if len(fa_train) > 1 else 0.0,
+ gap_mean=float(np.mean(gaps)),
+ gap_std=float(np.std(gaps, ddof=1)) if len(gaps) > 1 else 0.0,
+ train_gap_mean=float(np.mean(train_gaps)),
+ train_gap_std=float(np.std(train_gaps, ddof=1)) if len(train_gaps) > 1 else 0.0,
+ task_dimension=first.task_dimension,
+ fa_constraint_rank=first.fa_constraint_rank,
+ bp_capacity_margin=first.bp_capacity_margin,
+ fa_capacity_margin=first.fa_capacity_margin,
+ d_eff_mean=float(np.mean(d_eff)),
+ hard_rank_mean=float(np.mean(hard_rank)),
+ fa_burden_nats=float(burden),
+ redundancy_score_mean=float(np.mean(redundancy)),
+ )
+ )
+ return summaries
+
+
+def add_capacity_thresholds(summaries: list[WidthSummaryRow]) -> None:
+ bp_candidates = [row.width for row in summaries if row.bp_capacity_margin >= 0]
+ fa_candidates = [row.width for row in summaries if row.fa_capacity_margin >= 0]
+ if bp_candidates:
+ plt.axvline(
+ min(bp_candidates),
+ color="tab:blue",
+ linestyle="--",
+ linewidth=1,
+ alpha=0.65,
+ label="BP P>=N*out",
+ )
+ if fa_candidates:
+ plt.axvline(
+ min(fa_candidates),
+ color="tab:orange",
+ linestyle="--",
+ linewidth=1,
+ alpha=0.65,
+ label="FA P-K>=N*out",
+ )
+
+
+def save_plots(summaries: list[WidthSummaryRow], outdir: Path) -> list[Path]:
+ outdir.mkdir(parents=True, exist_ok=True)
+ paths: list[Path] = []
+ widths = np.array([row.width for row in summaries])
+
+ perf_path = outdir / "bp_fa_test_mse_vs_width.png"
+ plt.figure(figsize=(7, 4.5))
+ plt.errorbar(
+ widths,
+ [row.bp_test_mean for row in summaries],
+ yerr=[row.bp_test_std for row in summaries],
+ marker="o",
+ label="BP",
+ )
+ plt.errorbar(
+ widths,
+ [row.fa_test_mean for row in summaries],
+ yerr=[row.fa_test_std for row in summaries],
+ marker="o",
+ label="FA",
+ )
+ plt.xscale("log", base=2)
+ plt.yscale("log")
+ plt.xlabel("hidden width")
+ plt.ylabel("test MSE")
+ plt.title("Downstream capacity sweep")
+ add_capacity_thresholds(summaries)
+ plt.legend()
+ plt.tight_layout()
+ plt.savefig(perf_path, dpi=180)
+ plt.close()
+ paths.append(perf_path)
+
+ train_path = outdir / "bp_fa_train_mse_vs_width.png"
+ plt.figure(figsize=(7, 4.5))
+ plt.errorbar(
+ widths,
+ [row.bp_train_mean for row in summaries],
+ yerr=[row.bp_train_std for row in summaries],
+ marker="o",
+ label="BP",
+ )
+ plt.errorbar(
+ widths,
+ [row.fa_train_mean for row in summaries],
+ yerr=[row.fa_train_std for row in summaries],
+ marker="o",
+ label="FA",
+ )
+ plt.xscale("log", base=2)
+ plt.yscale("log")
+ plt.xlabel("hidden width")
+ plt.ylabel("train MSE")
+ plt.title("Memorization capacity sweep")
+ add_capacity_thresholds(summaries)
+ plt.legend()
+ plt.tight_layout()
+ plt.savefig(train_path, dpi=180)
+ plt.close()
+ paths.append(train_path)
+
+ gap_path = outdir / "gap_vs_width.png"
+ plt.figure(figsize=(7, 4.5))
+ plt.errorbar(
+ widths,
+ [row.gap_mean for row in summaries],
+ yerr=[row.gap_std for row in summaries],
+ marker="o",
+ )
+ plt.axhline(0.0, color="black", linewidth=1)
+ plt.xscale("log", base=2)
+ plt.xlabel("hidden width")
+ plt.ylabel("FA test MSE - BP test MSE")
+ plt.title("FA/BP gap vs width")
+ plt.tight_layout()
+ plt.savefig(gap_path, dpi=180)
+ plt.close()
+ paths.append(gap_path)
+
+ train_gap_path = outdir / "train_gap_vs_width.png"
+ plt.figure(figsize=(7, 4.5))
+ plt.errorbar(
+ widths,
+ [row.train_gap_mean for row in summaries],
+ yerr=[row.train_gap_std for row in summaries],
+ marker="o",
+ )
+ plt.axhline(0.0, color="black", linewidth=1)
+ plt.xscale("log", base=2)
+ plt.xlabel("hidden width")
+ plt.ylabel("FA train MSE - BP train MSE")
+ plt.title("FA/BP memorization gap vs width")
+ add_capacity_thresholds(summaries)
+ plt.tight_layout()
+ plt.savefig(train_gap_path, dpi=180)
+ plt.close()
+ paths.append(train_gap_path)
+
+ fa_margin_path = outdir / "train_gap_vs_fa_capacity_margin.png"
+ plt.figure(figsize=(7, 4.5))
+ plt.errorbar(
+ [row.fa_capacity_margin for row in summaries],
+ [row.train_gap_mean for row in summaries],
+ yerr=[row.train_gap_std for row in summaries],
+ marker="o",
+ linestyle="-",
+ )
+ plt.axvline(0.0, color="black", linewidth=1)
+ plt.axhline(0.0, color="black", linewidth=1)
+ plt.xlabel("P - K_FA - N*out")
+ plt.ylabel("FA train MSE - BP train MSE")
+ plt.title("Memorization gap vs predicted FA margin")
+ plt.tight_layout()
+ plt.savefig(fa_margin_path, dpi=180)
+ plt.close()
+ paths.append(fa_margin_path)
+
+ red_path = outdir / "gap_vs_redundancy_score.png"
+ plt.figure(figsize=(7, 4.5))
+ plt.errorbar(
+ [row.redundancy_score_mean for row in summaries],
+ [row.gap_mean for row in summaries],
+ yerr=[row.gap_std for row in summaries],
+ marker="o",
+ linestyle="-",
+ )
+ plt.axvline(0.0, color="black", linewidth=1)
+ plt.axhline(0.0, color="black", linewidth=1)
+ plt.xlabel("P - d_eff - k_FA_proxy")
+ plt.ylabel("FA test MSE - BP test MSE")
+ plt.title("Gap vs effective redundancy proxy")
+ plt.tight_layout()
+ plt.savefig(red_path, dpi=180)
+ plt.close()
+ paths.append(red_path)
+
+ dims_path = outdir / "capacity_terms_vs_width.png"
+ plt.figure(figsize=(7, 4.5))
+ plt.plot(widths, [row.parameter_count for row in summaries], marker="o", label="P")
+ plt.plot(
+ widths,
+ [row.parameter_count - row.fa_constraint_rank for row in summaries],
+ marker="o",
+ label="P - K_FA",
+ )
+ plt.plot(widths, [row.task_dimension for row in summaries], marker="o", label="N*out")
+ plt.plot(widths, [row.d_eff_mean for row in summaries], marker="o", label="d_eff")
+ plt.plot(widths, [row.fa_burden_nats for row in summaries], marker="o", label="k_FA proxy")
+ plt.xscale("log", base=2)
+ plt.yscale("log")
+ plt.xlabel("hidden width")
+ plt.ylabel("quantity")
+ plt.title("Capacity terms")
+ plt.legend()
+ plt.tight_layout()
+ plt.savefig(dims_path, dpi=180)
+ plt.close()
+ paths.append(dims_path)
+
+ return paths
+
+
+def save_distribution_plots(
+ rows: list[RunRow],
+ summaries: list[WidthSummaryRow],
+ outdir: Path,
+) -> list[Path]:
+ outdir.mkdir(parents=True, exist_ok=True)
+ paths: list[Path] = []
+ rng = np.random.default_rng(0)
+
+ train_path = outdir / "train_mse_trajectory_distribution.png"
+ plt.figure(figsize=(7.5, 4.8))
+ for run_type, color, marker, offset in [
+ ("bp", "tab:blue", "o", -0.035),
+ ("fa", "tab:orange", "x", 0.035),
+ ]:
+ subset = [row for row in rows if row.run_type == run_type]
+ x_values = np.array([row.width for row in subset], dtype=np.float64)
+ jitter = np.exp(rng.normal(offset, 0.018, size=len(subset)))
+ y_values = np.array([max(row.train_mse, 1e-10) for row in subset])
+ plt.scatter(
+ x_values * jitter,
+ y_values,
+ s=14,
+ alpha=0.25,
+ color=color,
+ marker=marker,
+ label=f"{run_type.upper()} trajectories",
+ )
+ summary_widths = np.array([row.width for row in summaries])
+ plt.plot(
+ summary_widths,
+ [max(row.bp_train_mean, 1e-10) for row in summaries],
+ color="tab:blue",
+ linewidth=2,
+ label="BP mean",
+ )
+ plt.plot(
+ summary_widths,
+ [max(row.fa_train_mean, 1e-10) for row in summaries],
+ color="tab:orange",
+ linewidth=2,
+ label="FA mean",
+ )
+ plt.xscale("log", base=2)
+ plt.yscale("log")
+ plt.xlabel("hidden width")
+ plt.ylabel("train MSE")
+ plt.title("Trajectory distribution with capacity thresholds")
+ add_capacity_thresholds(summaries)
+ plt.legend()
+ plt.tight_layout()
+ plt.savefig(train_path, dpi=180)
+ plt.close()
+ paths.append(train_path)
+
+ margin_path = outdir / "train_gap_trajectory_distribution_vs_fa_margin.png"
+ fa_rows = [row for row in rows if row.run_type == "fa"]
+ plt.figure(figsize=(7.5, 4.8))
+ plt.scatter(
+ [row.fa_capacity_margin for row in fa_rows],
+ [row.train_gap_to_bp for row in fa_rows],
+ s=14,
+ alpha=0.28,
+ color="tab:orange",
+ label="FA trajectories",
+ )
+ plt.plot(
+ [row.fa_capacity_margin for row in summaries],
+ [row.train_gap_mean for row in summaries],
+ color="black",
+ linewidth=2,
+ marker="o",
+ label="mean",
+ )
+ plt.axvline(0.0, color="black", linestyle="--", linewidth=1, label="FA margin=0")
+ plt.axhline(0.0, color="black", linewidth=1)
+ plt.xlabel("P - K_FA - N*out")
+ plt.ylabel("FA train MSE - BP train MSE")
+ plt.title("Trajectory gap vs predicted FA margin")
+ plt.legend()
+ plt.tight_layout()
+ plt.savefig(margin_path, dpi=180)
+ plt.close()
+ paths.append(margin_path)
+
+ return paths
+
+
+def main() -> None:
+ args = parse_args()
+ config = parse_config(args)
+ validate_config(config)
+ if config.torch_threads > 0:
+ torch.set_num_threads(config.torch_threads)
+ outdir = Path(config.outdir)
+ x_train, y_train, x_test, y_test, x_probe, _teacher = make_data(config)
+ rows: list[RunRow] = []
+ task_dim = task_dimension(config)
+
+ for width in config.widths:
+ p_count = parameter_count(config, width)
+ constraint_rank = fa_constraint_rank(config, width)
+ bp_margin = p_count - task_dim
+ fa_margin = p_count - constraint_rank - task_dim
+ burden = fa_burden_nats(config, width)
+ print(
+ f"width={width}: P={p_count}, K_FA={constraint_rank}, "
+ f"N*out={task_dim}, BP_margin={bp_margin}, FA_margin={fa_margin}, "
+ f"burden={burden:.6g}",
+ flush=True,
+ )
+ for init_index in range(config.init_seeds):
+ init_seed = 10_000 + init_index
+ initial_weights = initialize_weights(config, width, init_seed)
+ bp_weights = train(
+ initial_weights,
+ x_train,
+ y_train,
+ config.lr,
+ config.steps,
+ feedback=None,
+ optimizer=config.optimizer,
+ )
+ bp_train = mse(bp_weights, x_train, y_train)
+ bp_test = mse(bp_weights, x_test, y_test)
+ d_eff, hard_rank, lam = jacobian_effective_dimension(
+ bp_weights, x_probe, config.jacobian_lambda_rel
+ )
+ redundancy = p_count - d_eff - burden
+ rows.append(
+ RunRow(
+ width=width,
+ parameter_count=p_count,
+ init_seed=init_seed,
+ feedback_seed=-1,
+ run_type="bp",
+ train_mse=bp_train,
+ test_mse=bp_test,
+ bp_train_mse=bp_train,
+ bp_test_mse=bp_test,
+ train_gap_to_bp=0.0,
+ test_gap_to_bp=0.0,
+ task_dimension=task_dim,
+ fa_constraint_rank=constraint_rank,
+ bp_capacity_margin=bp_margin,
+ fa_capacity_margin=fa_margin,
+ d_eff=d_eff,
+ hard_rank=hard_rank,
+ jacobian_lambda=lam,
+ fa_burden_nats=burden,
+ redundancy_score=redundancy,
+ )
+ )
+
+ for feedback_index in range(config.feedback_seeds):
+ feedback_seed = 100_000 + init_index * 1000 + feedback_index
+ feedback = init_feedback(config, width, feedback_seed)
+ fa_weights = train(
+ initial_weights,
+ x_train,
+ y_train,
+ config.lr,
+ config.steps,
+ feedback=feedback,
+ optimizer=config.optimizer,
+ )
+ fa_train = mse(fa_weights, x_train, y_train)
+ fa_test = mse(fa_weights, x_test, y_test)
+ rows.append(
+ RunRow(
+ width=width,
+ parameter_count=p_count,
+ init_seed=init_seed,
+ feedback_seed=feedback_seed,
+ run_type="fa",
+ train_mse=fa_train,
+ test_mse=fa_test,
+ bp_train_mse=bp_train,
+ bp_test_mse=bp_test,
+ train_gap_to_bp=fa_train - bp_train,
+ test_gap_to_bp=fa_test - bp_test,
+ task_dimension=task_dim,
+ fa_constraint_rank=constraint_rank,
+ bp_capacity_margin=bp_margin,
+ fa_capacity_margin=fa_margin,
+ d_eff=d_eff,
+ hard_rank=hard_rank,
+ jacobian_lambda=lam,
+ fa_burden_nats=burden,
+ redundancy_score=redundancy,
+ )
+ )
+
+ partial_summaries = summarize_widths(rows)
+ outdir.mkdir(parents=True, exist_ok=True)
+ write_csv(outdir / "runs.partial.csv", rows)
+ write_csv(outdir / "width_summary.partial.csv", partial_summaries)
+
+ summaries = summarize_widths(rows)
+ outdir.mkdir(parents=True, exist_ok=True)
+ write_csv(outdir / "runs.csv", rows)
+ write_csv(outdir / "width_summary.csv", summaries)
+ payload = {
+ "config": asdict(config),
+ "width_summary": [asdict(row) for row in summaries],
+ }
+ (outdir / "summary.json").write_text(
+ json.dumps(payload, indent=2, sort_keys=True) + "\n"
+ )
+ plot_paths = []
+ if config.plot:
+ plot_paths.extend(save_plots(summaries, outdir))
+ plot_paths.extend(save_distribution_plots(rows, summaries, outdir))
+
+ for row in summaries:
+ print(
+ f"width={row.width}: "
+ f"BP_train={row.bp_train_mean:.6g}, FA_train={row.fa_train_mean:.6g}, "
+ f"train_gap={row.train_gap_mean:.6g}, "
+ f"BP_test={row.bp_test_mean:.6g}, FA_test={row.fa_test_mean:.6g}, "
+ f"test_gap={row.gap_mean:.6g}, "
+ f"BP_margin={row.bp_capacity_margin}, FA_margin={row.fa_capacity_margin}, "
+ f"d_eff={row.d_eff_mean:.3f}, R={row.redundancy_score_mean:.3f}"
+ )
+ print(f"runs: {outdir / 'runs.csv'}")
+ print(f"summary: {outdir / 'width_summary.csv'}")
+ for path in plot_paths:
+ print(f"plot: {path}")
+
+
+if __name__ == "__main__":
+ main()