Small-Gain Constrained Neural Modules / run_experiment.py

✓✓ Beats tuned baseline

Raw ⬇ ZIP
  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()