Positive-Regime Observable ReLU State Space / experiment.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import json
  2from pathlib import Path
  3import numpy as np
  4
  5SEED = 2844
  6rng = np.random.default_rng(SEED)
  7OUT = Path('results.json')
  8
  9
 10def relu(x):
 11    return np.maximum(x, 0.0)
 12
 13
 14def rollout(A, B, b, x0, U):
 15    xs = [x0.copy()]
 16    zs = []
 17    x = x0.copy()
 18    for u in U:
 19        z = A @ x + B @ u + b
 20        zs.append(z.copy())
 21        x = relu(z)
 22        xs.append(x.copy())
 23    return np.asarray(xs), np.asarray(zs)
 24
 25
 26def observability(A, C, T):
 27    blocks = []
 28    P = np.eye(A.shape[0])
 29    for _ in range(T + 1):
 30        blocks.append(C @ P)
 31        P = P @ A
 32    return np.vstack(blocks)
 33
 34
 35def rank_tol(M):
 36    s = np.linalg.svd(M, compute_uv=False)
 37    return int(np.sum(s > max(M.shape) * np.finfo(float).eps * s[0])) if s[0] else 0
 38
 39
 40def prediction_positive_regime():
 41    # Positive A, input, bias, and initial state imply every preactivation is positive.
 42    n, p, steps = 8, 3, 30
 43    A = 0.82 * np.eye(n) + 0.06 * np.ones((n, n)) / n
 44    B = 0.12 * np.ones((n, p))
 45    b = np.full(n, 0.03)
 46    x0 = np.full(n, 0.4)
 47    U = rng.uniform(0.0, 1.0, size=(steps, p))
 48    xr, z = rollout(A, B, b, x0, U)
 49    # Explicit affine recurrence, which must equal ReLU recurrence.
 50    xa = [x0.copy()]
 51    x = x0.copy()
 52    for u in U:
 53        x = A @ x + B @ u + b
 54        xa.append(x.copy())
 55    xa = np.asarray(xa)
 56    return {
 57        'predicted_max_difference': 0.0,
 58        'observed_max_difference': float(np.max(np.abs(xr - xa))),
 59        'negative_preactivation_fraction': float(np.mean(z < 0)),
 60        'pass': bool(np.max(np.abs(xr - xa)) < 1e-12 and np.all(z >= 0)),
 61    }
 62
 63
 64def prediction_rank_transition():
 65    # A diagonal system with distinct eigenvalues and one coordinate observed.
 66    # For C=[1,0,...], T+1 observations span k coordinates only when T+1 >= k.
 67    n = 6
 68    eig = np.array([0.92, 0.78, 0.64, 0.50, 0.36, 0.22])
 69    A = np.diag(eig)
 70    C = np.ones((1, n))  # all modes visible; Vandermonde rows become full rank at T=n-1
 71    rows = []
 72    for T in range(0, n + 3):
 73        Q = observability(A, C, T)
 74        s = np.linalg.svd(Q, compute_uv=False)
 75        rows.append({'T': T, 'rank': rank_tol(Q), 'sigma_min': float(s[-1])})
 76    observed_transition = next(r['T'] for r in rows if r['rank'] == n)
 77    predicted_transition = n - 1
 78    return {
 79        'predicted_full_rank_first_T': predicted_transition,
 80        'observed_full_rank_first_T': observed_transition,
 81        'sweep': rows,
 82        'pass': observed_transition == predicted_transition,
 83    }
 84
 85
 86def prediction_noise_scaling():
 87    # In the linear positive regime y=Qx0+noise. Least-squares error is governed by 1/sigma_min(Q).
 88    n = 5
 89    eig = np.array([0.95, 0.80, 0.65, 0.50, 0.35])
 90    A = np.diag(eig)
 91    C = np.ones((1, n))
 92    noise = 1e-3
 93    x_trials = rng.normal(size=(1500, n))
 94    result = []
 95    for T in range(n - 1, 13):
 96        Q = observability(A, C, T)
 97        s = np.linalg.svd(Q, compute_uv=False)
 98        # Keep initial conditions positive while preserving a linear identification problem.
 99        x = 0.5 + 0.15 * x_trials
