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 +69 -0
- physfdt/core.py +486 -0
- physfdt/numpy_backend.py +77 -0
- physfdt/spectral.py +208 -0
- physfdt/torch_backend.py +305 -0
- physfdt/trace.py +91 -0
- physfdt-0.3.1.dist-info/METADATA +316 -0
- physfdt-0.3.1.dist-info/RECORD +11 -0
- physfdt-0.3.1.dist-info/WHEEL +5 -0
- physfdt-0.3.1.dist-info/licenses/LICENSE +21 -0
- physfdt-0.3.1.dist-info/top_level.txt +1 -0
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
|
+
)
|
physfdt/numpy_backend.py
ADDED
|
@@ -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
|