sparsetrace commited on
Commit
bf80929
·
verified ·
1 Parent(s): b45714f

Upload 4 files

Browse files
Files changed (4) hide show
  1. attractors.py +336 -0
  2. datasets.py +289 -0
  3. forcing.py +521 -0
  4. 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
+