fix(pchip): close the flat-data holes in the slope guard (Dual) and its adjoint (Float64) - #203
Conversation
…ts adjoint (Float64) Two adjacent control points holding the same value make a secant exactly zero. Both derivative paths then fell into the degenerate branch they were meant to avoid. Forward: `_pchip_harmonic_mean` guarded the 0*0/0 case with `iszero(den)`, but `iszero` on a `ForwardDiff.Dual` inspects the partials, so `den` with value 0 and a nonzero partial skipped the guard and returned `Dual(NaN, NaN)` — a NaN in the VALUE. Test the primal instead, as `_constant_kernel` already does. Adjoint: `sign(0) != sign(0)` is false, so the interior harmonic-mean branch ran and divided by `δ² == 0`, writing NaN into `f_bar` on plain Float64 input. Skip the node when the forward pass takes its zero-denominator branch, via the shared `_flat_secants` predicate, so forward and reverse select the same branch. No behavior change for any input whose denominator is nonzero.
FastInterpolations.jl Benchmarks
All benchmarks (56 total, click to expand)
|
| Benchmark | Current | Previous | Imm. Ratio | Grad. Ratio | Tier |
|---|---|---|---|---|---|
12_cubic_eval_gridquery/vec_random |
7035.5 ns |
6246.3 ns |
1.126 |
0.867 |
immediate |
14_series_oneshot_batch/linear_inplace_vec_k8_q1000_rand |
12959.3 ns |
10770.2 ns |
1.203 |
0.955 |
immediate |
Thresholds: immediate > 1.1x (vs latest master), gradual > 1.1x (vs sliding window)
Runner:
sapphirerapids|4c— INTEL(R) XEON(R) PLATINUM 8573C, julia 1.12.6. Times are min-merged and compared only against this same machine's history.
This comment was automatically generated by Benchmark workflow.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #203 +/- ##
=======================================
Coverage 96.31% 96.31%
=======================================
Files 164 164
Lines 13965 13967 +2
=======================================
+ Hits 13450 13452 +2
Misses 515 515
🚀 New features to boost your workflow:
|
Two related bugs, one trigger: two adjacent control points holding the same value, which
makes a secant exactly zero. Both derivative paths then fall into the degenerate branch they
were meant to avoid. Values are fine — only the derivatives break.
Exact ties are not exotic: clamped or saturated data, quantized/rounded inputs, a plateau in an
otherwise monotone profile, or optimizer variables resting on a shared bound all produce them.
1. Forward:
_pchip_harmonic_meanreturnsDual(NaN, NaN)The guard for flat data (both secants zero →
0*0/0, added with the single-division form in#168) is
iszero(den). On aForwardDiff.Dual,iszerois partial-aware, so a seededderivative skips it:
End to end:
_constant_kernelalready handles this correctly (dL_primal = _extract_primal(dL)); this isthe one place the convention was missed.
2. Adjoint:
pchip_adjointreturns NaN on plainFloat64Same trigger, no AD required.
sign(δ_prev) != sign(δ_curr)is false for0vs0, so theinterior loop takes the active branch and divides by
δ²:(
D = w1/0 + w2/0 = Inf, thenS*w1/(D²*δ²) = Inf/NaN.) After the fix the tied case returnsthat second vector — the no-ties limit — which is one of the added tests.
Why this is easy to miss
two secant partials cancel on a uniform grid, restoring
iszero(den)— so single-variableAD/FD spot-checks pass while a full
ForwardDiff.jacobianis NaN-poisoned.evaluation as an invalid point and substitutes a constant fallback hands ForwardDiff a
partial-free value, so the whole seeded chunk comes back as exactly-zero Jacobian columns —
a structurally singular system with nothing to warn on. That failure mode is silent, which
is why it seems worth fixing at the source rather than leaving to callers.
Fix
iszero(_extract_primal(den)), matching_constant_kernel._flat_secants(δp, δc)predicate, so forward and reverse select the same branch. (Theadjoint builds
D = w1/δp + w2/δcrather thanden, hence stating the test in terms of thesecants; on the monotone branch the two are equivalent.) Applied to both the interior loop
and the periodic kernel; the endpoint blocks never divide by a secant and are unaffected.
At exactly-flat data the Fritsch–Carlson slope is genuinely non-differentiable (positively
homogeneous there), so zero is a subgradient choice — and it is the value the
Float64forwardpath already returns, which is what keeps the three paths consistent.
No behavior change for any input whose denominator is nonzero.
Testing
New
test/test_pchip_flat_data.jl(13 tests): the kernel guard under a seededDual; forwardForwardDiff.jacobianacross a plateau (no NaN, values unchanged, and agreement with finitedifferences where the data is smooth); and the adjoint on flat data (finite, equal to the
no-ties limit, and satisfying the dot-product identity
⟨W·y, ȳ⟩ == ⟨y, Wᵀ·ȳ⟩on uniform andnon-uniform grids).
Full suite on this branch: 70486 pass, 24 broken (pre-existing), 0 failures.