Skip to content

Commit d2e7701

Browse files
thisiscamclaude
andcommitted
log_add: shift-guard NaN gradient at structural-zero (-inf) - (-inf)
When both operands are structural zeros (sign=0, log_mag=-inf), ``small_log - big_log`` evaluates to ``(-inf) - (-inf) = NaN``. The downstream ``where`` cascade masks the forward value (returns LOG_ZERO correctly), but reverse-mode autodiff still differentiates through the NaN expression and propagates a NaN gradient. Apply the same ``big_log_safe = where(isneginf(big_log), 0, big_log)`` shift used in log_sum so the dangerous branches stay finite. The both-zero case is already masked to LOG_ZERO regardless, so the shift is irrelevant to the forward value but unblocks the gradient. Add test_log_add_both_zero_grad_finite as a regression lock. Latent fragility — production jet rules go through log_sum (already safe) — so no acopula experiment results were affected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c28564a commit d2e7701

2 files changed

Lines changed: 42 additions & 5 deletions

File tree

‎jet_array/log_space_ops.py‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,23 @@ def log_add(a: LogSeries, b: LogSeries) -> LogSeries:
110110
small_log = jnp.where(a_bigger, b.log_mag, a.log_mag)
111111
big_sign = jnp.where(a_bigger, a.sign, b.sign)
112112

113-
# `delta = small_log - big_log <= 0`. Note: -inf - finite = -inf gives
114-
# exp(-inf) = 0, so the small=zero case naturally yields log|sum| = big.
115-
delta = small_log - big_log
113+
# Shift big_log to a finite reference when both operands are structural
114+
# zero (big_log = -inf), so that `small_log - big_log_safe = -inf - 0 =
115+
# -inf` keeps the dangerous branches finite. The both-zero case is
116+
# masked out below to LOG_ZERO regardless, so this shift is irrelevant
117+
# to the forward value but keeps the reverse-mode gradient finite.
118+
big_log_safe = jnp.where(jnp.isneginf(big_log),
119+
jnp.zeros_like(big_log), big_log)
120+
121+
# `delta = small_log - big_log_safe <= 0`. With the shift, both-zero
122+
# gives delta = -inf instead of NaN; finite-vs-zero unchanged because
123+
# big_log was already finite.
124+
delta = small_log - big_log_safe
116125

117126
# log1p(exp(delta)) for same sign (always finite, >= log 1 = 0)
118127
# log1p(-exp(delta)) for opposite sign (could be -inf at exact cancellation)
119-
same_sign_log = big_log + jnp.log1p(jnp.exp(delta))
120-
opp_sign_log = big_log + jnp.log1p(-jnp.exp(delta))
128+
same_sign_log = big_log_safe + jnp.log1p(jnp.exp(delta))
129+
opp_sign_log = big_log_safe + jnp.log1p(-jnp.exp(delta))
121130

122131
# Default: one of the operands is zero, so result is the other.
123132
# (We compute `big_log` and `big_sign` which already reflect "the

‎tests/test_log_space.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,34 @@ def test_log_add_with_zero():
140140
np.testing.assert_allclose(log_to_raw(res), a + b, rtol=1e-13)
141141

142142

143+
def test_log_add_both_zero_grad_finite():
144+
"""Both operands structural zero: forward is the zero, gradient stays finite.
145+
146+
Without the big_log_safe shift in log_add, ``small_log - big_log`` evaluates
147+
to ``(-inf) - (-inf) = NaN`` along the dangerous (same_sign / opp_sign)
148+
branches; the forward is masked correctly via ``where``, but reverse-mode
149+
autodiff still differentiates through the NaN computation and propagates a
150+
NaN gradient. This regression locks in the shifted-reference behaviour.
151+
"""
152+
from jet_array.log_space import LogSeries
153+
154+
zero_a = raw_to_log(jnp.array(0.0))
155+
zero_b = raw_to_log(jnp.array(0.0))
156+
157+
res = log_add(zero_a, zero_b)
158+
assert float(res.sign) == 0.0
159+
assert bool(jnp.isneginf(res.log_mag))
160+
161+
# Differentiate the result's log_mag w.r.t. a sign-zero LogSeries whose
162+
# log_mag is exactly -inf. Pre-fix this returned NaN.
163+
def loss(la):
164+
a_perturbed = LogSeries(sign=zero_a.sign, log_mag=la)
165+
return log_add(a_perturbed, zero_b).log_mag
166+
167+
g = jax.grad(loss)(zero_a.log_mag)
168+
assert not bool(jnp.isnan(g)), f"gradient is NaN: {g}"
169+
170+
143171
def test_log_sum_signed_logsumexp():
144172
v = jnp.array([1.0, -2.0, 3.0, -4.0, 5.0])
145173
res = log_sum(raw_to_log(v), axis=0)

0 commit comments

Comments
 (0)