0028 log1p is kahans identity not math log 1 plus x - CyrilB1531/lodestar GitHub Wiki
0028 โ Log1P is Kahan's identity, not Math.Log(1 + x)
Status: accepted ยท Date: 2026-08-14
Context
MeanSquaredLogError needs log(1 + x) โ numpy's log1p, which is what
sklearn.metrics.mean_squared_log_error calls โ and neither target
framework has it: netstandard2.0 has no log1p of any name, so one
hand-written implementation has to serve both net10.0 and
netstandard2.0 or the two would disagree in the last place.
The obvious spelling, Math.Log(1.0 + value), loses the low-order bits of a
small value in the addition itself, before the logarithm ever sees them.
Measured against scikit-learn 1.9.0 on targets around 1e-9
(mean_squared_log_error([1e-9, 2e-9, 3e-9], [2e-9, 4e-9, 1e-9]), expected
2.9999999856666664e-18), that spelling returns 3.000000038019698e-18 โ
out by 1.7e-8 relative, about 17 000 times the 1e-12 relative bound
LogErrorTests.A_tiny_target_keeps_the_bits_that_one_plus_x_would_round_away
asserts. The frozen oracle corpus does not catch this itself: its
comparison rule scales by max(1, |expected|), so at 3e-18 it reduces to
an absolute 1e-9 and every implementation passes, including one that
returns zero.
Decision
Log1P uses Kahan's identity instead:
double shifted = 1.0 + value;
return shifted == 1.0
? value
: Math.Log(shifted) * value / (shifted - 1.0);
u = 1 + v rounds, but u - 1 recovers exactly what the addition actually
added โ not v itself, but the value the rounded u represents. Scaling
the correctly-rounded log(u) by the ratio v / (u - 1) corrects for
exactly the bits the addition lost. The shifted == 1.0 branch is where v
vanished entirely into the addition; there, log(1 + v) โ v to full
precision, and u - 1 would be zero, so the division is guarded rather than
computed.
Consequences
MeanSquaredLogError.LogResidual.Log1Pcarries a short pointer to this record instead of restating the derivation; the numbers above stay here where a later change to either can make them stale in only one place.- One hand-written
Log1Pserves both target frameworks, which is why it is written out at all rather than delegated to a BCL method that only one of them has. - Verified by
LogErrorTests.A_tiny_target_keeps_the_bits_that_one_plus_x_would_round_away, which asserts the relative error against the hand-measured scikit-learn value directly, because the frozen corpus's own tolerance cannot.