Forcing-Consistency Training Constraint / bench_fc_dynamics.py
Mechanism confirmed, baseline not beaten
1import sys, json, random, math
2import numpy as np
3import torch
4import torch.nn.functional as F
5
6sys.path.insert(0, "/home/maxwelhelp/all/math2nn")
7from bench import get_dataset, make_model, evaluate, sweep_baseline, make_report
8
9SEED0 = 1215
10EPOCHS = 12
11BATCH = 128
12THREADS = 4
13
14
15def seed_all(seed):
16 random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
17 if torch.cuda.is_available():
18 torch.cuda.manual_seed_all(seed)
19
20
21def js_batch(p, q):
22 m = (p + q) / 2
23 return 0.5 * ((p * (p.clamp_min(1e-8).log() - m.clamp_min(1e-8).log())).sum(-1) +
24 (q * (q.clamp_min(1e-8).log() - m.clamp_min(1e-8).log())).sum(-1))
25
26
27def math_check():
28 p = torch.tensor([[.9, .1], [.9, .1], [.2, .8]], dtype=torch.float64)
29 z = float(js_batch(p[:1], p[1:2])[0])
30 unequal = float(js_batch(p[:1], p[2:3])[0])
31 sets = [{0, 1}, {0}, {0, 2}]
32 empty = [{0}, {1}]
33 return {
34 "js_equal": z, "js_unequal": unequal,
35 "js_symmetric_error": abs(float(js_batch(p[:1], p[2:3])[0] - js_batch(p[2:3], p[:1])[0])),
36 "intersection_feasible": bool(set.intersection(*map(set, sets))),
37 "intersection_empty": not bool(set.intersection(*map(set, empty))),
38 "passed": z < 1e-12 and unequal > 0 and not bool(set.intersection(*map(set, empty))),
39 }
40
41
42def grouped_consistency(pred, x):
43 # Dynamics observations are aliased by a coarse theta projection. The
44 # scalar forecast induces a distribution over three forcing-mask choices.
45 theta_obs = x[:, -3]
46 keys = torch.round(theta_obs / 0.35).to(torch.int64)
47 centers = pred.new_tensor([-0.35, 0.0, 0.35])
48 q = F.softmax(-((pred - centers[None, :]) / 0.30) ** 2, dim=1)
49 vals = []
50 for k in torch.unique(keys):
51 ix = keys == k
52 if int(ix.sum()) > 1:
53 bar = q[ix].mean(0, keepdim=True)
54 vals.append(js_batch(q[ix], bar.expand_as(q[ix])).mean())
55 return torch.stack(vals).mean() if vals else pred.new_zeros(())
56
57
58def robust_safety(pred, x):
59 # Candidate forcing corrections; require one shared correction to keep all
60 # histories in each observation class inside the safe angular region.
61 theta_obs = x[:, -3]
62 keys = torch.round(theta_obs / 0.35).to(torch.int64)
63 deltas = pred.new_tensor([-0.20, 0.0, 0.20])
64 margins = torch.sigmoid((1.20 - (pred[:, None] + deltas[None, :]).abs()) / 0.12)
65 losses = []
66 for k in torch.unique(keys):
67 ix = keys == k
68 if int(ix.sum()) > 1:
69 worst = margins[ix].min(0).values
70 best = worst.max()
71 losses.append(F.relu(pred.new_tensor(0.75) - best))
72 return torch.stack(losses).mean() if losses else pred.new_zeros(())
73
74
75def train_one(seed, lr, weight_decay=0.0, fc_weight=0.0, safety_weight=0.0, return_sig=False):
76 seed_all(seed)
77 ds = get_dataset("dynamics", seed, n_train=400, n_test=200)
78 model = make_model("rnn_small", ds["input_shape"], ds["out_dim"])
79 device = "cuda" if torch.cuda.is_available() else "cpu"
80 try:
81 model.to(device)
82 opt = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)
83 x, y = ds["xtr"].to(device), ds["ytr"].to(device)
84 model.train()
85 for _ in range(EPOCHS):
86 order = torch.randperm(len(x), device=device)
87 for start in range(0, len(x), BATCH):
88 ix = order[start:start+BATCH]
89 pred = model(x[ix])
90 task = F.mse_loss(pred, y[ix])
91 loss = task
92 if fc_weight:
93 loss = loss + fc_weight * grouped_consistency(pred, x[ix])
94 if safety_weight:
95 loss = loss + safety_weight * robust_safety(pred, x[ix])
96 opt.zero_grad(set_to_none=True); loss.backward(); opt.step()
97 model.eval()
98 with torch.no_grad():
99 pred = model(ds["xte"].to(device))
100 metric = float(F.mse_loss(pred, ds["yte"].to(device)).cpu())
101 # Signature measured on the trained model, not an analytic toy.
102 kappa = float(grouped_consistency(pred, ds["xte"].to(device)).cpu())
103 safety = float(robust_safety(pred, ds["xte"].to(device)).cpu())
104 if return_sig:
105 return metric, {"observed_kappa": kappa, "observed_safety_hinge": safety}
106 return metric
107 except RuntimeError:
108 if device != "cpu":
109 torch.cuda.empty_cache()
110 return train_one(seed, lr, weight_decay, fc_weight, safety_weight, return_sig)
111 raise
112
113
114def main():
115 check = math_check()
116 assert check["passed"]
117 # Baseline decisive knob (weight decay) and learning rate are both swept.
118 grid = [{"lr": lr, "weight_decay": wd}
119 for lr in (1e-2, 3e-2, 1e-1) for wd in (0.0, 1e-4)]
120 base = sweep_baseline(
121 lambda c: lambda s: train_one(s, c["lr"], c["weight_decay"]), grid)
122 best_lr = base["best_cfg"]["lr"]
123 idea_grid = [{"lr": lr, "weight_decay": 0.0, "fc_weight": 0.08, "safety_weight": 0.08}
124 for lr in (1e-2, best_lr, 1e-1)]
125 idea_trials = []
126 for cfg in idea_grid:
127 r = evaluate(lambda s, c=cfg: train_one(s, c["lr"], c["weight_decay"], c["fc_weight"], c["safety_weight"]), (0,1,2,3))
128 idea_trials.append({"cfg": cfg, "mean": r["mean"]})
129 best_idea_cfg = min(idea_trials, key=lambda z: z["mean"])["cfg"]
130 idea = evaluate(lambda s: train_one(s, best_idea_cfg["lr"], best_idea_cfg["weight_decay"], best_idea_cfg["fc_weight"], best_idea_cfg["safety_weight"]))
131 _, sigs = zip(*(train_one(s, best_idea_cfg["lr"], best_idea_cfg["weight_decay"], best_idea_cfg["fc_weight"], best_idea_cfg["safety_weight"], True) for s in range(8)))
132 base_sig = [train_one(s, base["best_cfg"]["lr"], base["best_cfg"]["weight_decay"], return_sig=True)[1] for s in range(8)]
133 signature = {
134 "prediction": "FC should lower observational kappa on trained recurrent models",
135 "baseline_kappa_mean": float(np.mean([z["observed_kappa"] for z in base_sig])),
136 "idea_kappa_mean": float(np.mean([z["observed_kappa"] for z in sigs])),
137 "baseline_safety_hinge_mean": float(np.mean([z["observed_safety_hinge"] for z in base_sig])),
138 "idea_safety_hinge_mean": float(np.mean([z["observed_safety_hinge"] for z in sigs])),
139 "confirmed": float(np.mean([z["observed_kappa"] for z in sigs])) < float(np.mean([z["observed_kappa"] for z in base_sig]))
140 }
141 report = make_report("dynamics", "rnn_small", base, idea, {
142 "math_check": check, "idea_sweep": idea_trials,
143 "best_idea_cfg": best_idea_cfg, "signature": signature
144 })
145 with open("bench_report.json", "w") as f: json.dump(report, f, indent=2)
146 print(json.dumps(report, indent=2))
147
148if __name__ == "__main__": main()