SC‑WBD

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.

configurationfinal −log qrejected
nuisance_dim = 05.30340 / 120
nuisance_dim = 2does not convergecontinuous

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.