Projective-Gap Regularization for Random Jacobian Cocycles / projective_gap_experiment.py
Failed on benchmark
1import json, math, random
2from pathlib import Path
3import numpy as np
4
5SEED = 2518
6np.random.seed(SEED); random.seed(SEED)
7
8
9def cocycle_run(l1, l2, K=200, noise=0.20, theta0=None):
10 """Random diagonal positive cocycle; log singular increments have known means."""
11 if theta0 is None:
12 theta0 = np.random.uniform(0.25, 1.25, size=2)
13 # tangent vectors are represented by slopes y/x; projective distance is angle.
14 slopes = np.tan(theta0)
15 logs = np.zeros(2)
16 angles = [theta0.copy()]
17 q = np.eye(2)
18 qr_diag = []
19 for t in range(K):
20 # independent temporal randomness, but the same scalar derivative applies to both vectors
21 inc = np.array([l1, l2]) + noise*np.random.randn(2)
22 A = np.diag(np.exp(inc))
23 # QR iteration estimates both exponents
24 z = A @ q
25 q, R = np.linalg.qr(z)
26 # enforce positive diagonal, without changing Q directions
27 signs = np.sign(np.diag(R)); signs[signs == 0] = 1
28 q = q @ np.diag(signs); R = np.diag(signs) @ R
29 qr_diag.append(np.log(np.abs(np.diag(R))))
30 logs += inc
31 slopes *= np.exp(inc[1]-inc[0])
32 # angles to the top axis (modulo orientation)
33 angles.append(np.arctan(np.abs(slopes)))
34 return np.asarray(angles), np.asarray(qr_diag), logs/K
35
36
37def fit_slope(y, start=20, end=None):
38 if end is None: end = len(y)
39 x = np.arange(start, end)
40 return float(np.polyfit(x, np.log(np.maximum(y[start:end], 1e-300)), 1)[0])
41
42
43def toy_verification():
44 # Prediction 1: projective log-angle slope is -gamma.
45 gaps = [0.02, 0.05, 0.10, 0.20, 0.40]
46 rows = []
47 for g in gaps:
48 # average many paths to suppress finite-sample noise
49 slopes = []; estimates = []
50 for _ in range(80):
51 a, qr, means = cocycle_run(0.0, -g, K=180, noise=0.20)
52 slopes.append(fit_slope(a[:,0], 30, 170))
53 estimates.append(np.mean(qr[:,0]-qr[:,1]))
54 obs_s = float(np.mean(slopes)); obs_g = float(np.mean(estimates))
55 rows.append({'gamma_true':g, 'predicted_slope':-g, 'observed_slope':obs_s,
56 'predicted_gap':g, 'observed_qr_gap':obs_g,
57 'slope_rel_error':abs(obs_s+g)/g})
58
59 # Prediction 2: time to reach angle epsilon scales log(theta0/epsilon)/gamma.
60 threshold_rows = []
61 eps = 1e-3
62 for g in [0.05, 0.10, 0.20, 0.40]:
63 measured=[]; predicted=[]
64 for _ in range(100):
65 a, _, _ = cocycle_run(0.0, -g, K=500, noise=0.20,
66 theta0=np.array([0.8, 0.8]))
67 hit = np.flatnonzero(a[:,0] <= eps)
68 measured.append(int(hit[0]) if len(hit) else 500)
69 predicted.append(math.log(math.tan(0.8)/eps)/g)
70 threshold_rows.append({'gamma':g, 'predicted_steps':float(np.mean(predicted)),
71 'observed_steps':float(np.mean(measured)),
72 'ratio_observed_predicted':float(np.mean(measured)/np.mean(predicted))})
73
74 # Prediction 3: no gap means no projective contraction; with exactly equal rates,
75 # the angle remains constant even though the common exponent can be negative.
76 zero=[]
77 for l in [0.0, -0.2]:
78 a, qr, means = cocycle_run(l, l, K=150, noise=0.20)
79 zero.append({'common_lambda':l, 'angle_log_slope':fit_slope(a[:,0], 10, 140),
80 'qr_gap':float(np.mean(qr[:,0]-qr[:,1])),
81 'angle_ratio_final_initial':float(a[-1,0]/a[0,0])})
82 return {'gap_sweep':rows, 'threshold_sweep':threshold_rows, 'zero_gap':zero}
83
84
85# Optional practical check: train a tiny tanh RNN, regularizing a finite-window
86# Jacobian gap estimated by explicit 2x2 Jacobians (small enough to be transparent).
87def rnn_check(steps=180, seq_len=40, batch=32):
88 try:
89 import torch
90 torch.manual_seed(SEED)
91 device = 'cuda' if torch.cuda.is_available() else 'cpu'
92 class RNN(torch.nn.Module):
93 def __init__(self):
94 super().__init__(); self.W=torch.nn.Parameter(torch.randn(12,12)*.18)
95 self.U=torch.nn.Parameter(torch.randn(12,2)*.18); self.b=torch.nn.Parameter(torch.zeros(12))
96 self.out=torch.nn.Linear(12,1)
97 def forward(self,x, regularize=False):
98 z=torch.zeros(x.shape[0],12,device=x.device); gs=[]; l1s=[]
99 for t in range(x.shape[1]):
100 z=torch.tanh(z@self.W.T+x[:,t]@self.U.T+self.b)
101 # Exact Jacobian singular values for the hidden map at this state.
102 if regularize:
103 D=1-z*z
104 J=D.unsqueeze(2)*self.W.unsqueeze(0)
105 s=torch.linalg.svdvals(J)
106 l1s.append(torch.log(s[:,0].clamp_min(1e-8)))
107 gs.append(torch.log((s[:,0]/s[:,1]).clamp_min(1e-8)))
108 return self.out(z), (torch.stack(l1s,1).mean() if regularize else None), (torch.stack(gs,1).mean() if regularize else None)
109 def run(reg):
110 torch.manual_seed(SEED+int(reg)); m=RNN().to(device); opt=torch.optim.Adam(m.parameters(),lr=2e-3)
111 losses=[]; gaps=[]; exps=[]
112 for k in range(steps):
113 x=torch.rand(batch,seq_len,2,device=device); target=x[:,:,0].sum(1)+x[:,:,1].sum(1)
114 y,l1,g=m(x,reg); loss=((y[:,0]-target)**2).mean()
115 if reg: loss=loss+0.5*torch.relu(0.05-g)**2+0.5*l1*l1
116 opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(m.parameters(),1.0); opt.step()
117 losses.append(float(((y[:,0]-target)**2).mean().detach().cpu()))
118 if reg: gaps.append(float(g.detach().cpu())); exps.append(float(l1.detach().cpu()))
119 return {'final_loss':float(np.mean(losses[-20:])), 'gap':float(np.mean(gaps[-20:])) if reg else None,
120 'lambda1':float(np.mean(exps[-20:])) if reg else None, 'device':device}
121 return {'baseline':run(False),'regularized':run(True)}
122 except Exception as e:
123 return {'error':repr(e), 'fallback':'toy verification still valid'}
124
125
126if __name__ == '__main__':
127 result={'seed':SEED, 'toy':toy_verification(), 'rnn':rnn_check()}
128 Path('results.json').write_text(json.dumps(result, indent=2))
129 print(json.dumps(result, indent=2))