Adaptive reset neural ODE / adaptive_reset_experiment.py

Failed on benchmark

Raw ⬇ ZIP
  1import json
  2import math
  3from pathlib import Path
  4import numpy as np
  5
  6# Adaptive reset neural-ODE MVP.  The scalar flow is deliberately chosen so that
  7# its exact solution makes the paper's stopping rule quantitatively testable.
  8SEED = 2093
  9rng = np.random.default_rng(SEED)
 10
 11
 12def q95(x):
 13    return float(np.quantile(np.asarray(x), 0.95))
 14
 15
 16def constant_flow_error(s, lam, lam_hat, y0):
 17    return np.abs(np.exp(lam * s) - np.exp(lam_hat * s)) * np.abs(y0)
 18
 19
 20def analytic_crossing(eps, lam, lam_hat, yq, horizon=10.0):
 21    # First crossing of the exact supremum/quantile error.  Bisection is used
 22    # only for the analytic verification; the implementation uses a grid.
 23    def f(s):
 24        return abs(math.exp(lam*s) - math.exp(lam_hat*s)) * yq - eps
 25    if f(horizon) <= 0:
 26        return horizon
 27    lo, hi = 0.0, horizon
 28    for _ in range(80):
 29        mid = (lo + hi) / 2
 30        if f(mid) > 0:
 31            hi = mid
 32        else:
 33            lo = mid
 34    return (lo + hi) / 2
 35
 36
 37def grid_crossing(eps, lam, lam_hat, y0, dt=0.002, horizon=10.0):
 38    ts = np.arange(dt, horizon + 0.5 * dt, dt)
 39    e = np.array([q95(constant_flow_error(t, lam, lam_hat, y0)) for t in ts])
 40    hit = np.flatnonzero(e > eps)
 41    return float(ts[hit[0]]) if len(hit) else horizon
 42
 43
 44def verify_math():
 45    y0 = np.linspace(0.8, 1.2, 101)
 46    yq = np.quantile(y0, .95)
 47    rows = []
 48    # Prediction 1: grid boundary converges to the analytic first crossing.
 49    for eps in (.01, .03, .08):
 50        exact = analytic_crossing(eps, .5, .35, yq)
 51        observed = grid_crossing(eps, .5, .35, y0)
 52        rows.append({"kind": "tolerance", "epsilon": eps, "predicted": exact,
 53                     "observed": observed, "abs_error": abs(exact-observed)})
 54    # Prediction 2: increasing mismatch causes an earlier boundary.
 55    mismatch_rows = []
 56    for mh in (.49, .45, .40, .30):
 57        exact = analytic_crossing(.03, .5, mh, yq)
 58        observed = grid_crossing(.03, .5, mh, y0)
 59        mismatch_rows.append({"lambda_hat": mh, "predicted": exact, "observed": observed})
 60    # Prediction 3: at fixed mismatch, tolerance monotonically increases window.
 61    tol_exact = [analytic_crossing(e, .5, .35, yq) for e in (.005,.01,.02,.04,.08)]
 62    tol_obs = [grid_crossing(e, .5, .35, y0) for e in (.005,.01,.02,.04,.08)]
 63    # Strict checks are against the discrete-grid resolution, not an arbitrary win.
 64    max_grid_error = max(r["abs_error"] for r in rows)
 65    monotone_mismatch = all(mismatch_rows[i]["observed"] > mismatch_rows[i+1]["observed"]
 66                             for i in range(len(mismatch_rows)-1))
 67    monotone_tol = all(tol_obs[i] < tol_obs[i+1] for i in range(len(tol_obs)-1))
 68    passed = max_grid_error <= .00201 and monotone_mismatch and monotone_tol
 69    return {"passed": passed, "y95": float(yq), "tolerance_sweep": rows,
 70            "mismatch_sweep": mismatch_rows,
 71            "tolerance_monotonicity": {"predicted": tol_exact, "observed": tol_obs},
 72            "max_grid_abs_error": max_grid_error}
 73
 74
 75def teacher_a(t, difficulty=1.0):
 76    # A slowly changing vector-field coefficient: the local model is theta*y.
 77    return .35 + difficulty * (.30*np.sin(.9*t) + .22*np.sin(2.1*t + .4))
 78
 79
 80def teacher_solution(times, y0, difficulty=1.0):
 81    a = teacher_a(times, difficulty)
 82    integ = np.zeros_like(times)
 83    integ[1:] = np.cumsum(.5*(a[1:]+a[:-1])*np.diff(times))
 84    return y0[:, None] * np.exp(integ[None, :])
 85
 86
 87def fit_local_theta(times, ys, difficulty=1.0):
 88    # Teacher-forced derivative regression for f_theta(y)=theta*y.
 89    a = teacher_a(times, difficulty)
 90    y = ys[:, :-1]
 91    dy = a[:-1][None, :] * y
 92    return float(np.sum(y*dy) / (np.sum(y*y) + 1e-12))
 93
 94
 95def adaptive_windows(times, truth, y0, eps=.035, min_steps=4, cap_steps=35,
 96                     difficulty=1.0):
 97    windows, thetas = [], []
 98    start = 0
 99    n = len(times)-1
