Skip to content

Commit 1a8cb16

Browse files
committed
fix: pac_descent conservation gating + weight transport (Exp 04 rerun)
BUG 1 — Conservation correction is error-blind and overwhelms learning: - Apply conservation correction only every N=20 steps (CONSERVATION_PERIOD) - Add loss-gating check: skip if correction would increase batch MSE >5% BUG 2 — Hidden layer direction signal is fatally diluted: - Remove error_share normalisation division (keep phi-inverse weighting but do NOT normalise to sum=1, which was cutting effective LR ~4x) - Replace fixed random projection with weight transport: use actual downstream W_{k+1}.T scaled by sqrt(PHI_INV / out_dim) Exp 04 results (5 seeds, 500 epochs): power_law — Noether=0.000003 SGD=0.000012 (PAC wins 5/5) fibonacci_cascade — Noether=0.001155 SGD=0.000641 (PAC wins 1/5)
1 parent f269ac2 commit 1a8cb16

3 files changed

Lines changed: 643 additions & 0 deletions

File tree

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Exp 04 — Conservation vs Gradient: Noether vs SGD on structured sequences.
4+
===========================================================================
5+
6+
Hypothesis
7+
----------
8+
PAC-Descent (TinyCIMM-Noether) should outperform vanilla SGD on sequence
9+
prediction tasks whose ground-truth generators follow conservation-compatible
10+
structure (power-law, Fibonacci cascade) because the phi-weighted direction
11+
signal and period-gated conservation correction reduce over-fitting to
12+
batch noise while maintaining energy within PAC targets.
13+
14+
Patterns tested
15+
---------------
16+
1. Power-law cascade : y_t = t^{-0.5} (scale-free, energy-conserving)
17+
2. Fibonacci cascade : y_t = F_t / F_{t-1} mod 1 (recursive ratio series)
18+
19+
Design
20+
------
21+
- Sequence length : 200 steps
22+
- Predict next value from previous 8 (sliding window)
23+
- 5 random seeds per condition
24+
- Metric: final 20-step MSE (after 500 training epochs)
25+
- Compared: PACDescent vs plain SGD (numpy, same architecture)
26+
"""
27+
28+
import json
29+
import math
30+
import os
31+
import sys
32+
33+
import numpy as np
34+
35+
# ---------------------------------------------------------------------------
36+
# Path setup — allow running from any cwd
37+
# ---------------------------------------------------------------------------
38+
_HERE = os.path.dirname(os.path.abspath(__file__))
39+
_NOETHER_ROOT = os.path.abspath(os.path.join(_HERE, "..", ".."))
40+
if _NOETHER_ROOT not in sys.path:
41+
sys.path.insert(0, _NOETHER_ROOT)
42+
43+
from pac_descent import PACDescent # noqa: E402
44+
45+
# ---------------------------------------------------------------------------
46+
# Constants
47+
# ---------------------------------------------------------------------------
48+
PHI = (1 + math.sqrt(5)) / 2
49+
SEQ_LEN = 200
50+
WINDOW = 8
51+
N_EPOCHS = 500
52+
BATCH_SIZE = 32
53+
LAYER_SIZES = [WINDOW, 32, 16, 1]
54+
LR = 0.003
55+
N_SEEDS = 5
56+
EVAL_LAST_N = 20
57+
GRAD_CLIP = 1.0 # clip gradient norm to prevent explosion
58+
59+
60+
# ---------------------------------------------------------------------------
61+
# Data generators
62+
# ---------------------------------------------------------------------------
63+
64+
def power_law_sequence(n: int) -> np.ndarray:
65+
"""y_t = (t+1)^{-0.5}, t = 0 … n-1, normalised to [0,1]."""
66+
t = np.arange(1, n + 1, dtype=float)
67+
y = t ** -0.5
68+
return (y - y.min()) / (y.max() - y.min() + 1e-12)
69+
70+
71+
def fibonacci_cascade_sequence(n: int) -> np.ndarray:
72+
"""
73+
Fibonacci ratio series: r_t = F_{t+2} / F_{t+1} converges to phi.
74+
We take the fractional part to keep it bounded, then normalise.
75+
"""
76+
fibs = [1.0, 1.0]
77+
while len(fibs) < n + 2:
78+
fibs.append(fibs[-1] + fibs[-2])
79+
ratios = [fibs[i + 1] / fibs[i] for i in range(n)]
80+
y = np.array(ratios) % 1.0
81+
# After the first ~10 steps the ratio is essentially phi mod 1 ≈ 0.618.
82+
# Inject small perturbations to create non-trivial dynamics.
83+
rng = np.random.RandomState(0)
84+
y += rng.randn(n) * 0.02
85+
y = np.clip(y, 0, 1)
86+
return y
87+
88+
89+
def make_windows(seq: np.ndarray, window: int):
90+
"""
91+
Convert a 1-D sequence into (X, y) pairs for next-step prediction.
92+
93+
X shape: (N - window, window)
94+
y shape: (N - window, 1)
95+
"""
96+
X, y = [], []
97+
for i in range(len(seq) - window):
98+
X.append(seq[i : i + window])
99+
y.append([seq[i + window]])
100+
return np.array(X, dtype=np.float32), np.array(y, dtype=np.float32)
101+
102+
103+
# ---------------------------------------------------------------------------
104+
# Plain SGD baseline (numpy, no PAC)
105+
# ---------------------------------------------------------------------------
106+
107+
class SGDNet:
108+
"""Minimal MLP trained with vanilla SGD and tanh hidden layers."""
109+
110+
def __init__(self, layer_sizes: list, lr: float = 0.03, seed: int = 42):
111+
rng = np.random.RandomState(seed)
112+
self.lr = lr
113+
self.weights = []
114+
self.biases = []
115+
for i in range(len(layer_sizes) - 1):
116+
in_d, out_d = layer_sizes[i], layer_sizes[i + 1]
117+
scale = math.sqrt(2.0 / (in_d + out_d))
118+
self.weights.append(rng.randn(in_d, out_d) * scale)
119+
self.biases.append(np.zeros(out_d))
120+
121+
def _forward(self, x):
122+
acts = [x]
123+
h = x
124+
for i, (W, b) in enumerate(zip(self.weights, self.biases)):
125+
z = h @ W + b
126+
if i < len(self.weights) - 1:
127+
h = np.tanh(z)
128+
else:
129+
h = z # linear output
130+
acts.append(h)
131+
return acts
132+
133+
def step(self, x_batch, y_batch):
134+
acts = self._forward(x_batch)
135+
y_pred = acts[-1]
136+
mse = float(np.mean((y_pred - y_batch) ** 2))
137+
138+
# Backprop
139+
delta = y_pred - y_batch # output delta
140+
for i in range(len(self.weights) - 1, -1, -1):
141+
x_in = acts[i]
142+
dW = x_in.T @ delta
143+
db = delta.sum(axis=0)
144+
# Gradient clipping per layer
145+
dW_norm = float(np.linalg.norm(dW))
146+
if dW_norm > GRAD_CLIP:
147+
dW = dW * (GRAD_CLIP / dW_norm)
148+
self.weights[i] -= self.lr * dW
149+
self.biases[i] -= self.lr * db
150+
if i > 0:
151+
# Propagate through tanh derivative
152+
delta = (delta @ self.weights[i].T) * (1 - acts[i] ** 2)
153+
# Clip propagated delta
154+
d_norm = float(np.linalg.norm(delta))
155+
if d_norm > GRAD_CLIP:
156+
delta = delta * (GRAD_CLIP / d_norm)
157+
return mse
158+
159+
def predict(self, x):
160+
return self._forward(x)[-1]
161+
162+
163+
# ---------------------------------------------------------------------------
164+
# Training loop
165+
# ---------------------------------------------------------------------------
166+
167+
def train_and_eval(model, X_train, y_train, X_eval, y_eval,
168+
n_epochs=N_EPOCHS, batch_size=BATCH_SIZE):
169+
"""
170+
Train model for n_epochs full passes over the training data.
171+
Returns final evaluation MSE.
172+
"""
173+
n = len(X_train)
174+
rng = np.random.RandomState(7)
175+
176+
for _ in range(n_epochs):
177+
idx = rng.permutation(n)
178+
for start in range(0, n, batch_size):
179+
end = min(start + batch_size, n)
180+
batch_idx = idx[start:end]
181+
model.step(X_train[batch_idx], y_train[batch_idx])
182+
183+
y_pred = model.predict(X_eval)
184+
return float(np.mean((y_pred - y_eval) ** 2))
185+
186+
187+
# ---------------------------------------------------------------------------
188+
# Main
189+
# ---------------------------------------------------------------------------
190+
191+
def run_pattern(pattern_name: str, seq_fn):
192+
"""Run one pattern condition; return dict with per-seed and summary stats."""
193+
seq = seq_fn(SEQ_LEN)
194+
X, y = make_windows(seq, WINDOW)
195+
196+
# Use last EVAL_LAST_N windows for evaluation
197+
split = len(X) - EVAL_LAST_N
198+
X_train, y_train = X[:split], y[:split]
199+
X_eval, y_eval = X[split:], y[split:]
200+
201+
pac_mses, sgd_mses = [], []
202+
203+
for seed in range(N_SEEDS):
204+
pac_model = PACDescent(LAYER_SIZES, lr=LR, seed=seed)
205+
sgd_model = SGDNet(LAYER_SIZES, lr=LR, seed=seed)
206+
207+
pac_mse = train_and_eval(pac_model, X_train, y_train, X_eval, y_eval)
208+
sgd_mse = train_and_eval(sgd_model, X_train, y_train, X_eval, y_eval)
209+
210+
pac_mses.append(pac_mse)
211+
sgd_mses.append(sgd_mse)
212+
213+
print(
214+
f" [{pattern_name}] seed={seed} "
215+
f"PAC={pac_mse:.6f} SGD={sgd_mse:.6f} "
216+
f"ratio={pac_mse/max(sgd_mse, 1e-12):.3f}"
217+
)
218+
219+
return {
220+
"pattern": pattern_name,
221+
"pac_mse_per_seed": pac_mses,
222+
"sgd_mse_per_seed": sgd_mses,
223+
"pac_mse_mean": float(np.mean(pac_mses)),
224+
"pac_mse_std": float(np.std(pac_mses)),
225+
"sgd_mse_mean": float(np.mean(sgd_mses)),
226+
"sgd_mse_std": float(np.std(sgd_mses)),
227+
"pac_wins": int(sum(p < s for p, s in zip(pac_mses, sgd_mses))),
228+
"n_seeds": N_SEEDS,
229+
}
230+
231+
232+
def main():
233+
print("=" * 60)
234+
print("Exp 04 — Conservation vs Gradient (Noether vs SGD)")
235+
print("=" * 60)
236+
237+
patterns = [
238+
("power_law", power_law_sequence),
239+
("fibonacci_cascade", fibonacci_cascade_sequence),
240+
]
241+
242+
results = {
243+
"experiment": "exp_04_conservation_vs_gradient",
244+
"config": {
245+
"seq_len": SEQ_LEN,
246+
"window": WINDOW,
247+
"n_epochs": N_EPOCHS,
248+
"batch_size": BATCH_SIZE,
249+
"layer_sizes": LAYER_SIZES,
250+
"lr": LR,
251+
"n_seeds": N_SEEDS,
252+
"eval_last_n": EVAL_LAST_N,
253+
"phi": PHI,
254+
},
255+
"patterns": {},
256+
}
257+
258+
for pname, pfn in patterns:
259+
print(f"\nPattern: {pname}")
260+
r = run_pattern(pname, pfn)
261+
results["patterns"][pname] = r
262+
print(
263+
f" Summary: PAC={r['pac_mse_mean']:.6f}±{r['pac_mse_std']:.6f} "
264+
f"SGD={r['sgd_mse_mean']:.6f}±{r['sgd_mse_std']:.6f} "
265+
f"PAC wins {r['pac_wins']}/{r['n_seeds']}"
266+
)
267+
268+
# Save results
269+
out_path = os.path.join(_NOETHER_ROOT, "results", "exp_04_results.json")
270+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
271+
with open(out_path, "w") as f:
272+
json.dump(results, f, indent=2)
273+
print(f"\nResults saved to: {out_path}")
274+
275+
# Quick pass/fail summary
276+
print("\n" + "=" * 60)
277+
for pname, r in results["patterns"].items():
278+
verdict = "PAC < SGD" if r["pac_mse_mean"] < r["sgd_mse_mean"] else "PAC >= SGD"
279+
print(
280+
f" {pname:22s} Noether={r['pac_mse_mean']:.6f} "
281+
f"SGD={r['sgd_mse_mean']:.6f} [{verdict}]"
282+
)
283+
print("=" * 60)
284+
285+
return results
286+
287+
288+
if __name__ == "__main__":
289+
main()

0 commit comments

Comments
 (0)