Action-calibrated cycle-hopping RNN / experiment.py

Failed on benchmark

Raw ⬇ ZIP
  1import json, math
  2from pathlib import Path
  3import numpy as np
  4from scipy.optimize import minimize
  5
  6# Two stable limit cycles are represented by stable radii r=1 and r=2,
  7# with a common angular velocity. The separatrix is r=1.5.
  8R1, RS, R2 = 1.0, 1.5, 2.0
  9OMEGA = 1.0
 10DT = 0.01
 11
 12def fr(r):
 13    return -(r - R1) * (r - RS) * (r - R2)
 14
 15def dfr(r):
 16    return -(3*r*r - 2*(R1+RS+R2)*r + (R1*RS + R1*R2 + RS*R2))
 17
 18def action_integral(a, b, n=100000):
 19    x = np.linspace(a, b, n)
 20    return float(np.trapz(-fr(x), x))
 21
 22def discrete_action(x, T):
 23    # B=1, so this is exactly the stated 1/4 integral discretization.
 24    h = T / (len(x)-1)
 25    v = np.diff(x) / h
 26    mid = 0.5*(x[:-1] + x[1:])
 27    return float(h * np.sum((v - fr(mid))**2) / 4.0)
 28
 29def optimize_action(a, b, T=20.0, n=81):
 30    # Fixed endpoints and a smooth monotone initial path. The optimizer can
 31    # discover the minimum-action uphill path for the finite endpoint problem.
 32    x0 = np.linspace(a, b, n)
 33    h = T/(n-1)
 34    def fun(y):
 35        x = np.r_[a, y, b]
 36        return discrete_action(x, T)
 37    ans = minimize(fun, x0[1:-1], method='L-BFGS-B', options={'maxiter': 1200, 'ftol': 1e-12})
 38    return float(ans.fun), bool(ans.success)
 39
 40def simulate_rates(D, paths=160, steps=125000, seed=0):
 41    # Vectorized first-passage counting. Hysteretic labels avoid recrossing noise.
 42    rng = np.random.default_rng(seed)
 43    r = np.full(paths, R1)
 44    state = np.ones(paths, dtype=np.int8)
 45    counts = np.zeros(2, dtype=np.int64)
 46    for _ in range(steps):
 47        r += fr(r)*DT + math.sqrt(2*D*DT)*rng.standard_normal(paths)
 48        # radial coordinate is reflected at zero (irrelevant for rare events)
 49        r = np.abs(r)
 50        hit2 = (state == 1) & (r >= RS)
 51        hit1 = (state == 2) & (r <= RS)
 52        counts[0] += np.count_nonzero(hit2)
 53        counts[1] += np.count_nonzero(hit1)
 54        state[hit2] = 2
 55        state[hit1] = 1
 56    total_time = paths * steps * DT
 57    return counts / total_time, counts
 58
 59def main():
 60    # Prediction 1: action barrier from cycle 1 to separatrix.
 61    barrier = action_integral(R1, RS)
 62    reverse_barrier = action_integral(R2, RS)
 63    # Prediction 2: transverse Floquet multiplier exp(f'(cycle)*period).
 64    period = 2*math.pi/OMEGA
 65    floquet1 = math.exp(dfr(R1)*period)
 66    floquet2 = math.exp(dfr(R2)*period)
 67    opt_action, opt_ok = optimize_action(1.02, 1.48)
 68    finite_barrier = action_integral(1.02, 1.48)
 69
 70    # Prediction 3: log k is affine in 1/D in the rare-event regime, while
 71    # sufficiently large D leaves that regime. Five-plus values are required.
 72    Ds = np.array([0.004, 0.006, 0.010, 0.015, 0.030, 0.080, 0.200])
 73    rates = []
 74    counts = []
 75    for i, D in enumerate(Ds):
 76        rr, cc = simulate_rates(float(D), paths=48, steps=50000, seed=100+i)
 77        rates.append(rr)
 78        counts.append(cc.tolist())
 79    rates = np.asarray(rates)
 80    counts = np.asarray(counts)
 81    # Fit only forward rates with enough events; report rare subset and all-data fits.
 82    valid = (counts[:,0] >= 8) & (rates[:,0] > 0)
 83    rare = valid & (Ds <= 0.015)
 84    high = valid & (Ds >= 0.080)
 85    def fit(mask):
 86        if np.count_nonzero(mask) < 2: return [float('nan')]*3
 87        p = np.polyfit(1/Ds[mask], np.log(rates[mask,0]), 1)
 88        pred = np.polyval(p, 1/Ds[mask])
 89        r2 = 1 - np.sum((np.log(rates[mask,0])-pred)**2)/np.sum((np.log(rates[mask,0])-np.mean(np.log(rates[mask,0])))**2)
 90        return [float(p[0]), float(p[1]), float(r2)]
 91    fit_rare = fit(rare)
 92    fit_all = fit(valid)
 93    fit_high = fit(high)
 94    crossover_D = (RS-R1)**2/(2*period)
 95    result = {
 96      'model': 'dr=-(r-1)(r-1.5)(r-2)dt + sqrt(2D)dW; dtheta=dt',
 97      'predictions': {
 98        'action_barrier_1_to_separatrix': {'predicted': barrier, 'estimated_discrete_path_1.02_to_1.48': opt_action, 'finite_endpoint_integral': finite_barrier, 'relative_error_vs_finite_integral': abs(opt_action-finite_barrier)/finite_barrier},
 99        'floquet_transverse': {'period': period, 'cycle_1_derivative': dfr(R1), 'cycle_2_derivative': dfr(R2), 'cycle_1_multiplier': floquet1, 'cycle_2_multiplier': floquet2, 'neutral_phase_multiplier': 1.0},
100        'arrhenius_slope': {'predicted_slope': -barrier, 'rare_fit_slope': fit_rare[0], 'rare_fit_intercept': fit_rare[1], 'rare_fit_R2': fit_rare[2], 'all_fit_slope': fit_all[0], 'all_fit_R2': fit_all[2], 'high_noise_fit_slope': fit_high[0], 'high_noise_fit_R2': fit_high[2], 'predicted_crossover_D_from_cycle_width': crossover_D}
101      },
102      'sweep': [{'D':float(D), 'forward_rate':float(rates[i,0]), 'reverse_rate':float(rates[i,1]), 'forward_count':int(counts[i,0]), 'reverse_count':int(counts[i,1]), 'x=1/D':float(1/D)} for i,D in enumerate(Ds)],
103      'settings': {'dt':DT, 'paths':48, 'steps':50000, 'total_time_each_D':48*50000*DT, 'seed_base':100},
104      'mechanism_checks': {'action_agreement_pass': abs(opt_action-finite_barrier)/finite_barrier < .20, 'floquet_stable_pass': floquet1 < 1 and floquet2 < 1, 'rare_arrhenius_linear_pass': fit_rare[2] > .90, 'large_noise_deviation_from_rare_slope': abs(fit_high[0]-fit_rare[0]) > .20*abs(fit_rare[0])}
105    }
106    Path('results.json').write_text(json.dumps(result, indent=2))
107    print(json.dumps(result, indent=2))
108
109if __name__ == '__main__':
110    main()