100    while start < n:
101        stop_cap = min(n, start + cap_steps)
102        # Warm-start analogue: use teacher derivative data in the candidate cap.
103        theta = fit_local_theta(times[start:stop_cap+1],
104                                truth[:, start:stop_cap+1], difficulty)
105        offsets = np.arange(min_steps, stop_cap-start+1)
106        chosen = stop_cap
107        for off in offsets:
108            s = times[start+off] - times[start]
109            pred = truth[:, start:start+1] * np.exp(theta*s)
110            err = q95(np.abs(truth[:, start+off] - pred[:, 0]))
111            if err > eps:
112                chosen = start + int(off)
113                break
114        # enforce min window even if the first grid point crosses
115        chosen = max(chosen, min(n, start + min_steps))
116        windows.append((start, chosen))
117        thetas.append(theta)
118        start = chosen
119    return windows, thetas
120
121
122def rollout(times, y0, windows, thetas):
123    out = np.empty((len(y0), len(times)))
124    out[:, 0] = y0
125    for (start, stop), theta in zip(windows, thetas):
126        # At deployment the reset is the model's terminal state, not teacher data.
127        for j in range(start+1, stop+1):
128            out[:, j] = out[:, start] * np.exp(theta*(times[j]-times[start]))
129    return out
130
131
132def mini_experiment():
133    y0 = np.linspace(.8, 1.2, 128)
134    times = np.linspace(0, 12, 1201)
135    truth = teacher_solution(times, y0)
136    # Shared field is the standard one-field teacher-forced derivative fit.
137    theta = fit_local_theta(times, truth)
138    shared = y0[:, None] * np.exp(theta*times[None, :])
139    windows, thetas = adaptive_windows(times, truth, y0)
140    adaptive = rollout(times, y0, windows, thetas)
141    shared_rmse = float(np.sqrt(np.mean((shared-truth)**2)))
142    adaptive_rmse = float(np.sqrt(np.mean((adaptive-truth)**2)))
143    shared_final = float(np.sqrt(np.mean((shared[:,-1]-truth[:,-1])**2)))
144    adaptive_final = float(np.sqrt(np.mean((adaptive[:,-1]-truth[:,-1])**2)))
145    boundary_errors = [q95(np.abs(truth[:,s]-
146                         (truth[:,s-1] if False else adaptive[:,s]))) for s,_ in windows[1:]]
147    return {"shared_theta": theta, "windows": len(windows),
148            "window_lengths": [b-a for a,b in windows],
149            "shared_rmse": shared_rmse, "adaptive_rmse": adaptive_rmse,
150            "shared_final_rmse": shared_final, "adaptive_final_rmse": adaptive_final,
151            "adaptive_boundary_count": len(boundary_errors),
152            "adaptive_boundary_terminal_error_q95": boundary_errors[:8]}
153
154
155def main():
156    result = {"seed": SEED, "math_verification": verify_math(),
157              "mini_experiment": mini_experiment()}
158    Path("results.json").write_text(json.dumps(result, indent=2))
159    print(json.dumps(result, indent=2))
160
161if __name__ == "__main__":
162    main()