diff options
| author | YurenHao0426 <Blackhao0426@gmail.com> | 2026-06-05 16:25:02 -0500 |
|---|---|---|
| committer | YurenHao0426 <Blackhao0426@gmail.com> | 2026-06-05 16:25:02 -0500 |
| commit | 6f1ec9c1079d5076575dd0bb64fd2e93fc096c80 (patch) | |
| tree | 2e93b0717a7d9c5c1b159acc88e1a52a9b7d3c25 | |
| parent | 39ea1acbb5ad62043cb60c0f96b3bcb109607831 (diff) | |
Validate exact initial FA erosion distribution
| -rw-r--r-- | notes/32_e0_distribution_validation.md | 117 | ||||
| -rw-r--r-- | scripts/actual_fa_initial_erosion_distribution.py | 325 |
2 files changed, 442 insertions, 0 deletions
diff --git a/notes/32_e0_distribution_validation.md b/notes/32_e0_distribution_validation.md new file mode 100644 index 0000000..8e4fbd4 --- /dev/null +++ b/notes/32_e0_distribution_validation.md @@ -0,0 +1,117 @@ +# Exact e0 Distribution Validation + +This note records the clean distributional validation for the actual FA initial +operator erosion theorem. + +## Theorem Being Validated + +Use a one-hidden-layer MLP with Gaussian feedback: + +```text +x -> W1 -> ReLU -> W2 -> output +``` + +For fixed forward weights, data, gates, and residual: + +```text +speed_BP = ||g_output^BP||^2 + ||g_hidden^BP||^2 +speed_FA = ||g_output^BP||^2 + <g_hidden^BP, g_hidden^FA(B)> +e0(B) = 1 - speed_FA / speed_BP +``` + +Because the hidden FA gradient is linear in Gaussian feedback `B`: + +```text +<g_hidden^BP, g_hidden^FA(B)> = <C(W,r), B> +``` + +Therefore: + +```text +<C(W,r), B> ~ Normal(0, sigma_B^2 ||C(W,r)||^2) +``` + +and: + +```text +e0(B) ~ Normal( + 1 - ||g_output^BP||^2 / speed_BP, + sigma_B^2 ||C(W,r)||^2 / speed_BP^2 +) +``` + +This is an exact conditional distribution for one-hidden-layer Gaussian FA at +initialization. It does not involve training horizon `T`. + +## Experiment + +Script: + +```text +scripts/actual_fa_initial_erosion_distribution.py +``` + +Run: + +```text +python scripts/actual_fa_initial_erosion_distribution.py \ + --widths 16 32 64 128 \ + --init-seeds 3 \ + --feedback-samples 4096 \ + --torch-threads 8 \ + --outdir outputs/actual_fa_initial_erosion_distribution +``` + +Outputs: + +```text +outputs/actual_fa_initial_erosion_distribution/e0_distribution_theory_vs_empirical.png +outputs/actual_fa_initial_erosion_distribution/e0_distribution_moment_calibration.png +outputs/actual_fa_initial_erosion_distribution/e0_distribution_rows.csv +``` + +## Results + +Representative rows: + +| width | init | theory mean | empirical mean | theory std | empirical std | KS | p-value | +|---:|---:|---:|---:|---:|---:|---:|---:| +| 16 | 0 | 0.113830 | 0.113413 | 0.027228 | 0.027401 | 0.0150 | 0.314 | +| 16 | 1 | 0.113705 | 0.113859 | 0.027261 | 0.027278 | 0.0103 | 0.778 | +| 32 | 0 | 0.121730 | 0.121544 | 0.018952 | 0.018693 | 0.0114 | 0.654 | +| 32 | 2 | 0.227193 | 0.227691 | 0.031148 | 0.030779 | 0.0166 | 0.206 | +| 64 | 0 | 0.031155 | 0.031119 | 0.003652 | 0.003583 | 0.0118 | 0.609 | +| 128 | 0 | 0.023004 | 0.022989 | 0.001630 | 0.001610 | 0.0135 | 0.441 | + +All tested panels show close agreement between theory density and empirical +random-feedback histograms. + +## Paper Use + +This is the clean validation figure for the actual FA local erosion theorem. + +It should replace any tangent-hierarchy figure if the goal is: + +```text +theory predicts a distribution +large random-feedback sampling produces the same distribution +``` + +Suggested main-text wording: + +```text +For one-hidden-layer Gaussian FA, the conditional distribution of the initial +operator erosion is exactly Gaussian. Figure X compares this predicted density +against 4096 random feedback draws per initialization across widths; the +empirical histograms and theoretical densities coincide without fitted +parameters. +``` + +This figure belongs to: + +```text +Contribution 4: actual FA initial operator erosion theorem +Contribution 5: distributional empirical validation +``` + +It does not require a training-time axis. diff --git a/scripts/actual_fa_initial_erosion_distribution.py b/scripts/actual_fa_initial_erosion_distribution.py new file mode 100644 index 0000000..019ec15 --- /dev/null +++ b/scripts/actual_fa_initial_erosion_distribution.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +"""Plot exact initial FA erosion distributions for one-hidden-layer MLPs. + +For a fixed forward initialization and residual, one-hidden-layer FA has + + speed_FA = output_speed + <C, B> + +with Gaussian feedback B. Therefore the one-step erosion + + e0 = 1 - speed_FA / speed_BP + +is exactly Gaussian conditional on the forward weights and data. +""" + +from __future__ import annotations + +import argparse +import csv +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 DistributionRow: + width: int + init_seed: int + feedback_samples: int + bp_speed: float + output_speed: float + hidden_bp_speed: float + theory_mean: float + theory_std: float + empirical_mean: float + empirical_std: float + ks_stat: float + ks_pvalue: float + mean_error: float + std_error: float + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Exact e0 distribution validation.") + parser.add_argument("--input-dim", type=int, default=16) + parser.add_argument("--output-dim", type=int, default=4) + parser.add_argument("--widths", type=int, nargs="+", default=[16, 32, 64, 128]) + parser.add_argument("--train-samples", type=int, default=128) + parser.add_argument("--init-seeds", type=int, default=3) + parser.add_argument("--feedback-samples", type=int, default=4096) + parser.add_argument("--data-seed", type=int, default=123) + parser.add_argument( + "--feedback-scale", + choices=["relu", "fan-in", "unit"], + default="relu", + ) + parser.add_argument("--torch-threads", type=int, default=8) + parser.add_argument( + "--outdir", + type=Path, + default=Path("outputs/actual_fa_initial_erosion_distribution"), + ) + return parser.parse_args() + + +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 make_data(samples: int, input_dim: int, output_dim: int, seed: int) -> tuple[Tensor, Tensor]: + generator = torch.Generator(device="cpu") + generator.manual_seed(seed) + x = torch.randn(samples, input_dim, generator=generator, dtype=torch.float64) + y = torch.randn(samples, output_dim, generator=generator, dtype=torch.float64) + return x, y + + +def init_weights(input_dim: int, output_dim: int, width: int, seed: int) -> tuple[Tensor, Tensor]: + generator = torch.Generator(device="cpu") + generator.manual_seed(seed) + w1 = torch.randn(width, input_dim, generator=generator, dtype=torch.float64) * math.sqrt(2.0 / input_dim) + w2 = torch.randn(output_dim, width, generator=generator, dtype=torch.float64) / math.sqrt(width) + return w1, w2 + + +def forward(w1: Tensor, w2: Tensor, x: Tensor) -> tuple[Tensor, Tensor, Tensor]: + z = x @ w1.T + h = torch.relu(z) + out = h @ w2.T + return z, h, out + + +def theory_terms( + w1: Tensor, + w2: Tensor, + x: Tensor, + y: Tensor, + scale: float, +) -> tuple[float, float, float, float, float, Tensor]: + z, h, out = forward(w1, w2, x) + batch = x.shape[0] + delta_out = (out - y) / batch + gate = (z > 0).to(torch.float64) + + grad_out = delta_out.T @ h + delta_hidden_bp = (delta_out @ w2) * gate + grad_hidden_bp = delta_hidden_bp.T @ x + + output_speed = float(torch.sum(grad_out * grad_out)) + hidden_bp_speed = float(torch.sum(grad_hidden_bp * grad_hidden_bp)) + bp_speed = output_speed + hidden_bp_speed + + # g_hidden_FA[a, d] = sum_c B[a, c] S[a, c, d] + # C[a, c] = sum_d g_hidden_BP[a, d] S[a, c, d] + # Use einsum over samples n and input dimension d: + # S[a,c,d] = sum_n gate[n,a] * x[n,d] * delta_out[n,c] + s_tensor = torch.einsum("na,nd,nc->acd", gate, x, delta_out) + coeff = torch.einsum("ad,acd->ac", grad_hidden_bp, s_tensor) + z_std = scale * float(torch.linalg.norm(coeff)) + theory_mean = 1.0 - output_speed / bp_speed + theory_std = z_std / bp_speed + return bp_speed, output_speed, hidden_bp_speed, theory_mean, theory_std, coeff + + +def sample_erosions( + bp_speed: float, + output_speed: float, + coeff: Tensor, + scale: float, + samples: int, + seed: int, +) -> np.ndarray: + generator = torch.Generator(device="cpu") + generator.manual_seed(seed) + values = [] + for _ in range(samples): + feedback = torch.randn( + coeff.shape, + generator=generator, + dtype=torch.float64, + ) * scale + hidden_mixed = float(torch.sum(feedback * coeff)) + speed_fa = output_speed + hidden_mixed + values.append(1.0 - speed_fa / bp_speed) + return np.array(values, dtype=np.float64) + + +def run_one(args: argparse.Namespace, x: Tensor, y: Tensor, width: int, init_seed: int) -> tuple[DistributionRow, np.ndarray]: + w1, w2 = init_weights(args.input_dim, args.output_dim, width, init_seed) + scale = feedback_scale(width, args.feedback_scale) + bp_speed, output_speed, hidden_bp_speed, theory_mean, theory_std, coeff = theory_terms( + w1, + w2, + x, + y, + scale, + ) + erosions = sample_erosions( + bp_speed, + output_speed, + coeff, + scale, + args.feedback_samples, + seed=1_000_000 + 10_000 * init_seed + width, + ) + standardized = (erosions - theory_mean) / theory_std + ks = stats.kstest(standardized, "norm") + row = DistributionRow( + width=width, + init_seed=init_seed, + feedback_samples=args.feedback_samples, + bp_speed=bp_speed, + output_speed=output_speed, + hidden_bp_speed=hidden_bp_speed, + theory_mean=theory_mean, + theory_std=theory_std, + empirical_mean=float(np.mean(erosions)), + empirical_std=float(np.std(erosions, ddof=1)), + ks_stat=float(ks.statistic), + ks_pvalue=float(ks.pvalue), + mean_error=float(np.mean(erosions) - theory_mean), + std_error=float(np.std(erosions, ddof=1) - theory_std), + ) + return row, erosions + + +def write_rows(path: Path, rows: list[DistributionRow]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=list(DistributionRow.__annotations__.keys())) + writer.writeheader() + for row in rows: + writer.writerow(asdict(row)) + + +def plot_overlay( + rows: list[DistributionRow], + samples: dict[tuple[int, int], np.ndarray], + outdir: Path, +) -> Path: + selected: list[DistributionRow] = [] + for width in sorted({row.width for row in rows}): + width_rows = [row for row in rows if row.width == width] + selected.append(width_rows[0]) + + cols = 2 + panel_rows = int(math.ceil(len(selected) / cols)) + fig, axes = plt.subplots(panel_rows, cols, figsize=(11.0, 4.2 * panel_rows), dpi=180, squeeze=False) + for ax in axes.ravel(): + ax.axis("off") + + for ax, row in zip(axes.ravel(), selected): + ax.axis("on") + values = samples[(row.width, row.init_seed)] + lo = min(float(np.quantile(values, 0.001)), row.theory_mean - 4.0 * row.theory_std) + hi = max(float(np.quantile(values, 0.999)), row.theory_mean + 4.0 * row.theory_std) + grid = np.linspace(lo, hi, 500) + density = stats.norm.pdf(grid, loc=row.theory_mean, scale=row.theory_std) + ax.hist(values, bins=70, density=True, color="#c65f16", alpha=0.42, label="empirical samples") + ax.plot(grid, density, color="#174f91", linewidth=2.4, label="theory Gaussian") + ax.axvline(row.theory_mean, color="#174f91", linewidth=1.4, linestyle="--") + ax.axvline(row.empirical_mean, color="#a84708", linewidth=1.4, linestyle=":") + ax.set_title( + f"width={row.width}, init={row.init_seed} | " + f"KS={row.ks_stat:.3f}, p={row.ks_pvalue:.2f}" + ) + ax.set_xlabel("initial FA operator erosion e0") + ax.set_ylabel("density") + ax.legend(fontsize=8) + ax.grid(alpha=0.16) + + fig.suptitle("Exact e0 distribution: theory density vs random-feedback samples", y=1.01) + fig.tight_layout() + path = outdir / "e0_distribution_theory_vs_empirical.png" + fig.savefig(path, bbox_inches="tight") + plt.close(fig) + return path + + +def plot_moments(rows: list[DistributionRow], outdir: Path) -> Path: + fig, axes = plt.subplots(1, 2, figsize=(11.2, 4.8), dpi=180) + widths = sorted({row.width for row in rows}) + for width in widths: + sub = [row for row in rows if row.width == width] + axes[0].scatter( + [row.theory_mean for row in sub], + [row.empirical_mean for row in sub], + s=34, + alpha=0.72, + label=f"w={width}", + ) + axes[1].scatter( + [row.theory_std for row in sub], + [row.empirical_std for row in sub], + s=34, + alpha=0.72, + label=f"w={width}", + ) + for ax, title in zip(axes, ["mean", "standard deviation"]): + values = [] + if title == "mean": + values = [v for row in rows for v in (row.theory_mean, row.empirical_mean)] + else: + values = [v for row in rows for v in (row.theory_std, row.empirical_std)] + lo, hi = min(values), max(values) + pad = 0.04 * (hi - lo + 1e-12) + ax.plot([lo - pad, hi + pad], [lo - pad, hi + pad], color="black", linewidth=1.0) + ax.set_title(f"theory vs empirical {title}") + ax.set_xlabel("theory") + ax.set_ylabel("empirical") + ax.grid(alpha=0.16) + axes[0].legend(fontsize=8, ncols=2) + fig.tight_layout() + path = outdir / "e0_distribution_moment_calibration.png" + fig.savefig(path, bbox_inches="tight") + plt.close(fig) + return path + + +def main() -> None: + args = parse_args() + torch.set_num_threads(args.torch_threads) + x, y = make_data(args.train_samples, args.input_dim, args.output_dim, args.data_seed) + rows: list[DistributionRow] = [] + sample_map: dict[tuple[int, int], np.ndarray] = {} + for width in args.widths: + for init_seed in range(args.init_seeds): + row, erosions = run_one(args, x, y, width, init_seed) + rows.append(row) + sample_map[(width, init_seed)] = erosions + print( + f"width={width} init={init_seed}: " + f"mean theory={row.theory_mean:.6f} empirical={row.empirical_mean:.6f}; " + f"std theory={row.theory_std:.6f} empirical={row.empirical_std:.6f}; " + f"KS={row.ks_stat:.4f} p={row.ks_pvalue:.3g}", + flush=True, + ) + + args.outdir.mkdir(parents=True, exist_ok=True) + csv_path = args.outdir / "e0_distribution_rows.csv" + write_rows(csv_path, rows) + paths = [ + plot_overlay(rows, sample_map, args.outdir), + plot_moments(rows, args.outdir), + ] + print(f"rows: {csv_path}") + for path in paths: + print(f"plot: {path}") + + +if __name__ == "__main__": + main() |
