summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYurenHao0426 <Blackhao0426@gmail.com>2026-07-27 17:47:13 -0500
committerYurenHao0426 <Blackhao0426@gmail.com>2026-07-27 17:47:13 -0500
commite65dd2e2460b1da48c83a930304cf9b269fc4447 (patch)
tree94f583b0fb5e9989bd6c68f7f0e950c6ef91756d
parent82a49011de15287583d8cfec11ac5cca7efee747 (diff)
Add AAAI depth experiments and diagnostic figures
-rw-r--r--.gitignore3
-rw-r--r--README.md20
-rw-r--r--notes/40_reproduction_manifest.md13
-rw-r--r--notes/41_paper_plan.md6
-rw-r--r--notes/42_master_technical_reference.md6
-rw-r--r--notes/43_aaai_accept_bar_experiments.md272
-rw-r--r--notes/README.md6
-rw-r--r--paper/figures/aaai/cnn_initialization_calibration.pngbin0 -> 67216 bytes
-rw-r--r--paper/figures/aaai/figure1_mismatch_vs_initial_cost.pngbin0 -> 265670 bytes
-rw-r--r--paper/figures/aaai/figure2_training_dynamics.pngbin0 -> 314052 bytes
-rw-r--r--paper/figures/aaai/learning_rate_control_by_depth.pngbin0 -> 88232 bytes
-rw-r--r--paper/figures/aaai/parameter_matched_gap_by_depth.pngbin0 -> 68236 bytes
-rw-r--r--scripts/README.md6
-rw-r--r--scripts/aaai_depth_experiments.py633
-rw-r--r--scripts/cnn_initialization_validation.py320
-rw-r--r--scripts/feedback_rules.py187
-rw-r--r--scripts/learning_rate_compensation.py488
-rw-r--r--scripts/parameter_matched_depth_control.py174
-rw-r--r--scripts/plot_aaai_main_figures.py311
-rw-r--r--scripts/test_feedback_rules.py54
20 files changed, 2490 insertions, 9 deletions
diff --git a/.gitignore b/.gitignore
index 12c5ea2..148c1cd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,6 +40,9 @@ Thumbs.db
# Logs
*.log
+# Local handoff bundles
+facap_aaai_*.zip
+
# Paper build artifacts
paper/*.pdf
paper/*.aux
diff --git a/README.md b/README.md
index 171bd24..2bdd779 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,9 @@
# facap — What Does Random Feedback Cost?
-Theory + experiments quantifying the training cost of **feedback alignment**
-(FA: fixed random backward matrices instead of transposed weights) relative to
-backpropagation in MLPs. Target venue: **AAAI-27** (abstract 2026-07-20,
-paper 2026-07-27).
+Theory + experiments quantifying the training cost of **fixed random
+feedback** relative to backpropagation. Exact initialization results cover
+FA and DFA; finite-time analysis and experiments focus on FA. Target venue:
+**AAAI-27** (abstract 2026-07-20, paper 2026-07-27).
## Results in one table
@@ -11,17 +11,19 @@ paper 2026-07-27).
|---|---|
| static alignment is an exact Beta(1/2,(D-1)/2) law; log-volume cost adds to Theta(Ln^2) | 700k samples, KS p 0.17-0.99; Gamma(L,1) max KS 0.0042 |
| no prior-free feedback initialization beats isotropic (minimax 1/D) | exact theorem + lam_min table |
-| initial FA/BP speed gap = hidden-layer BP speed share, exactly, any depth | synthetic 0.4378 vs 0.4403; MNIST diff <= 0.0089 over share 0.39-0.82 |
+| exact expected initial deficit = nonoutput BP decrease share, for FA and DFA | depth 1/2/3/4/6: max row error 0.0092; CE CNN max error 0.0083 |
| one-hidden-layer e0 is exactly Gaussian | KS p 0.13-0.999, real-backward sampler |
-| the finite-time gap is a closed-form **soft ramp** — no phase transition | dense T=30000: log-linear R^2 0.993; closed form corr 0.933 matched / 0.982 ensemble; lam_min corr 0.987 |
-| a two-snapshot early-velocity estimator predicts the gap with no fit | 256 traj: MAE 0.0019, corr 0.999; stress grid 0.994-0.99989; MNIST MAE 0.0020 (3.9% rel) |
+| random-direction deletion is not an FA cost model | up to 250x finite-time overprediction |
+| matrix mismatch does not set FA's finite-time cost | depth 1->6: joint alignment proxy drops 18.1 orders while width-64 gap grows 0.0421->0.3461; frozen MAE 0.0669 vs two-snapshot 0.0104/0.0139 |
+| initialization-rate compensation is material but incomplete | gap reduction 29-51% across depths; independently tuned BP/FA retain a gap |
| optimization cost is invariant; test-side effect is task-dependent | teacher task: test gap sign flip (-0.120+/-0.013 at w=32) |
## Layout
-- `notes/` — project memory. Entry points: `notes/41_paper_plan.md` (plan),
+- `notes/` — project memory. Entry points: `notes/43_aaai_accept_bar_experiments.md`
+ (new experiments), `notes/41_paper_plan.md` (older writing plan),
`notes/36_evidence_ledger.md` (numbers), `notes/40_reproduction_manifest.md`
- (runs). `notes/README.md` indexes all 41 notes with status.
+ (runs). `notes/README.md` indexes all 43 notes with status.
- `scripts/` — all experiments (python, float64, CPU; deterministic seeds).
`scripts/README.md` has the status table and recipes.
- `outputs/` — gitignored, regenerable. Paper-grade runs are exactly those in
diff --git a/notes/40_reproduction_manifest.md b/notes/40_reproduction_manifest.md
index 009f058..d7390cb 100644
--- a/notes/40_reproduction_manifest.md
+++ b/notes/40_reproduction_manifest.md
@@ -54,10 +54,23 @@ deterministic given the seeds shown. Long runs use `nohup`.
| kernel code self-test vs autograd (<= 5.3e-15) | `python scripts/real_data_validation.py --self-test` | stdout | 38 |
| teacher task: train gap 0.249->0.0069 soft; test gap sign flip (-0.120+/-0.013 at w=32) | `python scripts/downstream_capacity_sweep.py --task mlp --teacher-width 64 --teacher-hidden-layers 2 --normalize-targets --widths 8 12 16 24 32 48 64 96 --train-samples 256 --test-samples 2048 --probe-samples 32 --steps 8000 --lr 0.01 --optimizer sgd --init-seeds 3 --feedback-seeds 8 --data-seed 7 --skip-jacobian --plot --outdir outputs/teacher_test_gap_sgd_T8000` then `python scripts/plot_teacher_test_gap.py` | `teacher_test_gap_sgd_T8000/` | 39 |
+## Focused-paper controls added 2026-07-21
+
+| claim | command | outputs | note |
+|---|---|---|---|
+| FA and DFA exact initialization calibration through depth 6: 60 rows, max abs error 0.009164 | `python scripts/aaai_depth_experiments.py --part init --depths 1 2 3 4 6 --width 64 --train-samples 64 --init-seeds 6 --feedback-draws 256 --torch-threads 16 --outdir outputs/aaai_depth_experiments` | `aaai_depth_experiments/initialization_rows.csv` | 43 |
+| FA finite-time depth x width grid: matrix-alignment proxy drops 18.06 orders while width-64 gap grows 0.0421 -> 0.3461; frozen MAE 0.06693; two-snapshot MAE 0.01043 / 0.01394 | `python scripts/aaai_depth_experiments.py --part finite --depths 1 2 3 4 6 --finite-widths 32 64 96 --train-samples 64 --finite-init-seeds 4 --finite-feedback-seeds 8 --lr 0.001 --horizon 50 --early-step 20 --torch-threads 16 --outdir outputs/aaai_depth_experiments`, then `--part summarize` | `aaai_depth_experiments/finite_time_*`, `mismatch_vs_cost_by_depth.*` | 43 |
+| parameter-matched depth control around P=5376: widths 269/64/47/39/31; gap is nonmonotone and peaks at depth 4 | `python scripts/parameter_matched_depth_control.py --depths 1 2 3 4 6 --reference-depth 2 --reference-width 64 --train-samples 64 --init-seeds 4 --feedback-seeds 8 --lr 0.001 --horizon 50 --early-step 20 --torch-threads 16 --outdir outputs/parameter_matched_depth_control` | `parameter_matched_depth_control/` | 43 |
+| initialization-rate compensation reduces but does not remove the gap (29-51% reduction); separate stability-confirmed tuning preserves a gap | `python scripts/learning_rate_compensation.py --depths 1 2 3 4 6 --width 64 --train-samples 128 --steps 200 --base-lr 0.001 --tune-init-seeds 4 --tune-feedback-seeds 4 --eval-init-seeds 6 --eval-feedback-seeds 8 --torch-threads 16 --outdir outputs/learning_rate_compensation` | `learning_rate_compensation/` | 43 |
+| cross-entropy CNN initialization identity for FA and DFA: max abs error 0.008309, max standardized error 1.308 | `python scripts/cnn_initialization_validation.py --train-samples 128 --image-size 8 --channels 8 12 --init-seeds 4 --feedback-draws 2048 --torch-threads 8 --outdir outputs/cnn_initialization_validation` | `cnn_initialization_validation/` | 43 |
+| two focused three-panel figures | `python scripts/plot_aaai_main_figures.py --outdir outputs/aaai_main_figures` | `aaai_main_figures/` | 43 |
+
## Figure -> file map (current best versions)
| paper figure (plan in note 41) | file |
|---|---|
+| focused Figure 1: mismatch vs exact initial cost | `aaai_main_figures/figure1_mismatch_vs_initial_cost.{png,pdf}` |
+| focused Figure 2: later cost and operator evolution | `aaai_main_figures/figure2_training_dynamics.{png,pdf}` |
| soft ramp + closed form + ramp law | `closed_form_soft_ramp/closed_form_vs_measured_ramp.png` |
| e0 moment calibration (synthetic) | `actual_fa_initial_operator_moments/predicted_vs_empirical_initial_erosion_mean.png` |
| e0 Gaussian panels | `actual_fa_initial_erosion_distribution/e0_distribution_theory_vs_empirical.png` |
diff --git a/notes/41_paper_plan.md b/notes/41_paper_plan.md
index d3bd12f..41f6a05 100644
--- a/notes/41_paper_plan.md
+++ b/notes/41_paper_plan.md
@@ -1,5 +1,11 @@
# AAAI-27 Paper Plan
+> **Superseded framing (2026-07-21).** The original four-figure soft-ramp plan
+> below predates the focused matrix-mismatch-versus-optimization-cost review.
+> New experiment results and the two replacement composite figures are in
+> note 43. Keep this file for venue constraints and historical scheduling,
+> not as the current narrative or figure plan.
+
The single planning document for the writing phase. Inputs: evidence ledger
(note 36), reproduction manifest (note 40), corrections (note 37). Supersedes
note 03 and the figure list in note 35.
diff --git a/notes/42_master_technical_reference.md b/notes/42_master_technical_reference.md
index 82c436e..65a2ae5 100644
--- a/notes/42_master_technical_reference.md
+++ b/notes/42_master_technical_reference.md
@@ -5,6 +5,12 @@ derivations, exact numbers, configs, and negative results. Built for writing
the paper by hand: you should not need any other file open. Cross-checked
against the raw CSVs on 2026-06-13.
+> **2026-07-21 experiment addendum:** note 43 adds the FA/DFA depth-1-to-6
+> calibration, FA finite-time depth/width grid, learning-rate controls, and
+> cross-entropy CNN validation. Its results supersede this file's earlier
+> statement that DFA and non-MLP validation were absent; the theorem/proof
+> material below remains the technical source.
+
Conventions in this file:
- "no fit" = no post-hoc scalar tuned to the reported data; predictions come
from initialization or two early kernel snapshots.
diff --git a/notes/43_aaai_accept_bar_experiments.md b/notes/43_aaai_accept_bar_experiments.md
new file mode 100644
index 0000000..11a981d
--- /dev/null
+++ b/notes/43_aaai_accept_bar_experiments.md
@@ -0,0 +1,272 @@
+# AAAI Accept-Bar Experiments
+
+Status: **complete, paper-grade local outputs** (2026-07-21). This note
+records the experiments added after the focused-paper review. It supplements
+the pre-existing master reference (note 42); it does not rewrite the paper.
+
+## 1. What was added
+
+Four missing controls are now implemented and run:
+
+1. FA and DFA initialization-cost calibration at hidden depths `{1,2,3,4,6}`.
+2. FA finite-time gap and operator-predictor errors over depth
+ `{1,2,3,4,6}` and width `{32,64,96}`.
+3. Same-rate, exact-initialization-rate-compensated, and independently tuned
+ BP/FA learning-rate controls.
+4. A non-MLP initialization validation: a two-convolution-layer CNN with
+ cross-entropy loss on an MNIST subset, for both FA and DFA.
+
+Two three-panel composite figures are generated from these results plus the
+existing random-direction-deletion negative control.
+
+## 2. Shared implementation checks
+
+New shared implementation: `scripts/feedback_rules.py`.
+
+- Arbitrary-depth manual BP agrees with autograd to `5.55e-17` max error.
+- FA and DFA output-layer gradients agree with BP exactly.
+- A 4096-feedback-draw numerical moment test for each rule lands within the
+ Monte Carlo interval predicted by the exact initialization result.
+- The CNN manual BP agrees with autograd to `5.55e-17`; FA and DFA preserve
+ the exact output gradient.
+
+Commands:
+
+```bash
+python scripts/test_feedback_rules.py
+python scripts/cnn_initialization_validation.py --self-test
+```
+
+## 3. FA/DFA initialization calibration across depth
+
+Configuration:
+
+- synthetic random regression, `N=64`, input dimension 16, output dimension 4;
+- fixed width 64;
+- hidden depth `{1,2,3,4,6}`;
+- 6 fixed forward initializations per depth;
+- 256 independent feedback draws per forward initialization and rule;
+- real FA/DFA backward pass for every draw (15,360 feedback evaluations).
+
+The predicted quantity is the relative deficit in BP's first-order loss
+decrease:
+
+```text
+1 - ||g_output^BP||^2 / sum_l ||g_l^BP||^2.
+```
+
+Depth-aggregated results:
+
+| depth | predicted | FA measured | DFA measured |
+|---:|---:|---:|---:|
+| 1 | 0.050380 | 0.050200 | 0.050578 |
+| 2 | 0.276966 | 0.275983 | 0.278078 |
+| 3 | 0.402635 | 0.402218 | 0.402282 |
+| 4 | 0.458542 | 0.457015 | 0.456779 |
+| 6 | 0.623193 | 0.624234 | 0.623791 |
+
+Across all 60 rule/depth/init rows, max absolute calibration error is
+`0.009164`; max standardized error is `2.412` Monte Carlo standard errors.
+The same exact prediction covers FA and DFA even though their feedback paths
+differ.
+
+Output: `outputs/aaai_depth_experiments/initialization_rows.csv` and
+`initial_deficit_calibration.png`.
+
+## 4. Finite-time FA cost across depth and width
+
+Configuration:
+
+- same synthetic task family, `N=64`, output dimension 4;
+- hidden depth `{1,2,3,4,6}`, width `{32,64,96}`;
+- 4 forward initializations x 8 FA feedback initializations per cell;
+- full-batch SGD, fixed learning rate `1e-3`, horizon `T=50`;
+- early operator snapshot `s=20`;
+- 480 FA trajectories plus cached matched BP trajectories.
+
+Curve uncertainty is computed across forward-initialization means (feedback
+draws are nested within each initialization), rather than treating all 32
+feedback trajectories in a cell as independent.
+
+At width 64, the measured mean gap rises with depth:
+
+| depth | measured | frozen `K(0)` | linear velocity | early retangent |
+|---:|---:|---:|---:|---:|
+| 1 | 0.042081 | 0.046148 | 0.041747 | 0.043214 |
+| 2 | 0.152697 | 0.176001 | 0.150869 | 0.159079 |
+| 3 | 0.252548 | 0.301208 | 0.246641 | 0.263831 |
+| 4 | 0.281210 | 0.360586 | 0.274093 | 0.302059 |
+| 6 | 0.346053 | 0.518519 | 0.312131 | 0.371649 |
+
+For the same fixed-width architecture, the independent per-block alignment
+proxy `prod_l 1/D_l` falls from `3.91e-3` at depth 1 to `3.39e-21` at depth 6,
+an 18.06-order-of-magnitude change. Over that sweep, the measured initial
+deficit changes from `0.0504` to `0.6240`, and the measured finite-time gap
+from `0.0421` to `0.3461`. Thus depth makes the matrix-level mismatch
+astronomically worse without producing a commensurate optimization-cost
+explosion. The direct comparison is saved in
+`mismatch_vs_cost_by_depth.{csv,png}`.
+
+Across the full 480-row grid:
+
+| predictor | MAE | relative MAE vs mean measured gap | correlation |
+|---|---:|---:|---:|
+| frozen `K(0)` | 0.06693 | 28.6% | 0.97166 |
+| linear operator velocity | 0.01043 | 4.45% | 0.99492 |
+| early retangent | 0.01394 | 5.95% | 0.99830 |
+
+The frozen operator increasingly underpredicts progress at larger depth. Two
+snapshots recover most of the magnitude, supporting the scoped statement that
+the later cost depends on operator evolution. The finite-time result remains
+FA-only.
+
+Output: `outputs/aaai_depth_experiments/finite_time_rows.csv`,
+`finite_time_metrics.csv`, and the corresponding plots.
+
+### Parameter-matched supplement
+
+The fixed-width result is accompanied by an equal-parameter-count control.
+Using depth-2 width-64 as the target (`P=5376`, no biases), the closest
+uniform widths are `{269,64,47,39,31}` for depths `{1,2,3,4,6}`, with all
+parameter counts within 49 of the target. Each cell contains 4 forward
+initializations x 8 feedback initializations; error bars again use the four
+forward-initialization means.
+
+| depth | width | parameters | measured gap |
+|---:|---:|---:|---:|
+| 1 | 269 | 5380 | 0.02695 |
+| 2 | 64 | 5376 | 0.15270 |
+| 3 | 47 | 5358 | 0.18857 |
+| 4 | 39 | 5343 | 0.37599 |
+| 6 | 31 | 5425 | 0.21024 |
+
+The gap is not monotone in depth once parameter count is fixed: it peaks at
+depth 4 and falls at depth 6. This is another reason not to infer optimization
+cost directly from the number of independently mismatched matrices. Output:
+`outputs/parameter_matched_depth_control/`.
+
+## 5. Learning-rate alternative explanation
+
+Configuration:
+
+- fixed width 64, depth `{1,2,3,4,6}`, `N=128`, 200 steps;
+- baseline BP/FA learning rate `1e-3`;
+- compensated FA rate `1e-3 / rho`, with `rho` read from one BP backward pass;
+- independent BP and FA rates selected using 4 tuning initializations x 4 FA
+ feedback seeds from
+ `{0.00025,0.0005,0.001,0.002,0.004,0.008,0.016,0.032,0.064,0.128,0.256}`;
+- stability-confirmed evaluation on 6 new forward initializations x 8 feedback
+ initializations; all 720 reported evaluation rows stable.
+
+Learning-rate curve uncertainty is likewise computed across the six forward
+initializations, with eight feedback draws nested within each one.
+
+| depth | same-rate gap | compensated gap | reduction | independently tuned gap |
+|---:|---:|---:|---:|---:|
+| 1 | 0.05906 | 0.04053 | 31.4% | 0.56940 |
+| 2 | 0.19916 | 0.09729 | 51.2% | 0.64210 |
+| 3 | 0.27346 | 0.16263 | 40.5% | 1.13002 |
+| 4 | 0.29340 | 0.18442 | 37.1% | 1.30812 |
+| 6 | 0.40664 | 0.28850 | 29.1% | 1.75739 |
+
+Initialization compensation removes a material part of the same-rate gap but
+does not remove it at any tested depth. Independent tuning does not rescue
+FA: the selected BP rates are `0.064-0.128`, whereas the selected FA rates
+fall from `0.064` at depth 1 to stability-confirmed `0.004` at depth 6. BP therefore makes much
+more progress in the same 200-step budget. This result should be described as
+a fixed-horizon optimization comparison, not a claim about asymptotic
+convergence.
+
+Output: `outputs/learning_rate_compensation/`.
+
+## 6. Cross-entropy CNN initialization validation
+
+Configuration:
+
+- MNIST subset, 128 examples resized to `8x8`;
+- `Conv(1,8,3x3) -> ReLU -> Conv(8,12,3x3) -> ReLU -> global average pool -> linear`;
+- cross-entropy loss;
+- 4 forward initializations;
+- 2048 real-backward feedback draws per initialization and rule (FA and DFA).
+
+The predicted deficit spans `0.592-0.816`. Across the eight rows, max
+absolute calibration error is `0.008309`, and max standardized error is only
+`1.308` Monte Carlo standard errors. This validates the initialization
+identity outside squared-loss MLPs without making a finite-time CNN claim.
+
+Output: `outputs/cnn_initialization_validation/`.
+
+## 7. Figures
+
+- `outputs/aaai_main_figures/figure1_mismatch_vs_initial_cost.{png,pdf}`:
+ BP/FA/DFA schematic; minimax mismatch plus the 250x null-model
+ overprediction; joint initialization calibration across rule, depth, MNIST
+ MLP, and cross-entropy CNN.
+- `outputs/aaai_main_figures/figure2_training_dynamics.{png,pdf}`:
+ measured finite-time cost over depth/width; frozen-operator calibration;
+ two-snapshot calibration.
+- Supplemental learning-rate figures:
+ `outputs/learning_rate_compensation/learning_rate_control_by_depth.png` and
+ `learning_rate_tuning_curves.png`.
+- Direct depth contrast:
+ `outputs/aaai_depth_experiments/mismatch_vs_cost_by_depth.{csv,png}`.
+- Parameter-matched supplement:
+ `outputs/parameter_matched_depth_control/parameter_matched_gap_by_depth.png`.
+
+## 8. Claim boundary after these experiments
+
+Supported:
+
+- The exact expected initialization result applies to both FA and DFA under
+ independent zero-mean feedback, and is validated through depth 6.
+- It also holds for a cross-entropy CNN in the tested convolutional feedback
+ implementation.
+- Simple initial-rate compensation reduces but does not eliminate the
+ finite-time FA/BP gap in the tested MLP setting.
+- FA's later gap and frozen-model error grow with depth; two early operator
+ snapshots remain predictive.
+
+Not supported / not claimed:
+
+- DFA finite-time dynamics.
+- CNN finite-time training equivalence or modern large-scale scaling.
+- A universal absence of width/depth thresholds.
+- A per-eigenmode erosion theorem.
+
+## 9. Reproduction commands
+
+```bash
+python scripts/aaai_depth_experiments.py --part init \
+ --depths 1 2 3 4 6 --width 64 --train-samples 64 \
+ --init-seeds 6 --feedback-draws 256 --torch-threads 16 \
+ --outdir outputs/aaai_depth_experiments
+
+python scripts/aaai_depth_experiments.py --part finite \
+ --depths 1 2 3 4 6 --finite-widths 32 64 96 --train-samples 64 \
+ --finite-init-seeds 4 --finite-feedback-seeds 8 \
+ --lr 0.001 --horizon 50 --early-step 20 --torch-threads 16 \
+ --outdir outputs/aaai_depth_experiments
+
+python scripts/aaai_depth_experiments.py --part summarize \
+ --outdir outputs/aaai_depth_experiments
+
+python scripts/parameter_matched_depth_control.py \
+ --depths 1 2 3 4 6 --reference-depth 2 --reference-width 64 \
+ --train-samples 64 --init-seeds 4 --feedback-seeds 8 \
+ --lr 0.001 --horizon 50 --early-step 20 --torch-threads 16 \
+ --outdir outputs/parameter_matched_depth_control
+
+python scripts/learning_rate_compensation.py \
+ --depths 1 2 3 4 6 --width 64 --train-samples 128 --steps 200 \
+ --base-lr 0.001 --tune-init-seeds 4 --tune-feedback-seeds 4 \
+ --eval-init-seeds 6 --eval-feedback-seeds 8 --torch-threads 16 \
+ --outdir outputs/learning_rate_compensation
+
+python scripts/cnn_initialization_validation.py \
+ --train-samples 128 --image-size 8 --channels 8 12 \
+ --init-seeds 4 --feedback-draws 2048 --torch-threads 8 \
+ --outdir outputs/cnn_initialization_validation
+
+python scripts/plot_aaai_main_figures.py \
+ --outdir outputs/aaai_main_figures
+```
diff --git a/notes/README.md b/notes/README.md
index 853c122..021b966 100644
--- a/notes/README.md
+++ b/notes/README.md
@@ -7,6 +7,7 @@ capacity & operator-gap analysis)** — target AAAI-27.
| doc | role |
|---|---|
+| [43_aaai_accept_bar_experiments.md](43_aaai_accept_bar_experiments.md) | **new focused-paper experiments**: FA/DFA depth calibration, finite-time depth grid, learning-rate controls, CE CNN validation, and two composite figures |
| [42_master_technical_reference.md](42_master_technical_reference.md) | **write the paper from this**: every theorem with full statement + proof, every result with exact numbers/configs, derivations, negative results, honesty boundaries -- self-contained |
| [41_paper_plan.md](41_paper_plan.md) | **the plan**: venue rules, narrative, section/page budget, theorem & figure numbering, related-work list, schedule, risks |
| [36_evidence_ledger.md](36_evidence_ledger.md) | **the numbers, condensed**: every claim with its corrected headline figure, claim discipline, booby traps |
@@ -73,6 +74,11 @@ Phase 6 — wrap-up for AAAI-27 (2026-06-09..13):
test gap**; 40 reproduction manifest; 41 paper plan; 42 **master technical
reference** (full proofs + numbers, the write-from doc).
+Phase 7 — focused accept-bar controls (2026-07-21):
+- 43 **FA/DFA depth calibration, FA finite-time depth/width grid, learning-rate
+ compensation and independent tuning, cross-entropy CNN initialization
+ validation, and focused Figures 1-2**.
+
## Note conventions
- Prefer formulas and precise claims over prose-only ideas.
diff --git a/paper/figures/aaai/cnn_initialization_calibration.png b/paper/figures/aaai/cnn_initialization_calibration.png
new file mode 100644
index 0000000..7312fe6
--- /dev/null
+++ b/paper/figures/aaai/cnn_initialization_calibration.png
Binary files differ
diff --git a/paper/figures/aaai/figure1_mismatch_vs_initial_cost.png b/paper/figures/aaai/figure1_mismatch_vs_initial_cost.png
new file mode 100644
index 0000000..1195734
--- /dev/null
+++ b/paper/figures/aaai/figure1_mismatch_vs_initial_cost.png
Binary files differ
diff --git a/paper/figures/aaai/figure2_training_dynamics.png b/paper/figures/aaai/figure2_training_dynamics.png
new file mode 100644
index 0000000..ddaf7d4
--- /dev/null
+++ b/paper/figures/aaai/figure2_training_dynamics.png
Binary files differ
diff --git a/paper/figures/aaai/learning_rate_control_by_depth.png b/paper/figures/aaai/learning_rate_control_by_depth.png
new file mode 100644
index 0000000..d363922
--- /dev/null
+++ b/paper/figures/aaai/learning_rate_control_by_depth.png
Binary files differ
diff --git a/paper/figures/aaai/parameter_matched_gap_by_depth.png b/paper/figures/aaai/parameter_matched_gap_by_depth.png
new file mode 100644
index 0000000..954423f
--- /dev/null
+++ b/paper/figures/aaai/parameter_matched_gap_by_depth.png
Binary files differ
diff --git a/scripts/README.md b/scripts/README.md
index 51dcbe3..c7c5ead 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -20,6 +20,12 @@ the recipes further down this file cover the static-geometry scripts).
| `finite_time_kernel_diagnostic.py` | oracle time-varying K(t) vs fixed K(0) | 13 |
| `real_data_validation.py` | MNIST: T3 + estimator (has `--self-test`) | 38 |
| `plot_teacher_test_gap.py` | teacher train-vs-test gap figure | 39 |
+| `feedback_rules.py`, `test_feedback_rules.py` | shared arbitrary-depth BP/FA/DFA implementation + numerical checks | 43 |
+| `aaai_depth_experiments.py` | FA/DFA initialization depth calibration + FA finite-time depth/width grid | 43 |
+| `parameter_matched_depth_control.py` | equal-parameter-count finite-time depth control for the supplement | 43 |
+| `learning_rate_compensation.py` | same-rate, initialization-compensated, and independently tuned controls | 43 |
+| `cnn_initialization_validation.py` | FA/DFA initialization theorem on a cross-entropy CNN | 43 |
+| `plot_aaai_main_figures.py` | two focused three-panel main figures | 43 |
**Diagnostics / negative results (kept for the record):**
`fa_tangent_kernel_capacity.py` (kernel capacity probe, note 07),
diff --git a/scripts/aaai_depth_experiments.py b/scripts/aaai_depth_experiments.py
new file mode 100644
index 0000000..bb52f85
--- /dev/null
+++ b/scripts/aaai_depth_experiments.py
@@ -0,0 +1,633 @@
+#!/usr/bin/env python3
+"""AAAI depth experiments for fixed random feedback.
+
+Part ``init`` validates the exact expected initial first-order deficit for both
+FA and DFA at hidden depths 1, 2, 3, 4, and 6. Part ``finite`` measures the
+FA/BP finite-time loss gap and evaluates the frozen-initialization, linear
+operator-velocity, and early-retangent predictors over depth and width.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import math
+import sys
+import time
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+import matplotlib
+
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+import numpy as np
+import torch
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+if str(SCRIPT_DIR) not in sys.path:
+ sys.path.insert(0, str(SCRIPT_DIR))
+
+import feedback_rules as fr # noqa: E402
+import real_data_validation as rdv # noqa: E402
+
+
+@dataclass(frozen=True)
+class InitRow:
+ rule: str
+ depth: int
+ width: int
+ init_seed: int
+ feedback_draws: int
+ bp_speed: float
+ output_speed: float
+ predicted_deficit: float
+ empirical_deficit: float
+ empirical_stderr: float
+ empirical_std: float
+ calibration_error: float
+
+
+@dataclass(frozen=True)
+class FiniteRow:
+ depth: int
+ width: int
+ train_samples: int
+ init_seed: int
+ feedback_seed: int
+ lr: float
+ horizon: int
+ early_step: int
+ initial_deficit_prediction: float
+ empirical_bp_loss: float
+ empirical_fa_loss: float
+ empirical_gap: float
+ fixed_gap: float
+ velocity_gap: float
+ retangent_gap: float
+ fixed_error: float
+ velocity_error: float
+ retangent_error: float
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--part", choices=["init", "finite", "both", "summarize"], default="both"
+ )
+ parser.add_argument("--depths", type=int, nargs="+", default=[1, 2, 3, 4, 6])
+ parser.add_argument("--width", type=int, default=64, help="fixed width for initialization")
+ parser.add_argument("--finite-widths", type=int, nargs="+", default=[32, 64, 96])
+ parser.add_argument("--input-dim", type=int, default=16)
+ parser.add_argument("--output-dim", type=int, default=4)
+ parser.add_argument("--train-samples", type=int, default=64)
+ parser.add_argument("--data-seed", type=int, default=2027)
+ parser.add_argument("--init-seeds", type=int, default=6)
+ parser.add_argument("--feedback-draws", type=int, default=256)
+ parser.add_argument("--finite-init-seeds", type=int, default=3)
+ parser.add_argument("--finite-feedback-seeds", type=int, default=4)
+ parser.add_argument("--lr", type=float, default=1e-3)
+ parser.add_argument("--horizon", type=int, default=50)
+ parser.add_argument("--early-step", type=int, default=20)
+ parser.add_argument("--feedback-scale", choices=["relu", "fan-in", "unit"], default="relu")
+ parser.add_argument("--torch-threads", type=int, default=16)
+ parser.add_argument("--outdir", type=Path, default=Path("outputs/aaai_depth_experiments"))
+ return parser.parse_args()
+
+
+def make_data(args: argparse.Namespace) -> tuple[torch.Tensor, torch.Tensor]:
+ generator = torch.Generator().manual_seed(args.data_seed)
+ x = torch.randn(args.train_samples, args.input_dim, generator=generator, dtype=torch.float64)
+ y = torch.randn(args.train_samples, args.output_dim, generator=generator, dtype=torch.float64)
+ return x, y
+
+
+def write_dataclass_csv(path: Path, rows: list[object]) -> None:
+ if not rows:
+ return
+ path.parent.mkdir(parents=True, exist_ok=True)
+ first = asdict(rows[0]) # type: ignore[arg-type]
+ with path.open("w", newline="") as handle:
+ writer = csv.DictWriter(handle, fieldnames=list(first))
+ writer.writeheader()
+ for row in rows:
+ writer.writerow(asdict(row)) # type: ignore[arg-type]
+
+
+def run_initialization(args: argparse.Namespace, x: torch.Tensor, y: torch.Tensor) -> list[InitRow]:
+ rows: list[InitRow] = []
+ for depth in args.depths:
+ dims = [args.input_dim, *([args.width] * depth), args.output_dim]
+ for init_index in range(args.init_seeds):
+ init_seed = 10_000 + init_index
+ weights = fr.initialize_mlp(dims, seed=init_seed)
+ bp_grads = fr.gradients(weights, x, y)
+ bp_speed = fr.squared_norm(bp_grads)
+ output_speed = fr.squared_norm([bp_grads[-1]])
+ predicted = 1.0 - output_speed / bp_speed
+ for rule_index, rule in enumerate(("fa", "dfa")):
+ deficits: list[float] = []
+ for draw in range(args.feedback_draws):
+ feedback_seed = 100_000_000 + 10_000 * depth + 1_000 * init_index + 2 * draw + rule_index
+ feedback = fr.init_feedback(
+ dims,
+ seed=feedback_seed,
+ rule=rule,
+ mode=args.feedback_scale,
+ )
+ rule_grads = fr.gradients(weights, x, y, rule=rule, feedback=feedback)
+ deficits.append(1.0 - fr.inner_product(bp_grads, rule_grads) / bp_speed)
+ values = np.asarray(deficits)
+ empirical = float(values.mean())
+ row = InitRow(
+ rule=rule.upper(),
+ depth=depth,
+ width=args.width,
+ init_seed=init_seed,
+ feedback_draws=args.feedback_draws,
+ bp_speed=bp_speed,
+ output_speed=output_speed,
+ predicted_deficit=predicted,
+ empirical_deficit=empirical,
+ empirical_stderr=float(values.std(ddof=1) / math.sqrt(len(values))),
+ empirical_std=float(values.std(ddof=1)),
+ calibration_error=empirical - predicted,
+ )
+ rows.append(row)
+ print(
+ f"[init] {rule.upper()} depth={depth} init={init_index}: "
+ f"prediction={predicted:.5f}, measured={empirical:.5f} "
+ f"+/- {row.empirical_stderr:.5f}",
+ flush=True,
+ )
+ return rows
+
+
+def train_with_snapshots(
+ weights0: list[torch.Tensor],
+ x: torch.Tensor,
+ y: torch.Tensor,
+ lr: float,
+ horizon: int,
+ feedback: list[torch.Tensor] | None,
+ early_step: int,
+) -> tuple[list[torch.Tensor], list[torch.Tensor]]:
+ final, snapshots = rdv.train_with_snapshots(
+ weights0,
+ x,
+ y,
+ lr,
+ horizon,
+ feedback,
+ {early_step},
+ )
+ return final, snapshots[early_step]
+
+
+def retangent_loss(
+ kernel_s: torch.Tensor,
+ residual_s: torch.Tensor,
+ lr: float,
+ samples: int,
+ horizon: int,
+ early_step: int,
+) -> float:
+ return rdv.rollout_loss(
+ kernel_s,
+ None,
+ residual_s,
+ lr,
+ samples,
+ horizon - early_step,
+ early_step,
+ )
+
+
+def run_finite_time(args: argparse.Namespace, x: torch.Tensor, y: torch.Tensor) -> list[FiniteRow]:
+ rows: list[FiniteRow] = []
+ n = x.shape[0]
+ for depth in args.depths:
+ for width in args.finite_widths:
+ dims = [args.input_dim, *([width] * depth), args.output_dim]
+ for init_index in range(args.finite_init_seeds):
+ block_start = time.time()
+ init_seed = 20_000 + init_index
+ weights0 = fr.initialize_mlp(dims, seed=init_seed)
+ r0 = (fr.predict(weights0, x) - y).reshape(-1)
+ bp_grads = fr.gradients(weights0, x, y)
+ initial_prediction = 1.0 - fr.squared_norm([bp_grads[-1]]) / fr.squared_norm(bp_grads)
+
+ k_bp0 = rdv.tangent_kernel(weights0, x, None)
+ bp_final, bp_early = train_with_snapshots(
+ weights0, x, y, args.lr, args.horizon, None, args.early_step
+ )
+ k_bps = rdv.tangent_kernel(bp_early, x, None)
+ bp_emp = fr.mse(bp_final, x, y)
+ bp_fixed = rdv.rollout_loss(
+ k_bp0, None, r0, args.lr, n, args.horizon, args.early_step
+ )
+ bp_velocity = rdv.rollout_loss(
+ k_bp0,
+ k_bps - k_bp0,
+ r0,
+ args.lr,
+ n,
+ args.horizon,
+ args.early_step,
+ )
+ bp_residual_s = (fr.predict(bp_early, x) - y).reshape(-1)
+ bp_retangent = retangent_loss(
+ k_bps, bp_residual_s, args.lr, n, args.horizon, args.early_step
+ )
+
+ for feedback_index in range(args.finite_feedback_seeds):
+ feedback_seed = 200_000 + 1_000 * init_index + feedback_index
+ feedback = fr.init_feedback(
+ dims,
+ seed=feedback_seed,
+ rule="fa",
+ mode=args.feedback_scale,
+ )
+ k_fa0 = rdv.tangent_kernel(weights0, x, feedback)
+ fa_final, fa_early = train_with_snapshots(
+ weights0,
+ x,
+ y,
+ args.lr,
+ args.horizon,
+ feedback,
+ args.early_step,
+ )
+ k_fas = rdv.tangent_kernel(fa_early, x, feedback)
+ fa_emp = fr.mse(fa_final, x, y)
+ fa_fixed = rdv.rollout_loss(
+ k_fa0, None, r0, args.lr, n, args.horizon, args.early_step
+ )
+ fa_velocity = rdv.rollout_loss(
+ k_fa0,
+ k_fas - k_fa0,
+ r0,
+ args.lr,
+ n,
+ args.horizon,
+ args.early_step,
+ )
+ fa_residual_s = (fr.predict(fa_early, x) - y).reshape(-1)
+ fa_retangent = retangent_loss(
+ k_fas, fa_residual_s, args.lr, n, args.horizon, args.early_step
+ )
+
+ empirical_gap = fa_emp - bp_emp
+ fixed_gap = fa_fixed - bp_fixed
+ velocity_gap = fa_velocity - bp_velocity
+ retangent_gap = fa_retangent - bp_retangent
+ rows.append(
+ FiniteRow(
+ depth=depth,
+ width=width,
+ train_samples=n,
+ init_seed=init_seed,
+ feedback_seed=feedback_seed,
+ lr=args.lr,
+ horizon=args.horizon,
+ early_step=args.early_step,
+ initial_deficit_prediction=initial_prediction,
+ empirical_bp_loss=bp_emp,
+ empirical_fa_loss=fa_emp,
+ empirical_gap=empirical_gap,
+ fixed_gap=fixed_gap,
+ velocity_gap=velocity_gap,
+ retangent_gap=retangent_gap,
+ fixed_error=fixed_gap - empirical_gap,
+ velocity_error=velocity_gap - empirical_gap,
+ retangent_error=retangent_gap - empirical_gap,
+ )
+ )
+ print(
+ f"[finite] depth={depth} width={width} init={init_index}: "
+ f"{args.finite_feedback_seeds} feedbacks in {time.time() - block_start:.1f}s",
+ flush=True,
+ )
+ return rows
+
+
+def safe_corr(left: np.ndarray, right: np.ndarray) -> float:
+ if len(left) < 2 or float(np.std(left) * np.std(right)) == 0.0:
+ return math.nan
+ return float(np.corrcoef(left, right)[0, 1])
+
+
+def summarize_finite(rows: list[FiniteRow]) -> list[dict[str, float | int | str]]:
+ metrics: list[dict[str, float | int | str]] = []
+ groups = sorted({(row.depth, row.width) for row in rows})
+ for depth, width in groups:
+ subset = [row for row in rows if row.depth == depth and row.width == width]
+ empirical = np.asarray([row.empirical_gap for row in subset])
+ for predictor, field in (
+ ("frozen K(0)", "fixed_gap"),
+ ("linear velocity", "velocity_gap"),
+ ("early retangent", "retangent_gap"),
+ ):
+ predicted = np.asarray([getattr(row, field) for row in subset])
+ metrics.append(
+ {
+ "depth": depth,
+ "width": width,
+ "predictor": predictor,
+ "rows": len(subset),
+ "empirical_gap_mean": float(empirical.mean()),
+ "empirical_gap_std": float(empirical.std(ddof=1)),
+ "mae": float(np.mean(np.abs(predicted - empirical))),
+ "bias": float(np.mean(predicted - empirical)),
+ "corr": safe_corr(predicted, empirical),
+ }
+ )
+ return metrics
+
+
+def write_dict_csv(path: Path, rows: list[dict[str, object]]) -> None:
+ if not rows:
+ return
+ with path.open("w", newline="") as handle:
+ writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
+ writer.writeheader()
+ writer.writerows(rows)
+
+
+def plot_initial(rows: list[InitRow], outdir: Path) -> None:
+ fig, ax = plt.subplots(figsize=(5.7, 5.2), dpi=180)
+ colors = plt.cm.viridis(np.linspace(0.1, 0.9, len(sorted({row.depth for row in rows}))))
+ for color, depth in zip(colors, sorted({row.depth for row in rows})):
+ for rule, marker in (("FA", "o"), ("DFA", "s")):
+ subset = [row for row in rows if row.depth == depth and row.rule == rule]
+ ax.errorbar(
+ [row.predicted_deficit for row in subset],
+ [row.empirical_deficit for row in subset],
+ yerr=[2 * row.empirical_stderr for row in subset],
+ fmt=marker,
+ ms=4.5,
+ color=color,
+ alpha=0.78,
+ capsize=1.5,
+ lw=0.8,
+ label=f"depth {depth}, {rule}",
+ )
+ values = [value for row in rows for value in (row.predicted_deficit, row.empirical_deficit)]
+ 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", lw=1)
+ ax.set_xlabel("exact expected initial deficit")
+ ax.set_ylabel("measured mean initial deficit")
+ ax.set_title("FA and DFA share the exact initialization cost")
+ ax.grid(alpha=0.16)
+ ax.legend(fontsize=6.5, ncols=2)
+ fig.tight_layout()
+ fig.savefig(outdir / "initial_deficit_calibration.png", bbox_inches="tight")
+ plt.close(fig)
+
+
+def plot_finite(rows: list[FiniteRow], metrics: list[dict[str, object]], outdir: Path) -> None:
+ fig, ax = plt.subplots(figsize=(6.4, 4.7), dpi=180)
+ for width, marker in zip(sorted({row.width for row in rows}), ("o", "s", "^", "D")):
+ depths = sorted({row.depth for row in rows if row.width == width})
+ means = [np.mean([row.empirical_gap for row in rows if row.width == width and row.depth == d]) for d in depths]
+ sems = []
+ for depth in depths:
+ cell = [row for row in rows if row.width == width and row.depth == depth]
+ init_means = [
+ np.mean([row.empirical_gap for row in cell if row.init_seed == init_seed])
+ for init_seed in sorted({row.init_seed for row in cell})
+ ]
+ sems.append(float(np.std(init_means, ddof=1) / math.sqrt(len(init_means))))
+ ax.errorbar(depths, means, yerr=sems, marker=marker, capsize=3, label=f"width {width}")
+ ax.set_xlabel("hidden-layer depth")
+ ax.set_ylabel("FA loss - BP loss at fixed horizon")
+ ax.set_title("Finite-time optimization cost across depth and width")
+ ax.grid(alpha=0.16)
+ ax.legend()
+ fig.tight_layout()
+ fig.savefig(outdir / "finite_gap_by_depth_width.png", bbox_inches="tight")
+ plt.close(fig)
+
+ fig, ax = plt.subplots(figsize=(7.0, 4.8), dpi=180)
+ predictors = ["frozen K(0)", "linear velocity", "early retangent"]
+ for predictor, marker in zip(predictors, ("^", "o", "s")):
+ subset = [row for row in metrics if row["predictor"] == predictor and row["width"] == 64]
+ ax.plot(
+ [int(row["depth"]) for row in subset],
+ [float(row["mae"]) for row in subset],
+ marker=marker,
+ label=predictor,
+ )
+ ax.set_xlabel("hidden-layer depth")
+ ax.set_ylabel("gap prediction MAE (width 64)")
+ ax.set_title("Operator drift, not initialization mismatch, controls later error")
+ ax.set_yscale("log")
+ ax.grid(alpha=0.16, which="both")
+ ax.legend()
+ fig.tight_layout()
+ fig.savefig(outdir / "predictor_mae_by_depth.png", bbox_inches="tight")
+ plt.close(fig)
+
+
+def summarize_mismatch_vs_cost(
+ init_rows: list[dict[str, str]],
+ finite_rows: list[dict[str, str]],
+ width: int,
+ output_dim: int,
+ outdir: Path,
+) -> list[dict[str, object]]:
+ """Contrast exponentially compounding matrix mismatch with measured costs."""
+ depths = sorted({int(row["depth"]) for row in init_rows})
+ records: list[dict[str, object]] = []
+ for depth in depths:
+ init_subset = [row for row in init_rows if int(row["depth"]) == depth]
+ finite_subset = [
+ row
+ for row in finite_rows
+ if int(row["depth"]) == depth and int(row["width"]) == width
+ ]
+ # One width x output block plus depth-1 width x width FA blocks.
+ joint_alignment_proxy = (1.0 / (width * output_dim)) * (
+ 1.0 / (width * width)
+ ) ** (depth - 1)
+ records.append(
+ {
+ "depth": depth,
+ "width": width,
+ "joint_alignment_proxy": joint_alignment_proxy,
+ "negative_log10_joint_alignment": -math.log10(joint_alignment_proxy),
+ "initial_deficit_mean": float(
+ np.mean([float(row["empirical_deficit"]) for row in init_subset])
+ ),
+ "finite_gap_mean": float(
+ np.mean([float(row["empirical_gap"]) for row in finite_subset])
+ ),
+ }
+ )
+
+ write_dict_csv(outdir / "mismatch_vs_cost_by_depth.csv", records)
+ fig, ax = plt.subplots(figsize=(7.0, 4.8), dpi=180)
+ ax.plot(
+ [int(row["depth"]) for row in records],
+ [float(row["joint_alignment_proxy"]) for row in records],
+ "o--",
+ color="black",
+ label=r"joint matrix-alignment proxy $\prod_l 1/D_l$",
+ )
+ ax.set_yscale("log")
+ ax.set_xlabel("hidden-layer depth")
+ ax.set_ylabel("joint squared-alignment proxy")
+ ax.grid(alpha=0.16, which="both")
+ ax2 = ax.twinx()
+ ax2.plot(
+ [int(row["depth"]) for row in records],
+ [float(row["initial_deficit_mean"]) for row in records],
+ "s-",
+ color="#2f6f9f",
+ label="measured initial deficit",
+ )
+ ax2.plot(
+ [int(row["depth"]) for row in records],
+ [float(row["finite_gap_mean"]) for row in records],
+ "^-",
+ color="#c65f16",
+ label="measured finite-time gap",
+ )
+ ax2.set_ylabel("optimization cost")
+ handles1, labels1 = ax.get_legend_handles_labels()
+ handles2, labels2 = ax2.get_legend_handles_labels()
+ ax2.legend(handles1 + handles2, labels1 + labels2, fontsize=8, loc="center right")
+ ax.set_title("Matrix mismatch compounds exponentially; optimization cost does not")
+ fig.tight_layout()
+ fig.savefig(outdir / "mismatch_vs_cost_by_depth.png", bbox_inches="tight")
+ plt.close(fig)
+ return records
+
+
+def main() -> None:
+ args = parse_args()
+ if args.early_step <= 0 or args.early_step >= args.horizon:
+ raise ValueError("early-step must lie strictly between 0 and horizon")
+ torch.set_num_threads(args.torch_threads)
+ args.outdir.mkdir(parents=True, exist_ok=True)
+ x, y = make_data(args)
+ summary: dict[str, object] = {
+ "config": {key: str(value) if isinstance(value, Path) else value for key, value in vars(args).items()}
+ }
+
+ if args.part == "summarize":
+ with (args.outdir / "initialization_rows.csv").open() as handle:
+ existing_init = list(csv.DictReader(handle))
+ with (args.outdir / "finite_time_rows.csv").open() as handle:
+ existing_finite = list(csv.DictReader(handle))
+ init_errors = np.asarray([float(row["calibration_error"]) for row in existing_init])
+ init_stderr = np.asarray([float(row["empirical_stderr"]) for row in existing_init])
+ summary["initialization"] = {
+ "rows": len(existing_init),
+ "max_abs_calibration_error": float(np.max(np.abs(init_errors))),
+ "max_standardized_error": float(np.max(np.abs(init_errors) / init_stderr)),
+ "rules": sorted({row["rule"] for row in existing_init}),
+ "depths": sorted({int(row["depth"]) for row in existing_init}),
+ }
+ empirical = np.asarray([float(row["empirical_gap"]) for row in existing_finite])
+ summary["finite_time"] = {
+ "rows": len(existing_finite),
+ "depths": sorted({int(row["depth"]) for row in existing_finite}),
+ "widths": sorted({int(row["width"]) for row in existing_finite}),
+ "overall": {
+ predictor: {
+ "mae": float(np.mean(np.abs(predicted - empirical))),
+ "corr": safe_corr(predicted, empirical),
+ }
+ for predictor, predicted in (
+ (
+ "frozen",
+ np.asarray([float(row["fixed_gap"]) for row in existing_finite]),
+ ),
+ (
+ "linear_velocity",
+ np.asarray([float(row["velocity_gap"]) for row in existing_finite]),
+ ),
+ (
+ "early_retangent",
+ np.asarray([float(row["retangent_gap"]) for row in existing_finite]),
+ ),
+ )
+ },
+ }
+ mismatch_records = summarize_mismatch_vs_cost(
+ existing_init,
+ existing_finite,
+ width=args.width,
+ output_dim=args.output_dim,
+ outdir=args.outdir,
+ )
+ summary["mismatch_vs_cost"] = {
+ "joint_alignment_proxy_depth_1": mismatch_records[0]["joint_alignment_proxy"],
+ "joint_alignment_proxy_depth_6": mismatch_records[-1]["joint_alignment_proxy"],
+ "orders_of_magnitude_drop": float(
+ math.log10(
+ float(mismatch_records[0]["joint_alignment_proxy"])
+ / float(mismatch_records[-1]["joint_alignment_proxy"])
+ )
+ ),
+ }
+ (args.outdir / "summary.json").write_text(json.dumps(summary, indent=2) + "\n")
+ print(f"summary: {args.outdir / 'summary.json'}")
+ return
+
+ if args.part in {"init", "both"}:
+ init_rows = run_initialization(args, x, y)
+ write_dataclass_csv(args.outdir / "initialization_rows.csv", init_rows)
+ plot_initial(init_rows, args.outdir)
+ summary["initialization"] = {
+ "rows": len(init_rows),
+ "max_abs_calibration_error": max(abs(row.calibration_error) for row in init_rows),
+ "max_standardized_error": max(
+ abs(row.calibration_error) / row.empirical_stderr for row in init_rows
+ ),
+ "rules": sorted({row.rule for row in init_rows}),
+ "depths": sorted({row.depth for row in init_rows}),
+ }
+
+ if args.part in {"finite", "both"}:
+ finite_rows = run_finite_time(args, x, y)
+ write_dataclass_csv(args.outdir / "finite_time_rows.csv", finite_rows)
+ metrics = summarize_finite(finite_rows)
+ write_dict_csv(args.outdir / "finite_time_metrics.csv", metrics) # type: ignore[arg-type]
+ plot_finite(finite_rows, metrics, args.outdir)
+ summary["finite_time"] = {
+ "rows": len(finite_rows),
+ "depths": sorted({row.depth for row in finite_rows}),
+ "widths": sorted({row.width for row in finite_rows}),
+ "overall": {
+ predictor: {
+ "mae": float(
+ np.mean(
+ [
+ abs(getattr(row, field) - row.empirical_gap)
+ for row in finite_rows
+ ]
+ )
+ ),
+ "corr": safe_corr(
+ np.asarray([getattr(row, field) for row in finite_rows]),
+ np.asarray([row.empirical_gap for row in finite_rows]),
+ ),
+ }
+ for predictor, field in (
+ ("frozen", "fixed_gap"),
+ ("linear_velocity", "velocity_gap"),
+ ("early_retangent", "retangent_gap"),
+ )
+ },
+ }
+
+ (args.outdir / "summary.json").write_text(json.dumps(summary, indent=2) + "\n")
+ print(f"summary: {args.outdir / 'summary.json'}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/cnn_initialization_validation.py b/scripts/cnn_initialization_validation.py
new file mode 100644
index 0000000..dcf4f92
--- /dev/null
+++ b/scripts/cnn_initialization_validation.py
@@ -0,0 +1,320 @@
+#!/usr/bin/env python3
+"""Cross-entropy CNN validation of the exact FA/DFA initialization cost.
+
+The model has two 3x3 convolutional ReLU layers, global average pooling, and a
+linear classifier. The experiment runs the real convolutional FA and DFA
+backward rules for every feedback draw and compares their mean first-order
+loss-decrease deficit with the hidden-parameter share from one BP backward
+pass. This directly tests that the initialization result is not a
+squared-loss MLP identity.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import math
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+import matplotlib
+
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+import numpy as np
+import torch
+import torch.nn.functional as functional
+
+
+Tensor = torch.Tensor
+
+
+@dataclass(frozen=True)
+class CNNRow:
+ architecture: str
+ loss: str
+ rule: str
+ init_seed: int
+ feedback_draws: int
+ bp_speed: float
+ output_speed: float
+ predicted_deficit: float
+ empirical_deficit: float
+ empirical_stderr: float
+ empirical_std: float
+ calibration_error: float
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--data-root", type=Path, default=Path("data"))
+ parser.add_argument("--train-samples", type=int, default=128)
+ parser.add_argument("--image-size", type=int, default=8)
+ parser.add_argument("--channels", type=int, nargs=2, default=[8, 12])
+ parser.add_argument("--init-seeds", type=int, default=4)
+ parser.add_argument("--feedback-draws", type=int, default=512)
+ parser.add_argument("--torch-threads", type=int, default=16)
+ parser.add_argument("--self-test", action="store_true")
+ parser.add_argument(
+ "--outdir", type=Path, default=Path("outputs/cnn_initialization_validation")
+ )
+ return parser.parse_args()
+
+
+def load_mnist(root: Path, samples: int, image_size: int) -> tuple[Tensor, Tensor]:
+ from torchvision import datasets
+
+ dataset = datasets.MNIST(root=str(root), train=True, download=True)
+ generator = torch.Generator().manual_seed(2027)
+ indices = torch.randperm(len(dataset), generator=generator)[:samples]
+ images = dataset.data[indices].to(torch.float64).unsqueeze(1) / 255.0
+ images = functional.interpolate(
+ images, size=(image_size, image_size), mode="bilinear", align_corners=False
+ )
+ images = (images - images.mean()) / (images.std() + 1e-12)
+ labels = dataset.targets[indices]
+ return images, labels
+
+
+def initialize_weights(channels: tuple[int, int], classes: int, seed: int) -> list[Tensor]:
+ c1, c2 = channels
+ generator = torch.Generator().manual_seed(seed)
+ w1 = torch.randn(c1, 1, 3, 3, generator=generator, dtype=torch.float64) * math.sqrt(2 / 9)
+ w2 = torch.randn(c2, c1, 3, 3, generator=generator, dtype=torch.float64) * math.sqrt(
+ 2 / (9 * c1)
+ )
+ w3 = torch.randn(classes, c2, generator=generator, dtype=torch.float64) / math.sqrt(c2)
+ return [w1, w2, w3]
+
+
+def forward(weights: list[Tensor], x: Tensor) -> tuple[Tensor, tuple[Tensor, ...]]:
+ w1, w2, w3 = weights
+ z1 = functional.conv2d(x, w1, padding=1)
+ h1 = torch.relu(z1)
+ z2 = functional.conv2d(h1, w2, padding=1)
+ h2 = torch.relu(z2)
+ pooled = h2.mean(dim=(2, 3))
+ logits = pooled @ w3.T
+ return logits, (z1, h1, z2, h2, pooled)
+
+
+def autograd_gradients(weights: list[Tensor], x: Tensor, labels: Tensor) -> list[Tensor]:
+ leaves = [weight.clone().detach().requires_grad_(True) for weight in weights]
+ logits, _ = forward(leaves, x)
+ loss = functional.cross_entropy(logits, labels)
+ return list(torch.autograd.grad(loss, leaves))
+
+
+def init_feedback(
+ weights: list[Tensor], classes: int, seed: int, rule: str
+) -> list[Tensor]:
+ w1, w2, _w3 = weights
+ c1, c2 = w1.shape[0], w2.shape[0]
+ generator = torch.Generator().manual_seed(seed)
+ if rule == "fa":
+ conv_map = torch.randn(w2.shape, generator=generator, dtype=torch.float64) * math.sqrt(
+ 2 / (9 * c1)
+ )
+ output_map = torch.randn(
+ classes, c2, generator=generator, dtype=torch.float64
+ ) * math.sqrt(2 / c2)
+ return [conv_map, output_map]
+ if rule == "dfa":
+ direct_first = torch.randn(
+ classes, c1, generator=generator, dtype=torch.float64
+ ) * math.sqrt(2 / c1)
+ direct_second = torch.randn(
+ classes, c2, generator=generator, dtype=torch.float64
+ ) * math.sqrt(2 / c2)
+ return [direct_first, direct_second]
+ raise ValueError("rule must be fa or dfa")
+
+
+def manual_gradients(
+ weights: list[Tensor],
+ x: Tensor,
+ labels: Tensor,
+ rule: str,
+ feedback: list[Tensor] | None = None,
+) -> list[Tensor]:
+ if rule not in {"bp", "fa", "dfa"}:
+ raise ValueError("rule must be bp, fa, or dfa")
+ if rule != "bp" and feedback is None:
+ raise ValueError("FA/DFA require feedback maps")
+
+ w1, w2, w3 = weights
+ logits, (z1, h1, z2, h2, pooled) = forward(weights, x)
+ batch, height, width = h2.shape[0], h2.shape[2], h2.shape[3]
+ probabilities = torch.softmax(logits, dim=1)
+ probabilities[torch.arange(batch), labels] -= 1.0
+ delta_output = probabilities / batch
+ grad_w3 = delta_output.T @ pooled
+
+ if rule == "bp":
+ delta_pooled = delta_output @ w3
+ delta_z2 = delta_pooled[:, :, None, None].expand_as(h2) / (height * width)
+ delta_z2 = delta_z2 * (z2 > 0)
+ delta_h1 = functional.conv_transpose2d(delta_z2, w2, padding=1)
+ delta_z1 = delta_h1 * (z1 > 0)
+ elif rule == "fa":
+ assert feedback is not None
+ conv_map, output_map = feedback
+ delta_pooled = delta_output @ output_map
+ delta_z2 = delta_pooled[:, :, None, None].expand_as(h2) / (height * width)
+ delta_z2 = delta_z2 * (z2 > 0)
+ delta_h1 = functional.conv_transpose2d(delta_z2, conv_map, padding=1)
+ delta_z1 = delta_h1 * (z1 > 0)
+ else:
+ assert feedback is not None
+ direct_first, direct_second = feedback
+ direct_z2 = delta_output @ direct_second
+ delta_z2 = direct_z2[:, :, None, None].expand_as(h2) / (height * width)
+ delta_z2 = delta_z2 * (z2 > 0)
+ direct_z1 = delta_output @ direct_first
+ delta_z1 = direct_z1[:, :, None, None].expand_as(z1) / (height * width)
+ delta_z1 = delta_z1 * (z1 > 0)
+
+ grad_w2 = torch.nn.grad.conv2d_weight(h1, w2.shape, delta_z2, padding=1)
+ grad_w1 = torch.nn.grad.conv2d_weight(x, w1.shape, delta_z1, padding=1)
+ return [grad_w1, grad_w2, grad_w3]
+
+
+def squared_norm(grads: list[Tensor]) -> float:
+ return float(sum(torch.sum(grad * grad) for grad in grads))
+
+
+def inner_product(left: list[Tensor], right: list[Tensor]) -> float:
+ return float(sum(torch.sum(a * b) for a, b in zip(left, right)))
+
+
+def self_test() -> None:
+ generator = torch.Generator().manual_seed(31)
+ x = torch.randn(5, 1, 6, 6, generator=generator, dtype=torch.float64)
+ labels = torch.randint(0, 3, (5,), generator=generator)
+ weights = initialize_weights((4, 5), classes=3, seed=41)
+ automatic = autograd_gradients(weights, x, labels)
+ manual = manual_gradients(weights, x, labels, rule="bp")
+ error = max(float((a - b).abs().max()) for a, b in zip(automatic, manual))
+ print(f"CNN BP manual/autograd max error: {error:.3e}")
+ assert error < 1e-11
+ for rule in ("fa", "dfa"):
+ feedback = init_feedback(weights, classes=3, seed=51, rule=rule)
+ rule_grads = manual_gradients(weights, x, labels, rule=rule, feedback=feedback)
+ output_error = float((rule_grads[-1] - manual[-1]).abs().max())
+ print(f"CNN {rule.upper()} output-gradient error: {output_error:.3e}")
+ assert output_error < 1e-12
+ print("CNN initialization self-test PASSED")
+
+
+def run(args: argparse.Namespace, x: Tensor, labels: Tensor) -> list[CNNRow]:
+ rows: list[CNNRow] = []
+ classes = 10
+ channels = (args.channels[0], args.channels[1])
+ for init_index in range(args.init_seeds):
+ init_seed = 10_000 + init_index
+ weights = initialize_weights(channels, classes, init_seed)
+ bp = manual_gradients(weights, x, labels, rule="bp")
+ bp_speed = squared_norm(bp)
+ output_speed = squared_norm([bp[-1]])
+ prediction = 1.0 - output_speed / bp_speed
+ for rule_index, rule in enumerate(("fa", "dfa")):
+ deficits: list[float] = []
+ for draw in range(args.feedback_draws):
+ feedback = init_feedback(
+ weights,
+ classes,
+ seed=100_000 + 10_000 * init_index + 2 * draw + rule_index,
+ rule=rule,
+ )
+ rule_grads = manual_gradients(weights, x, labels, rule, feedback)
+ deficits.append(1.0 - inner_product(bp, rule_grads) / bp_speed)
+ values = np.asarray(deficits)
+ empirical = float(values.mean())
+ row = CNNRow(
+ architecture=f"Conv({channels[0]},{channels[1]})-GAP-linear",
+ loss="cross_entropy",
+ rule=rule.upper(),
+ init_seed=init_seed,
+ feedback_draws=args.feedback_draws,
+ bp_speed=bp_speed,
+ output_speed=output_speed,
+ predicted_deficit=prediction,
+ empirical_deficit=empirical,
+ empirical_stderr=float(values.std(ddof=1) / math.sqrt(len(values))),
+ empirical_std=float(values.std(ddof=1)),
+ calibration_error=empirical - prediction,
+ )
+ rows.append(row)
+ print(
+ f"[CNN] {rule.upper()} init={init_index}: prediction={prediction:.5f}, "
+ f"measured={empirical:.5f} +/- {row.empirical_stderr:.5f}",
+ flush=True,
+ )
+ return rows
+
+
+def write_rows(path: Path, rows: list[CNNRow]) -> None:
+ with path.open("w", newline="") as handle:
+ writer = csv.DictWriter(handle, fieldnames=list(CNNRow.__annotations__))
+ writer.writeheader()
+ for row in rows:
+ writer.writerow(asdict(row))
+
+
+def plot(rows: list[CNNRow], outdir: Path) -> None:
+ fig, ax = plt.subplots(figsize=(5.5, 5.0), dpi=180)
+ for rule, marker, color in (("FA", "o", "#2f6f9f"), ("DFA", "s", "#c65f16")):
+ subset = [row for row in rows if row.rule == rule]
+ ax.errorbar(
+ [row.predicted_deficit for row in subset],
+ [row.empirical_deficit for row in subset],
+ yerr=[2 * row.empirical_stderr for row in subset],
+ fmt=marker,
+ color=color,
+ capsize=2,
+ label=rule,
+ )
+ values = [value for row in rows for value in (row.predicted_deficit, row.empirical_deficit)]
+ lo, hi = min(values), max(values)
+ pad = 0.05 * (hi - lo + 1e-12)
+ ax.plot([lo - pad, hi + pad], [lo - pad, hi + pad], color="black", lw=1)
+ ax.set_xlabel("exact expected initial deficit")
+ ax.set_ylabel("measured mean initial deficit")
+ ax.set_title("Cross-entropy CNN: FA and DFA initialization cost")
+ ax.grid(alpha=0.16)
+ ax.legend()
+ fig.tight_layout()
+ fig.savefig(outdir / "cnn_initialization_calibration.png", bbox_inches="tight")
+ plt.close(fig)
+
+
+def main() -> None:
+ args = parse_args()
+ torch.set_num_threads(args.torch_threads)
+ if args.self_test:
+ self_test()
+ return
+ args.outdir.mkdir(parents=True, exist_ok=True)
+ x, labels = load_mnist(args.data_root, args.train_samples, args.image_size)
+ self_test()
+ rows = run(args, x, labels)
+ write_rows(args.outdir / "cnn_initialization_rows.csv", rows)
+ plot(rows, args.outdir)
+ payload = {
+ "config": {key: str(value) if isinstance(value, Path) else value for key, value in vars(args).items()},
+ "rows": len(rows),
+ "max_abs_calibration_error": max(abs(row.calibration_error) for row in rows),
+ "max_standardized_error": max(
+ abs(row.calibration_error) / row.empirical_stderr for row in rows
+ ),
+ "rules": sorted({row.rule for row in rows}),
+ "architecture": rows[0].architecture,
+ "loss": rows[0].loss,
+ }
+ (args.outdir / "summary.json").write_text(json.dumps(payload, indent=2) + "\n")
+ print(f"summary: {args.outdir / 'summary.json'}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/feedback_rules.py b/scripts/feedback_rules.py
new file mode 100644
index 0000000..b658d1d
--- /dev/null
+++ b/scripts/feedback_rules.py
@@ -0,0 +1,187 @@
+#!/usr/bin/env python3
+"""Shared BP, FA, and DFA utilities for arbitrary-depth MLP experiments.
+
+The older experiment scripts predate the DFA validation and expose only the
+layerwise-FA rule. This module keeps the new paper-critical experiments on a
+single, explicitly tested implementation. All losses use
+``0.5 * mean(sum((prediction - target)**2, dim=1))``.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Sequence
+
+import torch
+
+
+Tensor = torch.Tensor
+Rule = str
+
+
+def initialize_mlp(dims: Sequence[int], seed: int, device: str = "cpu") -> list[Tensor]:
+ """He-initialize hidden layers and fan-in initialize the linear output."""
+ if len(dims) < 3:
+ raise ValueError("dims must contain input, at least one hidden layer, and output")
+ generator = torch.Generator(device=device)
+ generator.manual_seed(seed)
+ 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)
+ weights.append(
+ torch.randn(
+ fan_out,
+ fan_in,
+ generator=generator,
+ dtype=torch.float64,
+ device=device,
+ )
+ * scale
+ )
+ return weights
+
+
+def clone_weights(weights: Sequence[Tensor]) -> list[Tensor]:
+ return [weight.clone() for weight in weights]
+
+
+def forward(weights: Sequence[Tensor], x: Tensor) -> tuple[list[Tensor], list[Tensor]]:
+ activations = [x]
+ preacts: list[Tensor] = []
+ current = x
+ for layer, weight in enumerate(weights):
+ current = current @ weight.T
+ preacts.append(current)
+ if layer < len(weights) - 1:
+ current = torch.relu(current)
+ activations.append(current)
+ return activations, preacts
+
+
+def predict(weights: Sequence[Tensor], x: Tensor) -> Tensor:
+ return forward(weights, x)[0][-1]
+
+
+def mse(weights: Sequence[Tensor], x: Tensor, y: Tensor) -> float:
+ error = predict(weights, x) - y
+ return float(0.5 * torch.mean(torch.sum(error * error, dim=1)))
+
+
+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(
+ dims: Sequence[int],
+ seed: int,
+ rule: Rule,
+ mode: str = "relu",
+ device: str = "cpu",
+) -> list[Tensor]:
+ """Initialize independent zero-mean feedback maps for FA or DFA.
+
+ FA map ``l`` has shape ``(dims[l+1], dims[l+2])`` and replaces the
+ transpose action of the next forward matrix. DFA map ``l`` has shape
+ ``(dims[l+1], dims[-1])`` and maps output error directly to hidden layer
+ ``l``. The output layer never receives a feedback matrix.
+ """
+ if rule not in {"fa", "dfa"}:
+ raise ValueError("feedback rule must be 'fa' or 'dfa'")
+ generator = torch.Generator(device=device)
+ generator.manual_seed(seed)
+ shapes = (
+ [(dims[layer + 1], dims[layer + 2]) for layer in range(len(dims) - 2)]
+ if rule == "fa"
+ else [(dims[layer + 1], dims[-1]) for layer in range(len(dims) - 2)]
+ )
+ return [
+ torch.randn(rows, cols, generator=generator, dtype=torch.float64, device=device)
+ * _feedback_scale(rows, mode)
+ for rows, cols in shapes
+ ]
+
+
+def gradients(
+ weights: Sequence[Tensor],
+ x: Tensor,
+ y: Tensor,
+ rule: Rule = "bp",
+ feedback: Sequence[Tensor] | None = None,
+) -> list[Tensor]:
+ """Return BP gradients or FA/DFA pseudo-gradients for an MLP."""
+ if rule not in {"bp", "fa", "dfa"}:
+ raise ValueError("rule must be one of: bp, fa, dfa")
+ if rule == "bp" and feedback is not None:
+ raise ValueError("BP does not take feedback maps")
+ if rule != "bp" and (feedback is None or len(feedback) != len(weights) - 1):
+ raise ValueError(f"{rule.upper()} needs one feedback map per hidden layer")
+
+ activations, preacts = forward(weights, x)
+ batch = x.shape[0]
+ deltas: list[Tensor] = [torch.empty(0, dtype=x.dtype, device=x.device) for _ in weights]
+ deltas[-1] = (activations[-1] - y) / batch
+
+ if rule == "dfa":
+ assert feedback is not None
+ output_delta = deltas[-1]
+ for layer in range(len(weights) - 2, -1, -1):
+ deltas[layer] = (output_delta @ feedback[layer].T) * (preacts[layer] > 0)
+ else:
+ for layer in range(len(weights) - 2, -1, -1):
+ if rule == "bp":
+ back = deltas[layer + 1] @ weights[layer + 1]
+ else:
+ assert feedback is not None
+ 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(
+ weights0: Sequence[Tensor],
+ x: Tensor,
+ y: Tensor,
+ lr: float,
+ steps: int,
+ rule: Rule = "bp",
+ feedback: Sequence[Tensor] | None = None,
+) -> list[Tensor]:
+ weights = clone_weights(weights0)
+ for _ in range(steps):
+ grads = gradients(weights, x, y, rule=rule, feedback=feedback)
+ weights = [weight - lr * grad for weight, grad in zip(weights, grads)]
+ if not all(bool(torch.isfinite(weight).all()) for weight in weights):
+ break
+ return weights
+
+
+def squared_norm(grads: Sequence[Tensor]) -> float:
+ return float(sum(torch.sum(grad * grad) for grad in grads))
+
+
+def inner_product(left: Sequence[Tensor], right: Sequence[Tensor]) -> float:
+ return float(sum(torch.sum(a * b) for a, b in zip(left, right)))
+
+
+def first_order_statistics(
+ bp_grads: Sequence[Tensor], rule_grads: Sequence[Tensor]
+) -> tuple[float, float, float, float]:
+ """Return BP speed, output speed, rule/BP decrease ratio, and deficit."""
+ bp_speed = squared_norm(bp_grads)
+ output_speed = squared_norm([bp_grads[-1]])
+ ratio = inner_product(bp_grads, rule_grads) / bp_speed
+ return bp_speed, output_speed, ratio, 1.0 - ratio
+
+
+def autograd_bp_gradients(weights: Sequence[Tensor], x: Tensor, y: Tensor) -> list[Tensor]:
+ leaves = [weight.clone().detach().requires_grad_(True) for weight in weights]
+ error = predict(leaves, x) - y
+ loss = 0.5 * torch.mean(torch.sum(error * error, dim=1))
+ return list(torch.autograd.grad(loss, leaves))
diff --git a/scripts/learning_rate_compensation.py b/scripts/learning_rate_compensation.py
new file mode 100644
index 0000000..10e1a64
--- /dev/null
+++ b/scripts/learning_rate_compensation.py
@@ -0,0 +1,488 @@
+#!/usr/bin/env python3
+"""Learning-rate controls for the finite-time FA/BP optimization gap.
+
+For every depth, the script first tunes BP and FA learning rates on held-out
+initialization/feedback seeds. It then evaluates three FA conditions against
+BP at its tuned rate:
+
+1. the same learning rate as BP;
+2. initialization compensation ``eta_FA = eta_BP / rho``, where ``rho`` is
+ the exact expected fraction of BP's first-order decrease retained by FA;
+3. FA's independently tuned stable learning rate.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import math
+import sys
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+import matplotlib
+
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+import numpy as np
+import torch
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+if str(SCRIPT_DIR) not in sys.path:
+ sys.path.insert(0, str(SCRIPT_DIR))
+
+import feedback_rules as fr # noqa: E402
+
+
+@dataclass(frozen=True)
+class TuneRow:
+ depth: int
+ rule: str
+ lr: float
+ trials: int
+ stable_trials: int
+ mean_final_loss: float
+ median_final_loss: float
+
+
+@dataclass(frozen=True)
+class EvaluationRow:
+ depth: int
+ width: int
+ init_seed: int
+ feedback_seed: int
+ regime: str
+ steps: int
+ bp_lr: float
+ fa_lr: float
+ rho: float
+ initial_loss: float
+ bp_final_loss: float
+ fa_final_loss: float
+ gap_to_bp: float
+ stable: bool
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--depths", type=int, nargs="+", default=[1, 2, 3, 4, 6])
+ parser.add_argument("--width", type=int, default=64)
+ parser.add_argument("--input-dim", type=int, default=16)
+ parser.add_argument("--output-dim", type=int, default=4)
+ parser.add_argument("--train-samples", type=int, default=128)
+ parser.add_argument("--steps", type=int, default=200)
+ parser.add_argument("--base-lr", type=float, default=1e-3)
+ parser.add_argument(
+ "--lr-grid",
+ type=float,
+ nargs="+",
+ default=[
+ 0.00025,
+ 0.0005,
+ 0.001,
+ 0.002,
+ 0.004,
+ 0.008,
+ 0.016,
+ 0.032,
+ 0.064,
+ 0.128,
+ 0.256,
+ ],
+ )
+ parser.add_argument("--tune-init-seeds", type=int, default=2)
+ parser.add_argument("--tune-feedback-seeds", type=int, default=2)
+ parser.add_argument("--eval-init-seeds", type=int, default=4)
+ parser.add_argument("--eval-feedback-seeds", type=int, default=4)
+ parser.add_argument("--data-seed", type=int, default=4242)
+ parser.add_argument("--feedback-scale", choices=["relu", "fan-in", "unit"], default="relu")
+ parser.add_argument("--stability-factor", type=float, default=10.0)
+ parser.add_argument("--torch-threads", type=int, default=16)
+ parser.add_argument("--postprocess-only", action="store_true")
+ parser.add_argument("--outdir", type=Path, default=Path("outputs/learning_rate_compensation"))
+ return parser.parse_args()
+
+
+def make_data(args: argparse.Namespace) -> tuple[torch.Tensor, torch.Tensor]:
+ generator = torch.Generator().manual_seed(args.data_seed)
+ x = torch.randn(args.train_samples, args.input_dim, generator=generator, dtype=torch.float64)
+ y = torch.randn(args.train_samples, args.output_dim, generator=generator, dtype=torch.float64)
+ return x, y
+
+
+def stable_loss(
+ weights: list[torch.Tensor],
+ x: torch.Tensor,
+ y: torch.Tensor,
+ initial_loss: float,
+ stability_factor: float,
+) -> tuple[float, bool]:
+ loss = fr.mse(weights, x, y)
+ stable = math.isfinite(loss) and loss <= stability_factor * initial_loss
+ return loss, stable
+
+
+def tune_depth(
+ args: argparse.Namespace,
+ depth: int,
+ x: torch.Tensor,
+ y: torch.Tensor,
+) -> tuple[float, float, list[TuneRow]]:
+ dims = [args.input_dim, *([args.width] * depth), args.output_dim]
+ records: list[TuneRow] = []
+ for rule in ("bp", "fa"):
+ for lr in args.lr_grid:
+ losses: list[float] = []
+ stable_flags: list[bool] = []
+ for init_index in range(args.tune_init_seeds):
+ init_seed = 700_000 + init_index
+ weights0 = fr.initialize_mlp(dims, init_seed)
+ initial_loss = fr.mse(weights0, x, y)
+ feedback_count = 1 if rule == "bp" else args.tune_feedback_seeds
+ for feedback_index in range(feedback_count):
+ feedback = None
+ if rule == "fa":
+ feedback = fr.init_feedback(
+ dims,
+ 710_000 + 1_000 * init_index + feedback_index,
+ rule="fa",
+ mode=args.feedback_scale,
+ )
+ final = fr.train(
+ weights0,
+ x,
+ y,
+ lr,
+ args.steps,
+ rule=rule,
+ feedback=feedback,
+ )
+ loss, stable = stable_loss(
+ final, x, y, initial_loss, args.stability_factor
+ )
+ losses.append(loss if math.isfinite(loss) else math.inf)
+ stable_flags.append(stable)
+ stable_losses = [loss for loss, stable in zip(losses, stable_flags) if stable]
+ records.append(
+ TuneRow(
+ depth=depth,
+ rule=rule.upper(),
+ lr=lr,
+ trials=len(losses),
+ stable_trials=sum(stable_flags),
+ mean_final_loss=float(np.mean(stable_losses)) if stable_losses else math.inf,
+ median_final_loss=float(np.median(stable_losses)) if stable_losses else math.inf,
+ )
+ )
+ print(
+ f"[tune] depth={depth} {rule.upper()} lr={lr:g}: "
+ f"stable={sum(stable_flags)}/{len(losses)}, "
+ f"mean={records[-1].mean_final_loss:.5g}",
+ flush=True,
+ )
+
+ best: dict[str, float] = {}
+ for rule in ("BP", "FA"):
+ candidates = [
+ row
+ for row in records
+ if row.rule == rule and row.stable_trials == row.trials and math.isfinite(row.mean_final_loss)
+ ]
+ if not candidates:
+ raise RuntimeError(f"no fully stable {rule} learning rate at depth {depth}")
+ best[rule] = min(candidates, key=lambda row: row.mean_final_loss).lr
+ print(
+ f"[tune] depth={depth}: selected BP={best['BP']:g}, FA={best['FA']:g}",
+ flush=True,
+ )
+ return best["BP"], best["FA"], records
+
+
+def evaluate_depth(
+ args: argparse.Namespace,
+ depth: int,
+ bp_lr: float,
+ fa_lr: float,
+ x: torch.Tensor,
+ y: torch.Tensor,
+) -> list[EvaluationRow]:
+ dims = [args.input_dim, *([args.width] * depth), args.output_dim]
+ rows: list[EvaluationRow] = []
+ for init_index in range(args.eval_init_seeds):
+ init_seed = 10_000 + init_index
+ weights0 = fr.initialize_mlp(dims, init_seed)
+ initial_loss = fr.mse(weights0, x, y)
+ bp_grads = fr.gradients(weights0, x, y)
+ rho = fr.squared_norm([bp_grads[-1]]) / fr.squared_norm(bp_grads)
+ bp_losses: dict[str, tuple[float, bool]] = {}
+ for name, rate in (("base", args.base_lr), ("tuned", bp_lr)):
+ bp_final = fr.train(weights0, x, y, rate, args.steps, rule="bp")
+ bp_losses[name] = stable_loss(
+ bp_final, x, y, initial_loss, args.stability_factor
+ )
+ if not bp_losses[name][1]:
+ raise RuntimeError(
+ f"{name} BP rate became unstable at depth={depth}, init={init_seed}"
+ )
+
+ regimes = (
+ ("same_lr", args.base_lr, args.base_lr, "base"),
+ ("initial_compensation", args.base_lr, args.base_lr / rho, "base"),
+ ("independently_tuned", bp_lr, fa_lr, "tuned"),
+ )
+ for feedback_index in range(args.eval_feedback_seeds):
+ feedback_seed = 100_000 + 1_000 * init_index + feedback_index
+ feedback = fr.init_feedback(
+ dims,
+ feedback_seed,
+ rule="fa",
+ mode=args.feedback_scale,
+ )
+ for regime, bp_rate, fa_rate, bp_key in regimes:
+ fa_final = fr.train(
+ weights0,
+ x,
+ y,
+ fa_rate,
+ args.steps,
+ rule="fa",
+ feedback=feedback,
+ )
+ fa_final_loss, stable = stable_loss(
+ fa_final, x, y, initial_loss, args.stability_factor
+ )
+ rows.append(
+ EvaluationRow(
+ depth=depth,
+ width=args.width,
+ init_seed=init_seed,
+ feedback_seed=feedback_seed,
+ regime=regime,
+ steps=args.steps,
+ bp_lr=bp_rate,
+ fa_lr=fa_rate,
+ rho=rho,
+ initial_loss=initial_loss,
+ bp_final_loss=bp_losses[bp_key][0],
+ fa_final_loss=fa_final_loss,
+ gap_to_bp=fa_final_loss - bp_losses[bp_key][0],
+ stable=stable,
+ )
+ )
+ print(
+ f"[eval] depth={depth} init={init_index}: rho={rho:.4f}, "
+ f"compensation={args.base_lr / rho:.5g}",
+ flush=True,
+ )
+ return rows
+
+
+def write_dataclass_csv(path: Path, rows: list[object]) -> None:
+ if not rows:
+ return
+ first = asdict(rows[0]) # type: ignore[arg-type]
+ with path.open("w", newline="") as handle:
+ writer = csv.DictWriter(handle, fieldnames=list(first))
+ writer.writeheader()
+ for row in rows:
+ writer.writerow(asdict(row)) # type: ignore[arg-type]
+
+
+def summarize(rows: list[EvaluationRow]) -> list[dict[str, object]]:
+ result: list[dict[str, object]] = []
+ for depth in sorted({row.depth for row in rows}):
+ for regime in ("same_lr", "initial_compensation", "independently_tuned"):
+ subset = [row for row in rows if row.depth == depth and row.regime == regime]
+ gaps = np.asarray([row.gap_to_bp for row in subset])
+ fa_losses = np.asarray([row.fa_final_loss for row in subset])
+ init_gap_means = np.asarray(
+ [
+ np.mean(
+ [row.gap_to_bp for row in subset if row.init_seed == init_seed]
+ )
+ for init_seed in sorted({row.init_seed for row in subset})
+ ]
+ )
+ result.append(
+ {
+ "depth": depth,
+ "regime": regime,
+ "rows": len(subset),
+ "stable_rows": sum(row.stable for row in subset),
+ "bp_lr": subset[0].bp_lr,
+ "fa_lr_mean": float(np.mean([row.fa_lr for row in subset])),
+ "rho_mean": float(np.mean([row.rho for row in subset])),
+ "bp_loss_mean": float(np.mean([row.bp_final_loss for row in subset])),
+ "fa_loss_mean": float(fa_losses.mean()),
+ "gap_mean": float(gaps.mean()),
+ "gap_std": float(init_gap_means.std(ddof=1)),
+ "gap_sem": float(
+ init_gap_means.std(ddof=1) / math.sqrt(len(init_gap_means))
+ ),
+ }
+ )
+ return result
+
+
+def write_dict_csv(path: Path, rows: list[dict[str, object]]) -> None:
+ if not rows:
+ return
+ with path.open("w", newline="") as handle:
+ writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
+ writer.writeheader()
+ writer.writerows(rows)
+
+
+def plot_results(summary: list[dict[str, object]], tune_rows: list[TuneRow], outdir: Path) -> None:
+ fig, ax = plt.subplots(figsize=(7.0, 4.8), dpi=180)
+ styles = {
+ "same_lr": ("o", "same learning rate"),
+ "initial_compensation": ("s", "initial-rate compensation"),
+ "independently_tuned": ("^", "independently tuned"),
+ }
+ for regime, (marker, label) in styles.items():
+ subset = [row for row in summary if row["regime"] == regime]
+ ax.errorbar(
+ [int(row["depth"]) for row in subset],
+ [float(row["gap_mean"]) for row in subset],
+ yerr=[float(row["gap_sem"]) for row in subset],
+ marker=marker,
+ capsize=3,
+ label=label,
+ )
+ ax.axhline(0, color="black", lw=1)
+ ax.set_xlabel("hidden-layer depth")
+ ax.set_ylabel("FA loss - BP loss at fixed steps")
+ ax.set_title("Finite-time gap under three learning-rate controls")
+ ax.grid(alpha=0.16)
+ ax.legend()
+ fig.tight_layout()
+ fig.savefig(outdir / "learning_rate_control_by_depth.png", bbox_inches="tight")
+ plt.close(fig)
+
+ fig, axes = plt.subplots(1, 2, figsize=(10.5, 4.2), dpi=180, sharey=True)
+ for ax, rule in zip(axes, ("BP", "FA")):
+ for depth in sorted({row.depth for row in tune_rows}):
+ subset = [row for row in tune_rows if row.rule == rule and row.depth == depth]
+ ax.plot(
+ [row.lr for row in subset],
+ [row.mean_final_loss for row in subset],
+ marker="o",
+ ms=3,
+ label=f"depth {depth}",
+ )
+ ax.set_xscale("log")
+ ax.set_yscale("log")
+ ax.set_xlabel("learning rate")
+ ax.set_title(rule)
+ ax.grid(alpha=0.16, which="both")
+ axes[0].set_ylabel("held-out tuning loss")
+ axes[1].legend(fontsize=7, ncols=2)
+ fig.tight_layout()
+ fig.savefig(outdir / "learning_rate_tuning_curves.png", bbox_inches="tight")
+ plt.close(fig)
+
+
+def main() -> None:
+ args = parse_args()
+ torch.set_num_threads(args.torch_threads)
+ args.outdir.mkdir(parents=True, exist_ok=True)
+ if args.postprocess_only:
+ with (args.outdir / "tuning_rows.csv").open() as handle:
+ tune_dicts = list(csv.DictReader(handle))
+ with (args.outdir / "evaluation_rows.csv").open() as handle:
+ eval_dicts = list(csv.DictReader(handle))
+ tune_rows = [
+ TuneRow(
+ depth=int(row["depth"]),
+ rule=row["rule"],
+ lr=float(row["lr"]),
+ trials=int(row["trials"]),
+ stable_trials=int(row["stable_trials"]),
+ mean_final_loss=float(row["mean_final_loss"]),
+ median_final_loss=float(row["median_final_loss"]),
+ )
+ for row in tune_dicts
+ ]
+ eval_rows = [
+ EvaluationRow(
+ depth=int(row["depth"]),
+ width=int(row["width"]),
+ init_seed=int(row["init_seed"]),
+ feedback_seed=int(row["feedback_seed"]),
+ regime=row["regime"],
+ steps=int(row["steps"]),
+ bp_lr=float(row["bp_lr"]),
+ fa_lr=float(row["fa_lr"]),
+ rho=float(row["rho"]),
+ initial_loss=float(row["initial_loss"]),
+ bp_final_loss=float(row["bp_final_loss"]),
+ fa_final_loss=float(row["fa_final_loss"]),
+ gap_to_bp=float(row["gap_to_bp"]),
+ stable=row["stable"] == "True",
+ )
+ for row in eval_dicts
+ ]
+ summary_rows = summarize(eval_rows)
+ write_dict_csv(args.outdir / "regime_summary.csv", summary_rows)
+ plot_results(summary_rows, tune_rows, args.outdir)
+ summary_path = args.outdir / "summary.json"
+ payload = json.loads(summary_path.read_text())
+ payload["regime_summary"] = summary_rows
+ payload["uncertainty"] = "SEM and SD are computed across forward-initialization means."
+ summary_path.write_text(json.dumps(payload, indent=2) + "\n")
+ print(f"summary: {summary_path}")
+ return
+ x, y = make_data(args)
+ all_tune: list[TuneRow] = []
+ all_eval: list[EvaluationRow] = []
+ selected: dict[str, dict[str, float]] = {}
+ for depth in args.depths:
+ bp_lr, fa_lr, tune_rows = tune_depth(args, depth, x, y)
+ all_tune.extend(tune_rows)
+ while True:
+ evaluation = evaluate_depth(args, depth, bp_lr, fa_lr, x, y)
+ tuned_rows = [row for row in evaluation if row.regime == "independently_tuned"]
+ if all(row.stable for row in tuned_rows):
+ break
+ lower_candidates = [
+ row
+ for row in tune_rows
+ if row.rule == "FA"
+ and row.lr < fa_lr
+ and row.stable_trials == row.trials
+ and math.isfinite(row.mean_final_loss)
+ ]
+ if not lower_candidates:
+ raise RuntimeError(
+ f"no lower FA rate passed stability confirmation at depth {depth}"
+ )
+ previous = fa_lr
+ fa_lr = min(lower_candidates, key=lambda row: row.mean_final_loss).lr
+ print(
+ f"[stability] depth={depth}: FA lr {previous:g} failed on held-out "
+ f"evaluation seeds; retrying {fa_lr:g}",
+ flush=True,
+ )
+ selected[str(depth)] = {"bp_lr": bp_lr, "fa_lr": fa_lr}
+ all_eval.extend(evaluation)
+
+ write_dataclass_csv(args.outdir / "tuning_rows.csv", all_tune)
+ write_dataclass_csv(args.outdir / "evaluation_rows.csv", all_eval)
+ summary_rows = summarize(all_eval)
+ write_dict_csv(args.outdir / "regime_summary.csv", summary_rows)
+ plot_results(summary_rows, all_tune, args.outdir)
+ payload = {
+ "config": {key: str(value) if isinstance(value, Path) else value for key, value in vars(args).items()},
+ "selected_learning_rates": selected,
+ "rows": len(all_eval),
+ "regime_summary": summary_rows,
+ "uncertainty": "SEM and SD are computed across forward-initialization means.",
+ }
+ (args.outdir / "summary.json").write_text(json.dumps(payload, indent=2) + "\n")
+ print(f"summary: {args.outdir / 'summary.json'}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/parameter_matched_depth_control.py b/scripts/parameter_matched_depth_control.py
new file mode 100644
index 0000000..24f1f2b
--- /dev/null
+++ b/scripts/parameter_matched_depth_control.py
@@ -0,0 +1,174 @@
+#!/usr/bin/env python3
+"""Parameter-count-matched supplement for the FA finite-time depth sweep."""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import math
+import sys
+from dataclasses import asdict
+from pathlib import Path
+from types import SimpleNamespace
+
+import matplotlib
+
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+import numpy as np
+import torch
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+if str(SCRIPT_DIR) not in sys.path:
+ sys.path.insert(0, str(SCRIPT_DIR))
+
+import aaai_depth_experiments as depth_exp # noqa: E402
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--depths", type=int, nargs="+", default=[1, 2, 3, 4, 6])
+ parser.add_argument("--reference-depth", type=int, default=2)
+ parser.add_argument("--reference-width", type=int, default=64)
+ parser.add_argument("--input-dim", type=int, default=16)
+ parser.add_argument("--output-dim", type=int, default=4)
+ parser.add_argument("--train-samples", type=int, default=64)
+ parser.add_argument("--data-seed", type=int, default=2027)
+ parser.add_argument("--init-seeds", type=int, default=4)
+ parser.add_argument("--feedback-seeds", type=int, default=8)
+ parser.add_argument("--lr", type=float, default=1e-3)
+ parser.add_argument("--horizon", type=int, default=50)
+ parser.add_argument("--early-step", type=int, default=20)
+ parser.add_argument("--feedback-scale", choices=["relu", "fan-in", "unit"], default="relu")
+ parser.add_argument("--torch-threads", type=int, default=16)
+ parser.add_argument(
+ "--outdir", type=Path, default=Path("outputs/parameter_matched_depth_control")
+ )
+ return parser.parse_args()
+
+
+def parameter_count(input_dim: int, output_dim: int, depth: int, width: int) -> int:
+ return input_dim * width + (depth - 1) * width * width + width * output_dim
+
+
+def closest_width(input_dim: int, output_dim: int, depth: int, target: int) -> int:
+ upper = max(2, math.ceil(target / (input_dim + output_dim)) + 2)
+ return min(
+ range(1, upper + 1),
+ key=lambda width: abs(parameter_count(input_dim, output_dim, depth, width) - target),
+ )
+
+
+def write_rows(path: Path, rows: list[depth_exp.FiniteRow]) -> None:
+ with path.open("w", newline="") as handle:
+ writer = csv.DictWriter(handle, fieldnames=list(depth_exp.FiniteRow.__annotations__))
+ writer.writeheader()
+ for row in rows:
+ writer.writerow(asdict(row))
+
+
+def main() -> None:
+ args = parse_args()
+ torch.set_num_threads(args.torch_threads)
+ args.outdir.mkdir(parents=True, exist_ok=True)
+ target = parameter_count(
+ args.input_dim, args.output_dim, args.reference_depth, args.reference_width
+ )
+ widths = {
+ depth: closest_width(args.input_dim, args.output_dim, depth, target)
+ for depth in args.depths
+ }
+ generator = torch.Generator().manual_seed(args.data_seed)
+ x = torch.randn(args.train_samples, args.input_dim, generator=generator, dtype=torch.float64)
+ y = torch.randn(args.train_samples, args.output_dim, generator=generator, dtype=torch.float64)
+
+ rows: list[depth_exp.FiniteRow] = []
+ for depth in args.depths:
+ run_args = SimpleNamespace(
+ depths=[depth],
+ finite_widths=[widths[depth]],
+ input_dim=args.input_dim,
+ output_dim=args.output_dim,
+ finite_init_seeds=args.init_seeds,
+ finite_feedback_seeds=args.feedback_seeds,
+ lr=args.lr,
+ horizon=args.horizon,
+ early_step=args.early_step,
+ feedback_scale=args.feedback_scale,
+ )
+ rows.extend(depth_exp.run_finite_time(run_args, x, y))
+
+ write_rows(args.outdir / "parameter_matched_rows.csv", rows)
+ metrics = depth_exp.summarize_finite(rows)
+ depth_exp.write_dict_csv(args.outdir / "parameter_matched_metrics.csv", metrics) # type: ignore[arg-type]
+
+ summaries: list[dict[str, object]] = []
+ for depth in args.depths:
+ subset = [row for row in rows if row.depth == depth]
+ values = np.asarray([row.empirical_gap for row in subset])
+ init_means = np.asarray(
+ [
+ np.mean([row.empirical_gap for row in subset if row.init_seed == init_seed])
+ for init_seed in sorted({row.init_seed for row in subset})
+ ]
+ )
+ summaries.append(
+ {
+ "depth": depth,
+ "width": widths[depth],
+ "parameter_count": parameter_count(
+ args.input_dim, args.output_dim, depth, widths[depth]
+ ),
+ "parameter_count_error": parameter_count(
+ args.input_dim, args.output_dim, depth, widths[depth]
+ )
+ - target,
+ "rows": len(subset),
+ "empirical_gap_mean": float(values.mean()),
+ "empirical_gap_sem": float(
+ init_means.std(ddof=1) / math.sqrt(len(init_means))
+ ),
+ }
+ )
+ depth_exp.write_dict_csv(args.outdir / "parameter_matched_summary.csv", summaries)
+
+ fig, ax = plt.subplots(figsize=(6.6, 4.7), dpi=180)
+ ax.errorbar(
+ [int(row["depth"]) for row in summaries],
+ [float(row["empirical_gap_mean"]) for row in summaries],
+ yerr=[float(row["empirical_gap_sem"]) for row in summaries],
+ marker="o",
+ capsize=3,
+ color="#6f4c9b",
+ )
+ for row in summaries:
+ ax.annotate(
+ f"w={row['width']}",
+ (int(row["depth"]), float(row["empirical_gap_mean"])),
+ xytext=(0, 9),
+ textcoords="offset points",
+ ha="center",
+ fontsize=8,
+ )
+ ax.set_xlabel("hidden-layer depth")
+ ax.set_ylabel("FA loss - BP loss at fixed horizon")
+ ax.set_title(f"Parameter-matched depth control (target P={target})")
+ ax.grid(alpha=0.16)
+ fig.tight_layout()
+ fig.savefig(args.outdir / "parameter_matched_gap_by_depth.png", bbox_inches="tight")
+ plt.close(fig)
+
+ payload = {
+ "config": {key: str(value) if isinstance(value, Path) else value for key, value in vars(args).items()},
+ "target_parameter_count": target,
+ "widths": {str(depth): width for depth, width in widths.items()},
+ "rows": len(rows),
+ "summary": summaries,
+ }
+ (args.outdir / "summary.json").write_text(json.dumps(payload, indent=2) + "\n")
+ print(f"summary: {args.outdir / 'summary.json'}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/plot_aaai_main_figures.py b/scripts/plot_aaai_main_figures.py
new file mode 100644
index 0000000..cd12011
--- /dev/null
+++ b/scripts/plot_aaai_main_figures.py
@@ -0,0 +1,311 @@
+#!/usr/bin/env python3
+"""Render the two three-panel composite figures for the focused AAAI story."""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import math
+from pathlib import Path
+
+import matplotlib
+
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+import numpy as np
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--depth-dir", type=Path, default=Path("outputs/aaai_depth_experiments"))
+ parser.add_argument(
+ "--cnn-dir", type=Path, default=Path("outputs/cnn_initialization_validation")
+ )
+ parser.add_argument(
+ "--mnist-dir", type=Path, default=Path("outputs/real_data_validation_mnist")
+ )
+ parser.add_argument(
+ "--null-dir", type=Path, default=Path("outputs/soft_erosion_theory_vs_empirical")
+ )
+ parser.add_argument("--outdir", type=Path, default=Path("outputs/aaai_main_figures"))
+ return parser.parse_args()
+
+
+def read_csv(path: Path) -> list[dict[str, str]]:
+ if not path.exists():
+ raise FileNotFoundError(path)
+ with path.open() as handle:
+ return list(csv.DictReader(handle))
+
+
+def panel_label(ax: plt.Axes, label: str) -> None:
+ ax.text(-0.13, 1.06, label, transform=ax.transAxes, fontsize=12, fontweight="bold")
+
+
+def draw_schematic(ax: plt.Axes) -> None:
+ ax.set_xlim(0, 1)
+ ax.set_ylim(0, 1)
+ ax.axis("off")
+ rows = [(0.82, "BP", "#2f6f9f"), (0.50, "FA", "#c65f16"), (0.18, "DFA", "#2b8a3e")]
+ x_positions = [0.18, 0.42, 0.66, 0.90]
+ labels = ["$h_1$", "$h_2$", "$h_3$", "error"]
+ for y, name, color in rows:
+ ax.text(0.01, y, name, va="center", ha="left", fontweight="bold", color=color)
+ for x, node in zip(x_positions, labels):
+ ax.add_patch(plt.Circle((x, y), 0.035, facecolor="white", edgecolor=color, lw=1.5))
+ ax.text(x, y - 0.075, node, ha="center", va="top", fontsize=8)
+ if name in {"BP", "FA"}:
+ arrow_label = r"$W^\top$" if name == "BP" else "$B$"
+ for right, left in zip(x_positions[:0:-1], x_positions[-2::-1]):
+ ax.annotate(
+ "",
+ xy=(left + 0.04, y),
+ xytext=(right - 0.04, y),
+ arrowprops=dict(arrowstyle="->", color=color, lw=1.5),
+ )
+ ax.text((left + right) / 2, y + 0.035, arrow_label, ha="center", fontsize=8)
+ else:
+ for left in x_positions[:-1]:
+ ax.annotate(
+ "",
+ xy=(left + 0.04, y),
+ xytext=(x_positions[-1] - 0.04, y),
+ arrowprops=dict(arrowstyle="->", color=color, lw=1.2, alpha=0.9),
+ )
+ ax.text(0.56, y + 0.055, "direct random maps", ha="center", fontsize=8)
+ ax.set_title("Fixed random feedback rules", fontsize=10)
+
+
+def figure_one(args: argparse.Namespace) -> Path:
+ init_rows = read_csv(args.depth_dir / "initialization_rows.csv")
+ cnn_rows = read_csv(args.cnn_dir / "cnn_initialization_rows.csv")
+ mnist_rows = read_csv(args.mnist_dir / "e0_mnist_rows.csv")
+ theory_rows = read_csv(args.null_dir / "theory_gap_summary.csv")
+ empirical_rows = read_csv(args.null_dir / "empirical_gap_summary.csv")
+
+ fig, axes = plt.subplots(1, 3, figsize=(14.2, 4.1), dpi=220)
+ draw_schematic(axes[0])
+ panel_label(axes[0], "a")
+
+ ax = axes[1]
+ theory_by_width = {int(row["width"]): float(row["mean"]) for row in theory_rows}
+ empirical_by_width = {int(row["width"]): float(row["mean"]) for row in empirical_rows}
+ widths = sorted(set(theory_by_width) & set(empirical_by_width))
+ ax.plot(
+ widths,
+ [theory_by_width[width] for width in widths],
+ "o-",
+ color="#8c6d31",
+ label="random-direction deletion",
+ )
+ ax.plot(
+ widths,
+ [empirical_by_width[width] for width in widths],
+ "s-",
+ color="#c65f16",
+ label="measured FA cost",
+ )
+ ax.set_yscale("log")
+ ax.set_xlabel("width")
+ ax.set_ylabel("finite-time loss gap")
+ ax.grid(alpha=0.16, which="both")
+ ax2 = ax.twinx()
+ ax2.plot(widths, [1.0 / (width * width) for width in widths], "k--", lw=1.4, label="$1/D$")
+ ax2.set_yscale("log")
+ ax2.set_ylabel("best guaranteed squared alignment")
+ handles1, labels1 = ax.get_legend_handles_labels()
+ handles2, labels2 = ax2.get_legend_handles_labels()
+ ax.legend(handles1 + handles2, labels1 + labels2, fontsize=7, loc="upper right")
+ worst_ratio = max(theory_by_width[width] / empirical_by_width[width] for width in widths)
+ ax.text(0.04, 0.05, f"null overestimate: up to {worst_ratio:.0f}×", transform=ax.transAxes, fontsize=8)
+ ax.set_title("Matrix mismatch is not optimization cost", fontsize=10)
+ panel_label(ax, "b")
+
+ ax = axes[2]
+ depths = sorted({int(row["depth"]) for row in init_rows})
+ cmap = plt.cm.viridis
+ depth_color = {depth: cmap(index / max(len(depths) - 1, 1)) for index, depth in enumerate(depths)}
+ for row in init_rows:
+ depth = int(row["depth"])
+ marker = "o" if row["rule"] == "FA" else "s"
+ ax.errorbar(
+ float(row["predicted_deficit"]),
+ float(row["empirical_deficit"]),
+ yerr=2 * float(row["empirical_stderr"]),
+ fmt=marker,
+ ms=3.8,
+ color=depth_color[depth],
+ alpha=0.68,
+ lw=0.7,
+ capsize=1,
+ )
+ for row in mnist_rows:
+ ax.plot(float(row["hidden_share"]), float(row["empirical_mean"]), "^", color="#7b6fa6", ms=4, alpha=0.65)
+ for row in cnn_rows:
+ marker = "P" if row["rule"] == "FA" else "X"
+ ax.errorbar(
+ float(row["predicted_deficit"]),
+ float(row["empirical_deficit"]),
+ yerr=2 * float(row["empirical_stderr"]),
+ fmt=marker,
+ color="#b23a48",
+ ms=5,
+ capsize=1,
+ )
+ values = [
+ float(row[key])
+ for row in init_rows
+ for key in ("predicted_deficit", "empirical_deficit")
+ ]
+ values += [
+ float(row[key])
+ for row in cnn_rows
+ for key in ("predicted_deficit", "empirical_deficit")
+ ]
+ values += [float(row[key]) for row in mnist_rows for key in ("hidden_share", "empirical_mean")]
+ lo, hi = min(values), max(values)
+ pad = 0.04 * (hi - lo + 1e-12)
+ ax.plot([lo - pad, hi + pad], [lo - pad, hi + pad], "k-", lw=1)
+ legend_handles = [
+ plt.Line2D([], [], marker="o", ls="", color="0.35", label="synthetic FA"),
+ plt.Line2D([], [], marker="s", ls="", color="0.35", label="synthetic DFA"),
+ plt.Line2D([], [], marker="^", ls="", color="#7b6fa6", label="MNIST MLP"),
+ plt.Line2D([], [], marker="P", ls="", color="#b23a48", label="CE CNN FA/DFA"),
+ ]
+ shape_legend = ax.legend(handles=legend_handles, fontsize=7, loc="lower right")
+ ax.add_artist(shape_legend)
+ depth_handles = [
+ plt.Line2D([], [], marker="o", ls="", color=depth_color[depth], label=f"depth {depth}")
+ for depth in depths
+ ]
+ ax.legend(handles=depth_handles, fontsize=6, ncols=2, loc="upper left")
+ ax.set_xlabel("exact expected initial deficit")
+ ax.set_ylabel("measured mean deficit")
+ ax.set_title("One calibration across rule, depth, and data", fontsize=10)
+ ax.grid(alpha=0.16)
+ panel_label(ax, "c")
+
+ fig.tight_layout(w_pad=2.0)
+ path = args.outdir / "figure1_mismatch_vs_initial_cost.png"
+ fig.savefig(path, bbox_inches="tight")
+ fig.savefig(path.with_suffix(".pdf"), bbox_inches="tight")
+ plt.close(fig)
+ return path
+
+
+def scatter_predictor(
+ ax: plt.Axes,
+ rows: list[dict[str, str]],
+ fields: list[tuple[str, str, str]],
+ title: str,
+) -> None:
+ depths = sorted({int(row["depth"]) for row in rows})
+ colors = {depth: plt.cm.viridis(i / max(len(depths) - 1, 1)) for i, depth in enumerate(depths)}
+ values: list[float] = []
+ for field, marker, label in fields:
+ for row in rows:
+ x = float(row[field])
+ y = float(row["empirical_gap"])
+ values.extend([x, y])
+ ax.scatter(x, y, s=13, marker=marker, color=colors[int(row["depth"])], alpha=0.60)
+ ax.scatter([], [], marker=marker, color="0.35", label=label)
+ lo, hi = min(values), max(values)
+ pad = 0.04 * (hi - lo + 1e-12)
+ ax.plot([lo - pad, hi + pad], [lo - pad, hi + pad], "k-", lw=1)
+ ax.set_xlabel("predicted gap")
+ ax.set_ylabel("measured gap")
+ ax.set_title(title, fontsize=10)
+ ax.grid(alpha=0.16)
+ ax.legend(fontsize=7)
+
+
+def figure_two(args: argparse.Namespace) -> Path:
+ rows = read_csv(args.depth_dir / "finite_time_rows.csv")
+ fig, axes = plt.subplots(1, 3, figsize=(14.0, 4.1), dpi=220)
+
+ ax = axes[0]
+ widths = sorted({int(row["width"]) for row in rows})
+ markers = ["o", "s", "^"]
+ for width, marker in zip(widths, markers):
+ depths = sorted({int(row["depth"]) for row in rows if int(row["width"]) == width})
+ means = []
+ sems = []
+ for depth in depths:
+ cell = [
+ row
+ for row in rows
+ if int(row["width"]) == width and int(row["depth"]) == depth
+ ]
+ values = np.asarray([float(row["empirical_gap"]) for row in cell])
+ init_means = np.asarray(
+ [
+ np.mean(
+ [
+ float(row["empirical_gap"])
+ for row in cell
+ if int(row["init_seed"]) == init_seed
+ ]
+ )
+ for init_seed in sorted({int(row["init_seed"]) for row in cell})
+ ]
+ )
+ means.append(float(values.mean()))
+ sems.append(float(init_means.std(ddof=1) / math.sqrt(len(init_means))))
+ ax.errorbar(depths, means, yerr=sems, marker=marker, capsize=2, label=f"width {width}")
+ ax.set_xlabel("hidden-layer depth")
+ ax.set_ylabel("FA loss - BP loss")
+ ax.set_title("Later cost changes with training dynamics", fontsize=10)
+ ax.grid(alpha=0.16)
+ ax.legend(fontsize=7)
+ panel_label(ax, "a")
+
+ scatter_predictor(axes[1], rows, [("fixed_gap", "^", "frozen $K(0)$")], "Frozen initialization misses drift")
+ panel_label(axes[1], "b")
+
+ scatter_predictor(
+ axes[2],
+ rows,
+ [
+ ("velocity_gap", "o", "linear velocity"),
+ ("retangent_gap", "s", "early retangent"),
+ ],
+ "Two snapshots recover the later cost",
+ )
+ panel_label(axes[2], "c")
+
+ predictor_legend = axes[2].get_legend()
+ if predictor_legend is not None:
+ axes[2].add_artist(predictor_legend)
+
+ depth_handles = [
+ plt.Line2D(
+ [],
+ [],
+ marker="o",
+ ls="",
+ color=plt.cm.viridis(i / max(len(sorted({int(row['depth']) for row in rows})) - 1, 1)),
+ label=f"depth {depth}",
+ )
+ for i, depth in enumerate(sorted({int(row["depth"]) for row in rows}))
+ ]
+ axes[2].legend(handles=depth_handles, fontsize=6, ncols=2, loc="lower right")
+
+ fig.tight_layout(w_pad=2.0)
+ path = args.outdir / "figure2_training_dynamics.png"
+ fig.savefig(path, bbox_inches="tight")
+ fig.savefig(path.with_suffix(".pdf"), bbox_inches="tight")
+ plt.close(fig)
+ return path
+
+
+def main() -> None:
+ args = parse_args()
+ args.outdir.mkdir(parents=True, exist_ok=True)
+ path1 = figure_one(args)
+ path2 = figure_two(args)
+ print(f"figure: {path1}")
+ print(f"figure: {path2}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test_feedback_rules.py b/scripts/test_feedback_rules.py
new file mode 100644
index 0000000..9957315
--- /dev/null
+++ b/scripts/test_feedback_rules.py
@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+"""Numerical tests for the shared BP/FA/DFA implementation."""
+
+from __future__ import annotations
+
+import torch
+
+import feedback_rules as fr
+
+
+def main() -> None:
+ torch.set_num_threads(2)
+ dims = [5, 7, 6, 3]
+ weights = fr.initialize_mlp(dims, seed=11)
+ generator = torch.Generator().manual_seed(7)
+ x = torch.randn(9, dims[0], generator=generator, dtype=torch.float64)
+ y = torch.randn(9, dims[-1], generator=generator, dtype=torch.float64)
+
+ manual = fr.gradients(weights, x, y)
+ auto = fr.autograd_bp_gradients(weights, x, y)
+ bp_error = max(float((left - right).abs().max()) for left, right in zip(manual, auto))
+
+ fa = fr.init_feedback(dims, seed=21, rule="fa")
+ dfa = fr.init_feedback(dims, seed=22, rule="dfa")
+ fa_grads = fr.gradients(weights, x, y, rule="fa", feedback=fa)
+ dfa_grads = fr.gradients(weights, x, y, rule="dfa", feedback=dfa)
+ output_fa_error = float((manual[-1] - fa_grads[-1]).abs().max())
+ output_dfa_error = float((manual[-1] - dfa_grads[-1]).abs().max())
+
+ bp_speed = fr.squared_norm(manual)
+ output_share = fr.squared_norm([manual[-1]]) / bp_speed
+ for rule in ("fa", "dfa"):
+ ratios = []
+ for draw in range(4096):
+ feedback = fr.init_feedback(dims, seed=100_000 + draw, rule=rule)
+ rule_grads = fr.gradients(weights, x, y, rule=rule, feedback=feedback)
+ ratios.append(fr.inner_product(manual, rule_grads) / bp_speed)
+ mean_ratio = sum(ratios) / len(ratios)
+ stderr = torch.tensor(ratios, dtype=torch.float64).std(unbiased=True).item() / len(ratios) ** 0.5
+ error = abs(mean_ratio - output_share)
+ print(
+ f"{rule.upper()}: mean ratio={mean_ratio:.6f}, prediction={output_share:.6f}, "
+ f"error={error:.3g}, stderr={stderr:.3g}"
+ )
+ assert error <= 4.5 * stderr + 1e-4
+
+ print(f"BP manual/autograd max error: {bp_error:.3e}")
+ print(f"FA/DFA output-gradient errors: {output_fa_error:.3e}, {output_dfa_error:.3e}")
+ assert max(bp_error, output_fa_error, output_dfa_error) < 1e-11
+ print("feedback_rules self-test PASSED")
+
+
+if __name__ == "__main__":
+ main()