OSL-QIB Contractive State Observer / bench_observer.py
Mechanism confirmed, baseline not beaten
1import json, sys
2from pathlib import Path
3import numpy as np
4import torch
5import torch.nn as nn
6sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
7from bench import get_dataset, train_model, evaluate, sweep_baseline, make_report
8
9SEED0 = 2915
10EPOCHS = 18
11NTRAIN = 800
12NTEST = 300
13BATCH = 128
14LRS = [1e-3, 3e-3, 1e-2]
15
16class MatchedGRU(nn.Module):
17 """The benchmark rnn_small recurrent architecture, exposed as GRUCell.
18 Observer differs only by correction between recurrent updates."""
19 def __init__(self, observer=False, gain=0.0, hidden=64):
20 super().__init__()
21 self.cell = nn.GRUCell(3, hidden)
22 self.head = nn.Linear(hidden, 1)
23 self.observer = observer
24 self.gain = float(gain)
25
26 def forward(self, x):
27 z = x.view(x.shape[0], -1, 3)
28 h = torch.zeros(z.shape[0], self.cell.hidden_size, device=x.device, dtype=x.dtype)
29 for k in range(z.shape[1]):
30 # theta is the measured feature; C selects the first latent coordinate.
31 if self.observer:
32 innovation = z[:, k, 0] - h[:, 0]
33 h = h + self.gain * innovation.unsqueeze(1) * torch.eye(1, self.cell.hidden_size, device=x.device, dtype=x.dtype)
34 h = self.cell(z[:, k], h)
35 return self.head(h)
36
37
38def make_ds(seed):
39 return get_dataset('dynamics', seed, n_train=NTRAIN, n_test=NTEST)
40
41def train_one(seed, lr, idea, gain):
42 torch.manual_seed(seed); np.random.seed(seed)
43 d = make_ds(seed)
44 model = MatchedGRU(observer=idea, gain=gain)
45 _, metric, _ = train_model(model, d, epochs=EPOCHS, lr=lr, batch=BATCH, log=lambda *_: None)
46 return float(metric)
47
48def fn(idea, gain, lr):
49 return lambda seed: train_one(seed, lr, idea, gain)
50
51def signature(seed, lr, gain):
52 """Measure contraction on hidden states of a trained observer model.
53 Prediction is the continuous certificate upper bound using empirical local
54 Jacobian one-sided/QIB constants and P=I; observation is actual squared
55 perturbation ratio after one trained recurrent update."""
56 torch.manual_seed(seed); np.random.seed(seed)
57 d = make_ds(seed)
58 model = MatchedGRU(observer=True, gain=gain)
59 model, _, _ = train_model(model, d, epochs=EPOCHS, lr=lr, batch=BATCH, log=lambda *_: None)
60 model.eval(); device = next(model.parameters()).device; x = d['xte'][:128].to(device)
61 with torch.no_grad():
62 z = x.view(x.shape[0], -1, 3)
63 h = torch.zeros(x.shape[0], 64, device=device)
64 # collect actual hidden states and current observations
65 for k in range(4):
66 innovation=z[:,k,0]-h[:,0]
67 h=h+gain*innovation[:,None]*torch.eye(1,64,device=device)
68 h=model.cell(z[:,k],h)
69 base=h[:32].clone(); eps=1e-3
70 perturb=torch.randn_like(base); perturb=perturb/(perturb.norm(dim=1,keepdim=True)+1e-9)*eps
71 obs=z[:32,4,0]
72 def step(q):
73 qq=q+gain*(obs-q[:,0])[:,None]*torch.eye(1,64,device=device)
74 return model.cell(z[:32,4],qq)
75 hn=step(base); hp=step(base+perturb)
76 ratios=((hp-hn)**2).sum(1)/(perturb**2).sum(1)
77 observed=float(ratios.mean())
78 # Empirical local slope bound from finite differences of the trained map.
79 q=base[:8].detach(); vals=[]; fd=1e-3
80 for i in range(8):
81 qi=q[i:i+1]; oi=obs[i:i+1]; zi=z[i:i+1,4]
82 def local(v):
83 vv=v+gain*(oi-v[:,0])[:,None]*torch.eye(1,64,device=device)
84 return model.cell(zi,vv)[0]
85 cols=[]
86 for j in range(64):
87 dv=torch.zeros_like(qi); dv[0,j]=fd
88 cols.append(((local(qi+dv)-local(qi-dv))/(2*fd)).detach())
89 vals.append(torch.stack(cols,dim=1))
90 J=torch.stack(vals)
91 spectral=float(torch.linalg.matrix_norm(J,ord=2,dim=(1,2)).max())
92 # empirical local slope bound for the correction+cell map via autograd JVP
93 predicted=spectral*spectral
94 return {'gain':gain,'lr':lr,'predicted_V_ratio_bound':predicted,
95 'observed_mean_V_ratio':observed,'observed_max_V_ratio':float(ratios.max()),
96 'certificate_contraction_predicted':bool(predicted<1),
97 'observed_contraction':bool(observed<1),'confirmed':bool(predicted<1 and observed<1)}
98
99def main():
100 # Baseline sweep deliberately covers every LR used by the idea.
101 grid=[{'lr':lr,'gain':0.0} for lr in LRS]
102 base=sweep_baseline(lambda c: fn(False, 0.0, c['lr']), grid)
103 # Three observer settings: best baseline LR plus two nearby method settings.
104 idea_grid=[(base['best_cfg']['lr'], 0.25),(base['best_cfg']['lr'],0.5),(base['best_cfg']['lr'],1.0)]
105 # Also ensure all idea lrs have baseline evaluations (already true above).
106 trials=[]
107 for lr,g in idea_grid:
108 r=evaluate(fn(True,g,lr))
109 trials.append({'cfg':{'lr':lr,'gain':g},'result':r})
110 best=min(trials,key=lambda t:t['result']['mean'])
111 idea=best['result']; bestcfg=best['cfg']
112 sig=signature(0,bestcfg['lr'],bestcfg['gain'])
113 report=make_report('dynamics','rnn_small',base,idea,{
114 'description':'trained GRUCell hidden-state observer correction on pendulum windows',
115 'prediction':'local trained recurrent map should contract latent perturbations in V=||e||^2',
116 'trained_model_measurement':sig,
117 'idea_sweep':trials})
118 report['idea_best_cfg']=bestcfg
119 report['protocol_notes']={'track_match':'dynamics: controlled damped pendulum', 'epochs':EPOCHS,
120 'n_train':NTRAIN,'n_test':NTEST,'lr_union':LRS,
121 'paired_seeds':8,'baseline_sweep_seeds':4}
122 Path('bench_report.json').write_text(json.dumps(report,indent=2))
123 print(json.dumps(report,indent=2))
124
125if __name__=='__main__': main()