physfdt
Fluctuation–dissipation diagnostics for stochastic gradient descent.
physfdt answers one question during training: has this run equilibrated at
the current learning rate? It answers it from the optimiser's own arithmetic —
no held-out validation split, no assumption about the shape of the gradient
noise.
from physfdt import FDRMonitor, FDRConfig, FDREquilibriumLR
monitor = FDRMonitor(optimizer, FDRConfig(half_life=500))
sched = FDREquilibriumLR(optimizer, monitor, factor=0.5)
loss.backward()
with monitor.measure():
optimizer.step()
sched.step() # decays the LR when rho settles at 1
The relation
Any first-order optimiser can be written w_{t+1} = w_t − η·u_t, where u_t is
whatever direction it produces before the learning rate is applied. Take the
observable O(w) = ½|w|² and require its expectation to be stationary:
|w_{t+1}|² = |w_t|² − 2η ⟨w_t · u_t⟩ + η² ⟨|u_t|²⟩
⟹ 2⟨w · u⟩ = η ⟨|u|²⟩ (FDR-1)
Define
ρ = 2⟨w · u⟩ / (η ⟨|u|²⟩)
At stationarity ρ = 1 exactly: the system has equilibrated at that η, and
further steps at that η buy nothing. (The converse needs care — see Reading ρ
below.) physfdt tracks ρ with exponentially smoothed numerator and denominator (a ratio of
averages, never an average of ratios) and reports when it settles.
For plain SGD (u = g) this is the fluctuation–dissipation relation of
Yaida, arXiv:1810.00004: the left side is
dissipation, the right side is the second moment of the mini-batch gradient,
noise included. It assumes neither Gaussian nor isotropic noise — only
stationarity. That is what makes it different from effective-temperature
definitions that need a noise model.
For other optimisers the identity remains algebraically exact and is still a
valid stationarity diagnostic, but the fluctuation–dissipation reading is not
established. physfdt warns rather than hides this.
Reading ρ
The same algebra gives an exact drift law, E[Δ|w|²] = η²⟨|u|²⟩(1 − ρ). It
is tempting to read the norm's behaviour off the sign of 1 − ρ. Don't.
ρ is a ratio whose denominator η⟨|u|²⟩ can be tiny and dominated by a few
samples; measured on near-separable logistic regression with weight decay, ρ
was negative on 16,451 of 40,000 steps while |w| sat at 3.67 to three
decimals. So physfdt takes the verdict on the norm from |w|² directly — a
trend test over the last 4 × half_life steps that must be both statistically
significant (norm_z) and physically non-negligible (norm_rel_tol, 0.5%) —
and uses ρ only for what it is good at: given a stationary norm, has the FDR
balance been reached.
regime |
meaning |
|---|---|
norm_growing / norm_shrinking |
` |
equilibrated |
norm flat and ` |
stationary_noisy |
norm flat but ρ outside the band — ρ is too noisy at this half_life; compare rho_std with tol |
warming_up |
not enough history for a norm verdict yet |
If the norm keeps growing
Two different situations produce a sustained norm_growing, and they need
opposite responses:
- No weight decay, cross-entropy. Once the data is separated, max-margin
dynamics drives
|w| → ∞(Soudry et al., JMLR 2018). There is no stationary state; FDR-1 never applies. Add weight decay. - Weight decay present. The equilibrium norm exists but has moved — for instance after a learning-rate decay, or when starting from a small initialisation. This is a transient. Train longer.
Measured on logistic regression over separable data, 40k steps:
| weight decay | |w| start → end | final ρ | |
|---|---|---|---|
| 0 | 0.46 → 11.07 | −14294 | diverging, no stationary state |
| 1e-4 | 0.46 → 10.15 | −4614 | diverging |
| 1e-3 | 0.46 → 6.90 | 0.39 | not yet settled |
| 1e-2 | 0.46 → 3.67 | 1.07 | stationary |
| 5e-2 | 0.46 → 2.07 | 1.02 | stationary |
physfdt raises a one-time RuntimeWarning naming both causes once the norm
has grown for nonstationary_patience (default 4 × half_life) steps.
Check tol against the noise floor
ρ is an exponentially smoothed estimator, so it has sampling scatter of order
1/√half_life. If tol is below that scatter, equilibrated is being decided
by noise — and the detector typically never fires at all. Every state reports
its own measured noise floor:
state.rho_std # measured scatter of rho
state.tol_is_achievable # False => tol is below 3 * rho_std
Measured on the solvable quadratic:
half_life |
rho_std |
3σ | tol=0.05 usable? |
|---|---|---|---|
| 100 | 0.036 | 0.107 | no |
| 200 | 0.018 | 0.055 | no |
| 500 | 0.0077 | 0.023 | yes |
| 2000 | 0.0019 | 0.006 | yes |
The defaults (half_life=500, tol=0.10) are self-consistent by construction.
Badly conditioned problems need much more smoothing: near-separable
classification collapses most per-sample gradients, leaving ⟨|u|²⟩ small and
dominated by a few hard examples, and ρ can carry O(1) scatter there. Check
tol_is_achievable before believing equilibrated.
Attribution
The FDR relations and the adaptive scheduler built on them are Yaida's, not
ours. This package exists to make them easy to measure and to reproduce, and to
pair them with the spectral observables (α, IPR) from
Physica A 692 (2026) 131474.
If you use the scheduler, cite Yaida.
Install
pip install physfdt # core, NumPy only
pip install "physfdt[torch]" # + the PyTorch monitor and scheduler
From source:
git clone https://github.com/platalise/physfdt && cd physfdt
pip install -e ".[dev]"
pytest -q
The core has no torch dependency. import physfdt works in a NumPy-only
environment; the torch symbols are imported lazily.
Validate it before you trust it
python examples/01_validate_quadratic.py
Runs SGD on L(w) = ½ wᵀAw with noisy gradients. That system is an
Ornstein–Uhlenbeck process whose stationary covariance provably satisfies FDR-1,
so ρ → 1 is an exact prediction rather than a fit. Expected output:
eta mean rho (last 20k) std verdict
0.005 1.0002 0.0204 OK
0.010 1.0000 0.0119 OK
0.020 0.9999 0.0055 OK
0.050 1.0000 0.0024 OK
0.100 1.0000 0.0011 OK
Part 2 of the same script starts far from the minimum (ρ ≈ 36), watches the
detector fire four learning-rate cuts, and shows ρ returning to 1 after each.
Monitor a real run
python examples/02_torch_quickstart.py # offline, synthetic
python examples/02_torch_quickstart.py --dataset mnist # real data
python examples/02_torch_quickstart.py --schedule fdr # detector drives the LR
Writes a CSV with one row per measured step: rho, both sides of FDR-1, the
learning rate, the loss, and the spectral exponent α of a probe layer.
Benchmark it honestly
python examples/03_benchmark_arms.py --seeds 5
Four arms — constant, tuned cosine, ReduceLROnPlateau, FDR — on an identical learning-rate tuning grid, with the monitor's overhead measured and subtracted. The script prints three pre-registered failure criteria and the numbers needed to check them. Read them before you read the result.
API
FDRConfig(half_life, tol, patience, min_steps) |
detector settings; defaults are self-consistent (see Reading ρ) |
state.regime, state.norm_trend, state.rho_std, state.tol_is_achievable |
is this measurement meaningful? |
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. |
FDREquilibriumLR(optimizer, monitor, factor, min_lr) |
decay on equilibrium |
NumpyFDRMonitor(config) |
toy models and hand-written optimisers |
spectral_report(W, method="mle"|"window") |
α, its standard error, R², IPR |
TraceWriter(path, extra=[...]) |
CSV trace, one row per measured step |
Cost. One pass over the parameters per measured step, plus one
parameter-sized buffer in delta mode. Measured: at every=1 the monitor took
~39% of wall clock on a small MLP on CPU — the per-step tensor work is
comparable to the training step itself when the model is tiny. Use every=20
(the benchmark default) to amortise it to ~2%. If you are benchmarking
wall-clock or energy, keep the monitor on in the baseline arm too
(--monitor-in-baseline), or subtract it explicitly.
Always reset() after a learning-rate change. FDR-1 describes the stationary
state at a given η; mixing histories across learning rates is meaningless.
FDREquilibriumLR does this for you.
Scope and limitations
Read this before putting a number in a paper.
- It measures stationarity, not progress.
ρ = 1says the weight norm has stopped drifting at this η. It does not say the loss is low or that the model generalises. - Single-epoch pre-training is out of scope. Large language models are trained under a fixed compute budget for roughly one pass, and stop because the budget ran out, not because they equilibrated. Such a run is never stationary and FDR-1 does not apply to it. The regime where this tool is useful is multi-epoch fine-tuning on small data — where runs do reach stationarity, and where a held-out split is expensive precisely because labelled data is scarce.
- Momentum and Adam. The identity holds; the physics does not transfer
unchanged. Treat
ρas a stationarity diagnostic there, not as a measured effective temperature. - It has hyperparameters.
tol,patienceandhalf_lifeare real choices, and they are coupled:tolmust sit above the1/√half_lifenoise floor or the detector never fires. A method that needs them retuned per workload is not hyperparameter-free, and its net saving is smaller than a naive comparison suggests. This is failure criterion F2 in the benchmark script. - It needs a confining term. Plain cross-entropy with no weight decay has no stationary state at all. See Reading ρ above.
- Small
ηinflates the smoothing timescale. After a decay, allow at least a fewhalf_lifebefore trustingρagain.min_stepsenforces a floor. - No claim of compute savings is made here. Whether FDR-triggered decay beats
a properly tuned cosine schedule is an empirical question that
examples/03_benchmark_arms.pyexists to settle, in either direction.
Tests
pytest -q # 51 tests (2 need torch)
The physics tests are the ones that matter. tests/test_validation_quadratic.py
checks ρ → 1 against a case with a closed-form answer, across five learning
rates, three noise magnitudes, and — the one that matters most — strongly
anisotropic noise, where model-based effective temperatures break down.
tests/test_nonstationary_regime.py pins the opposite case: that a run with no
stationary state is detected and named rather than silently mis-reported.
Citing
@software{physfdt,
title = {physfdt: fluctuation-dissipation diagnostics for SGD},
author = {Nguyen, Quang},
year = {2026},
url = {https://github.com/platalise/physfdt}
}
Please also cite Yaida (arXiv:1810.00004) for the FDR relations and the scheduler.
Licence
MIT.
Release files for physfdt 0.3.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| physfdt-0.3.1.tar.gz | 37.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| physfdt-0.3.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 62.0 kB
Release files / physfdt-0.3.1.tar.gz
| Download URL | physfdt-0.3.1.tar.gz |
|---|---|
| Size | 37.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ab6420e6963c1607b718b3856f598bdb3eb7ec6b702a9acf4520ffd3b9cf7b39
|
|
BLAKE2b-256 checksum How to use checksums |
c34698acc24fc599ebcd4f86d2b233a8f1e0201a68ef161bfd54d171e1cc79d5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.
Transparency logRelease files / physfdt-0.3.1-py3-none-any.whl
| Download URL | physfdt-0.3.1-py3-none-any.whl |
|---|---|
| Size | 25.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
325513e8a3dd13c7aa3798c3c89b2c65df167ec9b3c1f542ef0b8029f5848acc
|
|
BLAKE2b-256 checksum How to use checksums |
44c734b4e1abf23761a1e6fc5f6207f7b40bad36a28aff1b634eeba9db5a4b28
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.
Transparency log