100        y = x @ Q.T + rng.normal(scale=noise, size=(len(x), Q.shape[0]))
101        est = np.linalg.lstsq(Q, y.T, rcond=None)[0].T
102        rmse = float(np.sqrt(np.mean((est - x) ** 2)))
103        result.append({'T': T, 'sigma_min': float(s[-1]), 'rmse': rmse,
104                       'rmse_times_sigma': rmse * float(s[-1])})
105    # Compare the monotonic qualitative prediction and a fitted log slope.
106    sig = np.array([r['sigma_min'] for r in result])
107    err = np.array([r['rmse'] for r in result])
108    slope = float(np.polyfit(np.log(sig), np.log(err), 1)[0])
109    monotonic = bool(np.all(np.diff(sig) > 0) and np.all(np.diff(err) < 0))
110    return {
111        'predicted': 'sigma_min increases with T and reconstruction RMSE decreases approximately as 1/sigma_min',
112        'observed_log_error_vs_log_sigma_slope': slope,
113        'sweep': result,
114        'pass': monotonic and slope < -0.5,
115    }
116
117
118def positivity_penalty_sweep():
119    # A fourth practical check: shifting bias upward removes masks and penalty.
120    n, p, steps = 8, 2, 20
121    A = 0.65 * np.eye(n) + 0.03 * np.ones((n, n))
122    B = 0.08 * np.ones((n, p))
123    x0 = np.full(n, 0.2)
124    U = rng.uniform(-1, 1, size=(steps, p))
125    out = []
126    for bias in [-0.20, -0.10, 0.0, 0.10, 0.20]:
127        xs, z = rollout(A, B, np.full(n, bias), x0, U)
128        neg = np.maximum(-z, 0)
129        out.append({'bias': bias, 'negative_fraction': float(np.mean(z < 0)),
130                    'positivity_penalty': float(np.sum(neg * neg))})
131    return out
132
133
134def baseline_vs_positive():
135    # Same observable system dimensions and inputs; compare signed ReLU dynamics
136    # against a nonnegative system. The latter is the proposed regime.
137    n, m, steps = 8, 4, 12
138    C = np.eye(n)[:m]
139    x0 = np.full(n, 0.3)
140    U = rng.uniform(-0.05, 0.05, size=(steps, 2))
141    cases = {
142        'baseline_signed_relu': (0.55 * rng.normal(size=(n, n)) / np.sqrt(n),
143                                 0.05 * rng.normal(size=(n, 2)), np.zeros(n)),
144        'positive_regime_relu': (0.55 * (0.5 + rng.random((n, n))) / np.sqrt(n),
145                                 0.05 * (0.5 + rng.random((n, 2))), np.full(n, 0.12)),
146    }
147    out = {}
148    for name, (A, B, b) in cases.items():
149        xs, zs = rollout(A, B, b, x0, U)
150        # Finite differences test the linear perturbation claim along the realized path.
151        d = rng.normal(size=n); d *= 1e-6
152        xp, _ = rollout(A, B, b, x0 + d, U)
153        actual = (xp[1:] - xs[1:])
154        predicted = np.asarray([np.linalg.matrix_power(A, t + 1) @ d for t in range(steps)])
155        rel_err = np.linalg.norm(actual - predicted) / max(np.linalg.norm(actual), 1e-15)
156        Q = observability(A, C, steps - 1)
157        out[name] = {
158            'negative_preactivation_fraction': float(np.mean(zs < 0)),
159            'linearized_perturbation_relative_error': float(rel_err),
160            'sigma_min_Q': float(np.linalg.svd(Q, compute_uv=False)[-1]),
161            'observability_rank': rank_tol(Q),
162        }
163    return out
164
165
166def main():
167    results = {
168        'seed': SEED,
169        'positive_regime_exactness': prediction_positive_regime(),
170        'observability_rank_transition': prediction_rank_transition(),
171        'noise_reconstruction_scaling': prediction_noise_scaling(),
172        'positivity_bias_sweep': positivity_penalty_sweep(),
173        'baseline_vs_positive': baseline_vs_positive(),
174    }
175    results['mechanism_pass'] = all([
176        results['positive_regime_exactness']['pass'],
177        results['observability_rank_transition']['pass'],
178        results['noise_reconstruction_scaling']['pass'],
179    ])
180    OUT.write_text(json.dumps(results, indent=2))
181    print(json.dumps(results, indent=2))
182
183if __name__ == '__main__':
184    main()