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 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.
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+