Skip to content

fix NaN mixture log_prob gradient at zero weights - #2224

Open
esennesh wants to merge 2 commits into
pyro-ppl:masterfrom
esennesh:bugfix/null_mixture_weights_grad
Open

fix NaN mixture log_prob gradient at zero weights#2224
esennesh wants to merge 2 commits into
pyro-ppl:masterfrom
esennesh:bugfix/null_mixture_weights_grad

Conversation

@esennesh

Copy link
Copy Markdown
Contributor

_MixtureBase.log_prob wrote the density in log-weight form via log_softmax(mixing.logits) + log p_k. For a probs-parameterized mixing Categorical with an exact-zero weight, logits takes log(0): the forward value was masked to finite but the VJP evaluated 1/0 = inf, producing a NaN gradient into every parameter feeding the weights.

esennesh and others added 2 commits July 20, 2026 16:36
Introduce a shared `_CategoricalBase` for `CategoricalProbs` and
`CategoricalLogits`, holding the common `mean`/`variance`/`support`/
`enumerate_support` keyed off a `_param` hook (the raw stored parameter,
so shape/dtype queries never trigger a probs<->logits conversion).

Each subclass exposes `probs` without routing through the log-probs:
`CategoricalProbs` returns the stored weights directly (exact zeros
preserved), `CategoricalLogits` returns `softmax(logits)`. This gives a
mixing distribution a `probs` property that is safe to differentiate at
exact-zero weights, which the mixture log_prob fix relies on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_MixtureBase.log_prob wrote the density in log-weight form via
log_softmax(mixing.logits) + log p_k. For a probs-parameterized mixing
Categorical with an exact-zero weight, logits takes log(0): the forward
value was masked to finite but the VJP evaluated 1/0 = inf, producing a
NaN gradient into every parameter feeding the weights.

Compute log_prob as a numerically stable weighted logsumexp instead,
  log p(x) = m + log sum_k w_k exp(log p_k(x) - m),
reading mixing.probs (linear weights, never log(w_k)). This is exact in
both value and gradient: d/dw_k = p_k(x) / sum_j w_j p_j(x) stays finite
at w_k == 0, and d/d(log p_k) is the responsibility. The direct sum is
used rather than jax.nn.logsumexp's `b=`, which would silently zero the
gradient of any exactly-zero-weight component. Out-of-support components
(log p_k = -inf) drop out with an exact zero, and an all-out-of-support
value yields -inf with a zero (not NaN) gradient.

A private _bare_component_log_probs returns log p_k(x); component_log_probs
keeps its documented contract, now shared in _MixtureBase as
log(mixing.probs) + bare.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Qazalbash
Qazalbash requested review from Qazalbash, Copilot and juanitorduz and removed request for Copilot July 21, 2026 08:46
@Qazalbash

Copy link
Copy Markdown
Collaborator

Hi @esennesh, thank you. Do you have an MRE where this bug was appearing?

@esennesh

esennesh commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Hi @esennesh, thank you. Do you have an MRE where this bug was appearing?

The below should trigger it. That's minimal, of course. My actual use-case was a Gaussian mixture model in which the means and weights were dynamically determined by upstream latent variables, so that sometimes zero weights would occur in some components and sometimes not. Since we're working in Jax, the dynamic control flow to just leave out all zero-weight components is a bit finicky, if it's possible.

import jax.numpy as jnp
import numpyro.distributions as dist

def log_prob(probs):
    mixing = dist.Categorical(probs=probs)
    components = dist.Normal(jnp.array([0.0, 1.0, 2.0]), 1.0)
    return dist.MixtureSameFamily(mixing, components).log_prob(0.3)

probs = jnp.array([0.0, 0.5, 0.5])  # one component has exactly zero weight

print(log_prob(probs))            # finite either way: ~ -1.359
print(jax.grad(log_prob)(probs))  # master: [nan nan nan] — fixed: [1.454, 0.960, 0.577]```

@Qazalbash

Copy link
Copy Markdown
Collaborator

@esennesh, thanks for the MRE. I have modified it a little.

import jax
import jax.numpy as jnp

import numpyro.distributions as dist


