Skip to content

Fix #480: tanh derivative saturates instead of collapsing to a wrong finite value - #503

Open
petlenz wants to merge 2 commits into
mainfrom
fix-480-tanh-overflow
Open

petlenz wants to merge 2 commits into
mainfrom
fix-480-tanh-overflow

Conversation

@petlenz

@petlenz petlenz commented Sep 19, 2026

Copy link
Copy Markdown
Member

Closes #480.

Fix level: the composition (option a), with a documented residual. tanh was (exp(2x) − 1) / (exp(2x) + 1), whose quotient-rule derivative is two terms near +2 and −2 that cancel; once (exp(2x)+1)² overflows one term drops out and the sum is a finite wrong number on the t2s side, NaN on the scalar side, and the value itself was NaN past x ≈ 355. Both domains now build 2 / (1 + exp(−2x)) − 1: still one exp term (so the #180 duplicate-insert hazard stays closed), and the derivative is a single product 4·exp(−2x)·(1+exp(−2x))⁻² that cannot cancel.

argument main this PR true
value, x = ±400 NaN ±1 ±1
d/dx, x = 50 0 1.49e-43 1.49e-43
d/dx, x = 200 NaN 7.66068e-174 7.66068e-174
d/dA tanh(tr A)₀₀, tr = +197.324 41.2392 (t2s) 1.61663e-171 1.61663e-171
positive side, 0 → 420 −inf / NaN above 179 exact (worst 1 ulp)

Residual — this PR moves a narrow defect from the positive side to the negative side. Below x ≈ −179 the squared denominator leaves the double range and pow returns a denormal, so the derivative over-estimates: +0.26 % at −185, +29 % at −186, +75 % at −186.25; it is zero from −186.5 (truth ~4e-162) and NaN past −354.25. Main is exact in that band. The trade is deliberate: main's worst undetectable error on the positive side is ~171 orders of magnitude (41.2 vs 1.6e-171), this PR's is a factor of 1.75 in a ~7-wide band. tanh(−y) folds to −tanh(y) and stays exact, so only a bare argument that evaluates negative at runtime reaches it.

The symmetric form the review suggested was tried and is worse. (eˣ − e⁻ˣ)/(eˣ + e⁻ˣ) differentiates to 1 − sinh²/cosh², i.e. catastrophic cancellation: at ±186.25 it returns 1.11e-16 where the truth is 6.72e-162 — wrong by 145 orders of magnitude, on both sides, versus 1.75× here. The mirror form 1 − 2/(exp(2x)+1) is exactly this PR's behaviour reflected, so it only swaps which sign is wrong. A single-term derivative requires a constant numerator, and no such composition is stable on both sides — 4/(eˣ+e⁻ˣ)² is the stable expression but no composition differentiates to it without an algebraic rewrite. Dedicated hyperbolic nodes with a sech² rule (#126) are the only complete fix; an if_then_else split was considered and rejected (it would put a branch in every emitted tanh).

Tests. CoreBugFix.TanhStaysSoundForLargeArguments and TensorToScalarDifferentiationTest.TanhOfLargeTraceGradientIsSound pin the fixed behaviour. CoreBugFix.TanhDerivativeDegradesOnlyInTheKnownBand and TanhGradientDegradesOnlyInTheKnownBand sweep −179.25 → −354 in 0.25 steps (0.5 for t2s, whose evaluation is heavier) and assert the degradation is one-sided and bounded by 2×, so it cannot widen unnoticed; the first version of these tests sampled −50 and −200 and straddled the band, which an adversarial review caught.

Negative control (both headers restored from main, rebuilt): all four tanh tests fail — the positive-side ones on the value and derivative, the band ones on the far tail and on the negation-fold case (main returns inf there). The negative-band assertions themselves pass on main, since main is exact there; they are characterisation of this PR's residual, not a demonstration of a main bug, and are labelled as such.

Siblings checked at large and extreme arguments in both signs: sinh, cosh grow consistently to inf; acosh, atanh, log10 exact. asinh has the same class on its negative side (−1e7 → value 0.03 % and derivative 1.5 % wrong; −1e8 → −inf/NaN); a symmetric identity was probed and rejected because it degrades the currently-exact positive side. Filed as #502.

Full suite 2409/2409 in Debug and in Release (gcc-14); clang-format clean.

Follow-up: PR #481's fuzz generators bound hyperbolic arguments to dodge this limitation; the tanh bound can be relaxed to the positive side once this lands, while asinh keeps its bound until #502.

tanh was (exp(2x) - 1) / (exp(2x) + 1), whose quotient-rule derivative is
two terms near +2 and -2 that cancel; once (exp(2x) + 1)^2 overflows one
term drops out and the sum is finite and wrong (t2s: 41.2 where the truth
is ~1e-171), or NaN on the scalar side. The value itself was NaN past x~355.

Both domains now build tanh as 2 / (1 + exp(-2x)) - 1: still a single exp
term, so differentiation never inserts one exp child twice into an add, and
the derivative is one product, 4 exp(-2x) / (1 + exp(-2x))^2, which is exact
for large positive x and underflows to zero on the negative side. The value
is correct for every argument.

Signed-off-by: petlenz <peterlenz89.pl@gmail.com>
The exp(-2x) form is exact for every positive argument, but below -179 the
squared denominator leaves the double range: the derivative over-estimates
by up to 1.75x through a denormal intermediate, then underflows to zero,
then goes NaN past -354. The earlier tests sampled -50 and -200 and so
straddled the band entirely.

Both domains now sweep it and pin the degradation one-sided and bounded by
2x, so it cannot widen unnoticed, plus the fold that keeps a syntactically
negated argument on the exact branch.

Signed-off-by: petlenz <peterlenz89.pl@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

t2s tanh derivative returns a finite wrong value for large arguments (overflow collapses one term)

1 participant