Engineering · 07
Three ways to break a density
A density reported -log q of 4.489e9, bit-identical across independent runs, from the very first step. Fixing it took three separate changes, and what found all three was a counter rather than a bound.
What the guard was for
The first response was defensive: bound the loss to the envelope the flow's
own construction implies. The coupling scale is tanh-bounded to
±2.5 and to_unconstrained clamps before atanh, so
there is a computable ceiling on -log q. Anything past it is a
pathological batch, not a hard example.
The important half was not the bound. It was this:
npe_rejected: int = 0
npe_seen_max: float = 0.0
A guard that rejects and reports nothing is a mask. A guard that rejects and reports a rate is a diagnostic. The first diagnosis — "a single degenerate batch" — was wrong, and the counter is what disproved it:
step 1 npe 0 rejected 1
step 400 npe 0 rejected 400
400 rejections in 400 steps is not a rare event. Without the counter, the posterior loss would have been silently zeroed for an entire run and the model would have shipped with an amortized posterior that never trained, under a green log.
Cause one: the conditioning, not the input
An instrumented dump on the first real rejection:
REJ#0 all=True kept=0/64 per=[1.5e7, 1.3e8, 3.3e7, ...]
y|max=19.34 c|max=13.18 u|max=3.962 u_fin=True th|max=6.144
u is bounded and finite, so the flow's input was never
the problem. The conditioning was: |c|max ≈ 13–18 in training
against ~0.4 for the same network on unmasked corpus data. A coupling's
scale is tanh-bounded; its translation is linear
in its input far from the origin. A conditioning vector of 13 drives
z far enough that -½z² reaches −4.5e9.
A LayerNorm on the conditioning, applied in
log_prob and sample alike so density and samples
cannot disagree, bounds -log q at 17.04 across a 200× input
range.
Cause two: a point mass
The loss became finite and stayed absurd — around 4000 for an eight-dimensional
flow. The configuration said nuisance_dim: 2, which accurately
describes an intent to estimate EEG gain and sensor noise. The trainer passed
torch.zeros.
So two of the flow's eight target dimensions were a constant at the origin. No normalizing flow can represent a point mass with finite density, and the attempt dominated everything else.
| configuration | final −log q | rejected |
|---|---|---|
nuisance_dim = 0 | 5.3034 | 0 / 120 |
nuisance_dim = 2 | does not converge | continuous |
Nothing in the config was wrong. The defect was that the intent had never been implemented and the placeholder standing in for it was silently pathological.
Cause three: one learning rate for two different objects
step 80 npe 8.449 rejected 0
step 100 npe 110.1
step 140 npe 1229
Stable through the warmup, climbing at exactly the point the one-cycle schedule reached its maximum — while the forecast head kept improving throughout. A step size that is merely large for a residual block is destabilising for a density, for the same reason as cause one: the translation is linear in its input. The posterior now trains in its own parameter group at 0.1× the stage rate.
A fourth claim, withdrawn
The obvious next move was to bound the translation the way the scale already is. That was committed as "the root cause, not a delay" — before the comparison finished. It finished:
UNBOUNDED final 7.3007 worst 9.2648 rejected 0/600
T_BOUND=12.0 final 7.3006 worst 9.2643 rejected 0/600
The unbounded arm did not diverge, so the test cannot confirm the bound fixes anything. It establishes only that the bound costs nothing measurable, so it stays as free insurance against a mechanism that is real in principle and unproven here.
The honest summary: three fixes moved the onset from step 1 to step 800, the fourth is free, and the training run remains the only test that has ever reproduced the failure.
What generalises
Each cause was invisible to the others' tests. The conditioning bug passed every isolation test because the summary network is scale-robust on unmasked data. The nuisance bug needed a converging flow before it became the dominant term. The learning-rate bug could not be reached until the first two were fixed.
Three sequential failures, each masking the next — and a rate, rather than a boolean, is what made them separable at all.