fix: prevent probability underflow in pairwise cost matrix

Replaces premature exponentiation-based row normalization with log-sum-exp arithmetic to prevent tiny probabilities from collapsing to exactly zero. This eliminates spurious infinite costs for valid but rare transitions while preserving correct IEEE 754 semantics for genuinely unobserved pairs. Adds explicit guards against NaN in degenerate rows and includes a regression test verifying finite costs for probabilities as low as 1e-200.
This commit is contained in:
Eric Coissac
2026-08-17 09:41:50 +02:00
parent 9654201885
commit c8f2b16b4c
3 changed files with 145 additions and 32 deletions
@@ -4100,12 +4100,46 @@ precision (not the model file's truncated 6 decimals). Written alongside
<code>_iqtree.model</code>/<code>_iqtree.fasta</code> from the same <code>CompactAlphabet</code> both
already use, so there is no risk of the three files disagreeing. Covered
by <code>states_csv_maps_compact_symbols_back_to_canonical_ones</code>.</p>
<p>The zero-exchangeability pattern itself (state 0 in the user's report,
frequency 3.26%, <code>R=0</code> with every other state) is not yet explained —
plausibly a genuinely unobserved transition in the calibration
(<code>cardinality_transitions</code>/<code>composition_transitions</code> count <code>0</code> for every
pair involving it), which is a legitimate, if numerically extreme, result
of <code>-ln(0)</code>, not necessarily a bug — not investigated further.</p>
<p><strong>Root cause of the zero-exchangeability pattern found and fixed
(2026-08-15): premature <code>exp()</code> in <code>pairwise_cost_matrix</code> underflowed
merely-tiny probabilities to exactly <code>0.0</code>.</strong> The user also reported
<code>iqtree3</code> emitting "Numerical underflow for lh-derivative" warnings on
the same run — a real signal, traced to <code>obikphylo/src/cardcomp.rs</code>'s
<code>pairwise_cost_matrix</code>, not to the frequency computation (which is a
plain, safe <code>f64</code> division, never close to underflow at any realistic
scale). The function already accumulated <code>log_p</code> in log-space (correct),
but then row-normalised by exponentiating each cell <em>first</em>
(<code>raw[a][b] = log_p.exp()</code>) and summing the results — <code>f64::exp</code> hard
underflows to exactly <code>0.0</code> for any input below roughly <code>-709</code>, which a
sum of several individually-small-but-nonzero probability factors
(composition/cardinality terms, <code>best_pairing_cost</code>'s pairing terms) can
reach easily on real, skewed calibration data. Once <code>raw[a][b]</code> was
exactly <code>0.0</code>, normalisation and <code>-ln</code> turned a merely tiny probability
into a <code>+∞</code> cost indistinguishable from a <em>literally</em> unobserved
transition (<code>p == 0.0</code> exactly, e.g. <code>p_comp[i][j]</code> never once tallied) —
conflating two different things: "never observed" (should be <code>+∞</code>, a
correct MLE result) and "observed, but the joint probability of this
multi-step transition is extremely small" (should be a large <em>finite</em>
cost).</p>
<p><strong>Fix</strong>: row-normalise via log-sum-exp instead of exponentiating first —
<code>row_max = max_b(log_p[a][b])</code>, <code>log_sum = row_max + ln(Σ_b
exp(log_p[a][b] - row_max))</code> (every shifted term is in <code>(0,1]</code>, so this
never underflows for a finite <code>log_p[a][b]</code>), then
<code>cost[a][b] = log_sum - log_p[a][b]</code> directly — no intermediate
probability is ever materialised. This falls out of IEEE 754 arithmetic
without a special case: a genuinely-unobserved factor (<code>log_p[a][b] ==
-∞</code>, from the existing <code>if p &gt; 0.0 {...} else { NEG_INFINITY }</code> guards
already in the log-accumulation loop) still yields <code>cost = +∞</code> exactly
(<code>finite (−∞) = +∞</code>), preserving the correct semantics for that case,
while every merely-tiny-but-nonzero transition now gets a large but
<em>finite</em> cost. A degenerate all-<code>-∞</code> row (a state with literally zero
probability of transitioning to anything, <code>row_max == -∞</code>) is guarded
explicitly to avoid a <code>-∞ (-∞) = NaN</code> in the log-sum-exp itself.
Covered by <code>cardcomp::tests::underflow_prone_transition_gets_finite_cost_not_infinite</code>
(all off-diagonal composition probabilities set to <code>1e-200</code>, well past
where the old <code>exp()</code>-first code would have underflowed to <code>0.0</code>, cost
asserted finite). Every pre-existing <code>cardcomp</code> test still passes
unchanged (numerically identical results when no underflow occurs).</p>
<h2 id="references">References</h2>
<p>The Mash mutation-rate model this discussion contrasts with:
(Fan <em>et al.</em> 2015; Marbl Lab 2026)<sup id="fnref:Mash-distances-doc"><a class="footnote-ref" href="#fn:Mash-distances-doc">1</a></sup> <sup id="fnref:Fan2015-mash-formula"><a class="footnote-ref" href="#fn:Fan2015-mash-formula">2</a></sup>.</p>