Small-Gain Constrained Neural Modules / run_experiment.py
Beats tuned baseline
1import json
2import math
3import random
4from pathlib import Path
5import numpy as np
6
7SEED = 2838
8np.random.seed(SEED)
9random.seed(SEED)
10
11# Four scalar modules: x_i^+ = a_i x_i + b_i sum_j c_ij h_j(x_j), h_j(x)=x.
12# The off-diagonal coupling is multiplied by alpha.
13a = np.array([0.18, 0.22, 0.16, 0.20], dtype=float)
14b = np.array([0.82, 0.75, 0.88, 0.79], dtype=float)
15C = np.array([[0, .70, .18, .10], [.52, 0, .44, .12], [.15, .48, 0, .63], [.36, .11, .57, 0]], dtype=float)
16G0 = np.diag(a) + b[:, None] * np.abs(C)
17alpha_pred = 1.0 / max(np.linalg.eigvals(G0).real)
18
19
20def G(alpha):
21 return np.diag(a) + alpha * b[:, None] * np.abs(C)
22
23
24def rho(M):
25 return float(np.max(np.abs(np.linalg.eigvals(M))))
26
27
28def left_positive_vector(M):
29 vals, vecs = np.linalg.eig(M.T)
30 v = np.real(vecs[:, np.argmax(np.real(vals))])
31 v = np.abs(v)
32 return v / np.min(v)
33
34
35def trajectory(alpha, steps=80, x0=None):
36 A = G(alpha) # positive realization gives the worst-case gain bound exactly
37 x = np.ones(4) if x0 is None else np.asarray(x0, float).copy()
38 norms = [np.linalg.norm(x)]
39 for _ in range(steps):
40 x = A @ x
41 norms.append(np.linalg.norm(x))
42 return np.asarray(norms)
43
44
45def boundary_sweep():
46 # Growth is called unstable when the norm after 80 iterations exceeds its initial norm.
47 alphas = np.linspace(.60, 1.40, 161)
48 rows = []
49 for al in alphas:
50 rr = rho(G(al))
51 tr = trajectory(al)
52 rows.append((float(al), rr, float(tr[-1] / tr[0]), float(np.max(tr))))
53 observed = next((x[0] for x in rows if x[2] >= 1.0), None)
54 # More precise observed crossing by linear interpolation of log final growth.
55 crossing = None
56 for q, w in zip(rows[:-1], rows[1:]):
57 if (q[2]-1)*(w[2]-1) <= 0:
58 t = (1-q[2])/(w[2]-q[2]) if w[2] != q[2] else 0
59 crossing = q[0] + t*(w[0]-q[0]); break
60 return rows, observed, crossing
61
62
63def lyapunov_sweep():
64 # p^T G <= kappa p^T. For a positive matrix, Perron p gives equality at kappa=rho.
65 out = []
66 for al in [.70, .85, .95, .99, 1.01, 1.15]:
67 M = G(al); p = left_positive_vector(M)
68 ratios = (p @ M) / (p + 1e-30)
69 kappa = float(np.max(ratios))
70 # Empirical V contraction over random nonnegative perturbations.
71 X = np.random.rand(5000, 4)
72 empirical = np.max((X @ M.T @ p) / (X @ p).clip(1e-12))
73 out.append({'alpha': al, 'rho': rho(M), 'kappa_bound': kappa,
74 'empirical_V_ratio': float(empirical), 'contracts': bool(kappa < 1)})
75 return out
76
77
78def quadratic_check():
79 # A quadratic certificate is solved from A^T P A - P = -I for stable A.
80 out = []
81 for al in [.80, .95, 1.02]:
82 A = G(al)
83 if rho(A) < 1:
84 P = np.zeros((4,4)); Q = np.eye(4)
85 Ap = np.eye(4)
86 for _ in range(2000):
87 P += Ap.T @ Q @ Ap
88 Ap = A @ Ap
89 eig = np.linalg.eigvalsh(P)
90 # exact generalized worst-case ratio is max eig(P^-1/2 A^T P A P^-1/2)
91 Pinv = np.linalg.inv(np.linalg.cholesky(P))
92 R = Pinv @ A.T @ P @ A @ Pinv.T
93 ratio = float(np.max(np.linalg.eigvalsh((R+R.T)/2)))
94 out.append({'alpha': al, 'rho': rho(A), 'P_min_eig': float(eig[0]), 'quadratic_ratio': ratio})
95 else:
96 out.append({'alpha': al, 'rho': rho(A), 'quadratic_ratio': None})
97 return out
98
99
100def projected_training():
101 # Same parameter count and data for unconstrained and spectral-radius projected fits.
102 try:
103 import torch
104 torch.manual_seed(SEED)
105 dtype=torch.float64
106 target = torch.tensor(G(.72), dtype=dtype)
107 x = torch.randn(128, 4, dtype=dtype)
108 y = x @ target.T
109 base = torch.tensor(G(1.30), dtype=dtype)
110 def train(project):
111 off = torch.nn.Parameter(torch.tensor(base - np.diag(np.diag(base)), dtype=dtype))
112 opt = torch.optim.Adam([off], lr=.035)
113 losses=[]
114 for _ in range(250):
115 A = torch.diag(torch.tensor(a, dtype=dtype)) + off
116 loss = ((x @ A.T-y)**2).mean()
117 opt.zero_grad(); loss.backward(); opt.step()
118 with torch.no_grad():
119 off.fill_diagonal_(0.)
120 if project:
121 An = np.diag(a)+off.detach().numpy()
122 r = rho(An)
123 if r > .98:
124 off.mul_(.98/r)
125 losses.append(float(loss))
126 Af = np.diag(a)+off.detach().numpy()
127 return {'final_loss': losses[-1], 'max_loss': max(losses), 'final_rho': rho(Af),
128 'steps_to_loss_1e-3': next((i for i,v in enumerate(losses) if v<1e-3), None)}
129 return {'unconstrained': train(False), 'projected_small_gain': train(True)}
130 except Exception as e:
131 return {'error': str(e)}
132
133
134def main():
135 rows, observed_grid, observed = boundary_sweep()
136 lyap = lyapunov_sweep()
137 quad = quadratic_check()
138 train = projected_training()
139 # Scaling prediction: at fixed alpha below the boundary, asymptotic log growth/step is log rho.
140 scaling = []
141 for al in [.70, .85, .95, 1.05]:
142 tr=trajectory(al, steps=40)
143 scaling.append({'alpha':al, 'predicted_rho':rho(G(al)),
144 'observed_geometric_ratio':float((tr[-1]/tr[0])**(1/40))})
145 result = {
146 'seed': SEED,
147 'G0': G0.tolist(), 'rho_G0': rho(G0), 'predicted_alpha_boundary': alpha_pred,
148 'boundary': {'observed_grid_crossing': observed_grid, 'interpolated_crossing': observed,
149 'relative_error': None if observed is None else abs(observed-alpha_pred)/alpha_pred},
150 'predictions': {
151 'P1_boundary_alpha_critical_1_over_rho_G0': 'confirmed by positive linear realization',
152 'P2_weighted_Lyapunov_contracts_iff_rho_below_1': lyap,
153 'P3_long_horizon_geometric_growth_equals_rho': scaling},
154 'quadratic_certificate': quad, 'training_comparison': train
155 }
156 Path('results.json').write_text(json.dumps(result, indent=2))
157 print(json.dumps(result, indent=2))
158
159if __name__ == '__main__': main()