Spectral-Band Dual-Timescale Network / spectral_band_experiment.py

Failed on benchmark

Raw ⬇ ZIP
  1import json
  2import numpy as np
  3from scipy.linalg import expm
  4from pathlib import Path
  5
  6
  7def block(gf, gs, k):
  8    return np.array([[-gf, k], [k, -gs]], dtype=float)
  9
 10
 11def euler(A, h, n, x0=None, forcing=None):
 12    x = np.zeros((n + 1, A.shape[0]))
 13    if x0 is not None:
 14        x[0] = x0
 15    for t in range(n):
 16        u = 0.0 if forcing is None else forcing[t]
 17        b = np.ones(A.shape[0]) * u
 18        x[t + 1] = x[t] + h * (A @ x[t] + b)
 19    return x
 20
 21
 22def frequency_transfer(A, w, b=None, c=None):
 23    b = np.ones(A.shape[0]) if b is None else np.asarray(b)
 24    c = np.ones(A.shape[0]) if c is None else np.asarray(c)
 25    z = 1j * w
 26    return c @ np.linalg.solve(z * np.eye(A.shape[0]) - A, b)
 27
 28
 29def decay_rates_from_impulse(A, dt=0.002, duration=12.0):
 30    # Independently simulate exact sampled propagation, then fit each modal
 31    # amplitude. This tests decay rather than simply reporting eig(A).
 32    vals, vecs = np.linalg.eigh(A)
 33    x = np.ones(2)
 34    n = int(duration / dt)
 35    modal = np.empty((n + 1, 2))
 36    modal[0] = np.linalg.solve(vecs, x)
 37    P = expm(A * dt)
 38    for i in range(n):
 39        x = P @ x
 40        modal[i + 1] = np.linalg.solve(vecs, x)
 41    t = np.arange(n + 1) * dt
 42    fitted = []
 43    for j in range(2):
 44        # Avoid the final numerical underflow tail and fit log magnitude.
 45        keep = (np.abs(modal[:, j]) > 1e-10) & (t > 0.1) & (t < duration * .8)
 46        slope = np.polyfit(t[keep], np.log(np.abs(modal[keep, j])), 1)[0]
 47        fitted.append(-slope)
 48    return np.sort(-vals), np.sort(np.asarray(fitted))
 49
 50
 51def least_squares_reconstruction(gf=10.0, gs=1.0, k=0.15, seed=0):
 52    rng = np.random.default_rng(seed)
 53    n_train, n_test = 3000, 1500
 54    t = np.arange(n_train + n_test)
 55    y = (np.sin(2 * np.pi * 0.025 * t) + 0.7 * np.sin(2 * np.pi * 0.22 * t + .4))
 56    x = y + 0.03 * rng.standard_normal(len(t))
 57    # Discrete stable dynamics; each state is a causal filtered view of x.
 58    h = 0.03
 59    Ad = np.eye(2) + h * block(gf, gs, k)
 60    states = np.zeros((len(x) + 1, 2))
 61    for i, u in enumerate(x):
 62        states[i + 1] = Ad @ states[i] + h * np.array([u, u])
 63    # Baseline: ordinary one-timescale slow state, matched parameter count as
 64    # closely as possible by fitting its readout on the same training segment.
 65    sb = np.zeros(len(x) + 1)
 66    ad = 1 - h * gs
 67    for i, u in enumerate(x):
 68        sb[i + 1] = ad * sb[i] + h * u
 69    def fit_predict(features):
 70        F = np.column_stack([features, np.ones(len(features))])
 71        coef = np.linalg.lstsq(F[:n_train], y[:n_train], rcond=None)[0]
 72        return F[n_train:] @ coef
 73    pred_dual = fit_predict(states[1:])
 74    pred_single = fit_predict(sb[1:, None])
 75    return float(np.mean((pred_single - y[n_train:]) ** 2)), float(np.mean((pred_dual - y[n_train:]) ** 2))
 76
 77
 78def main():
 79    out = {}
 80    # Prediction 1: symmetric-part stability changes sign at k=sqrt(gf*gs).
 81    gf, gs = 10.0, 1.0
 82    kcrit = np.sqrt(gf * gs)
 83    ks = np.linspace(0, 1.5 * kcrit, 301)
 84    lmax = np.array([np.linalg.eigvalsh((block(gf, gs, k) + block(gf, gs, k).T) / 2).max() for k in ks])
 85    crossing = ks[np.argmin(np.abs(lmax))]
 86    out['stability_prediction'] = {
 87        'predicted_kcrit': float(kcrit), 'observed_kcrit_grid': float(crossing),
 88        'relative_error': float(abs(crossing-kcrit)/kcrit),
 89        'stable_at_0.8kcrit': bool(np.linalg.eigvalsh(block(gf, gs, .8*kcrit)).max() <= 1e-12),
 90        'unstable_at_1.2kcrit': bool(np.linalg.eigvalsh(block(gf, gs, 1.2*kcrit)).max() > 1e-12),
 91    }
 92    # Prediction 2: a gap yields two decay rates, with timescale ratio tracking
 93    # the imposed gap (small exchange perturbation).
 94    gap_rows = []
 95    for ratio in [2, 5, 10, 20, 40]:
 96        gs0, gf0 = 1.0, float(ratio)
 97        kk = .05 * np.sqrt(gf0 * gs0)
 98        rates, fitted = decay_rates_from_impulse(block(gf0, gs0, kk))
 99        gap_rows.append({'ratio': ratio, 'predicted_rates': rates.tolist(), 'measured_rates': fitted.tolist(),
100                         'measured_rate_ratio': float(fitted[1]/fitted[0])})
101    out['gap_prediction'] = gap_rows
102    # Prediction 3: slow-only reduction loses the fast response around w~gf.
103    A = block(10., 1., .15)
104    freqs = np.logspace(-2, 2, 300)
105    full = np.array([abs(frequency_transfer(A, w)) for w in freqs])
106    slow = np.array([abs(1 / (1j*w + 1)) for w in freqs])
107    relerr = np.abs(full-slow) / np.maximum(full, 1e-12)
108    low = float(np.mean(relerr[freqs < 0.3]))
109    high = float(np.mean(relerr[freqs > 10.]))
110    near = float(np.mean(relerr[(freqs > 7) & (freqs < 14)]))
111    out['frequency_prediction'] = {'fast_rate': 10.0, 'observed_peak_error_frequency': float(freqs[np.argmax(relerr)]),
112                                   'mean_relative_error_low_w': low, 'mean_relative_error_near_fast_rate': near,
113                                   'mean_relative_error_high_w': high, 'error_ratio_near_vs_low': near/max(low,1e-12)}
114    single_mse, dual_mse = least_squares_reconstruction()
115    out['toy_reconstruction'] = {'single_timescale_mse': single_mse, 'dual_band_mse': dual_mse,
116                                 'relative_mse_change': (single_mse-dual_mse)/single_mse}
117    Path('results.json').write_text(json.dumps(out, indent=2))
118    print(json.dumps(out, indent=2))
119
120if __name__ == '__main__':
121    main()