physfdt 0.3.1__tar.gz
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-0.3.1/LICENSE +21 -0
- physfdt-0.3.1/PKG-INFO +316 -0
- physfdt-0.3.1/README.md +290 -0
- physfdt-0.3.1/pyproject.toml +38 -0
- physfdt-0.3.1/setup.cfg +4 -0
- physfdt-0.3.1/src/physfdt/__init__.py +69 -0
- physfdt-0.3.1/src/physfdt/core.py +486 -0
- physfdt-0.3.1/src/physfdt/numpy_backend.py +77 -0
- physfdt-0.3.1/src/physfdt/spectral.py +208 -0
- physfdt-0.3.1/src/physfdt/torch_backend.py +305 -0
- physfdt-0.3.1/src/physfdt/trace.py +91 -0
- physfdt-0.3.1/src/physfdt.egg-info/PKG-INFO +316 -0
- physfdt-0.3.1/src/physfdt.egg-info/SOURCES.txt +18 -0
- physfdt-0.3.1/src/physfdt.egg-info/dependency_links.txt +1 -0
- physfdt-0.3.1/src/physfdt.egg-info/requires.txt +8 -0
- physfdt-0.3.1/src/physfdt.egg-info/top_level.txt +1 -0
- physfdt-0.3.1/tests/test_core_and_spectral.py +176 -0
- physfdt-0.3.1/tests/test_nonstationary_regime.py +295 -0
- physfdt-0.3.1/tests/test_torch_backend.py +248 -0
- physfdt-0.3.1/tests/test_validation_quadratic.py +128 -0
physfdt-0.3.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Quang Nguyen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
physfdt-0.3.1/PKG-INFO
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: physfdt
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Fluctuation-dissipation diagnostics for stochastic gradient descent
|
|
5
|
+
Author: Quang Nguyen
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/platalise/physfdt
|
|
8
|
+
Project-URL: Issues, https://github.com/platalise/physfdt/issues
|
|
9
|
+
Keywords: deep-learning,statistical-physics,fluctuation-dissipation,learning-rate-schedule,random-matrix-theory,sgd
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: numpy>=1.21
|
|
20
|
+
Provides-Extra: torch
|
|
21
|
+
Requires-Dist: torch>=1.13; extra == "torch"
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
24
|
+
Requires-Dist: torch>=1.13; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# physfdt
|
|
28
|
+
|
|
29
|
+
**Fluctuation–dissipation diagnostics for stochastic gradient descent.**
|
|
30
|
+
|
|
31
|
+
`physfdt` answers one question during training: *has this run equilibrated at
|
|
32
|
+
the current learning rate?* It answers it from the optimiser's own arithmetic —
|
|
33
|
+
no held-out validation split, no assumption about the shape of the gradient
|
|
34
|
+
noise.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from physfdt import FDRMonitor, FDRConfig, FDREquilibriumLR
|
|
38
|
+
|
|
39
|
+
monitor = FDRMonitor(optimizer, FDRConfig(half_life=500))
|
|
40
|
+
sched = FDREquilibriumLR(optimizer, monitor, factor=0.5)
|
|
41
|
+
|
|
42
|
+
loss.backward()
|
|
43
|
+
with monitor.measure():
|
|
44
|
+
optimizer.step()
|
|
45
|
+
sched.step() # decays the LR when rho settles at 1
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## The relation
|
|
51
|
+
|
|
52
|
+
Any first-order optimiser can be written `w_{t+1} = w_t − η·u_t`, where `u_t` is
|
|
53
|
+
whatever direction it produces before the learning rate is applied. Take the
|
|
54
|
+
observable `O(w) = ½|w|²` and require its expectation to be stationary:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
|w_{t+1}|² = |w_t|² − 2η ⟨w_t · u_t⟩ + η² ⟨|u_t|²⟩
|
|
58
|
+
|
|
59
|
+
⟹ 2⟨w · u⟩ = η ⟨|u|²⟩ (FDR-1)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Define
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
ρ = 2⟨w · u⟩ / (η ⟨|u|²⟩)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
At stationarity `ρ = 1` exactly: the system has equilibrated at that η, and
|
|
69
|
+
further steps at that η buy nothing. (The converse needs care — see *Reading ρ*
|
|
70
|
+
below.) `physfdt` tracks `ρ` with exponentially smoothed numerator and denominator (a ratio of
|
|
71
|
+
averages, never an average of ratios) and reports when it settles.
|
|
72
|
+
|
|
73
|
+
For **plain SGD** (`u = g`) this is the fluctuation–dissipation relation of
|
|
74
|
+
Yaida, [arXiv:1810.00004](https://arxiv.org/abs/1810.00004): the left side is
|
|
75
|
+
dissipation, the right side is the second moment of the mini-batch gradient,
|
|
76
|
+
noise included. It assumes neither Gaussian nor isotropic noise — only
|
|
77
|
+
stationarity. That is what makes it different from effective-temperature
|
|
78
|
+
definitions that need a noise model.
|
|
79
|
+
|
|
80
|
+
For **other optimisers** the identity remains algebraically exact and is still a
|
|
81
|
+
valid stationarity diagnostic, but the fluctuation–dissipation reading is not
|
|
82
|
+
established. `physfdt` warns rather than hides this.
|
|
83
|
+
|
|
84
|
+
## Reading ρ
|
|
85
|
+
|
|
86
|
+
The same algebra gives an exact drift law, `E[Δ|w|²] = η²⟨|u|²⟩(1 − ρ)`. It
|
|
87
|
+
is tempting to read the norm's behaviour off the sign of `1 − ρ`. **Don't.**
|
|
88
|
+
ρ is a ratio whose denominator `η⟨|u|²⟩` can be tiny and dominated by a few
|
|
89
|
+
samples; measured on near-separable logistic regression with weight decay, ρ
|
|
90
|
+
was negative on 16,451 of 40,000 steps while `|w|` sat at 3.67 to three
|
|
91
|
+
decimals. So `physfdt` takes the verdict on the norm from `|w|²` directly — a
|
|
92
|
+
trend test over the last `4 × half_life` steps that must be both statistically
|
|
93
|
+
significant (`norm_z`) and physically non-negligible (`norm_rel_tol`, 0.5%) —
|
|
94
|
+
and uses ρ only for what it is good at: *given* a stationary norm, has the FDR
|
|
95
|
+
balance been reached.
|
|
96
|
+
|
|
97
|
+
| `regime` | meaning |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `norm_growing` / `norm_shrinking` | `|w|²` is trending. Not equilibrated, whatever ρ says. |
|
|
100
|
+
| `equilibrated` | norm flat **and** `|ρ − 1| < tol` for `patience` steps |
|
|
101
|
+
| `stationary_noisy` | norm flat but ρ outside the band — ρ is too noisy at this `half_life`; compare `rho_std` with `tol` |
|
|
102
|
+
| `warming_up` | not enough history for a norm verdict yet |
|
|
103
|
+
|
|
104
|
+
### If the norm keeps growing
|
|
105
|
+
|
|
106
|
+
Two different situations produce a sustained `norm_growing`, and they need
|
|
107
|
+
opposite responses:
|
|
108
|
+
|
|
109
|
+
- **No weight decay, cross-entropy.** Once the data is separated, max-margin
|
|
110
|
+
dynamics drives `|w| → ∞`
|
|
111
|
+
([Soudry et al., JMLR 2018](https://jmlr.org/papers/v19/18-188.html)). There
|
|
112
|
+
is **no stationary state**; FDR-1 never applies. Add weight decay.
|
|
113
|
+
- **Weight decay present.** The equilibrium norm exists but has moved — for
|
|
114
|
+
instance after a learning-rate decay, or when starting from a small
|
|
115
|
+
initialisation. This is a transient. Train longer.
|
|
116
|
+
|
|
117
|
+
Measured on logistic regression over separable data, 40k steps:
|
|
118
|
+
|
|
119
|
+
| weight decay | \|w\| start → end | final ρ | |
|
|
120
|
+
|---|---|---|---|
|
|
121
|
+
| 0 | 0.46 → **11.07** | **−14294** | diverging, no stationary state |
|
|
122
|
+
| 1e-4 | 0.46 → 10.15 | −4614 | diverging |
|
|
123
|
+
| 1e-3 | 0.46 → 6.90 | 0.39 | not yet settled |
|
|
124
|
+
| **1e-2** | 0.46 → **3.67** | **1.07** | stationary |
|
|
125
|
+
| **5e-2** | 0.46 → **2.07** | **1.02** | stationary |
|
|
126
|
+
|
|
127
|
+
`physfdt` raises a one-time `RuntimeWarning` naming both causes once the norm
|
|
128
|
+
has grown for `nonstationary_patience` (default `4 × half_life`) steps.
|
|
129
|
+
|
|
130
|
+
### Check `tol` against the noise floor
|
|
131
|
+
|
|
132
|
+
ρ is an exponentially smoothed estimator, so it has sampling scatter of order
|
|
133
|
+
`1/√half_life`. If `tol` is below that scatter, `equilibrated` is being decided
|
|
134
|
+
by noise — and the detector typically never fires at all. Every state reports
|
|
135
|
+
its own measured noise floor:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
state.rho_std # measured scatter of rho
|
|
139
|
+
state.tol_is_achievable # False => tol is below 3 * rho_std
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Measured on the solvable quadratic:
|
|
143
|
+
|
|
144
|
+
| `half_life` | `rho_std` | 3σ | `tol=0.05` usable? |
|
|
145
|
+
|---|---|---|---|
|
|
146
|
+
| 100 | 0.036 | 0.107 | no |
|
|
147
|
+
| 200 | 0.018 | 0.055 | no |
|
|
148
|
+
| 500 | 0.0077 | 0.023 | yes |
|
|
149
|
+
| 2000 | 0.0019 | 0.006 | yes |
|
|
150
|
+
|
|
151
|
+
The defaults (`half_life=500`, `tol=0.10`) are self-consistent by construction.
|
|
152
|
+
Badly conditioned problems need much more smoothing: near-separable
|
|
153
|
+
classification collapses most per-sample gradients, leaving `⟨|u|²⟩` small and
|
|
154
|
+
dominated by a few hard examples, and ρ can carry O(1) scatter there. Check
|
|
155
|
+
`tol_is_achievable` before believing `equilibrated`.
|
|
156
|
+
|
|
157
|
+
## Attribution
|
|
158
|
+
|
|
159
|
+
The FDR relations and the adaptive scheduler built on them are Yaida's, not
|
|
160
|
+
ours. This package exists to make them easy to measure and to reproduce, and to
|
|
161
|
+
pair them with the spectral observables (`α`, IPR) from
|
|
162
|
+
[Physica A **692** (2026) 131474](https://doi.org/10.1016/j.physa.2026.131474).
|
|
163
|
+
If you use the scheduler, cite Yaida.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Install
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
pip install physfdt # core, NumPy only
|
|
171
|
+
pip install "physfdt[torch]" # + the PyTorch monitor and scheduler
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
From source:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
git clone https://github.com/platalise/physfdt && cd physfdt
|
|
178
|
+
pip install -e ".[dev]"
|
|
179
|
+
pytest -q
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The core has **no torch dependency**. `import physfdt` works in a NumPy-only
|
|
183
|
+
environment; the torch symbols are imported lazily.
|
|
184
|
+
|
|
185
|
+
## Validate it before you trust it
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
python examples/01_validate_quadratic.py
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Runs SGD on `L(w) = ½ wᵀAw` with noisy gradients. That system is an
|
|
192
|
+
Ornstein–Uhlenbeck process whose stationary covariance provably satisfies FDR-1,
|
|
193
|
+
so `ρ → 1` is an exact prediction rather than a fit. Expected output:
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
eta mean rho (last 20k) std verdict
|
|
197
|
+
0.005 1.0002 0.0204 OK
|
|
198
|
+
0.010 1.0000 0.0119 OK
|
|
199
|
+
0.020 0.9999 0.0055 OK
|
|
200
|
+
0.050 1.0000 0.0024 OK
|
|
201
|
+
0.100 1.0000 0.0011 OK
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Part 2 of the same script starts far from the minimum (`ρ ≈ 36`), watches the
|
|
205
|
+
detector fire four learning-rate cuts, and shows `ρ` returning to 1 after each.
|
|
206
|
+
|
|
207
|
+
## Monitor a real run
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
python examples/02_torch_quickstart.py # offline, synthetic
|
|
211
|
+
python examples/02_torch_quickstart.py --dataset mnist # real data
|
|
212
|
+
python examples/02_torch_quickstart.py --schedule fdr # detector drives the LR
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Writes a CSV with one row per measured step: `rho`, both sides of FDR-1, the
|
|
216
|
+
learning rate, the loss, and the spectral exponent `α` of a probe layer.
|
|
217
|
+
|
|
218
|
+
## Benchmark it honestly
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
python examples/03_benchmark_arms.py --seeds 5
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Four arms — constant, **tuned** cosine, ReduceLROnPlateau, FDR — on an identical
|
|
225
|
+
learning-rate tuning grid, with the monitor's overhead measured and subtracted.
|
|
226
|
+
The script prints three pre-registered failure criteria and the numbers needed
|
|
227
|
+
to check them. Read them before you read the result.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## API
|
|
232
|
+
|
|
233
|
+
| | |
|
|
234
|
+
|---|---|
|
|
235
|
+
| `FDRConfig(half_life, tol, patience, min_steps)` | detector settings; defaults are self-consistent (see *Reading ρ*) |
|
|
236
|
+
| `state.regime`, `state.norm_trend`, `state.rho_std`, `state.tol_is_achievable` | is this measurement meaningful? |
|
|
237
|
+
| `FDRMonitor(optimizer, config, mode, every)` | torch monitor; `mode="delta"` (any optimiser) or `"grad"` (plain SGD, zero extra memory). Config timescales are in **optimiser** steps whatever `every` is. |
|
|
238
|
+
| `FDREquilibriumLR(optimizer, monitor, factor, min_lr)` | decay on equilibrium |
|
|
239
|
+
| `NumpyFDRMonitor(config)` | toy models and hand-written optimisers |
|
|
240
|
+
| `spectral_report(W, method="mle"\|"window")` | `α`, its standard error, R², IPR |
|
|
241
|
+
| `TraceWriter(path, extra=[...])` | CSV trace, one row per measured step |
|
|
242
|
+
|
|
243
|
+
**Cost.** One pass over the parameters per measured step, plus one
|
|
244
|
+
parameter-sized buffer in `delta` mode. **Measured: at `every=1` the monitor took
|
|
245
|
+
~39% of wall clock** on a small MLP on CPU — the per-step tensor work is
|
|
246
|
+
comparable to the training step itself when the model is tiny. Use `every=20`
|
|
247
|
+
(the benchmark default) to amortise it to ~2%. If you are benchmarking
|
|
248
|
+
wall-clock or energy, keep the monitor on in the baseline arm too
|
|
249
|
+
(`--monitor-in-baseline`), or subtract it explicitly.
|
|
250
|
+
|
|
251
|
+
**Always `reset()` after a learning-rate change.** FDR-1 describes the stationary
|
|
252
|
+
state *at a given η*; mixing histories across learning rates is meaningless.
|
|
253
|
+
`FDREquilibriumLR` does this for you.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Scope and limitations
|
|
258
|
+
|
|
259
|
+
Read this before putting a number in a paper.
|
|
260
|
+
|
|
261
|
+
- **It measures stationarity, not progress.** `ρ = 1` says the weight norm has
|
|
262
|
+
stopped drifting at this η. It does not say the loss is low or that the model
|
|
263
|
+
generalises.
|
|
264
|
+
- **Single-epoch pre-training is out of scope.** Large language models are
|
|
265
|
+
trained under a fixed compute budget for roughly one pass, and stop because the
|
|
266
|
+
budget ran out, not because they equilibrated. Such a run is never stationary
|
|
267
|
+
and FDR-1 does not apply to it. The regime where this tool is useful is
|
|
268
|
+
**multi-epoch fine-tuning on small data** — where runs do reach stationarity,
|
|
269
|
+
and where a held-out split is expensive precisely because labelled data is
|
|
270
|
+
scarce.
|
|
271
|
+
- **Momentum and Adam.** The identity holds; the physics does not transfer
|
|
272
|
+
unchanged. Treat `ρ` as a stationarity diagnostic there, not as a measured
|
|
273
|
+
effective temperature.
|
|
274
|
+
- **It has hyperparameters.** `tol`, `patience` and `half_life` are real choices,
|
|
275
|
+
and they are *coupled*: `tol` must sit above the `1/√half_life` noise floor or
|
|
276
|
+
the detector never fires. A method that needs them retuned per workload is not
|
|
277
|
+
hyperparameter-free, and its net saving is smaller than a naive comparison
|
|
278
|
+
suggests. This is failure criterion F2 in the benchmark script.
|
|
279
|
+
- **It needs a confining term.** Plain cross-entropy with no weight decay has no
|
|
280
|
+
stationary state at all. See *Reading ρ* above.
|
|
281
|
+
- **Small `η` inflates the smoothing timescale.** After a decay, allow at least
|
|
282
|
+
a few `half_life` before trusting `ρ` again. `min_steps` enforces a floor.
|
|
283
|
+
- **No claim of compute savings is made here.** Whether FDR-triggered decay beats
|
|
284
|
+
a properly tuned cosine schedule is an empirical question that
|
|
285
|
+
`examples/03_benchmark_arms.py` exists to settle, in either direction.
|
|
286
|
+
|
|
287
|
+
## Tests
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
pytest -q # 51 tests (2 need torch)
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
The physics tests are the ones that matter. `tests/test_validation_quadratic.py`
|
|
294
|
+
checks `ρ → 1` against a case with a closed-form answer, across five learning
|
|
295
|
+
rates, three noise magnitudes, and — the one that matters most — strongly
|
|
296
|
+
anisotropic noise, where model-based effective temperatures break down.
|
|
297
|
+
`tests/test_nonstationary_regime.py` pins the opposite case: that a run with no
|
|
298
|
+
stationary state is detected and named rather than silently mis-reported.
|
|
299
|
+
|
|
300
|
+
## Citing
|
|
301
|
+
|
|
302
|
+
```bibtex
|
|
303
|
+
@software{physfdt,
|
|
304
|
+
title = {physfdt: fluctuation-dissipation diagnostics for SGD},
|
|
305
|
+
author = {Nguyen, Quang},
|
|
306
|
+
year = {2026},
|
|
307
|
+
url = {https://github.com/platalise/physfdt}
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Please also cite Yaida (arXiv:1810.00004) for the FDR relations and the
|
|
312
|
+
scheduler.
|
|
313
|
+
|
|
314
|
+
## Licence
|
|
315
|
+
|
|
316
|
+
MIT.
|
physfdt-0.3.1/README.md
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# physfdt
|
|
2
|
+
|
|
3
|
+
**Fluctuation–dissipation diagnostics for stochastic gradient descent.**
|
|
4
|
+
|
|
5
|
+
`physfdt` answers one question during training: *has this run equilibrated at
|
|
6
|
+
the current learning rate?* It answers it from the optimiser's own arithmetic —
|
|
7
|
+
no held-out validation split, no assumption about the shape of the gradient
|
|
8
|
+
noise.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from physfdt import FDRMonitor, FDRConfig, FDREquilibriumLR
|
|
12
|
+
|
|
13
|
+
monitor = FDRMonitor(optimizer, FDRConfig(half_life=500))
|
|
14
|
+
sched = FDREquilibriumLR(optimizer, monitor, factor=0.5)
|
|
15
|
+
|
|
16
|
+
loss.backward()
|
|
17
|
+
with monitor.measure():
|
|
18
|
+
optimizer.step()
|
|
19
|
+
sched.step() # decays the LR when rho settles at 1
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## The relation
|
|
25
|
+
|
|
26
|
+
Any first-order optimiser can be written `w_{t+1} = w_t − η·u_t`, where `u_t` is
|
|
27
|
+
whatever direction it produces before the learning rate is applied. Take the
|
|
28
|
+
observable `O(w) = ½|w|²` and require its expectation to be stationary:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
|w_{t+1}|² = |w_t|² − 2η ⟨w_t · u_t⟩ + η² ⟨|u_t|²⟩
|
|
32
|
+
|
|
33
|
+
⟹ 2⟨w · u⟩ = η ⟨|u|²⟩ (FDR-1)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Define
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
ρ = 2⟨w · u⟩ / (η ⟨|u|²⟩)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
At stationarity `ρ = 1` exactly: the system has equilibrated at that η, and
|
|
43
|
+
further steps at that η buy nothing. (The converse needs care — see *Reading ρ*
|
|
44
|
+
below.) `physfdt` tracks `ρ` with exponentially smoothed numerator and denominator (a ratio of
|
|
45
|
+
averages, never an average of ratios) and reports when it settles.
|
|
46
|
+
|
|
47
|
+
For **plain SGD** (`u = g`) this is the fluctuation–dissipation relation of
|
|
48
|
+
Yaida, [arXiv:1810.00004](https://arxiv.org/abs/1810.00004): the left side is
|
|
49
|
+
dissipation, the right side is the second moment of the mini-batch gradient,
|
|
50
|
+
noise included. It assumes neither Gaussian nor isotropic noise — only
|
|
51
|
+
stationarity. That is what makes it different from effective-temperature
|
|
52
|
+
definitions that need a noise model.
|
|
53
|
+
|
|
54
|
+
For **other optimisers** the identity remains algebraically exact and is still a
|
|
55
|
+
valid stationarity diagnostic, but the fluctuation–dissipation reading is not
|
|
56
|
+
established. `physfdt` warns rather than hides this.
|
|
57
|
+
|
|
58
|
+
## Reading ρ
|
|
59
|
+
|
|
60
|
+
The same algebra gives an exact drift law, `E[Δ|w|²] = η²⟨|u|²⟩(1 − ρ)`. It
|
|
61
|
+
is tempting to read the norm's behaviour off the sign of `1 − ρ`. **Don't.**
|
|
62
|
+
ρ is a ratio whose denominator `η⟨|u|²⟩` can be tiny and dominated by a few
|
|
63
|
+
samples; measured on near-separable logistic regression with weight decay, ρ
|
|
64
|
+
was negative on 16,451 of 40,000 steps while `|w|` sat at 3.67 to three
|
|
65
|
+
decimals. So `physfdt` takes the verdict on the norm from `|w|²` directly — a
|
|
66
|
+
trend test over the last `4 × half_life` steps that must be both statistically
|
|
67
|
+
significant (`norm_z`) and physically non-negligible (`norm_rel_tol`, 0.5%) —
|
|
68
|
+
and uses ρ only for what it is good at: *given* a stationary norm, has the FDR
|
|
69
|
+
balance been reached.
|
|
70
|
+
|
|
71
|
+
| `regime` | meaning |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `norm_growing` / `norm_shrinking` | `|w|²` is trending. Not equilibrated, whatever ρ says. |
|
|
74
|
+
| `equilibrated` | norm flat **and** `|ρ − 1| < tol` for `patience` steps |
|
|
75
|
+
| `stationary_noisy` | norm flat but ρ outside the band — ρ is too noisy at this `half_life`; compare `rho_std` with `tol` |
|
|
76
|
+
| `warming_up` | not enough history for a norm verdict yet |
|
|
77
|
+
|
|
78
|
+
### If the norm keeps growing
|
|
79
|
+
|
|
80
|
+
Two different situations produce a sustained `norm_growing`, and they need
|
|
81
|
+
opposite responses:
|
|
82
|
+
|
|
83
|
+
- **No weight decay, cross-entropy.** Once the data is separated, max-margin
|
|
84
|
+
dynamics drives `|w| → ∞`
|
|
85
|
+
([Soudry et al., JMLR 2018](https://jmlr.org/papers/v19/18-188.html)). There
|
|
86
|
+
is **no stationary state**; FDR-1 never applies. Add weight decay.
|
|
87
|
+
- **Weight decay present.** The equilibrium norm exists but has moved — for
|
|
88
|
+
instance after a learning-rate decay, or when starting from a small
|
|
89
|
+
initialisation. This is a transient. Train longer.
|
|
90
|
+
|
|
91
|
+
Measured on logistic regression over separable data, 40k steps:
|
|
92
|
+
|
|
93
|
+
| weight decay | \|w\| start → end | final ρ | |
|
|
94
|
+
|---|---|---|---|
|
|
95
|
+
| 0 | 0.46 → **11.07** | **−14294** | diverging, no stationary state |
|
|
96
|
+
| 1e-4 | 0.46 → 10.15 | −4614 | diverging |
|
|
97
|
+
| 1e-3 | 0.46 → 6.90 | 0.39 | not yet settled |
|
|
98
|
+
| **1e-2** | 0.46 → **3.67** | **1.07** | stationary |
|
|
99
|
+
| **5e-2** | 0.46 → **2.07** | **1.02** | stationary |
|
|
100
|
+
|
|
101
|
+
`physfdt` raises a one-time `RuntimeWarning` naming both causes once the norm
|
|
102
|
+
has grown for `nonstationary_patience` (default `4 × half_life`) steps.
|
|
103
|
+
|
|
104
|
+
### Check `tol` against the noise floor
|
|
105
|
+
|
|
106
|
+
ρ is an exponentially smoothed estimator, so it has sampling scatter of order
|
|
107
|
+
`1/√half_life`. If `tol` is below that scatter, `equilibrated` is being decided
|
|
108
|
+
by noise — and the detector typically never fires at all. Every state reports
|
|
109
|
+
its own measured noise floor:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
state.rho_std # measured scatter of rho
|
|
113
|
+
state.tol_is_achievable # False => tol is below 3 * rho_std
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Measured on the solvable quadratic:
|
|
117
|
+
|
|
118
|
+
| `half_life` | `rho_std` | 3σ | `tol=0.05` usable? |
|
|
119
|
+
|---|---|---|---|
|
|
120
|
+
| 100 | 0.036 | 0.107 | no |
|
|
121
|
+
| 200 | 0.018 | 0.055 | no |
|
|
122
|
+
| 500 | 0.0077 | 0.023 | yes |
|
|
123
|
+
| 2000 | 0.0019 | 0.006 | yes |
|
|
124
|
+
|
|
125
|
+
The defaults (`half_life=500`, `tol=0.10`) are self-consistent by construction.
|
|
126
|
+
Badly conditioned problems need much more smoothing: near-separable
|
|
127
|
+
classification collapses most per-sample gradients, leaving `⟨|u|²⟩` small and
|
|
128
|
+
dominated by a few hard examples, and ρ can carry O(1) scatter there. Check
|
|
129
|
+
`tol_is_achievable` before believing `equilibrated`.
|
|
130
|
+
|
|
131
|
+
## Attribution
|
|
132
|
+
|
|
133
|
+
The FDR relations and the adaptive scheduler built on them are Yaida's, not
|
|
134
|
+
ours. This package exists to make them easy to measure and to reproduce, and to
|
|
135
|
+
pair them with the spectral observables (`α`, IPR) from
|
|
136
|
+
[Physica A **692** (2026) 131474](https://doi.org/10.1016/j.physa.2026.131474).
|
|
137
|
+
If you use the scheduler, cite Yaida.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Install
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
pip install physfdt # core, NumPy only
|
|
145
|
+
pip install "physfdt[torch]" # + the PyTorch monitor and scheduler
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
From source:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
git clone https://github.com/platalise/physfdt && cd physfdt
|
|
152
|
+
pip install -e ".[dev]"
|
|
153
|
+
pytest -q
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The core has **no torch dependency**. `import physfdt` works in a NumPy-only
|
|
157
|
+
environment; the torch symbols are imported lazily.
|
|
158
|
+
|
|
159
|
+
## Validate it before you trust it
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
python examples/01_validate_quadratic.py
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Runs SGD on `L(w) = ½ wᵀAw` with noisy gradients. That system is an
|
|
166
|
+
Ornstein–Uhlenbeck process whose stationary covariance provably satisfies FDR-1,
|
|
167
|
+
so `ρ → 1` is an exact prediction rather than a fit. Expected output:
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
eta mean rho (last 20k) std verdict
|
|
171
|
+
0.005 1.0002 0.0204 OK
|
|
172
|
+
0.010 1.0000 0.0119 OK
|
|
173
|
+
0.020 0.9999 0.0055 OK
|
|
174
|
+
0.050 1.0000 0.0024 OK
|
|
175
|
+
0.100 1.0000 0.0011 OK
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Part 2 of the same script starts far from the minimum (`ρ ≈ 36`), watches the
|
|
179
|
+
detector fire four learning-rate cuts, and shows `ρ` returning to 1 after each.
|
|
180
|
+
|
|
181
|
+
## Monitor a real run
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
python examples/02_torch_quickstart.py # offline, synthetic
|
|
185
|
+
python examples/02_torch_quickstart.py --dataset mnist # real data
|
|
186
|
+
python examples/02_torch_quickstart.py --schedule fdr # detector drives the LR
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Writes a CSV with one row per measured step: `rho`, both sides of FDR-1, the
|
|
190
|
+
learning rate, the loss, and the spectral exponent `α` of a probe layer.
|
|
191
|
+
|
|
192
|
+
## Benchmark it honestly
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
python examples/03_benchmark_arms.py --seeds 5
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Four arms — constant, **tuned** cosine, ReduceLROnPlateau, FDR — on an identical
|
|
199
|
+
learning-rate tuning grid, with the monitor's overhead measured and subtracted.
|
|
200
|
+
The script prints three pre-registered failure criteria and the numbers needed
|
|
201
|
+
to check them. Read them before you read the result.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## API
|
|
206
|
+
|
|
207
|
+
| | |
|
|
208
|
+
|---|---|
|
|
209
|
+
| `FDRConfig(half_life, tol, patience, min_steps)` | detector settings; defaults are self-consistent (see *Reading ρ*) |
|
|
210
|
+
| `state.regime`, `state.norm_trend`, `state.rho_std`, `state.tol_is_achievable` | is this measurement meaningful? |
|
|
211
|
+
| `FDRMonitor(optimizer, config, mode, every)` | torch monitor; `mode="delta"` (any optimiser) or `"grad"` (plain SGD, zero extra memory). Config timescales are in **optimiser** steps whatever `every` is. |
|
|
212
|
+
| `FDREquilibriumLR(optimizer, monitor, factor, min_lr)` | decay on equilibrium |
|
|
213
|
+
| `NumpyFDRMonitor(config)` | toy models and hand-written optimisers |
|
|
214
|
+
| `spectral_report(W, method="mle"\|"window")` | `α`, its standard error, R², IPR |
|
|
215
|
+
| `TraceWriter(path, extra=[...])` | CSV trace, one row per measured step |
|
|
216
|
+
|
|
217
|
+
**Cost.** One pass over the parameters per measured step, plus one
|
|
218
|
+
parameter-sized buffer in `delta` mode. **Measured: at `every=1` the monitor took
|
|
219
|
+
~39% of wall clock** on a small MLP on CPU — the per-step tensor work is
|
|
220
|
+
comparable to the training step itself when the model is tiny. Use `every=20`
|
|
221
|
+
(the benchmark default) to amortise it to ~2%. If you are benchmarking
|
|
222
|
+
wall-clock or energy, keep the monitor on in the baseline arm too
|
|
223
|
+
(`--monitor-in-baseline`), or subtract it explicitly.
|
|
224
|
+
|
|
225
|
+
**Always `reset()` after a learning-rate change.** FDR-1 describes the stationary
|
|
226
|
+
state *at a given η*; mixing histories across learning rates is meaningless.
|
|
227
|
+
`FDREquilibriumLR` does this for you.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Scope and limitations
|
|
232
|
+
|
|
233
|
+
Read this before putting a number in a paper.
|
|
234
|
+
|
|
235
|
+
- **It measures stationarity, not progress.** `ρ = 1` says the weight norm has
|
|
236
|
+
stopped drifting at this η. It does not say the loss is low or that the model
|
|
237
|
+
generalises.
|
|
238
|
+
- **Single-epoch pre-training is out of scope.** Large language models are
|
|
239
|
+
trained under a fixed compute budget for roughly one pass, and stop because the
|
|
240
|
+
budget ran out, not because they equilibrated. Such a run is never stationary
|
|
241
|
+
and FDR-1 does not apply to it. The regime where this tool is useful is
|
|
242
|
+
**multi-epoch fine-tuning on small data** — where runs do reach stationarity,
|
|
243
|
+
and where a held-out split is expensive precisely because labelled data is
|
|
244
|
+
scarce.
|
|
245
|
+
- **Momentum and Adam.** The identity holds; the physics does not transfer
|
|
246
|
+
unchanged. Treat `ρ` as a stationarity diagnostic there, not as a measured
|
|
247
|
+
effective temperature.
|
|
248
|
+
- **It has hyperparameters.** `tol`, `patience` and `half_life` are real choices,
|
|
249
|
+
and they are *coupled*: `tol` must sit above the `1/√half_life` noise floor or
|
|
250
|
+
the detector never fires. A method that needs them retuned per workload is not
|
|
251
|
+
hyperparameter-free, and its net saving is smaller than a naive comparison
|
|
252
|
+
suggests. This is failure criterion F2 in the benchmark script.
|
|
253
|
+
- **It needs a confining term.** Plain cross-entropy with no weight decay has no
|
|
254
|
+
stationary state at all. See *Reading ρ* above.
|
|
255
|
+
- **Small `η` inflates the smoothing timescale.** After a decay, allow at least
|
|
256
|
+
a few `half_life` before trusting `ρ` again. `min_steps` enforces a floor.
|
|
257
|
+
- **No claim of compute savings is made here.** Whether FDR-triggered decay beats
|
|
258
|
+
a properly tuned cosine schedule is an empirical question that
|
|
259
|
+
`examples/03_benchmark_arms.py` exists to settle, in either direction.
|
|
260
|
+
|
|
261
|
+
## Tests
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
pytest -q # 51 tests (2 need torch)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
The physics tests are the ones that matter. `tests/test_validation_quadratic.py`
|
|
268
|
+
checks `ρ → 1` against a case with a closed-form answer, across five learning
|
|
269
|
+
rates, three noise magnitudes, and — the one that matters most — strongly
|
|
270
|
+
anisotropic noise, where model-based effective temperatures break down.
|
|
271
|
+
`tests/test_nonstationary_regime.py` pins the opposite case: that a run with no
|
|
272
|
+
stationary state is detected and named rather than silently mis-reported.
|
|
273
|
+
|
|
274
|
+
## Citing
|
|
275
|
+
|
|
276
|
+
```bibtex
|
|
277
|
+
@software{physfdt,
|
|
278
|
+
title = {physfdt: fluctuation-dissipation diagnostics for SGD},
|
|
279
|
+
author = {Nguyen, Quang},
|
|
280
|
+
year = {2026},
|
|
281
|
+
url = {https://github.com/platalise/physfdt}
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Please also cite Yaida (arXiv:1810.00004) for the FDR relations and the
|
|
286
|
+
scheduler.
|
|
287
|
+
|
|
288
|
+
## Licence
|
|
289
|
+
|
|
290
|
+
MIT.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "physfdt"
|
|
7
|
+
version = "0.3.1"
|
|
8
|
+
description = "Fluctuation-dissipation diagnostics for stochastic gradient descent"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Quang Nguyen" }]
|
|
13
|
+
keywords = ["deep-learning", "statistical-physics", "fluctuation-dissipation",
|
|
14
|
+
"learning-rate-schedule", "random-matrix-theory", "sgd"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["numpy>=1.21"]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
torch = ["torch>=1.13"]
|
|
27
|
+
dev = ["pytest>=7", "torch>=1.13"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/platalise/physfdt"
|
|
31
|
+
Issues = "https://github.com/platalise/physfdt/issues"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
38
|
+
addopts = "-q"
|
physfdt-0.3.1/setup.cfg
ADDED