Upload 4 files
Browse files- attractors.py +336 -0
- datasets.py +289 -0
- forcing.py +521 -0
- metrics.py +494 -0
attractors.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# attractors.py
|
| 2 |
+
"""
|
| 3 |
+
A small zoo of chaotic attractors with a standardized API.
|
| 4 |
+
|
| 5 |
+
All generators return a trajectory array of shape (T, D), where:
|
| 6 |
+
- T is the number of time points / steps.
|
| 7 |
+
- D is the state dimension (e.g., 3 for Lorenz-63).
|
| 8 |
+
|
| 9 |
+
For continuous-time systems (ODEs), dt is the integration step (RK4).
|
| 10 |
+
For discrete-time maps (Hénon, Ikeda), dt is accepted for API consistency but ignored.
|
| 11 |
+
|
| 12 |
+
You can later standardize/split/chop trajectories for train/test externally.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
from typing import Callable, Dict, Optional, Tuple
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
Array = np.ndarray
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# -----------------------------
|
| 27 |
+
# generic RK4 integrator
|
| 28 |
+
# -----------------------------
|
| 29 |
+
def rk4(f: Callable[[Array], Array], x0: Array, T: int, dt: float) -> Array:
|
| 30 |
+
"""
|
| 31 |
+
Integrate x' = f(x) using fixed-step RK4.
|
| 32 |
+
|
| 33 |
+
Parameters
|
| 34 |
+
----------
|
| 35 |
+
f : function x -> dx/dt
|
| 36 |
+
x0 : (D,)
|
| 37 |
+
T : number of time points (trajectory length)
|
| 38 |
+
dt : step size
|
| 39 |
+
|
| 40 |
+
Returns
|
| 41 |
+
-------
|
| 42 |
+
traj : (T, D)
|
| 43 |
+
"""
|
| 44 |
+
x = np.asarray(x0, dtype=float).copy()
|
| 45 |
+
D = x.size
|
| 46 |
+
traj = np.zeros((int(T), D), dtype=float)
|
| 47 |
+
for t in range(int(T)):
|
| 48 |
+
traj[t] = x
|
| 49 |
+
k1 = f(x)
|
| 50 |
+
k2 = f(x + 0.5 * dt * k1)
|
| 51 |
+
k3 = f(x + 0.5 * dt * k2)
|
| 52 |
+
k4 = f(x + dt * k3)
|
| 53 |
+
x = x + (dt / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
|
| 54 |
+
return traj
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _rng_from_seed(seed: Optional[int]) -> np.random.Generator:
|
| 58 |
+
return np.random.default_rng(None if seed is None else int(seed))
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _discard_burn_in(traj: Array, burn_in: int) -> Array:
|
| 62 |
+
burn_in = int(burn_in)
|
| 63 |
+
if burn_in <= 0:
|
| 64 |
+
return traj
|
| 65 |
+
if burn_in >= traj.shape[0]:
|
| 66 |
+
raise ValueError(f"burn_in={burn_in} must be < T={traj.shape[0]}")
|
| 67 |
+
return traj[burn_in:]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# -----------------------------
|
| 71 |
+
# 1) Lorenz-63 (3D ODE)
|
| 72 |
+
# -----------------------------
|
| 73 |
+
def lorenz63(
|
| 74 |
+
T: int,
|
| 75 |
+
dt: float = 0.01,
|
| 76 |
+
*,
|
| 77 |
+
sigma: float = 10.0,
|
| 78 |
+
rho: float = 28.0,
|
| 79 |
+
beta: float = 8.0 / 3.0,
|
| 80 |
+
x0: Optional[Array] = None,
|
| 81 |
+
seed: Optional[int] = 0,
|
| 82 |
+
burn_in: int = 0,
|
| 83 |
+
) -> Array:
|
| 84 |
+
"""
|
| 85 |
+
Lorenz-63 system (classic chaotic regime).
|
| 86 |
+
Returns (T-burn_in, 3).
|
| 87 |
+
"""
|
| 88 |
+
rng = _rng_from_seed(seed)
|
| 89 |
+
if x0 is None:
|
| 90 |
+
x0 = np.array([1.0, 1.0, 1.0], dtype=float) + 0.01 * rng.standard_normal(3)
|
| 91 |
+
else:
|
| 92 |
+
x0 = np.asarray(x0, dtype=float).reshape(3)
|
| 93 |
+
|
| 94 |
+
def f(x: Array) -> Array:
|
| 95 |
+
return np.array(
|
| 96 |
+
[
|
| 97 |
+
sigma * (x[1] - x[0]),
|
| 98 |
+
x[0] * (rho - x[2]) - x[1],
|
| 99 |
+
x[0] * x[1] - beta * x[2],
|
| 100 |
+
],
|
| 101 |
+
dtype=float,
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
traj = rk4(f, x0, T=T, dt=float(dt))
|
| 105 |
+
return _discard_burn_in(traj, burn_in)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# -----------------------------
|
| 109 |
+
# 2) Lorenz-96 (dD ODE)
|
| 110 |
+
# -----------------------------
|
| 111 |
+
def lorenz96(
|
| 112 |
+
T: int,
|
| 113 |
+
dt: float = 0.01,
|
| 114 |
+
*,
|
| 115 |
+
dim: int = 40,
|
| 116 |
+
F: float = 8.0,
|
| 117 |
+
x0: Optional[Array] = None,
|
| 118 |
+
seed: Optional[int] = 0,
|
| 119 |
+
burn_in: int = 0,
|
| 120 |
+
) -> Array:
|
| 121 |
+
"""
|
| 122 |
+
Lorenz-96 in the standard chaotic regime (F ~ 8, dim ~ 40).
|
| 123 |
+
Returns (T-burn_in, dim).
|
| 124 |
+
"""
|
| 125 |
+
rng = _rng_from_seed(seed)
|
| 126 |
+
d = int(dim)
|
| 127 |
+
if x0 is None:
|
| 128 |
+
# common init: near-constant with small noise
|
| 129 |
+
x0 = F * np.ones(d, dtype=float)
|
| 130 |
+
x0 += 0.01 * rng.standard_normal(d)
|
| 131 |
+
# add a localized bump
|
| 132 |
+
x0[0] += 0.1
|
| 133 |
+
else:
|
| 134 |
+
x0 = np.asarray(x0, dtype=float).reshape(d)
|
| 135 |
+
|
| 136 |
+
def f(x: Array) -> Array:
|
| 137 |
+
# cyclic indices: x_{i+1}, x_{i-1}, x_{i-2}
|
| 138 |
+
xp1 = np.roll(x, -1)
|
| 139 |
+
xm1 = np.roll(x, 1)
|
| 140 |
+
xm2 = np.roll(x, 2)
|
| 141 |
+
return (xp1 - xm2) * xm1 - x + F
|
| 142 |
+
|
| 143 |
+
traj = rk4(f, x0, T=T, dt=float(dt))
|
| 144 |
+
return _discard_burn_in(traj, burn_in)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# -----------------------------
|
| 148 |
+
# 3) Rössler (3D ODE)
|
| 149 |
+
# -----------------------------
|
| 150 |
+
def rossler(
|
| 151 |
+
T: int,
|
| 152 |
+
dt: float = 0.02,
|
| 153 |
+
*,
|
| 154 |
+
a: float = 0.2,
|
| 155 |
+
b: float = 0.2,
|
| 156 |
+
c: float = 5.7,
|
| 157 |
+
x0: Optional[Array] = None,
|
| 158 |
+
seed: Optional[int] = 0,
|
| 159 |
+
burn_in: int = 0,
|
| 160 |
+
) -> Array:
|
| 161 |
+
"""
|
| 162 |
+
Rössler attractor (classic chaotic parameters).
|
| 163 |
+
Returns (T-burn_in, 3).
|
| 164 |
+
"""
|
| 165 |
+
rng = _rng_from_seed(seed)
|
| 166 |
+
if x0 is None:
|
| 167 |
+
x0 = np.array([0.1, 0.0, 0.0], dtype=float) + 0.01 * rng.standard_normal(3)
|
| 168 |
+
else:
|
| 169 |
+
x0 = np.asarray(x0, dtype=float).reshape(3)
|
| 170 |
+
|
| 171 |
+
def f(x: Array) -> Array:
|
| 172 |
+
x1, y1, z1 = x
|
| 173 |
+
return np.array(
|
| 174 |
+
[
|
| 175 |
+
-y1 - z1,
|
| 176 |
+
x1 + a * y1,
|
| 177 |
+
b + z1 * (x1 - c),
|
| 178 |
+
],
|
| 179 |
+
dtype=float,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
traj = rk4(f, x0, T=T, dt=float(dt))
|
| 183 |
+
return _discard_burn_in(traj, burn_in)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
# -----------------------------
|
| 187 |
+
# 4) Hénon map (2D discrete)
|
| 188 |
+
# -----------------------------
|
| 189 |
+
def henon(
|
| 190 |
+
T: int,
|
| 191 |
+
dt: float = 1.0, # ignored (kept for API consistency)
|
| 192 |
+
*,
|
| 193 |
+
a: float = 1.4,
|
| 194 |
+
b: float = 0.3,
|
| 195 |
+
x0: Optional[Array] = None,
|
| 196 |
+
seed: Optional[int] = 0,
|
| 197 |
+
burn_in: int = 0,
|
| 198 |
+
) -> Array:
|
| 199 |
+
"""
|
| 200 |
+
Hénon map (discrete-time chaos). dt is ignored.
|
| 201 |
+
Returns (T-burn_in, 2).
|
| 202 |
+
"""
|
| 203 |
+
rng = _rng_from_seed(seed)
|
| 204 |
+
if x0 is None:
|
| 205 |
+
x = np.array([0.1, 0.1], dtype=float) + 0.01 * rng.standard_normal(2)
|
| 206 |
+
else:
|
| 207 |
+
x = np.asarray(x0, dtype=float).reshape(2)
|
| 208 |
+
|
| 209 |
+
traj = np.zeros((int(T), 2), dtype=float)
|
| 210 |
+
for t in range(int(T)):
|
| 211 |
+
traj[t] = x
|
| 212 |
+
xn = 1.0 - a * x[0] ** 2 + x[1]
|
| 213 |
+
yn = b * x[0]
|
| 214 |
+
x = np.array([xn, yn], dtype=float)
|
| 215 |
+
|
| 216 |
+
return _discard_burn_in(traj, burn_in)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# -----------------------------
|
| 220 |
+
# 5) Ikeda map (2D discrete)
|
| 221 |
+
# -----------------------------
|
| 222 |
+
def ikeda(
|
| 223 |
+
T: int,
|
| 224 |
+
dt: float = 1.0, # ignored
|
| 225 |
+
*,
|
| 226 |
+
u: float = 0.9,
|
| 227 |
+
k: float = 0.4,
|
| 228 |
+
p: float = 6.0,
|
| 229 |
+
x0: Optional[Array] = None,
|
| 230 |
+
seed: Optional[int] = 0,
|
| 231 |
+
burn_in: int = 0,
|
| 232 |
+
) -> Array:
|
| 233 |
+
"""
|
| 234 |
+
Ikeda map (discrete-time chaos). dt is ignored.
|
| 235 |
+
Standard real-form update:
|
| 236 |
+
t = k - p / (1 + x^2 + y^2)
|
| 237 |
+
x' = 1 + u * (x cos t - y sin t)
|
| 238 |
+
y' = u * (x sin t + y cos t)
|
| 239 |
+
Returns (T-burn_in, 2).
|
| 240 |
+
"""
|
| 241 |
+
rng = _rng_from_seed(seed)
|
| 242 |
+
if x0 is None:
|
| 243 |
+
x = np.array([0.1, 0.1], dtype=float) + 0.01 * rng.standard_normal(2)
|
| 244 |
+
else:
|
| 245 |
+
x = np.asarray(x0, dtype=float).reshape(2)
|
| 246 |
+
|
| 247 |
+
traj = np.zeros((int(T), 2), dtype=float)
|
| 248 |
+
for tstep in range(int(T)):
|
| 249 |
+
traj[tstep] = x
|
| 250 |
+
x1, y1 = x
|
| 251 |
+
t = k - p / (1.0 + x1 * x1 + y1 * y1)
|
| 252 |
+
ct = np.cos(t)
|
| 253 |
+
st = np.sin(t)
|
| 254 |
+
xn = 1.0 + u * (x1 * ct - y1 * st)
|
| 255 |
+
yn = u * (x1 * st + y1 * ct)
|
| 256 |
+
x = np.array([xn, yn], dtype=float)
|
| 257 |
+
|
| 258 |
+
return _discard_burn_in(traj, burn_in)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
# -----------------------------
|
| 262 |
+
# 6) Duffing oscillator (forced, 2D ODE)
|
| 263 |
+
# -----------------------------
|
| 264 |
+
def duffing(
|
| 265 |
+
T: int,
|
| 266 |
+
dt: float = 0.01,
|
| 267 |
+
*,
|
| 268 |
+
delta: float = 0.2,
|
| 269 |
+
gamma: float = 0.3,
|
| 270 |
+
omega: float = 1.2,
|
| 271 |
+
# equation: x'' + delta x' - x + x^3 = gamma cos(omega t)
|
| 272 |
+
x0: Optional[Array] = None, # (x, v)
|
| 273 |
+
seed: Optional[int] = 0,
|
| 274 |
+
burn_in: int = 0,
|
| 275 |
+
) -> Array:
|
| 276 |
+
"""
|
| 277 |
+
Forced Duffing oscillator in a commonly chaotic regime.
|
| 278 |
+
State is (x, v) where v = x'.
|
| 279 |
+
Returns (T-burn_in, 2).
|
| 280 |
+
|
| 281 |
+
Note: This is already nonautonomous due to cos(omega t).
|
| 282 |
+
"""
|
| 283 |
+
rng = _rng_from_seed(seed)
|
| 284 |
+
if x0 is None:
|
| 285 |
+
x = np.array([0.1, 0.0], dtype=float) + 0.01 * rng.standard_normal(2)
|
| 286 |
+
else:
|
| 287 |
+
x = np.asarray(x0, dtype=float).reshape(2)
|
| 288 |
+
|
| 289 |
+
traj = np.zeros((int(T), 2), dtype=float)
|
| 290 |
+
|
| 291 |
+
# Nonautonomous RK4 (depends on time)
|
| 292 |
+
t = 0.0
|
| 293 |
+
for n in range(int(T)):
|
| 294 |
+
traj[n] = x
|
| 295 |
+
|
| 296 |
+
def f(tcur: float, s: Array) -> Array:
|
| 297 |
+
xx, vv = s
|
| 298 |
+
# x' = v
|
| 299 |
+
# v' = x - x^3 - delta v + gamma cos(omega t)
|
| 300 |
+
return np.array(
|
| 301 |
+
[vv, xx - xx**3 - delta * vv + gamma * np.cos(omega * tcur)],
|
| 302 |
+
dtype=float,
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
k1 = f(t, x)
|
| 306 |
+
k2 = f(t + 0.5 * dt, x + 0.5 * dt * k1)
|
| 307 |
+
k3 = f(t + 0.5 * dt, x + 0.5 * dt * k2)
|
| 308 |
+
k4 = f(t + dt, x + dt * k3)
|
| 309 |
+
x = x + (dt / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
|
| 310 |
+
t += dt
|
| 311 |
+
|
| 312 |
+
return _discard_burn_in(traj, burn_in)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
# -----------------------------
|
| 316 |
+
# registry / convenience
|
| 317 |
+
# -----------------------------
|
| 318 |
+
ATTRACTORS: Dict[str, Callable[..., Array]] = {
|
| 319 |
+
"lorenz63": lorenz63,
|
| 320 |
+
"lorenz96": lorenz96,
|
| 321 |
+
"rossler": rossler,
|
| 322 |
+
"henon": henon,
|
| 323 |
+
"ikeda": ikeda,
|
| 324 |
+
"duffing": duffing,
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def generate(name: str, T: int, dt: float = 0.01, **kwargs) -> Array:
|
| 329 |
+
"""
|
| 330 |
+
Convenience wrapper:
|
| 331 |
+
traj = generate("lorenz63", T=20000, dt=0.01, burn_in=2000)
|
| 332 |
+
"""
|
| 333 |
+
key = name.lower()
|
| 334 |
+
if key not in ATTRACTORS:
|
| 335 |
+
raise KeyError(f"Unknown attractor '{name}'. Options: {sorted(ATTRACTORS.keys())}")
|
| 336 |
+
return ATTRACTORS[key](T=T, dt=dt, **kwargs)
|
datasets.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# datasets.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
import time
|
| 7 |
+
import urllib.request
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from typing import Iterable, Optional, Sequence, Tuple, Union
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
import pandas as pd
|
| 15 |
+
except Exception as e: # pragma: no cover
|
| 16 |
+
raise ImportError("datasets.py requires pandas. Install with: pip install pandas") from e
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# -----------------------------
|
| 20 |
+
# Config
|
| 21 |
+
# -----------------------------
|
| 22 |
+
ELECTRICITY_URL = "https://data.openei.org/files/8562/historic_load_hourly_2016_2023_county.h5"
|
| 23 |
+
ELECTRICITY_H5 = "historic_load_hourly_2016_2023_county.h5"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# -----------------------------
|
| 27 |
+
# Helpers
|
| 28 |
+
# -----------------------------
|
| 29 |
+
def download_with_progress(url: str, out_path: str, chunk_size: int = 1024 * 1024) -> None:
|
| 30 |
+
"""
|
| 31 |
+
Download a large file with a simple text progress bar (no extra deps).
|
| 32 |
+
Skips if file already exists.
|
| 33 |
+
"""
|
| 34 |
+
if os.path.exists(out_path):
|
| 35 |
+
return
|
| 36 |
+
|
| 37 |
+
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
|
| 38 |
+
print(f"[datasets] Downloading:\n {url}\n -> {out_path}")
|
| 39 |
+
|
| 40 |
+
with urllib.request.urlopen(url) as response, open(out_path, "wb") as out_file:
|
| 41 |
+
total = getattr(response, "length", None)
|
| 42 |
+
downloaded = 0
|
| 43 |
+
start_time = time.time()
|
| 44 |
+
|
| 45 |
+
while True:
|
| 46 |
+
chunk = response.read(chunk_size)
|
| 47 |
+
if not chunk:
|
| 48 |
+
break
|
| 49 |
+
out_file.write(chunk)
|
| 50 |
+
downloaded += len(chunk)
|
| 51 |
+
|
| 52 |
+
if total:
|
| 53 |
+
frac = downloaded / total
|
| 54 |
+
pct = frac * 100
|
| 55 |
+
bar_len = 30
|
| 56 |
+
filled = int(bar_len * frac)
|
| 57 |
+
bar = "#" * filled + "-" * (bar_len - filled)
|
| 58 |
+
rate = downloaded / (time.time() - start_time + 1e-9)
|
| 59 |
+
sys.stdout.write(
|
| 60 |
+
f"\r[{bar}] {pct:6.2f}% "
|
| 61 |
+
f"{downloaded / 1e6:8.1f} / {total / 1e6:8.1f} MB "
|
| 62 |
+
f"{rate / 1e6:4.1f} MB/s"
|
| 63 |
+
)
|
| 64 |
+
sys.stdout.flush()
|
| 65 |
+
|
| 66 |
+
if total:
|
| 67 |
+
print("\n[datasets] Download complete.")
|
| 68 |
+
else:
|
| 69 |
+
print("[datasets] Download complete.")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _safe_cache_name(
|
| 73 |
+
*,
|
| 74 |
+
start: Optional[str],
|
| 75 |
+
stop: Optional[str],
|
| 76 |
+
resample: Optional[str],
|
| 77 |
+
agg: str,
|
| 78 |
+
n_features: Optional[int],
|
| 79 |
+
feature_method: str,
|
| 80 |
+
seed: int,
|
| 81 |
+
downcast_float32: bool,
|
| 82 |
+
) -> str:
|
| 83 |
+
def clean(x: Optional[str]) -> str:
|
| 84 |
+
if x is None:
|
| 85 |
+
return "none"
|
| 86 |
+
return str(x).replace(":", "-").replace("/", "-").replace(" ", "_")
|
| 87 |
+
|
| 88 |
+
return (
|
| 89 |
+
"electricity"
|
| 90 |
+
f"_start-{clean(start)}"
|
| 91 |
+
f"_stop-{clean(stop)}"
|
| 92 |
+
f"_res-{clean(resample)}"
|
| 93 |
+
f"_agg-{agg}"
|
| 94 |
+
f"_nf-{n_features if n_features is not None else 'all'}"
|
| 95 |
+
f"_fsel-{feature_method}"
|
| 96 |
+
f"_seed-{int(seed)}"
|
| 97 |
+
f"_f32-{int(bool(downcast_float32))}"
|
| 98 |
+
".npz"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _require_tables_hint(err: Exception) -> None:
|
| 103 |
+
msg = (
|
| 104 |
+
"\n[datasets] Failed to read .h5. This usually means the optional dependency `tables` is missing.\n"
|
| 105 |
+
"Install once to build a cache:\n"
|
| 106 |
+
" pip install tables\n"
|
| 107 |
+
"or, if using extras:\n"
|
| 108 |
+
" pip install .[electricity]\n"
|
| 109 |
+
)
|
| 110 |
+
raise ImportError(msg) from err
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _coerce_datetime_index(df: pd.DataFrame) -> pd.DataFrame:
|
| 114 |
+
if not isinstance(df.index, pd.DatetimeIndex):
|
| 115 |
+
df.index = pd.to_datetime(df.index, utc=True)
|
| 116 |
+
if df.index.tz is None:
|
| 117 |
+
df.index = df.index.tz_localize("UTC")
|
| 118 |
+
return df.sort_index()
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _fill_missing(df: pd.DataFrame) -> pd.DataFrame:
|
| 122 |
+
# Conservative: forward-fill then back-fill; if still NaN, fill 0.
|
| 123 |
+
df = df.ffill().bfill()
|
| 124 |
+
if df.isna().values.any():
|
| 125 |
+
df = df.fillna(0.0)
|
| 126 |
+
return df
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _select_features(
|
| 130 |
+
df: pd.DataFrame,
|
| 131 |
+
*,
|
| 132 |
+
features: Optional[Sequence[str]] = None,
|
| 133 |
+
n_features: Optional[int] = None,
|
| 134 |
+
method: str = "variance",
|
| 135 |
+
seed: int = 0,
|
| 136 |
+
) -> pd.DataFrame:
|
| 137 |
+
"""
|
| 138 |
+
Feature selection on columns.
|
| 139 |
+
- features: explicit list of column labels
|
| 140 |
+
- n_features: choose subset
|
| 141 |
+
method: "variance" (top variance) or "random"
|
| 142 |
+
"""
|
| 143 |
+
if features is not None:
|
| 144 |
+
missing = [c for c in features if c not in df.columns]
|
| 145 |
+
if missing:
|
| 146 |
+
raise KeyError(f"Requested features not found in columns: {missing[:10]}{'...' if len(missing)>10 else ''}")
|
| 147 |
+
return df.loc[:, list(features)]
|
| 148 |
+
|
| 149 |
+
if n_features is None:
|
| 150 |
+
return df
|
| 151 |
+
|
| 152 |
+
n_features = int(n_features)
|
| 153 |
+
if n_features <= 0:
|
| 154 |
+
raise ValueError("n_features must be positive")
|
| 155 |
+
|
| 156 |
+
if n_features >= df.shape[1]:
|
| 157 |
+
return df
|
| 158 |
+
|
| 159 |
+
method = method.lower()
|
| 160 |
+
if method == "random":
|
| 161 |
+
rng = np.random.default_rng(int(seed))
|
| 162 |
+
cols = list(df.columns)
|
| 163 |
+
idx = rng.choice(len(cols), size=n_features, replace=False)
|
| 164 |
+
sel = [cols[i] for i in idx]
|
| 165 |
+
return df.loc[:, sel]
|
| 166 |
+
|
| 167 |
+
if method == "variance":
|
| 168 |
+
# top variance across time (after missing fill)
|
| 169 |
+
v = df.var(axis=0, ddof=0)
|
| 170 |
+
sel = list(v.sort_values(ascending=False).index[:n_features])
|
| 171 |
+
return df.loc[:, sel]
|
| 172 |
+
|
| 173 |
+
raise ValueError("feature_method must be one of: {'variance','random'}")
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# -----------------------------
|
| 177 |
+
# Main API: Electricity
|
| 178 |
+
# -----------------------------
|
| 179 |
+
@dataclass(frozen=True)
|
| 180 |
+
class ElectricityData:
|
| 181 |
+
R_tX: np.ndarray
|
| 182 |
+
t_index: np.ndarray # int64 UTC ns
|
| 183 |
+
X_index: np.ndarray # dtype=object strings
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def load_electricity(
|
| 187 |
+
*,
|
| 188 |
+
data_dir: str = "data",
|
| 189 |
+
url: str = ELECTRICITY_URL,
|
| 190 |
+
h5_name: str = ELECTRICITY_H5,
|
| 191 |
+
# slicing
|
| 192 |
+
start: Optional[str] = None, # e.g. "2019-01-01"
|
| 193 |
+
stop: Optional[str] = None, # e.g. "2020-01-01"
|
| 194 |
+
# resampling
|
| 195 |
+
resample: Optional[str] = None, # e.g. "D" for daily, "W" weekly; None keeps hourly
|
| 196 |
+
agg: str = "sum", # "sum" or "mean"
|
| 197 |
+
# feature downselect
|
| 198 |
+
features: Optional[Sequence[str]] = None,
|
| 199 |
+
n_features: Optional[int] = None,
|
| 200 |
+
feature_method: str = "variance", # "variance" or "random"
|
| 201 |
+
seed: int = 0,
|
| 202 |
+
# dtype / missing
|
| 203 |
+
downcast_float32: bool = True,
|
| 204 |
+
# caching
|
| 205 |
+
cache: bool = True,
|
| 206 |
+
cache_dir: Optional[str] = None,
|
| 207 |
+
force_rebuild_cache: bool = False,
|
| 208 |
+
) -> ElectricityData:
|
| 209 |
+
"""
|
| 210 |
+
Returns a manageable (T, X) array from the OpenEI historic county load dataset.
|
| 211 |
+
|
| 212 |
+
Behavior:
|
| 213 |
+
- If a matching .npz cache exists -> loads it (NO `tables` required).
|
| 214 |
+
- Else downloads .h5 and reads via pandas (requires `tables` installed once),
|
| 215 |
+
then applies slicing/resampling/feature-selection, and optionally writes cache.
|
| 216 |
+
|
| 217 |
+
Notes:
|
| 218 |
+
- index is returned as int64 UTC nanoseconds (portable, no pandas dependency later).
|
| 219 |
+
- columns are returned as strings (county IDs like 'p36041').
|
| 220 |
+
"""
|
| 221 |
+
data_dir = str(data_dir)
|
| 222 |
+
os.makedirs(data_dir, exist_ok=True)
|
| 223 |
+
|
| 224 |
+
if cache_dir is None:
|
| 225 |
+
cache_dir = os.path.join(data_dir, "cache")
|
| 226 |
+
os.makedirs(cache_dir, exist_ok=True)
|
| 227 |
+
|
| 228 |
+
agg = agg.lower()
|
| 229 |
+
if agg not in ("sum", "mean"):
|
| 230 |
+
raise ValueError("agg must be 'sum' or 'mean'")
|
| 231 |
+
|
| 232 |
+
cache_name = _safe_cache_name(
|
| 233 |
+
start=start, stop=stop, resample=resample, agg=agg,
|
| 234 |
+
n_features=n_features, feature_method=feature_method,
|
| 235 |
+
seed=seed, downcast_float32=downcast_float32,
|
| 236 |
+
)
|
| 237 |
+
cache_path = os.path.join(cache_dir, cache_name)
|
| 238 |
+
|
| 239 |
+
# 1) Load cache if present
|
| 240 |
+
if cache and (not force_rebuild_cache) and os.path.exists(cache_path):
|
| 241 |
+
z = np.load(cache_path, allow_pickle=True)
|
| 242 |
+
R_tX = z["R_tX"]
|
| 243 |
+
t_index = z["t_index"]
|
| 244 |
+
X_index = z["X_index"]
|
| 245 |
+
return ElectricityData(R_tX=R_tX, t_index=t_index, X_index=X_index)
|
| 246 |
+
|
| 247 |
+
# 2) Ensure H5 present
|
| 248 |
+
h5_path = os.path.join(data_dir, h5_name)
|
| 249 |
+
download_with_progress(url, h5_path)
|
| 250 |
+
|
| 251 |
+
# 3) Read H5 (requires tables)
|
| 252 |
+
try:
|
| 253 |
+
df = pd.read_hdf(h5_path)
|
| 254 |
+
except Exception as e:
|
| 255 |
+
_require_tables_hint(e)
|
| 256 |
+
|
| 257 |
+
# 4) Basic preprocess
|
| 258 |
+
df = _coerce_datetime_index(df)
|
| 259 |
+
df = _fill_missing(df)
|
| 260 |
+
|
| 261 |
+
# 5) Time slice
|
| 262 |
+
if start is not None or stop is not None:
|
| 263 |
+
df = df.loc[start:stop]
|
| 264 |
+
|
| 265 |
+
# 6) Resample
|
| 266 |
+
if resample is not None:
|
| 267 |
+
if agg == "sum":
|
| 268 |
+
df = df.resample(resample).sum()
|
| 269 |
+
else:
|
| 270 |
+
df = df.resample(resample).mean()
|
| 271 |
+
|
| 272 |
+
# 7) Feature selection
|
| 273 |
+
df = _select_features(df, features=features, n_features=n_features, method=feature_method, seed=seed)
|
| 274 |
+
|
| 275 |
+
# 8) Downcast
|
| 276 |
+
if downcast_float32:
|
| 277 |
+
df = df.astype("float32", copy=False)
|
| 278 |
+
|
| 279 |
+
# 9) Build outputs
|
| 280 |
+
R_tX = df.to_numpy()
|
| 281 |
+
t_index = df.index.view("int64") # UTC ns
|
| 282 |
+
X_index = np.asarray(df.columns.astype(str), dtype=object)
|
| 283 |
+
|
| 284 |
+
# 10) Cache
|
| 285 |
+
if cache:
|
| 286 |
+
np.savez_compressed(cache_path, R_tX=R_tX, t_index=t_index, X_index=X_index)
|
| 287 |
+
print(f"[datasets] Wrote cache: {cache_path}")
|
| 288 |
+
|
| 289 |
+
return ElectricityData(R_tX=R_tX, t_index=t_index, X_index=X_index)
|
forcing.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# forcing.py
|
| 2 |
+
"""
|
| 3 |
+
Forced / nonstationary variants of chaotic attractors.
|
| 4 |
+
|
| 5 |
+
Standard output is always a single long trajectory:
|
| 6 |
+
R_tX : (T-burn_in, D)
|
| 7 |
+
|
| 8 |
+
For ODEs, forcing MUST be applied inside the integrator (nonautonomous RK4),
|
| 9 |
+
not post-hoc. This file provides three forcing mechanisms for Lorenz-63:
|
| 10 |
+
|
| 11 |
+
1) Additive forcing to the dynamics (adds kappa*u(t) to dx/dt, dy/dt, dz/dt)
|
| 12 |
+
2) Parameter drift (e.g., rho(t) varies with time / forcing)
|
| 13 |
+
3) Regime switching (piecewise-constant params over time)
|
| 14 |
+
|
| 15 |
+
All functions accept (T, dt) as primary inputs, with reasonable defaults.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
from typing import Callable, Dict, Optional, Sequence, Tuple, Union
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
|
| 25 |
+
Array = np.ndarray
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# -----------------------------
|
| 29 |
+
# small utils
|
| 30 |
+
# -----------------------------
|
| 31 |
+
def _rng_from_seed(seed: Optional[int]) -> np.random.Generator:
|
| 32 |
+
return np.random.default_rng(None if seed is None else int(seed))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _discard_burn_in(traj: Array, burn_in: int) -> Array:
|
| 36 |
+
burn_in = int(burn_in)
|
| 37 |
+
if burn_in <= 0:
|
| 38 |
+
return traj
|
| 39 |
+
if burn_in >= traj.shape[0]:
|
| 40 |
+
raise ValueError(f"burn_in={burn_in} must be < T={traj.shape[0]}")
|
| 41 |
+
return traj[burn_in:]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _as_1d(u: Union[None, float, Array]) -> Optional[Array]:
|
| 45 |
+
if u is None:
|
| 46 |
+
return None
|
| 47 |
+
if np.isscalar(u):
|
| 48 |
+
return np.asarray([float(u)], dtype=float)
|
| 49 |
+
u = np.asarray(u, dtype=float)
|
| 50 |
+
if u.ndim != 1:
|
| 51 |
+
raise ValueError(f"Expected 1D forcing array, got shape {u.shape}")
|
| 52 |
+
return u
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _build_time_grid(T: int, dt: float) -> Array:
|
| 56 |
+
return np.arange(int(T), dtype=float) * float(dt)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _interp_u(u_t: Array, t_grid: Array, tcur: float) -> float:
|
| 60 |
+
"""
|
| 61 |
+
Forcing evaluated at arbitrary time using linear interpolation.
|
| 62 |
+
Uses endpoint values outside the grid.
|
| 63 |
+
"""
|
| 64 |
+
if u_t.size == 1:
|
| 65 |
+
return float(u_t[0])
|
| 66 |
+
return float(np.interp(tcur, t_grid, u_t, left=u_t[0], right=u_t[-1]))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _kappa_vec(kappa: Union[float, Sequence[float], Array], axis: Optional[int] = None) -> Array:
|
| 70 |
+
"""
|
| 71 |
+
Convert kappa specification into a length-3 vector for Lorenz63.
|
| 72 |
+
- If axis is provided, interpret kappa as scalar magnitude applied to that axis.
|
| 73 |
+
- Else if kappa is scalar -> applied to x-axis by default.
|
| 74 |
+
- Else if kappa has length 3 -> used directly.
|
| 75 |
+
"""
|
| 76 |
+
if axis is not None:
|
| 77 |
+
kv = np.zeros(3, dtype=float)
|
| 78 |
+
kv[int(axis)] = float(kappa) # type: ignore[arg-type]
|
| 79 |
+
return kv
|
| 80 |
+
|
| 81 |
+
if np.isscalar(kappa):
|
| 82 |
+
# default: force x-equation
|
| 83 |
+
return np.array([float(kappa), 0.0, 0.0], dtype=float)
|
| 84 |
+
|
| 85 |
+
kv = np.asarray(kappa, dtype=float).reshape(-1)
|
| 86 |
+
if kv.size != 3:
|
| 87 |
+
raise ValueError("kappa must be scalar, length-3, or scalar+axis")
|
| 88 |
+
return kv
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# -----------------------------
|
| 92 |
+
# forcing signal helpers (optional)
|
| 93 |
+
# -----------------------------
|
| 94 |
+
def forcing_sine(T: int, dt: float, *, amp: float = 1.0, freq_hz: float = 0.1, phase: float = 0.0, bias: float = 0.0) -> Array:
|
| 95 |
+
"""
|
| 96 |
+
u(t) = bias + amp * sin(2*pi*freq_hz*t + phase)
|
| 97 |
+
Returns u_t of length T aligned with sample times n*dt.
|
| 98 |
+
"""
|
| 99 |
+
t = _build_time_grid(T, dt)
|
| 100 |
+
return (bias + amp * np.sin(2.0 * np.pi * float(freq_hz) * t + float(phase))).astype(float)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def forcing_piecewise_constant(T: int, *, values: Sequence[float], lengths: Sequence[int]) -> Array:
|
| 104 |
+
"""
|
| 105 |
+
Build a piecewise-constant forcing u_t of length T.
|
| 106 |
+
Example: values=[0,1,0.5], lengths=[2000,3000,5000]
|
| 107 |
+
"""
|
| 108 |
+
vals = list(values)
|
| 109 |
+
lens = list(map(int, lengths))
|
| 110 |
+
if len(vals) != len(lens):
|
| 111 |
+
raise ValueError("values and lengths must have same length")
|
| 112 |
+
u = np.concatenate([np.full(L, v, dtype=float) for v, L in zip(vals, lens)], axis=0)
|
| 113 |
+
if u.size < T:
|
| 114 |
+
u = np.pad(u, (0, T - u.size), mode="edge")
|
| 115 |
+
return u[:T]
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# -----------------------------
|
| 119 |
+
# core integrator: nonautonomous RK4 for Lorenz63
|
| 120 |
+
# -----------------------------
|
| 121 |
+
def _lorenz63_step_rk4(
|
| 122 |
+
x: Array,
|
| 123 |
+
t: float,
|
| 124 |
+
dt: float,
|
| 125 |
+
*,
|
| 126 |
+
sigma_fn: Callable[[float], float],
|
| 127 |
+
rho_fn: Callable[[float], float],
|
| 128 |
+
beta_fn: Callable[[float], float],
|
| 129 |
+
force_fn: Callable[[float], Array], # returns (3,) additive term to dx/dt
|
| 130 |
+
) -> Array:
|
| 131 |
+
"""
|
| 132 |
+
One RK4 step for x' = f(t,x) with Lorenz63 + forcing.
|
| 133 |
+
"""
|
| 134 |
+
x = np.asarray(x, dtype=float)
|
| 135 |
+
|
| 136 |
+
def f(tcur: float, s: Array) -> Array:
|
| 137 |
+
sigma = float(sigma_fn(tcur))
|
| 138 |
+
rho = float(rho_fn(tcur))
|
| 139 |
+
beta = float(beta_fn(tcur))
|
| 140 |
+
|
| 141 |
+
fx = np.empty(3, dtype=float)
|
| 142 |
+
fx[0] = sigma * (s[1] - s[0])
|
| 143 |
+
fx[1] = s[0] * (rho - s[2]) - s[1]
|
| 144 |
+
fx[2] = s[0] * s[1] - beta * s[2]
|
| 145 |
+
fx += force_fn(tcur)
|
| 146 |
+
return fx
|
| 147 |
+
|
| 148 |
+
k1 = f(t, x)
|
| 149 |
+
k2 = f(t + 0.5 * dt, x + 0.5 * dt * k1)
|
| 150 |
+
k3 = f(t + 0.5 * dt, x + 0.5 * dt * k2)
|
| 151 |
+
k4 = f(t + dt, x + dt * k3)
|
| 152 |
+
return x + (dt / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _simulate_lorenz63_nonauto(
|
| 156 |
+
T: int,
|
| 157 |
+
dt: float,
|
| 158 |
+
*,
|
| 159 |
+
x0: Array,
|
| 160 |
+
sigma_fn: Callable[[float], float],
|
| 161 |
+
rho_fn: Callable[[float], float],
|
| 162 |
+
beta_fn: Callable[[float], float],
|
| 163 |
+
force_fn: Callable[[float], Array],
|
| 164 |
+
) -> Array:
|
| 165 |
+
T = int(T)
|
| 166 |
+
dt = float(dt)
|
| 167 |
+
x = np.asarray(x0, dtype=float).reshape(3).copy()
|
| 168 |
+
traj = np.zeros((T, 3), dtype=float)
|
| 169 |
+
t = 0.0
|
| 170 |
+
for n in range(T):
|
| 171 |
+
traj[n] = x
|
| 172 |
+
x = _lorenz63_step_rk4(
|
| 173 |
+
x, t, dt,
|
| 174 |
+
sigma_fn=sigma_fn,
|
| 175 |
+
rho_fn=rho_fn,
|
| 176 |
+
beta_fn=beta_fn,
|
| 177 |
+
force_fn=force_fn,
|
| 178 |
+
)
|
| 179 |
+
t += dt
|
| 180 |
+
return traj
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# -----------------------------
|
| 184 |
+
# 1) Additive forcing: x' = f(x) + kappa * u(t)
|
| 185 |
+
# -----------------------------
|
| 186 |
+
def lorenz63_forced_additive(
|
| 187 |
+
T: int,
|
| 188 |
+
dt: float = 0.01,
|
| 189 |
+
*,
|
| 190 |
+
sigma: float = 10.0,
|
| 191 |
+
rho: float = 28.0,
|
| 192 |
+
beta: float = 8.0 / 3.0,
|
| 193 |
+
u_t: Optional[Array] = None,
|
| 194 |
+
u_fn: Optional[Callable[[float], float]] = None,
|
| 195 |
+
kappa: Union[float, Sequence[float], Array] = 1.0,
|
| 196 |
+
axis: Optional[int] = 0,
|
| 197 |
+
x0: Optional[Array] = None,
|
| 198 |
+
seed: Optional[int] = 0,
|
| 199 |
+
burn_in: int = 0,
|
| 200 |
+
return_u: bool = False,
|
| 201 |
+
) -> Union[Array, Tuple[Array, Array]]:
|
| 202 |
+
"""
|
| 203 |
+
Additive forcing applied to dx/dt,dy/dt,dz/dt.
|
| 204 |
+
|
| 205 |
+
You may provide either:
|
| 206 |
+
- u_t: array of length T (sampled at n*dt), or
|
| 207 |
+
- u_fn: callable u(t)
|
| 208 |
+
|
| 209 |
+
kappa:
|
| 210 |
+
- scalar + axis (default) => applies to one coordinate derivative
|
| 211 |
+
- length-3 vector => applies to all derivatives
|
| 212 |
+
- scalar with axis=None => defaults to x-axis
|
| 213 |
+
|
| 214 |
+
Returns:
|
| 215 |
+
traj : (T-burn_in, 3)
|
| 216 |
+
and optionally u_used : (T,) if return_u=True
|
| 217 |
+
"""
|
| 218 |
+
rng = _rng_from_seed(seed)
|
| 219 |
+
if x0 is None:
|
| 220 |
+
x0 = np.array([1.0, 1.0, 1.0], dtype=float) + 0.01 * rng.standard_normal(3)
|
| 221 |
+
else:
|
| 222 |
+
x0 = np.asarray(x0, dtype=float).reshape(3)
|
| 223 |
+
|
| 224 |
+
kv = _kappa_vec(kappa, axis=axis)
|
| 225 |
+
|
| 226 |
+
# build forcing evaluator
|
| 227 |
+
if u_fn is None:
|
| 228 |
+
ut = _as_1d(u_t)
|
| 229 |
+
if ut is None:
|
| 230 |
+
ut = np.zeros(int(T), dtype=float)
|
| 231 |
+
# ensure length T
|
| 232 |
+
if ut.size != int(T):
|
| 233 |
+
if ut.size < int(T):
|
| 234 |
+
ut = np.pad(ut, (0, int(T) - ut.size), mode="edge")
|
| 235 |
+
ut = ut[: int(T)]
|
| 236 |
+
t_grid = _build_time_grid(T, dt)
|
| 237 |
+
|
| 238 |
+
def u_eval(tcur: float) -> float:
|
| 239 |
+
return _interp_u(ut, t_grid, tcur)
|
| 240 |
+
|
| 241 |
+
u_used = ut
|
| 242 |
+
else:
|
| 243 |
+
def u_eval(tcur: float) -> float:
|
| 244 |
+
return float(u_fn(tcur))
|
| 245 |
+
|
| 246 |
+
# produce a sampled u for logging/return
|
| 247 |
+
t_grid = _build_time_grid(T, dt)
|
| 248 |
+
u_used = np.array([u_eval(ti) for ti in t_grid], dtype=float)
|
| 249 |
+
|
| 250 |
+
def sigma_fn(_t: float) -> float:
|
| 251 |
+
return float(sigma)
|
| 252 |
+
|
| 253 |
+
def rho_fn(_t: float) -> float:
|
| 254 |
+
return float(rho)
|
| 255 |
+
|
| 256 |
+
def beta_fn(_t: float) -> float:
|
| 257 |
+
return float(beta)
|
| 258 |
+
|
| 259 |
+
def force_fn(tcur: float) -> Array:
|
| 260 |
+
return kv * u_eval(tcur)
|
| 261 |
+
|
| 262 |
+
traj = _simulate_lorenz63_nonauto(
|
| 263 |
+
T=T, dt=dt, x0=x0,
|
| 264 |
+
sigma_fn=sigma_fn, rho_fn=rho_fn, beta_fn=beta_fn,
|
| 265 |
+
force_fn=force_fn
|
| 266 |
+
)
|
| 267 |
+
traj = _discard_burn_in(traj, burn_in)
|
| 268 |
+
u_used2 = _discard_burn_in(u_used[:, None], burn_in).reshape(-1) # align
|
| 269 |
+
return (traj, u_used2) if return_u else traj
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
# -----------------------------
|
| 273 |
+
# 2) Parameter drift: rho(t) varies (optionally driven by u(t))
|
| 274 |
+
# -----------------------------
|
| 275 |
+
def lorenz63_param_drift(
|
| 276 |
+
T: int,
|
| 277 |
+
dt: float = 0.01,
|
| 278 |
+
*,
|
| 279 |
+
sigma: float = 10.0,
|
| 280 |
+
rho0: float = 28.0,
|
| 281 |
+
beta: float = 8.0 / 3.0,
|
| 282 |
+
# specify rho(t) either directly or via forcing
|
| 283 |
+
rho_t: Optional[Array] = None, # length T at n*dt
|
| 284 |
+
rho_fn: Optional[Callable[[float], float]] = None,
|
| 285 |
+
u_t: Optional[Array] = None, # used only if rho_t/rho_fn not provided
|
| 286 |
+
u_fn: Optional[Callable[[float], float]] = None,
|
| 287 |
+
kappa_rho: float = 1.0, # rho(t) = rho0 + kappa_rho * u(t)
|
| 288 |
+
# optional additive forcing simultaneously (set to 0 to disable)
|
| 289 |
+
kappa_force: Union[float, Sequence[float], Array] = 0.0,
|
| 290 |
+
axis_force: Optional[int] = 0,
|
| 291 |
+
x0: Optional[Array] = None,
|
| 292 |
+
seed: Optional[int] = 0,
|
| 293 |
+
burn_in: int = 0,
|
| 294 |
+
return_rho: bool = False,
|
| 295 |
+
) -> Union[Array, Tuple[Array, Array]]:
|
| 296 |
+
"""
|
| 297 |
+
Nonstationary Lorenz63 where rho(t) drifts.
|
| 298 |
+
|
| 299 |
+
Priority for defining rho(t):
|
| 300 |
+
1) rho_fn(t)
|
| 301 |
+
2) rho_t sampled at n*dt
|
| 302 |
+
3) rho0 + kappa_rho * u(t) (u from u_fn or u_t; defaults to 0)
|
| 303 |
+
|
| 304 |
+
You can also add *simultaneous additive forcing* via kappa_force/axis_force.
|
| 305 |
+
|
| 306 |
+
Returns:
|
| 307 |
+
traj : (T-burn_in, 3)
|
| 308 |
+
and optionally rho_used : (T-burn_in,) if return_rho=True
|
| 309 |
+
"""
|
| 310 |
+
rng = _rng_from_seed(seed)
|
| 311 |
+
if x0 is None:
|
| 312 |
+
x0 = np.array([1.0, 1.0, 1.0], dtype=float) + 0.01 * rng.standard_normal(3)
|
| 313 |
+
else:
|
| 314 |
+
x0 = np.asarray(x0, dtype=float).reshape(3)
|
| 315 |
+
|
| 316 |
+
# build u evaluator (only if needed)
|
| 317 |
+
ut = _as_1d(u_t)
|
| 318 |
+
if ut is None:
|
| 319 |
+
ut = np.zeros(int(T), dtype=float)
|
| 320 |
+
if ut.size != int(T):
|
| 321 |
+
if ut.size < int(T):
|
| 322 |
+
ut = np.pad(ut, (0, int(T) - ut.size), mode="edge")
|
| 323 |
+
ut = ut[: int(T)]
|
| 324 |
+
t_grid = _build_time_grid(T, dt)
|
| 325 |
+
|
| 326 |
+
if u_fn is None:
|
| 327 |
+
def u_eval(tcur: float) -> float:
|
| 328 |
+
return _interp_u(ut, t_grid, tcur)
|
| 329 |
+
u_used = ut
|
| 330 |
+
else:
|
| 331 |
+
def u_eval(tcur: float) -> float:
|
| 332 |
+
return float(u_fn(tcur))
|
| 333 |
+
u_used = np.array([u_eval(ti) for ti in t_grid], dtype=float)
|
| 334 |
+
|
| 335 |
+
# build rho evaluator
|
| 336 |
+
if rho_fn is not None:
|
| 337 |
+
def rho_eval(tcur: float) -> float:
|
| 338 |
+
return float(rho_fn(tcur))
|
| 339 |
+
rho_used = np.array([rho_eval(ti) for ti in t_grid], dtype=float)
|
| 340 |
+
elif rho_t is not None:
|
| 341 |
+
rt = _as_1d(rho_t)
|
| 342 |
+
if rt is None:
|
| 343 |
+
raise ValueError("rho_t must be a 1D array if provided")
|
| 344 |
+
if rt.size != int(T):
|
| 345 |
+
if rt.size < int(T):
|
| 346 |
+
rt = np.pad(rt, (0, int(T) - rt.size), mode="edge")
|
| 347 |
+
rt = rt[: int(T)]
|
| 348 |
+
|
| 349 |
+
def rho_eval(tcur: float) -> float:
|
| 350 |
+
return float(np.interp(tcur, t_grid, rt, left=rt[0], right=rt[-1]))
|
| 351 |
+
rho_used = rt
|
| 352 |
+
else:
|
| 353 |
+
def rho_eval(tcur: float) -> float:
|
| 354 |
+
return float(rho0 + kappa_rho * u_eval(tcur))
|
| 355 |
+
rho_used = np.array([rho_eval(ti) for ti in t_grid], dtype=float)
|
| 356 |
+
|
| 357 |
+
kv_force = _kappa_vec(kappa_force, axis=axis_force)
|
| 358 |
+
|
| 359 |
+
def sigma_fn(_t: float) -> float:
|
| 360 |
+
return float(sigma)
|
| 361 |
+
|
| 362 |
+
def rho_fn2(tcur: float) -> float:
|
| 363 |
+
return float(rho_eval(tcur))
|
| 364 |
+
|
| 365 |
+
def beta_fn(_t: float) -> float:
|
| 366 |
+
return float(beta)
|
| 367 |
+
|
| 368 |
+
def force_fn(tcur: float) -> Array:
|
| 369 |
+
# optional additive forcing in dynamics
|
| 370 |
+
return kv_force * u_eval(tcur) if np.any(kv_force != 0.0) else np.zeros(3, dtype=float)
|
| 371 |
+
|
| 372 |
+
traj = _simulate_lorenz63_nonauto(
|
| 373 |
+
T=T, dt=dt, x0=x0,
|
| 374 |
+
sigma_fn=sigma_fn, rho_fn=rho_fn2, beta_fn=beta_fn,
|
| 375 |
+
force_fn=force_fn
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
traj = _discard_burn_in(traj, burn_in)
|
| 379 |
+
rho_used2 = _discard_burn_in(rho_used[:, None], burn_in).reshape(-1)
|
| 380 |
+
return (traj, rho_used2) if return_rho else traj
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
# -----------------------------
|
| 384 |
+
# 3) Regime switching: piecewise-constant params (sigma,rho,beta)
|
| 385 |
+
# -----------------------------
|
| 386 |
+
@dataclass(frozen=True)
|
| 387 |
+
class Lorenz63Params:
|
| 388 |
+
sigma: float = 10.0
|
| 389 |
+
rho: float = 28.0
|
| 390 |
+
beta: float = 8.0 / 3.0
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def lorenz63_regime_switch(
|
| 394 |
+
T: int,
|
| 395 |
+
dt: float = 0.01,
|
| 396 |
+
*,
|
| 397 |
+
# a schedule of regimes: (start_step, params)
|
| 398 |
+
schedule: Sequence[Tuple[int, Lorenz63Params]] = ((0, Lorenz63Params()),),
|
| 399 |
+
# optional additive forcing simultaneously
|
| 400 |
+
u_t: Optional[Array] = None,
|
| 401 |
+
u_fn: Optional[Callable[[float], float]] = None,
|
| 402 |
+
kappa: Union[float, Sequence[float], Array] = 0.0,
|
| 403 |
+
axis: Optional[int] = 0,
|
| 404 |
+
x0: Optional[Array] = None,
|
| 405 |
+
seed: Optional[int] = 0,
|
| 406 |
+
burn_in: int = 0,
|
| 407 |
+
return_params: bool = False,
|
| 408 |
+
) -> Union[Array, Tuple[Array, Dict[str, Array]]]:
|
| 409 |
+
"""
|
| 410 |
+
Piecewise-constant parameter switching. `schedule` is a list of
|
| 411 |
+
(start_step, Lorenz63Params). Example:
|
| 412 |
+
|
| 413 |
+
schedule = [
|
| 414 |
+
(0, Lorenz63Params(rho=28.0)),
|
| 415 |
+
(8000, Lorenz63Params(rho=35.0)),
|
| 416 |
+
(14000,Lorenz63Params(rho=25.0)),
|
| 417 |
+
]
|
| 418 |
+
|
| 419 |
+
Regime selection at time t uses idx = floor(t/dt), then picks the last
|
| 420 |
+
schedule entry with start_step <= idx.
|
| 421 |
+
|
| 422 |
+
Returns:
|
| 423 |
+
traj : (T-burn_in, 3)
|
| 424 |
+
and optionally a dict with sigma_t, rho_t, beta_t arrays (aligned).
|
| 425 |
+
"""
|
| 426 |
+
rng = _rng_from_seed(seed)
|
| 427 |
+
if x0 is None:
|
| 428 |
+
x0 = np.array([1.0, 1.0, 1.0], dtype=float) + 0.01 * rng.standard_normal(3)
|
| 429 |
+
else:
|
| 430 |
+
x0 = np.asarray(x0, dtype=float).reshape(3)
|
| 431 |
+
|
| 432 |
+
# build regime arrays (per step)
|
| 433 |
+
T = int(T)
|
| 434 |
+
sigma_t = np.empty(T, dtype=float)
|
| 435 |
+
rho_t = np.empty(T, dtype=float)
|
| 436 |
+
beta_t = np.empty(T, dtype=float)
|
| 437 |
+
|
| 438 |
+
sched = sorted([(int(s), p) for s, p in schedule], key=lambda z: z[0])
|
| 439 |
+
if not sched or sched[0][0] != 0:
|
| 440 |
+
raise ValueError("schedule must start at step 0")
|
| 441 |
+
|
| 442 |
+
# fill by segments
|
| 443 |
+
for i, (s0, p0) in enumerate(sched):
|
| 444 |
+
s1 = sched[i + 1][0] if i + 1 < len(sched) else T
|
| 445 |
+
s0 = max(0, min(T, s0))
|
| 446 |
+
s1 = max(s0, min(T, s1))
|
| 447 |
+
sigma_t[s0:s1] = float(p0.sigma)
|
| 448 |
+
rho_t[s0:s1] = float(p0.rho)
|
| 449 |
+
beta_t[s0:s1] = float(p0.beta)
|
| 450 |
+
|
| 451 |
+
# forcing evaluator (optional)
|
| 452 |
+
kv = _kappa_vec(kappa, axis=axis)
|
| 453 |
+
|
| 454 |
+
if u_fn is None:
|
| 455 |
+
ut = _as_1d(u_t)
|
| 456 |
+
if ut is None:
|
| 457 |
+
ut = np.zeros(T, dtype=float)
|
| 458 |
+
if ut.size != T:
|
| 459 |
+
if ut.size < T:
|
| 460 |
+
ut = np.pad(ut, (0, T - ut.size), mode="edge")
|
| 461 |
+
ut = ut[:T]
|
| 462 |
+
t_grid = _build_time_grid(T, dt)
|
| 463 |
+
|
| 464 |
+
def u_eval(tcur: float) -> float:
|
| 465 |
+
return _interp_u(ut, t_grid, tcur)
|
| 466 |
+
|
| 467 |
+
u_used = ut
|
| 468 |
+
else:
|
| 469 |
+
t_grid = _build_time_grid(T, dt)
|
| 470 |
+
|
| 471 |
+
def u_eval(tcur: float) -> float:
|
| 472 |
+
return float(u_fn(tcur))
|
| 473 |
+
|
| 474 |
+
u_used = np.array([u_eval(ti) for ti in t_grid], dtype=float)
|
| 475 |
+
|
| 476 |
+
# parameter evaluators: stepwise constant
|
| 477 |
+
def _param_at(arr: Array, tcur: float) -> float:
|
| 478 |
+
idx = int(tcur / float(dt))
|
| 479 |
+
if idx < 0:
|
| 480 |
+
idx = 0
|
| 481 |
+
elif idx >= T:
|
| 482 |
+
idx = T - 1
|
| 483 |
+
return float(arr[idx])
|
| 484 |
+
|
| 485 |
+
def sigma_fn(tcur: float) -> float:
|
| 486 |
+
return _param_at(sigma_t, tcur)
|
| 487 |
+
|
| 488 |
+
def rho_fn(tcur: float) -> float:
|
| 489 |
+
return _param_at(rho_t, tcur)
|
| 490 |
+
|
| 491 |
+
def beta_fn(tcur: float) -> float:
|
| 492 |
+
return _param_at(beta_t, tcur)
|
| 493 |
+
|
| 494 |
+
def force_fn(tcur: float) -> Array:
|
| 495 |
+
if np.all(kv == 0.0):
|
| 496 |
+
return np.zeros(3, dtype=float)
|
| 497 |
+
return kv * u_eval(tcur)
|
| 498 |
+
|
| 499 |
+
traj = _simulate_lorenz63_nonauto(
|
| 500 |
+
T=T, dt=dt, x0=x0,
|
| 501 |
+
sigma_fn=sigma_fn, rho_fn=rho_fn, beta_fn=beta_fn,
|
| 502 |
+
force_fn=force_fn
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
traj = _discard_burn_in(traj, burn_in)
|
| 506 |
+
if not return_params:
|
| 507 |
+
return traj
|
| 508 |
+
|
| 509 |
+
# align parameter arrays with burn_in
|
| 510 |
+
sigma_used = _discard_burn_in(sigma_t[:, None], burn_in).reshape(-1)
|
| 511 |
+
rho_used = _discard_burn_in(rho_t[:, None], burn_in).reshape(-1)
|
| 512 |
+
beta_used = _discard_burn_in(beta_t[:, None], burn_in).reshape(-1)
|
| 513 |
+
u_used2 = _discard_burn_in(u_used[:, None], burn_in).reshape(-1)
|
| 514 |
+
|
| 515 |
+
info = {
|
| 516 |
+
"sigma_t": sigma_used,
|
| 517 |
+
"rho_t": rho_used,
|
| 518 |
+
"beta_t": beta_used,
|
| 519 |
+
"u_t": u_used2,
|
| 520 |
+
}
|
| 521 |
+
return traj, info
|
metrics.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Minimal usage
|
| 2 |
+
# from metrics import compare_all, mse_by_horizon
|
| 3 |
+
#
|
| 4 |
+
# metrics = compare_all(truth, pred, burn_in=0, acf_max_lag=200, mmd_sample=1024, seed=0)
|
| 5 |
+
# print(metrics)
|
| 6 |
+
#
|
| 7 |
+
# mseh = mse_by_horizon(truth, pred) # per-step MSE curve
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# metrics.py
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
from typing import Dict, Optional, Tuple
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _as_2d(a: np.ndarray) -> np.ndarray:
|
| 18 |
+
a = np.asarray(a)
|
| 19 |
+
if a.ndim == 1:
|
| 20 |
+
return a[:, None]
|
| 21 |
+
if a.ndim != 2:
|
| 22 |
+
raise ValueError(f"Expected 1D or 2D array, got shape {a.shape}")
|
| 23 |
+
return a
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _slice_time(
|
| 27 |
+
R: np.ndarray,
|
| 28 |
+
Q: np.ndarray,
|
| 29 |
+
*,
|
| 30 |
+
burn_in: int = 0,
|
| 31 |
+
T_max: Optional[int] = None,
|
| 32 |
+
stride: int = 1,
|
| 33 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 34 |
+
R = _as_2d(R)
|
| 35 |
+
Q = _as_2d(Q)
|
| 36 |
+
if R.shape != Q.shape:
|
| 37 |
+
raise ValueError(f"R and Q must have same shape, got {R.shape} vs {Q.shape}")
|
| 38 |
+
|
| 39 |
+
b = int(burn_in)
|
| 40 |
+
s = int(stride)
|
| 41 |
+
if b < 0 or s <= 0:
|
| 42 |
+
raise ValueError("burn_in must be >=0 and stride must be >=1")
|
| 43 |
+
|
| 44 |
+
T = R.shape[0]
|
| 45 |
+
t0 = min(b, T)
|
| 46 |
+
t1 = T if T_max is None else min(T, t0 + int(T_max))
|
| 47 |
+
R2 = R[t0:t1:s]
|
| 48 |
+
Q2 = Q[t0:t1:s]
|
| 49 |
+
return R2, Q2
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# -----------------------------
|
| 53 |
+
# 1) MSE
|
| 54 |
+
# -----------------------------
|
| 55 |
+
def mse(R_tX: np.ndarray, Q_tX: np.ndarray, *, burn_in: int = 0, T_max: Optional[int] = None, stride: int = 1) -> float:
|
| 56 |
+
R, Q = _slice_time(R_tX, Q_tX, burn_in=burn_in, T_max=T_max, stride=stride)
|
| 57 |
+
return float(np.mean((R - Q) ** 2))
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def mse_by_horizon(R_tX: np.ndarray, Q_tX: np.ndarray, *, burn_in: int = 0, T_max: Optional[int] = None, stride: int = 1) -> np.ndarray:
|
| 61 |
+
"""
|
| 62 |
+
Per-step MSE across features: returns shape (T',)
|
| 63 |
+
"""
|
| 64 |
+
R, Q = _slice_time(R_tX, Q_tX, burn_in=burn_in, T_max=T_max, stride=stride)
|
| 65 |
+
return np.mean((R - Q) ** 2, axis=1)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def mse_per_feature(R_tX: np.ndarray, Q_tX: np.ndarray, *, burn_in: int = 0, T_max: Optional[int] = None, stride: int = 1) -> np.ndarray:
|
| 69 |
+
"""
|
| 70 |
+
Per-feature MSE: returns shape (X,)
|
| 71 |
+
"""
|
| 72 |
+
R, Q = _slice_time(R_tX, Q_tX, burn_in=burn_in, T_max=T_max, stride=stride)
|
| 73 |
+
return np.mean((R - Q) ** 2, axis=0)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# -----------------------------
|
| 77 |
+
# 2) PSD log-MSE (spectrum mismatch)
|
| 78 |
+
# -----------------------------
|
| 79 |
+
def _hann(n: int) -> np.ndarray:
|
| 80 |
+
if n <= 1:
|
| 81 |
+
return np.ones((n,), dtype=float)
|
| 82 |
+
i = np.arange(n, dtype=float)
|
| 83 |
+
return 0.5 - 0.5 * np.cos(2.0 * np.pi * i / (n - 1))
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def psd_logmse(
|
| 87 |
+
R_tX: np.ndarray,
|
| 88 |
+
Q_tX: np.ndarray,
|
| 89 |
+
*,
|
| 90 |
+
burn_in: int = 0,
|
| 91 |
+
T_max: Optional[int] = None,
|
| 92 |
+
stride: int = 1,
|
| 93 |
+
detrend: bool = True,
|
| 94 |
+
window: str = "hann",
|
| 95 |
+
eps: float = 1e-12,
|
| 96 |
+
normalize_per_feature: bool = True,
|
| 97 |
+
) -> float:
|
| 98 |
+
"""
|
| 99 |
+
Compare log power spectra of R and Q.
|
| 100 |
+
- computes one-sided PSD via rFFT along time for each feature
|
| 101 |
+
- returns mean squared error of log(PSD + eps) over (freq, feature)
|
| 102 |
+
|
| 103 |
+
normalize_per_feature=True rescales PSD of each feature to sum to 1
|
| 104 |
+
(so mismatch reflects shape, not total variance).
|
| 105 |
+
"""
|
| 106 |
+
R, Q = _slice_time(R_tX, Q_tX, burn_in=burn_in, T_max=T_max, stride=stride)
|
| 107 |
+
T, X = R.shape
|
| 108 |
+
if T < 8:
|
| 109 |
+
return float("nan")
|
| 110 |
+
|
| 111 |
+
Rw = R.astype(float)
|
| 112 |
+
Qw = Q.astype(float)
|
| 113 |
+
|
| 114 |
+
if detrend:
|
| 115 |
+
Rw = Rw - np.mean(Rw, axis=0, keepdims=True)
|
| 116 |
+
Qw = Qw - np.mean(Qw, axis=0, keepdims=True)
|
| 117 |
+
|
| 118 |
+
if window is None or window.lower() == "none":
|
| 119 |
+
w = np.ones((T,), dtype=float)
|
| 120 |
+
elif window.lower() == "hann":
|
| 121 |
+
w = _hann(T)
|
| 122 |
+
else:
|
| 123 |
+
raise ValueError("window must be 'hann' or None/'none'")
|
| 124 |
+
|
| 125 |
+
# apply window (broadcast over features)
|
| 126 |
+
Rw = Rw * w[:, None]
|
| 127 |
+
Qw = Qw * w[:, None]
|
| 128 |
+
|
| 129 |
+
# rFFT
|
| 130 |
+
FR = np.fft.rfft(Rw, axis=0)
|
| 131 |
+
FQ = np.fft.rfft(Qw, axis=0)
|
| 132 |
+
|
| 133 |
+
# raw power
|
| 134 |
+
PR = (np.abs(FR) ** 2) / max(T, 1)
|
| 135 |
+
PQ = (np.abs(FQ) ** 2) / max(T, 1)
|
| 136 |
+
|
| 137 |
+
if normalize_per_feature:
|
| 138 |
+
PR = PR / (np.sum(PR, axis=0, keepdims=True) + eps)
|
| 139 |
+
PQ = PQ / (np.sum(PQ, axis=0, keepdims=True) + eps)
|
| 140 |
+
|
| 141 |
+
LR = np.log(PR + eps)
|
| 142 |
+
LQ = np.log(PQ + eps)
|
| 143 |
+
return float(np.mean((LR - LQ) ** 2))
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# -----------------------------
|
| 147 |
+
# 3) ACF-MSE (autocorrelation mismatch)
|
| 148 |
+
# -----------------------------
|
| 149 |
+
def _acf_1d(x: np.ndarray, max_lag: int) -> np.ndarray:
|
| 150 |
+
"""
|
| 151 |
+
Normalized autocorrelation via FFT, lags 0..max_lag.
|
| 152 |
+
"""
|
| 153 |
+
x = np.asarray(x, float)
|
| 154 |
+
n = x.size
|
| 155 |
+
if n == 0:
|
| 156 |
+
return np.zeros((max_lag + 1,), dtype=float)
|
| 157 |
+
x = x - np.mean(x)
|
| 158 |
+
# next power of 2 for speed
|
| 159 |
+
nfft = 1 << int(np.ceil(np.log2(max(1, 2 * n - 1))))
|
| 160 |
+
fx = np.fft.rfft(x, n=nfft)
|
| 161 |
+
acov = np.fft.irfft(fx * np.conj(fx), n=nfft)[: max_lag + 1]
|
| 162 |
+
acov = acov / max(n, 1)
|
| 163 |
+
if acov[0] <= 0:
|
| 164 |
+
return np.zeros((max_lag + 1,), dtype=float)
|
| 165 |
+
return acov / acov[0]
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def acf_mse(
|
| 169 |
+
R_tX: np.ndarray,
|
| 170 |
+
Q_tX: np.ndarray,
|
| 171 |
+
*,
|
| 172 |
+
max_lag: int = 200,
|
| 173 |
+
burn_in: int = 0,
|
| 174 |
+
T_max: Optional[int] = None,
|
| 175 |
+
stride: int = 1,
|
| 176 |
+
) -> float:
|
| 177 |
+
"""
|
| 178 |
+
Mean squared error between autocorrelation functions of R and Q,
|
| 179 |
+
averaged over features and lags 1..max_lag (excluding lag 0).
|
| 180 |
+
"""
|
| 181 |
+
R, Q = _slice_time(R_tX, Q_tX, burn_in=burn_in, T_max=T_max, stride=stride)
|
| 182 |
+
T, X = R.shape
|
| 183 |
+
if T < max_lag + 2:
|
| 184 |
+
max_lag = max(1, min(max_lag, T - 2))
|
| 185 |
+
|
| 186 |
+
errs = []
|
| 187 |
+
for j in range(X):
|
| 188 |
+
aR = _acf_1d(R[:, j], max_lag=max_lag)
|
| 189 |
+
aQ = _acf_1d(Q[:, j], max_lag=max_lag)
|
| 190 |
+
# exclude lag 0 (always 1)
|
| 191 |
+
errs.append(np.mean((aR[1:] - aQ[1:]) ** 2))
|
| 192 |
+
return float(np.mean(errs)) if errs else float("nan")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# -----------------------------
|
| 196 |
+
# 4) MMD² (distribution/attractor fidelity)
|
| 197 |
+
# -----------------------------
|
| 198 |
+
def _sq_dists(A: np.ndarray, B: np.ndarray) -> np.ndarray:
|
| 199 |
+
# ||a-b||^2 = ||a||^2 + ||b||^2 - 2a·b
|
| 200 |
+
AA = np.sum(A * A, axis=1, keepdims=True)
|
| 201 |
+
BB = np.sum(B * B, axis=1, keepdims=True).T
|
| 202 |
+
D2 = AA + BB - 2.0 * (A @ B.T)
|
| 203 |
+
return np.maximum(D2, 0.0)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _median_bandwidth(Z: np.ndarray, max_pairs: int = 4096, seed: int = 0, eps: float = 1e-12) -> float:
|
| 207 |
+
rng = np.random.default_rng(seed)
|
| 208 |
+
n = Z.shape[0]
|
| 209 |
+
if n < 2:
|
| 210 |
+
return 1.0
|
| 211 |
+
m = min(max_pairs, n * (n - 1) // 2)
|
| 212 |
+
i = rng.integers(0, n, size=m)
|
| 213 |
+
j = rng.integers(0, n, size=m)
|
| 214 |
+
mask = i != j
|
| 215 |
+
i, j = i[mask], j[mask]
|
| 216 |
+
if i.size == 0:
|
| 217 |
+
return 1.0
|
| 218 |
+
d2 = np.sum((Z[i] - Z[j]) ** 2, axis=1)
|
| 219 |
+
med = np.median(d2)
|
| 220 |
+
# kernel uses exp(-||x-y||^2/(2*sigma^2))
|
| 221 |
+
sigma = np.sqrt(max(med, 0.0) / 2.0 + eps)
|
| 222 |
+
return float(max(sigma, 1e-6))
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def mmd_rbf(
|
| 226 |
+
R_tX: np.ndarray,
|
| 227 |
+
Q_tX: np.ndarray,
|
| 228 |
+
*,
|
| 229 |
+
burn_in: int = 0,
|
| 230 |
+
T_max: Optional[int] = None,
|
| 231 |
+
stride: int = 1,
|
| 232 |
+
sample: int = 2048,
|
| 233 |
+
sigma: Optional[float] = None,
|
| 234 |
+
sigma_seed: int = 0,
|
| 235 |
+
unbiased: bool = True,
|
| 236 |
+
seed: int = 0,
|
| 237 |
+
) -> float:
|
| 238 |
+
"""
|
| 239 |
+
MMD^2 between the empirical distributions of state samples from R and Q.
|
| 240 |
+
|
| 241 |
+
Uses an RBF kernel: k(x,y) = exp(-||x-y||^2/(2*sigma^2))
|
| 242 |
+
|
| 243 |
+
Notes:
|
| 244 |
+
- O(sample^2) time/memory, so keep sample modest (e.g. 512–4096).
|
| 245 |
+
- If sigma is None, uses median heuristic on pooled samples.
|
| 246 |
+
"""
|
| 247 |
+
R, Q = _slice_time(R_tX, Q_tX, burn_in=burn_in, T_max=T_max, stride=stride)
|
| 248 |
+
rng = np.random.default_rng(seed)
|
| 249 |
+
|
| 250 |
+
T = R.shape[0]
|
| 251 |
+
if T == 0:
|
| 252 |
+
return float("nan")
|
| 253 |
+
|
| 254 |
+
n = min(int(sample), T)
|
| 255 |
+
idxR = rng.choice(T, size=n, replace=False) if n < T else np.arange(T)
|
| 256 |
+
idxQ = rng.choice(T, size=n, replace=False) if n < T else np.arange(T)
|
| 257 |
+
|
| 258 |
+
Xs = R[idxR].astype(float)
|
| 259 |
+
Ys = Q[idxQ].astype(float)
|
| 260 |
+
|
| 261 |
+
if sigma is None:
|
| 262 |
+
Z = np.vstack([Xs, Ys])
|
| 263 |
+
sigma = _median_bandwidth(Z, seed=sigma_seed)
|
| 264 |
+
|
| 265 |
+
sig2 = float(sigma) ** 2
|
| 266 |
+
if sig2 <= 0:
|
| 267 |
+
raise ValueError("sigma must be > 0")
|
| 268 |
+
|
| 269 |
+
Kxx = np.exp(-_sq_dists(Xs, Xs) / (2.0 * sig2))
|
| 270 |
+
Kyy = np.exp(-_sq_dists(Ys, Ys) / (2.0 * sig2))
|
| 271 |
+
Kxy = np.exp(-_sq_dists(Xs, Ys) / (2.0 * sig2))
|
| 272 |
+
|
| 273 |
+
if unbiased and n > 1:
|
| 274 |
+
np.fill_diagonal(Kxx, 0.0)
|
| 275 |
+
np.fill_diagonal(Kyy, 0.0)
|
| 276 |
+
mmd2 = (Kxx.sum() / (n * (n - 1))) + (Kyy.sum() / (n * (n - 1))) - 2.0 * (Kxy.mean())
|
| 277 |
+
else:
|
| 278 |
+
mmd2 = Kxx.mean() + Kyy.mean() - 2.0 * Kxy.mean()
|
| 279 |
+
|
| 280 |
+
return float(max(mmd2, 0.0))
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
# -----------------------------
|
| 284 |
+
# convenience: compute all metrics at once
|
| 285 |
+
# -----------------------------
|
| 286 |
+
def compare_all(
|
| 287 |
+
R_tX: np.ndarray,
|
| 288 |
+
Q_tX: np.ndarray,
|
| 289 |
+
*,
|
| 290 |
+
burn_in: int = 0,
|
| 291 |
+
T_max: Optional[int] = None,
|
| 292 |
+
stride: int = 1,
|
| 293 |
+
acf_max_lag: int = 200,
|
| 294 |
+
mmd_sample: int = 2048,
|
| 295 |
+
mmd_sigma: Optional[float] = None,
|
| 296 |
+
seed: int = 0,
|
| 297 |
+
) -> Dict[str, float]:
|
| 298 |
+
"""
|
| 299 |
+
Returns a dict with scalar metrics.
|
| 300 |
+
"""
|
| 301 |
+
out = {}
|
| 302 |
+
out["mse"] = mse(R_tX, Q_tX, burn_in=burn_in, T_max=T_max, stride=stride)
|
| 303 |
+
out["psd_logmse"] = psd_logmse(R_tX, Q_tX, burn_in=burn_in, T_max=T_max, stride=stride)
|
| 304 |
+
out["acf_mse"] = acf_mse(R_tX, Q_tX, max_lag=acf_max_lag, burn_in=burn_in, T_max=T_max, stride=stride)
|
| 305 |
+
out["mmd2_rbf"] = mmd_rbf(
|
| 306 |
+
R_tX, Q_tX,
|
| 307 |
+
burn_in=burn_in, T_max=T_max, stride=stride,
|
| 308 |
+
sample=mmd_sample, sigma=mmd_sigma,
|
| 309 |
+
seed=seed
|
| 310 |
+
)
|
| 311 |
+
return out
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
from typing import Optional, Tuple, Dict
|
| 316 |
+
|
| 317 |
+
def compare_all_with_curves(
|
| 318 |
+
R_tX: np.ndarray,
|
| 319 |
+
Q_tX: np.ndarray,
|
| 320 |
+
*,
|
| 321 |
+
burn_in: int = 0,
|
| 322 |
+
T_max: Optional[int] = None,
|
| 323 |
+
stride: int = 1,
|
| 324 |
+
acf_max_lag: int = 200,
|
| 325 |
+
mmd_sample: int = 2048,
|
| 326 |
+
mmd_sigma: Optional[float] = None,
|
| 327 |
+
seed: int = 0,
|
| 328 |
+
) -> Tuple[Dict[str, float], Dict[str, np.ndarray]]:
|
| 329 |
+
"""
|
| 330 |
+
Returns:
|
| 331 |
+
scalars: dict[str, float]
|
| 332 |
+
curves : dict[str, np.ndarray]
|
| 333 |
+
"""
|
| 334 |
+
scalars = compare_all(
|
| 335 |
+
R_tX, Q_tX,
|
| 336 |
+
burn_in=burn_in, T_max=T_max, stride=stride,
|
| 337 |
+
acf_max_lag=acf_max_lag,
|
| 338 |
+
mmd_sample=mmd_sample,
|
| 339 |
+
mmd_sigma=mmd_sigma,
|
| 340 |
+
seed=seed,
|
| 341 |
+
)
|
| 342 |
+
curves = {
|
| 343 |
+
"mse_by_horizon": mse_by_horizon(R_tX, Q_tX, burn_in=burn_in, T_max=T_max, stride=stride),
|
| 344 |
+
"mse_per_feature": mse_per_feature(R_tX, Q_tX, burn_in=burn_in, T_max=T_max, stride=stride),
|
| 345 |
+
}
|
| 346 |
+
return scalars, curves
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def compare_all_fast(
|
| 356 |
+
R_tX: np.ndarray,
|
| 357 |
+
Q_tX: np.ndarray,
|
| 358 |
+
*,
|
| 359 |
+
burn_in: int = 0,
|
| 360 |
+
T_max: Optional[int] = None,
|
| 361 |
+
stride: int = 1,
|
| 362 |
+
acf_max_lag: int = 200,
|
| 363 |
+
include_mmd_rff: bool = False,
|
| 364 |
+
mmd_sample: int = 1024,
|
| 365 |
+
mmd_rff_dim: int = 512,
|
| 366 |
+
mmd_sigma: Optional[float] = None,
|
| 367 |
+
seed: int = 0,
|
| 368 |
+
) -> Dict[str, float]:
|
| 369 |
+
out = {}
|
| 370 |
+
out["mse"] = mse(R_tX, Q_tX, burn_in=burn_in, T_max=T_max, stride=stride)
|
| 371 |
+
out["psd_logmse"] = psd_logmse(R_tX, Q_tX, burn_in=burn_in, T_max=T_max, stride=stride)
|
| 372 |
+
out["acf_mse"] = acf_mse_fast(
|
| 373 |
+
R_tX, Q_tX,
|
| 374 |
+
max_lag=acf_max_lag,
|
| 375 |
+
burn_in=burn_in, T_max=T_max, stride=stride,
|
| 376 |
+
)
|
| 377 |
+
if include_mmd_rff:
|
| 378 |
+
out["mmd2_rff"] = mmd_rff(
|
| 379 |
+
R_tX, Q_tX,
|
| 380 |
+
burn_in=burn_in, T_max=T_max, stride=stride,
|
| 381 |
+
sample=mmd_sample, rff_dim=mmd_rff_dim,
|
| 382 |
+
sigma=mmd_sigma,
|
| 383 |
+
seed=seed,
|
| 384 |
+
)
|
| 385 |
+
return out
|
| 386 |
+
|
| 387 |
+
import numpy as np
|
| 388 |
+
from typing import Optional, Dict, Tuple
|
| 389 |
+
|
| 390 |
+
def acf_mse_fast(
|
| 391 |
+
R_tX: np.ndarray,
|
| 392 |
+
Q_tX: np.ndarray,
|
| 393 |
+
*,
|
| 394 |
+
max_lag: int = 200,
|
| 395 |
+
burn_in: int = 0,
|
| 396 |
+
T_max: Optional[int] = None,
|
| 397 |
+
stride: int = 1,
|
| 398 |
+
eps: float = 1e-12,
|
| 399 |
+
) -> float:
|
| 400 |
+
R, Q = _slice_time(R_tX, Q_tX, burn_in=burn_in, T_max=T_max, stride=stride)
|
| 401 |
+
T, X = R.shape
|
| 402 |
+
if T < 8:
|
| 403 |
+
return float("nan")
|
| 404 |
+
if T < max_lag + 2:
|
| 405 |
+
max_lag = max(1, min(max_lag, T - 2))
|
| 406 |
+
|
| 407 |
+
def acf_all(Z: np.ndarray) -> np.ndarray:
|
| 408 |
+
Z = Z.astype(np.float32)
|
| 409 |
+
Z = Z - Z.mean(axis=0, keepdims=True)
|
| 410 |
+
n = Z.shape[0]
|
| 411 |
+
nfft = 1 << int(np.ceil(np.log2(max(1, 2*n - 1))))
|
| 412 |
+
F = np.fft.rfft(Z, n=nfft, axis=0)
|
| 413 |
+
acov = np.fft.irfft(F * np.conj(F), n=nfft, axis=0)[: max_lag + 1]
|
| 414 |
+
acov = acov / max(n, 1)
|
| 415 |
+
ac0 = acov[0:1, :] # (1,X)
|
| 416 |
+
return np.where(ac0 > eps, acov / ac0, 0.0)
|
| 417 |
+
|
| 418 |
+
aR = acf_all(R)
|
| 419 |
+
aQ = acf_all(Q)
|
| 420 |
+
return float(np.mean((aR[1:] - aQ[1:]) ** 2))
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def mmd_rff(
|
| 424 |
+
R_tX: np.ndarray,
|
| 425 |
+
Q_tX: np.ndarray,
|
| 426 |
+
*,
|
| 427 |
+
burn_in: int = 0,
|
| 428 |
+
T_max: Optional[int] = None,
|
| 429 |
+
stride: int = 1,
|
| 430 |
+
sample: int = 2048,
|
| 431 |
+
rff_dim: int = 512,
|
| 432 |
+
sigma: Optional[float] = None,
|
| 433 |
+
seed: int = 0,
|
| 434 |
+
) -> float:
|
| 435 |
+
R, Q = _slice_time(R_tX, Q_tX, burn_in=burn_in, T_max=T_max, stride=stride)
|
| 436 |
+
rng = np.random.default_rng(seed)
|
| 437 |
+
T, X = R.shape
|
| 438 |
+
if T == 0:
|
| 439 |
+
return float("nan")
|
| 440 |
+
|
| 441 |
+
n = min(int(sample), T)
|
| 442 |
+
idxR = rng.choice(T, size=n, replace=False) if n < T else np.arange(T)
|
| 443 |
+
idxQ = rng.choice(T, size=n, replace=False) if n < T else np.arange(T)
|
| 444 |
+
|
| 445 |
+
Xs = R[idxR].astype(np.float32)
|
| 446 |
+
Ys = Q[idxQ].astype(np.float32)
|
| 447 |
+
|
| 448 |
+
if sigma is None:
|
| 449 |
+
Z = np.vstack([Xs, Ys])
|
| 450 |
+
sigma = _median_bandwidth(Z, seed=seed)
|
| 451 |
+
|
| 452 |
+
sigma = float(sigma)
|
| 453 |
+
W = rng.normal(0.0, 1.0 / sigma, size=(X, rff_dim)).astype(np.float32)
|
| 454 |
+
b = rng.uniform(0.0, 2*np.pi, size=(rff_dim,)).astype(np.float32)
|
| 455 |
+
|
| 456 |
+
def phi(A):
|
| 457 |
+
return np.sqrt(2.0 / rff_dim) * np.cos(A @ W + b)
|
| 458 |
+
|
| 459 |
+
mx = phi(Xs).mean(axis=0)
|
| 460 |
+
my = phi(Ys).mean(axis=0)
|
| 461 |
+
return float(np.mean((mx - my) ** 2))
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def compare_all_fast_with_curves(
|
| 467 |
+
R_tX: np.ndarray,
|
| 468 |
+
Q_tX: np.ndarray,
|
| 469 |
+
*,
|
| 470 |
+
burn_in: int = 0,
|
| 471 |
+
T_max: Optional[int] = None,
|
| 472 |
+
stride: int = 1,
|
| 473 |
+
acf_max_lag: int = 200,
|
| 474 |
+
include_mmd_rff: bool = False,
|
| 475 |
+
mmd_sample: int = 1024,
|
| 476 |
+
mmd_rff_dim: int = 512,
|
| 477 |
+
mmd_sigma: Optional[float] = None,
|
| 478 |
+
seed: int = 0,
|
| 479 |
+
) -> Tuple[Dict[str, float], Dict[str, np.ndarray]]:
|
| 480 |
+
scalars = compare_all_fast(
|
| 481 |
+
R_tX, Q_tX,
|
| 482 |
+
burn_in=burn_in, T_max=T_max, stride=stride,
|
| 483 |
+
acf_max_lag=acf_max_lag,
|
| 484 |
+
include_mmd_rff=include_mmd_rff,
|
| 485 |
+
mmd_sample=mmd_sample, mmd_rff_dim=mmd_rff_dim,
|
| 486 |
+
mmd_sigma=mmd_sigma,
|
| 487 |
+
seed=seed,
|
| 488 |
+
)
|
| 489 |
+
curves = {
|
| 490 |
+
"mse_by_horizon": mse_by_horizon(R_tX, Q_tX, burn_in=burn_in, T_max=T_max, stride=stride),
|
| 491 |
+
"mse_per_feature": mse_per_feature(R_tX, Q_tX, burn_in=burn_in, T_max=T_max, stride=stride),
|
| 492 |
+
}
|
| 493 |
+
return scalars, curves
|
| 494 |
+
|