SC‑WBD

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.

Applies to the normalising-flow density head, as built for run 2. The three causes are properties of that head's construction and recur wherever it is reused.

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.

Follow-up: run 4 repaired the rate, and overshot

The learning rate above was the last of the three, and SC-WBD-004 is the run that carried the fix into production. A four-seed sweep chose the rate before launch, and on that sweep the coupling-gain posterior recovered R² 0.674–0.766 against roughly zero in run 3.

The repair worked. Run 3's posterior ignored its conditioning entirely: every posterior standard deviation sat within 3% of the prior's and its mean moved by 9% of a prior standard deviation as the data changed. Run 4's coupling-gain posterior is eight times narrower than the prior and its mean now moves by 1.10 prior standard deviations with the data. The flow reads its conditioning, which three runs could not achieve.

And it is not usable, because it is confidently wrong. The standardised residual has a spread of 59.25 where a calibrated posterior sits near 1.0. Simulation-based calibration returns a Kolmogorov–Smirnov p of 1.0 × 10−147; expected-coverage error is 0.203 against run 3's 0.021. It narrowed by eight times and earned about a quarter of that. Four of the six parameters still explain no variance.

So the failure mode inverted rather than resolving. Run 3's posterior was uninformative and honest — a distribution that returns the prior is perfectly calibrated by construction, which is why the calibration block certified it. Run 4's is partly informative and overconfident. Neither supports inference, and they are not two points on a path from worse to better; they are two ways to be unusable. The claim publishes as unsupported, where run 3's identical claim published as partial.

One number is worth stating because it constrains what to do next: the one-stage sweep predicted 0.674–0.766 and the full six-stage curriculum returned 0.284, under half. Why is unmeasured. The natural reading is that the two measured-return stages, which follow the founding stage at reduced and then zero posterior weight, give part of it back — but that is a hypothesis, and the experiment that would settle it (scoring the founding stage's own checkpoint through the same calibration block) has not been run. The lesson the page above already carries applies to its own sequel: a fix validated on one stage is not thereby validated on six.