def log_prob(probs):
    mixing_distribution = dist.Categorical(probs=probs, validate_args=True)
    component_distributions = dist.Normal(
        loc=jnp.array([0.0, 1.0, 2.0]),
        scale=1.0,
        validate_args=True,
    )
    return dist.MixtureSameFamily(
        mixing_distribution,
        component_distributions,
        validate_args=True,
    ).log_prob(0.3)


log_prob_jit = jax.jit(log_prob)

mixing_probs = jnp.array([0.0, 0.5, 0.5])

log_prob_val = log_prob(mixing_probs)
print(log_prob_val)

log_prob_val = log_prob_jit(mixing_probs)
print(log_prob_val)

with jax.debug_nans(True):
    grad_log_prob_val = jax.grad(log_prob)(mixing_probs)
    print(grad_log_prob_val)

with jax.debug_nans(True):
    grad_log_prob_val = jax.grad(log_prob_jit)(mixing_probs)
    print(grad_log_prob_val)

It is reproducing the same error. This is the output before fix:

-1.5938032
-1.5938032
Traceback (most recent call last):
  File "/home/gradf/academia/numpyro/test.py", line 32, in <module>
    grad_log_prob_val = jax.grad(log_prob)(mixing_probs)
  File "/home/gradf/academia/numpyro/test.py", line 18, in log_prob
    ).log_prob(0.3)
  File "/home/gradf/academia/numpyro/numpyro/distributions/util.py", line 799, in wrapper
    log_prob = log_prob_fn(self, *args, **kwargs)
  File "/home/gradf/academia/numpyro/numpyro/distributions/mixtures.py", line 170, in log_prob
    sum_log_probs = self.component_log_probs(value)
  File "/home/gradf/academia/numpyro/numpyro/distributions/mixtures.py", line 296, in component_log_probs
    return jax.nn.log_softmax(self.mixing_distribution.logits) + component_log_probs
  File "/home/gradf/academia/numpyro/numpyro/distributions/util.py", line 790, in __get__
    value = self.wrapped(instance)
  File "/home/gradf/academia/numpyro/numpyro/distributions/discrete.py", line 722, in logits
    return _to_logits_multinom(self.probs)
  File "/home/gradf/academia/numpyro/numpyro/distributions/discrete.py", line 72, in _to_logits_multinom
    return jnp.clip(jnp.log(probs), minval)
jax._src.source_info_util.JaxStackTraceBeforeTransformation: FloatingPointError: invalid value (nan) encountered in div

The preceding stack trace is the source of the JAX operation that, once transformed by JAX, triggered the following exception.

--------------------

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/gradf/academia/numpyro/test.py", line 32, in <module>
    grad_log_prob_val = jax.grad(log_prob)(mixing_probs)
FloatingPointError: invalid value (nan) encountered in div
--------------------
For simplicity, JAX has removed its internal frames from the traceback of the following exception. Set JAX_TRACEBACK_FILTERING=off to include these.

and after fix:

-1.5938032
-1.5938032
[ 0.          0.53704965 -0.53704953]
[ 0.          0.53704965 -0.53704953]

I assume the numerical answers are correct. And the fix I have proposed is,

def _to_logits_multinom(probs: ArrayLike) -> ArrayLike:
-    minval = jnp.finfo(jnp.result_type(probs)).min
-    return jnp.clip(jnp.log(probs), minval)
+    safe_probs = jnp.where(probs > 0, probs, 1.0)
+    safe_log_probs = jnp.where(probs > 0, jnp.log(safe_probs), -jnp.inf)
+    return safe_log_probs

@juanitorduz what are your thoughts?

@fehiepsi

Copy link
Copy Markdown
Member

Do I understand correctly that the issue can be addressed with simpler change?

@Qazalbash

Copy link
Copy Markdown
Collaborator

Do I understand correctly that the issue can be addressed with simpler change?

Yes. The MRE does pass with the little change. Although I have not tested it on the rest of the test suite.

@Qazalbash

Copy link
Copy Markdown
Collaborator

Hi @esennesh, have you had a chance to try my fix in your workflow to see if it is working?

@esennesh

esennesh commented Aug 1, 2026 via email

Copy link
Copy Markdown
Contributor Author

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.

3 participants