physfdt 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
physfdt/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ """physfdt -- fluctuation-dissipation diagnostics for stochastic gradient descent.
2
+
3
+ Measures whether SGD training has equilibrated at the current learning rate,
4
+ using the exact stationarity relation
5
+
6
+ 2 <w . u> = eta <|u|^2>
7
+
8
+ with no assumption about the structure of the gradient noise and no held-out
9
+ validation set.
10
+
11
+ Quick start
12
+ -----------
13
+ ::
14
+
15
+ from physfdt import FDRMonitor, FDRConfig, FDREquilibriumLR
16
+
17
+ monitor = FDRMonitor(optimizer, FDRConfig(half_life=200))
18
+ sched = FDREquilibriumLR(optimizer, monitor, factor=0.5)
19
+
20
+ loss.backward()
21
+ with monitor.measure():
22
+ optimizer.step()
23
+ sched.step()
24
+
25
+ See the README for scope and limitations before using the numbers in a paper.
26
+ """
27
+
28
+ from .core import FDRAccumulator, FDRConfig, FDRState, NonStationaryWarning
29
+ from .numpy_backend import NumpyFDRMonitor, fdr_terms
30
+ from .spectral import (
31
+ SpectralReport,
32
+ esd,
33
+ ipr,
34
+ powerlaw_alpha,
35
+ spectral_report,
36
+ spectral_report_torch,
37
+ )
38
+ from .trace import TraceWriter
39
+
40
+ __version__ = "0.3.1"
41
+
42
+ __all__ = [
43
+ "FDRAccumulator",
44
+ "FDRConfig",
45
+ "FDRState",
46
+ "NonStationaryWarning",
47
+ "NumpyFDRMonitor",
48
+ "fdr_terms",
49
+ "SpectralReport",
50
+ "esd",
51
+ "ipr",
52
+ "powerlaw_alpha",
53
+ "spectral_report",
54
+ "spectral_report_torch",
55
+ "TraceWriter",
56
+ "FDRMonitor",
57
+ "FDREquilibriumLR",
58
+ "__version__",
59
+ ]
60
+
61
+
62
+ def __getattr__(name):
63
+ # Torch-dependent symbols are imported lazily so that `import physfdt`
64
+ # works in a NumPy-only environment.
65
+ if name in ("FDRMonitor", "FDREquilibriumLR"):
66
+ from . import torch_backend
67
+
68
+ return getattr(torch_backend, name)
69
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
physfdt/core.py ADDED
@@ -0,0 +1,486 @@
1
+ """Framework-free core of the FDR stationarity diagnostic.
2
+
3
+ Physics
4
+ -------
5
+ Any first-order optimiser can be written as
6
+
7
+ w_{t+1} = w_t - eta * u_t
8
+
9
+ where ``u_t`` is whatever direction the optimiser produces before the learning
10
+ rate is applied (plain gradient, momentum buffer, Adam's preconditioned step,
11
+ gradient plus weight decay, ...).
12
+
13
+ Take the observable ``O(w) = 0.5 * |w|^2`` and impose stationarity of its
14
+ expectation, ``<O(w_{t+1})> = <O(w_t)>``:
15
+
16
+ |w_{t+1}|^2 = |w_t|^2 - 2 eta <w_t . u_t> + eta^2 <|u_t|^2>
17
+
18
+ => 2 <w_t . u_t> = eta <|u_t|^2> (FDR-1)
19
+
20
+ Define the dimensionless ratio
21
+
22
+ rho = 2 <w_t . u_t> / (eta <|u_t|^2>)
23
+
24
+ At stationarity ``rho = 1``. Continuing to train at that learning rate buys
25
+ nothing further.
26
+
27
+ Interpretation depends on the optimiser:
28
+
29
+ * **Plain SGD** (``u = grad``): this is exactly the fluctuation-dissipation
30
+ relation of Yaida (arXiv:1810.00004). The left side is dissipation, the right
31
+ side is the fluctuation (second moment of the mini-batch gradient, noise
32
+ included). It assumes neither Gaussian nor isotropic gradient noise -- only
33
+ stationarity. This is the regime where the physical reading holds.
34
+ * **Any other optimiser**: the identity is still algebraically exact and remains
35
+ a valid *stationarity diagnostic*, but the fluctuation-dissipation reading is
36
+ not established. ``physfdt`` reports this distinction rather than hiding it.
37
+
38
+ Two independent signals
39
+ -----------------------
40
+ ``rho`` is a ratio of two smoothed averages, and its denominator
41
+ ``eta <|u|^2>`` can be tiny and dominated by a few samples (near-separable
42
+ classification is the standard example). There ``rho`` swings through large
43
+ positive *and negative* values while the weight norm is perfectly stationary.
44
+ So the sign of ``rho`` is **not** used to decide whether the norm is drifting.
45
+ That verdict comes from the norm itself: backends pass ``|w|^2`` alongside the
46
+ two FDR-1 terms, and the accumulator tests for a trend in it directly. ``rho``
47
+ then answers the narrower question it is good at -- *given* a stationary norm,
48
+ has the FDR balance been reached.
49
+
50
+ Notes
51
+ -----
52
+ The ratio is formed from separately smoothed numerator and denominator
53
+ (ratio of averages), never from an average of per-step ratios: the per-step
54
+ ratio has heavy tails and its mean is not the quantity of interest.
55
+
56
+ All timescales in :class:`FDRConfig` count *measured* steps of the accumulator.
57
+ The PyTorch monitor converts them from optimiser steps when ``every > 1``.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import math
63
+ import warnings
64
+ from collections import deque
65
+ from dataclasses import dataclass
66
+ from typing import Optional
67
+
68
+ __all__ = ["FDRConfig", "FDRState", "FDRAccumulator", "NonStationaryWarning"]
69
+
70
+
71
+ class NonStationaryWarning(RuntimeWarning):
72
+ """Raised once, when the weight norm has been growing for
73
+ ``nonstationary_patience`` consecutive measured steps -- i.e. FDR-1 does
74
+ not (yet) apply to this run.
75
+
76
+ Subclasses ``RuntimeWarning`` so existing ``except RuntimeWarning`` or
77
+ ``filterwarnings("...", RuntimeWarning)`` code keeps working. But prefer
78
+ filtering on ``NonStationaryWarning`` specifically: ``RuntimeWarning`` is
79
+ also the category NumPy itself uses for floating-point warnings (divide
80
+ by zero, overflow, invalid value -- these can come from a BLAS backend on
81
+ perfectly finite input, e.g. a known Apple Accelerate artifact), so
82
+ catching the bare category can silently pick up noise that has nothing to
83
+ do with stationarity.
84
+ """
85
+
86
+ #: Number of |w|^2 samples kept for the trend test. The window spans
87
+ #: ``norm_window_factor * half_life`` measured steps, subsampled to this many
88
+ #: points, so the cost is bounded regardless of half_life.
89
+ _NORM_POINTS = 200
90
+
91
+
92
+ @dataclass
93
+ class FDRConfig:
94
+ """Settings for the equilibrium detector.
95
+
96
+ Parameters
97
+ ----------
98
+ half_life:
99
+ Measured steps over which the exponential moving averages of the two
100
+ FDR-1 terms lose half their weight. Sets the scatter of ``rho``
101
+ (``~ 1/sqrt(half_life)``) and every other timescale below.
102
+ tol:
103
+ ``rho`` counts as balanced when ``|rho - 1| < tol``.
104
+ patience:
105
+ ``|rho - 1| < tol`` must hold for this many consecutive measured steps.
106
+ Default: ``half_life``. Anything much shorter lets ``rho`` qualify while
107
+ it is still drifting through the band at the tail of a transient,
108
+ because the smoothed estimator cannot move appreciably in fewer steps
109
+ than its own half-life.
110
+ min_steps:
111
+ No equilibrium can be declared this soon after a reset. Default:
112
+ ``half_life``.
113
+ norm_window_factor:
114
+ The trend test on ``|w|^2`` looks back over
115
+ ``norm_window_factor * half_life`` measured steps. Longer windows are
116
+ less easily fooled by slow fluctuations; shorter ones react faster.
117
+ norm_z:
118
+ The norm is called drifting when the difference between the means of
119
+ the two halves of the window exceeds ``norm_z`` standard deviations of
120
+ ``|w|^2`` over the window. A pure linear trend scores ``sqrt(3) ~ 1.73``,
121
+ so the default of 1 catches trend-dominated windows and ignores
122
+ fluctuation-dominated ones.
123
+ norm_rel_tol:
124
+ ...and the relative change of ``|w|^2`` across the window must also
125
+ exceed this. Statistical significance alone is not enough: at a
126
+ weight-decay equilibrium ``|w|`` fluctuates so little that a physically
127
+ negligible creep (0.03%) can still score ``z > 1``. Measured on
128
+ separable logistic regression: with no weight decay the late-time trend
129
+ is ~1.5% per window; with weight decay 1e-2 its 95th percentile is
130
+ ~0.36%. The default 0.5% sits between them.
131
+ warn_nonstationary:
132
+ Emit a one-time warning after the norm has been growing for
133
+ ``nonstationary_patience`` consecutive measured steps.
134
+ nonstationary_patience:
135
+ Default: ``4 * half_life``.
136
+ """
137
+
138
+ # Defaults are chosen to be SELF-CONSISTENT: the sampling scatter of rho is
139
+ # ~1/sqrt(half_life) (measured: 3 sigma ~ 0.059 at half_life=200, ~0.025 at
140
+ # 500), so tol must sit comfortably above it or the detector is thresholding
141
+ # its own noise. Check yours with FDRState.tol_is_achievable.
142
+ half_life: int = 500
143
+ tol: float = 0.10
144
+ patience: Optional[int] = None
145
+ min_steps: Optional[int] = None
146
+ norm_window_factor: int = 4
147
+ norm_z: float = 1.0
148
+ norm_rel_tol: float = 0.005
149
+ warn_nonstationary: bool = True
150
+ nonstationary_patience: Optional[int] = None
151
+
152
+ def __post_init__(self) -> None:
153
+ if self.half_life < 1:
154
+ raise ValueError("half_life must be >= 1")
155
+ if self.tol <= 0:
156
+ raise ValueError("tol must be > 0")
157
+ if self.patience is None:
158
+ self.patience = self.half_life
159
+ if self.min_steps is None:
160
+ self.min_steps = self.half_life
161
+ if self.nonstationary_patience is None:
162
+ self.nonstationary_patience = 4 * self.half_life
163
+ if self.patience < 1:
164
+ raise ValueError("patience must be >= 1")
165
+ if self.min_steps < 1:
166
+ raise ValueError("min_steps must be >= 1")
167
+ if self.norm_window_factor < 1:
168
+ raise ValueError("norm_window_factor must be >= 1")
169
+ if self.norm_z <= 0:
170
+ raise ValueError("norm_z must be > 0")
171
+ if self.norm_rel_tol < 0:
172
+ raise ValueError("norm_rel_tol must be >= 0")
173
+
174
+ def rescaled(self, every: int) -> "FDRConfig":
175
+ """Return a copy with every timescale divided by ``every``.
176
+
177
+ Use when the accumulator is fed only one step in ``every``, so that
178
+ ``half_life=500`` keeps meaning 500 *optimiser* steps.
179
+ """
180
+ if every <= 1:
181
+ return self
182
+
183
+ def s(x: int) -> int:
184
+ return max(1, int(round(x / every)))
185
+
186
+ return FDRConfig(
187
+ half_life=s(self.half_life),
188
+ tol=self.tol,
189
+ patience=s(self.patience),
190
+ min_steps=s(self.min_steps),
191
+ norm_window_factor=self.norm_window_factor,
192
+ norm_z=self.norm_z,
193
+ norm_rel_tol=self.norm_rel_tol,
194
+ warn_nonstationary=self.warn_nonstationary,
195
+ nonstationary_patience=s(self.nonstationary_patience),
196
+ )
197
+
198
+
199
+ @dataclass
200
+ class FDRState:
201
+ """One measurement, as returned by :meth:`FDRAccumulator.observe`."""
202
+
203
+ step: int
204
+ steps_since_reset: int
205
+ lhs: float
206
+ """Smoothed dissipation term, ``2 <w . u>``."""
207
+ rhs: float
208
+ """Smoothed fluctuation term, ``eta <|u|^2>``."""
209
+ rho: float
210
+ """``lhs / rhs``. Equals 1 at stationarity."""
211
+ residual: float
212
+ """``lhs - rhs``, in the raw units of the loss."""
213
+ regime: str
214
+ """What the run is doing.
215
+
216
+ ``"norm_growing"`` / ``"norm_shrinking"``
217
+ ``|w|^2`` has a significant trend over the trend window. Not
218
+ equilibrated, whatever ``rho`` says.
219
+ ``"equilibrated"``
220
+ Norm has no significant trend *and* ``|rho - 1| < tol``.
221
+ ``"stationary_noisy"``
222
+ Norm has no significant trend but ``rho`` is outside the band. Usually
223
+ means ``rho`` is too noisy at this ``half_life`` -- compare
224
+ ``rho_std`` with ``tol``. Common in near-separable classification.
225
+ ``"warming_up"``
226
+ Not enough history yet for a norm verdict.
227
+ ``"unknown"``
228
+ ``rho`` undefined (zero denominator).
229
+
230
+ When no ``norm2`` is supplied to :meth:`FDRAccumulator.observe`, the norm
231
+ labels fall back to the sign of ``1 - rho``, which is unreliable when
232
+ ``rho`` is noisy. Both shipped backends supply it.
233
+ """
234
+ norm_trend: float
235
+ """Relative change of ``|w|^2`` between the two halves of the trend window,
236
+ ``(mean_late - mean_early) / mean``. NaN until the window is full."""
237
+ norm_trend_z: float
238
+ """The same difference in units of the window's ``|w|^2`` standard
239
+ deviation. Drifting requires ``|norm_trend_z| > norm_z`` *and*
240
+ ``|norm_trend| > norm_rel_tol``."""
241
+ nonstationary_run: int
242
+ """Consecutive measured steps with the norm judged to be growing. A long run
243
+ with no weight decay usually means there is no stationary state at all:
244
+ cross-entropy on separable data drives ``|w| -> inf`` (Soudry et al., JMLR
245
+ 2018). With weight decay present it more often means a slow approach to a
246
+ larger equilibrium norm, e.g. after a learning-rate decay."""
247
+ rho_std: float
248
+ """Measured scatter of ``rho`` about its own mean over a trailing window --
249
+ the noise floor of the estimator. NaN until the window is full."""
250
+ in_band: bool
251
+ """Whether ``|rho - 1| < tol`` on this step."""
252
+ band_run: int
253
+ """How many consecutive steps ``in_band`` has held."""
254
+ equilibrated: bool
255
+ """Band held for ``patience`` steps, at least ``min_steps`` since reset, and
256
+ the norm is not drifting."""
257
+ _tol: float = 0.0
258
+
259
+ @property
260
+ def tol_is_achievable(self) -> bool:
261
+ """Whether ``tol`` sits above this estimator's own noise floor.
262
+
263
+ False means the band is narrower than the scatter of ``rho``, so
264
+ ``equilibrated`` is being decided by sampling noise. Raise ``half_life``
265
+ (scatter falls as ``1/sqrt(half_life)``) or widen ``tol``.
266
+ """
267
+ if self.rho_std != self.rho_std or self.rho_std == 0.0:
268
+ return True
269
+ return self._tol > 3.0 * self.rho_std
270
+
271
+
272
+ class FDRAccumulator:
273
+ """Exponential moving averages of the two sides of FDR-1, plus a direct
274
+ trend test on the weight norm.
275
+
276
+ This class knows nothing about tensors. Feed it scalars each step. The
277
+ PyTorch and NumPy backends are thin adapters that compute them.
278
+
279
+ Examples
280
+ --------
281
+ >>> acc = FDRAccumulator(FDRConfig(half_life=10, patience=2, min_steps=3))
282
+ >>> for _ in range(20):
283
+ ... s = acc.observe(lhs=2.0, rhs=2.0)
284
+ >>> round(s.rho, 6)
285
+ 1.0
286
+ >>> s.equilibrated
287
+ True
288
+ """
289
+
290
+ def __init__(self, config: Optional[FDRConfig] = None) -> None:
291
+ self.config = config or FDRConfig()
292
+ self._decay = 0.5 ** (1.0 / self.config.half_life)
293
+ span = self.config.norm_window_factor * self.config.half_life
294
+ self._norm_stride = max(1, span // _NORM_POINTS)
295
+ # Number of points the window actually holds. When span >= _NORM_POINTS
296
+ # this is _NORM_POINTS (bounded cost, subsampled by stride above). When
297
+ # span < _NORM_POINTS (small half_life), stride is 1 and the window must
298
+ # hold only `span` points -- NOT _NORM_POINTS. Getting this wrong means
299
+ # the window needs _NORM_POINTS=200 raw ticks to fill no matter how
300
+ # small half_life is, silently overriding the documented contract that
301
+ # the trend test looks back over `norm_window_factor * half_life`
302
+ # measured steps, and making `min_steps`/`patience` unable to produce a
303
+ # verdict faster than 200 steps even when set to 1.
304
+ self._norm_win_len = max(2, min(_NORM_POINTS, span // self._norm_stride))
305
+ self.step = 0
306
+ self._warned = False
307
+ self.reset()
308
+
309
+ # ------------------------------------------------------------------
310
+ def reset(self) -> None:
311
+ """Clear all history.
312
+
313
+ Call this whenever the learning rate changes: FDR-1 is a statement
314
+ about the stationary state *at a given eta*, so the old history is
315
+ about a different equilibrium and must not be mixed in.
316
+ """
317
+ self._s_lhs = 0.0
318
+ self._s_rhs = 0.0
319
+ self._n = 0
320
+ self._band_run = 0
321
+ self._nonstat_run = 0
322
+ self._rho_window: deque = deque(
323
+ maxlen=min(20 * self.config.half_life, 20_000)
324
+ )
325
+ self._w_s1 = 0.0
326
+ self._w_s2 = 0.0
327
+ self._norm_win: deque = deque(maxlen=self._norm_win_len)
328
+ self._norm_tick = 0
329
+ self._norm_trend = float("nan")
330
+ self._norm_z = float("nan")
331
+ self._saw_norm = False
332
+ self.steps_since_reset = 0
333
+
334
+ # ------------------------------------------------------------------
335
+ def _update_norm(self, norm2: float) -> None:
336
+ self._saw_norm = True
337
+ self._norm_tick += 1
338
+ if self._norm_tick % self._norm_stride:
339
+ return
340
+ self._norm_win.append(float(norm2))
341
+ win = self._norm_win
342
+ if len(win) < win.maxlen:
343
+ return
344
+ vals = list(win)
345
+ half = len(vals) // 2
346
+ m1 = sum(vals[:half]) / half
347
+ m2 = sum(vals[half:]) / (len(vals) - half)
348
+ mean = (m1 + m2) / 2.0
349
+ var = sum((v - mean) ** 2 for v in vals) / (len(vals) - 1)
350
+ sd = math.sqrt(var) if var > 0 else 0.0
351
+ self._norm_trend = (m2 - m1) / mean if mean > 0 else float("nan")
352
+ if sd > 0:
353
+ self._norm_z = (m2 - m1) / sd
354
+ else:
355
+ self._norm_z = 0.0 if m2 == m1 else math.copysign(math.inf, m2 - m1)
356
+
357
+ # ------------------------------------------------------------------
358
+ def observe(self, lhs: float, rhs: float, norm2: Optional[float] = None) -> FDRState:
359
+ """Record one measurement and update the state.
360
+
361
+ Parameters
362
+ ----------
363
+ lhs:
364
+ ``2 * sum_p (w_p . u_p)`` summed over all parameters.
365
+ rhs:
366
+ ``sum_p eta_p * |u_p|^2`` summed over all parameters. ``eta`` sits
367
+ inside the sum so per-group learning rates are handled correctly.
368
+ norm2:
369
+ ``|w|^2`` before the step. Strongly recommended: it is what decides
370
+ whether the norm is drifting. Without it the regime falls back to
371
+ the sign of ``1 - rho``, which noise can flip.
372
+ """
373
+ cfg = self.config
374
+ d = self._decay
375
+ self._s_lhs = d * self._s_lhs + (1.0 - d) * float(lhs)
376
+ self._s_rhs = d * self._s_rhs + (1.0 - d) * float(rhs)
377
+ self._n += 1
378
+ self.step += 1
379
+ self.steps_since_reset += 1
380
+
381
+ bias = 1.0 - d**self._n
382
+ lhs_hat = self._s_lhs / bias
383
+ rhs_hat = self._s_rhs / bias
384
+ rho = lhs_hat / rhs_hat if rhs_hat != 0.0 else float("nan")
385
+
386
+ # --- noise floor of rho (trailing window, O(1) running sums) -------
387
+ if rho == rho:
388
+ win = self._rho_window
389
+ x = rho - 1.0
390
+ if len(win) == win.maxlen:
391
+ old = win[0]
392
+ self._w_s1 -= old
393
+ self._w_s2 -= old * old
394
+ win.append(x)
395
+ self._w_s1 += x
396
+ self._w_s2 += x * x
397
+ n = len(self._rho_window)
398
+ if n == self._rho_window.maxlen and n > 1:
399
+ var = (self._w_s2 - self._w_s1 * self._w_s1 / n) / (n - 1)
400
+ rho_std = var**0.5 if var > 0.0 else 0.0
401
+ else:
402
+ rho_std = float("nan")
403
+
404
+ # --- norm trend ------------------------------------------------------
405
+ if norm2 is not None:
406
+ self._update_norm(norm2)
407
+
408
+ in_band = (rho == rho) and abs(rho - 1.0) < cfg.tol
409
+ self._band_run = self._band_run + 1 if in_band else 0
410
+
411
+ # --- regime ----------------------------------------------------------
412
+ if rho != rho:
413
+ regime = "unknown"
414
+ drifting = None
415
+ elif self._saw_norm:
416
+ z, rel = self._norm_z, self._norm_trend
417
+ big = rel == rel and abs(rel) > cfg.norm_rel_tol
418
+ if z != z:
419
+ regime, drifting = "warming_up", None
420
+ elif z > cfg.norm_z and big:
421
+ regime, drifting = "norm_growing", "up"
422
+ elif z < -cfg.norm_z and big:
423
+ regime, drifting = "norm_shrinking", "down"
424
+ elif in_band:
425
+ regime, drifting = "equilibrated", "no"
426
+ else:
427
+ regime, drifting = "stationary_noisy", "no"
428
+ else:
429
+ # Fallback: E[d|w|^2] = eta^2 <|u|^2> (1 - rho). Reliable only
430
+ # when rho is well above its noise floor.
431
+ drifting = "no" if in_band else ("up" if rho < 1.0 else "down")
432
+ regime = {
433
+ "no": "equilibrated",
434
+ "up": "norm_growing",
435
+ "down": "norm_shrinking",
436
+ }[drifting]
437
+
438
+ self._nonstat_run = self._nonstat_run + 1 if drifting == "up" else 0
439
+ if (
440
+ cfg.warn_nonstationary
441
+ and not self._warned
442
+ and self._nonstat_run >= cfg.nonstationary_patience
443
+ ):
444
+ self._warned = True
445
+ trend = (
446
+ f" (|w|^2 up {100 * self._norm_trend:.1f}% across the trend window)"
447
+ if self._norm_trend == self._norm_trend
448
+ else ""
449
+ )
450
+ warnings.warn(
451
+ f"The weight norm has been growing for {self._nonstat_run} "
452
+ f"consecutive measured steps{trend}, so this run is not stationary "
453
+ f"and FDR-1 does not yet apply. Two common causes: (1) no weight "
454
+ f"decay with cross-entropy on separable data -- max-margin "
455
+ f"dynamics drives |w| -> inf and there is NO stationary state; add "
456
+ f"weight decay. (2) weight decay present but the equilibrium norm "
457
+ f"has moved, e.g. after a learning-rate decay -- this is a "
458
+ f"transient; train longer. Silence with "
459
+ f"FDRConfig(warn_nonstationary=False).",
460
+ NonStationaryWarning,
461
+ stacklevel=3,
462
+ )
463
+
464
+ equilibrated = (
465
+ self._band_run >= cfg.patience
466
+ and self.steps_since_reset >= cfg.min_steps
467
+ and (drifting == "no" if self._saw_norm else True)
468
+ )
469
+
470
+ return FDRState(
471
+ step=self.step,
472
+ steps_since_reset=self.steps_since_reset,
473
+ lhs=lhs_hat,
474
+ rhs=rhs_hat,
475
+ rho=rho,
476
+ residual=lhs_hat - rhs_hat,
477
+ regime=regime,
478
+ norm_trend=self._norm_trend,
479
+ norm_trend_z=self._norm_z,
480
+ nonstationary_run=self._nonstat_run,
481
+ rho_std=rho_std,
482
+ in_band=in_band,
483
+ band_run=self._band_run,
484
+ equilibrated=equilibrated,
485
+ _tol=cfg.tol,
486
+ )
@@ -0,0 +1,77 @@
1
+ """NumPy backend.
2
+
3
+ Useful for toy models, for the analytic validation in ``tests/`` and
4
+ ``examples/01_validate_quadratic.py``, and for any optimiser you write by hand.
5
+
6
+ You supply ``w`` (parameters *before* the step), ``u`` (the update direction
7
+ before the learning rate multiplies it) and ``eta``. Everything else is handled
8
+ by :class:`physfdt.core.FDRAccumulator`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Optional, Sequence, Union
14
+
15
+ import numpy as np
16
+
17
+ from .core import FDRAccumulator, FDRConfig, FDRState
18
+
19
+ __all__ = ["NumpyFDRMonitor", "fdr_terms"]
20
+
21
+ ArrayLike = Union[np.ndarray, Sequence[np.ndarray]]
22
+
23
+
24
+ def _flatten(x: ArrayLike) -> np.ndarray:
25
+ if isinstance(x, np.ndarray):
26
+ return x.ravel()
27
+ return np.concatenate([np.asarray(a).ravel() for a in x])
28
+
29
+
30
+ def fdr_terms(w: ArrayLike, u: ArrayLike, eta: float) -> tuple[float, float]:
31
+ """Return ``(lhs, rhs)`` of FDR-1 for one step.
32
+
33
+ ``lhs = 2 * (w . u)`` and ``rhs = eta * |u|^2``, following the convention
34
+ ``w_next = w - eta * u``.
35
+ """
36
+ wf = _flatten(w)
37
+ uf = _flatten(u)
38
+ if wf.shape != uf.shape:
39
+ raise ValueError(f"w and u must have the same size, got {wf.shape} vs {uf.shape}")
40
+ lhs = 2.0 * float(np.dot(wf, uf))
41
+ rhs = float(eta) * float(np.dot(uf, uf))
42
+ return lhs, rhs
43
+
44
+
45
+ class NumpyFDRMonitor:
46
+ """Thin NumPy adapter around :class:`~physfdt.core.FDRAccumulator`.
47
+
48
+ Examples
49
+ --------
50
+ >>> import numpy as np
51
+ >>> mon = NumpyFDRMonitor(FDRConfig(half_life=50, min_steps=10, patience=5))
52
+ >>> w = np.array([1.0, 1.0])
53
+ >>> eta = 0.05
54
+ >>> rng = np.random.default_rng(0)
55
+ >>> for _ in range(4000):
56
+ ... u = w + 0.3 * rng.standard_normal(2) # grad of 0.5|w|^2 plus noise
57
+ ... state = mon.observe(w, u, eta)
58
+ ... w = w - eta * u
59
+ >>> bool(abs(state.rho - 1.0) < 0.1)
60
+ True
61
+ """
62
+
63
+ def __init__(self, config: Optional[FDRConfig] = None) -> None:
64
+ self.acc = FDRAccumulator(config)
65
+
66
+ def observe(self, w: ArrayLike, u: ArrayLike, eta: float) -> FDRState:
67
+ lhs, rhs = fdr_terms(w, u, eta)
68
+ wf = _flatten(w)
69
+ return self.acc.observe(lhs, rhs, norm2=float(np.dot(wf, wf)))
70
+
71
+ def reset(self) -> None:
72
+ """Clear history. Call after every learning-rate change."""
73
+ self.acc.reset()
74
+
75
+ @property
76
+ def config(self) -> FDRConfig:
77
+ return self.acc.config