Skip to content

Commit 9f60a8c

Browse files
0xSoftBoiclaude
andauthored
fix(layers): make SoftExponential autograd-correct and NaN-safe (#788) (#809)
SoftExponential.forward branched on the learnable `alpha` parameter with a Python `if`, which had three problems: - `if self.alpha < 0.0` forces a host-device sync and drops the branch decision from the autograd graph, so `alpha` could only ever learn within whichever sign region it was initialised in. - `self.alpha == 0.0` is an exact-float test that is unreachable after the first optimizer step, so the intended near-zero identity never fired. - the `alpha < 0` formula `-log(1 - alpha*(x + alpha))/alpha` produces NaN/Inf for sufficiently negative `x`, which then poisons `alpha.grad`. Select the branches with `torch.where` (keeping both formulas in the graph), treat `|alpha| < eps` as the identity region, and guard the log argument and denominators so the activation and its gradient stay finite. Values in the well-defined region are numerically unchanged. Adds regression tests for finiteness of the output and of `alpha.grad` on negative inputs, and that `alpha` receives a usable gradient for both signs. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent af2685b commit 9f60a8c

3 files changed

Lines changed: 82 additions & 6 deletions

File tree

docs/changes.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ nav_order: 3
77
# Change Log
88

99
## Unreleased
10+
- **Fix: `SoftExponential` activation autograd correctness and NaN safety (#788).** `forward` now selects
11+
its `alpha < 0` / `alpha > 0` / `alpha ≈ 0` branches with `torch.where` instead of a Python `if` on the
12+
learnable `alpha` parameter. The old `if self.alpha < 0.0` forced a host-device sync and dropped the
13+
branch from the autograd graph (so `alpha` was effectively trapped in its initial sign region); the
14+
`alpha == 0.0` exact-float test was unreachable after the first optimizer step; and the `alpha < 0`
15+
formula produced NaN/Inf for sufficiently negative inputs. The log argument and denominators are now
16+
guarded so both the activation and `alpha.grad` stay finite. Values in the well-defined region are
17+
unchanged.
1018
- **New: `matgl.utils.MCDropoutWrapper` for uncertainty-aware inference.** Enables Monte Carlo Dropout
1119
(Gal & Ghahramani, 2016) on any pretrained MatGL model (CHGNet, M3GNet, TensorNet, …) without
1220
retraining: the backbone stays in `eval()` while only the readout dropout is sampled, and

src/matgl/layers/_activations.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ class SoftExponential(nn.Module):
5656
References: https://arxiv.org/pdf/1602.01321.pdf
5757
"""
5858

59+
# |alpha| below this is treated as the identity (alpha -> 0) region, and it
60+
# also floors the log argument / denominator to keep the activation and its
61+
# gradient finite.
62+
_eps = 1e-6
63+
5964
def __init__(self, alpha: float | None = None):
6065
"""Init SoftExponential with alpha value.
6166
@@ -75,17 +80,41 @@ def __init__(self, alpha: float | None = None):
7580
def forward(self, x: torch.Tensor) -> torch.Tensor:
7681
"""Evaluate activation function given the input tensor x.
7782
83+
Branch selection uses ``torch.where`` rather than a Python ``if`` on
84+
``self.alpha``: a host-side ``bool(self.alpha < 0)`` test forces a device
85+
sync and drops the branch from the autograd graph, so ``alpha`` could
86+
only ever learn within whichever sign region it was initialised in.
87+
``torch.where`` keeps both formulas in the graph. Because ``where`` still
88+
evaluates both sides, the denominators and the ``log`` argument are
89+
guarded so the discarded branch cannot inject NaN/Inf into the gradient
90+
(the "double where" trick).
91+
7892
Args:
7993
x (torch.tensor): Input tensor
8094
8195
Returns:
8296
out (torch.tensor): Output tensor
8397
"""
84-
if self.alpha == 0.0:
85-
return x
86-
if self.alpha < 0.0:
87-
return -torch.log(1.0 - self.alpha * (x + self.alpha)) / self.alpha
88-
return (torch.exp(self.alpha * x) - 1.0) / self.alpha + self.alpha
98+
alpha = self.alpha
99+
# Treat |alpha| < eps as the identity region (the alpha -> 0 limit)
100+
# instead of testing exact equality to 0.0, which a learned float never
101+
# hits after the first optimizer step.
102+
near_zero = alpha.abs() < self._eps
103+
# Never divide by a (near) zero alpha, even on the branch that gets
104+
# discarded, otherwise NaN/Inf would poison alpha.grad.
105+
safe_alpha = torch.where(near_zero, torch.ones_like(alpha), alpha)
106+
107+
# alpha < 0 branch: -log(1 - alpha*(x + alpha)) / alpha. The log argument
108+
# can go <= 0 for sufficiently negative x; clamp it to a small positive
109+
# floor on the live branch so it never produces NaN/Inf.
110+
neg_log_arg = torch.where(alpha < 0.0, 1.0 - alpha * (x + alpha), torch.ones_like(x))
111+
neg = -torch.log(neg_log_arg.clamp_min(self._eps)) / safe_alpha
112+
113+
# alpha > 0 branch: (exp(alpha*x) - 1)/alpha + alpha; expm1 is accurate near 0.
114+
pos = torch.expm1(safe_alpha * x) / safe_alpha + safe_alpha
115+
116+
out = torch.where(alpha < 0.0, neg, pos)
117+
return torch.where(near_zero, x, out)
89118

90119

91120
def softplus_inverse(x: torch.Tensor):

tests/layers/test_activations.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,53 @@ def test_softplus2(x):
1818

1919

2020
def test_soft_exponential(x):
21+
# alpha == 0 is the identity, but the output now participates in autograd
22+
# (alpha is a learnable parameter), so detach before going to numpy.
2123
out = SoftExponential()(x)
22-
np.testing.assert_allclose(out.numpy(), np.array([1.0, 2.0]))
24+
np.testing.assert_allclose(out.detach().numpy(), np.array([1.0, 2.0]))
2325
out = SoftExponential(1.0)(x)
2426
np.testing.assert_allclose(out.detach().numpy(), np.array([2.7182817, 7.389056]))
2527

2628
out = SoftExponential(-1.0)(x)
2729
np.testing.assert_allclose(out.detach().numpy(), np.array([0.0, 0.693147]), atol=1e-5)
2830

2931

32+
def test_soft_exponential_negative_x_is_finite():
33+
"""alpha < 0 must not produce NaN/Inf for large-negative inputs.
34+
35+
The alpha < 0 branch evaluates ``-log(1 - alpha*(x + alpha)) / alpha``. For
36+
sufficiently negative ``x`` the log argument goes <= 0, which yields NaN/Inf.
37+
Such inputs are easy to hit early in training when features are large.
38+
"""
39+
act = SoftExponential(-1.0)
40+
x = torch.tensor([-10.0, -5.0, -1.0, 0.0, 1.0])
41+
out = act(x)
42+
assert torch.isfinite(out).all(), f"non-finite output: {out}"
43+
44+
45+
def test_soft_exponential_alpha_grad_is_finite():
46+
"""dL/d(alpha) must stay finite even when inputs hit the undefined region.
47+
48+
A NaN in the forward pass propagates into ``alpha.grad`` and poisons the
49+
optimizer, silently killing training.
50+
"""
51+
act = SoftExponential(-1.0)
52+
x = torch.tensor([-10.0, -5.0, 1.0])
53+
act(x).sum().backward()
54+
assert act.alpha.grad is not None
55+
assert torch.isfinite(act.alpha.grad).all(), f"non-finite alpha.grad: {act.alpha.grad}"
56+
57+
58+
def test_soft_exponential_alpha_is_learnable_both_signs():
59+
"""alpha must receive a finite, non-zero gradient in both sign regions so it can train."""
60+
for alpha_init in (-0.7, 0.7):
61+
act = SoftExponential(alpha_init)
62+
x = torch.tensor([0.5, 1.5, 2.0])
63+
act(x).sum().backward()
64+
assert act.alpha.grad is not None
65+
assert torch.isfinite(act.alpha.grad).all()
66+
assert act.alpha.grad.abs() > 0
67+
68+
3069
def test_softplus_inverse(x):
3170
assert torch.allclose(softplus_inverse(torch.nn.functional.softplus(x)), x, atol=1e-5)

0 commit comments

Comments
 (0)