27. Custom Autograd Function: Numerically Stable Softplus
torch.autograd.Function subclasses in production PyTorch code, and for the standard max(x,0) + log1p(exp(-|x|)) identity used to keep softplus-style nonlinearities from overflowing on extreme activations.Problem
Cartesia's models train on long audio/sequence data where activations can hit extreme magnitudes, so a naive nonlinearity can silently overflow. The softplus activation, softplus(x) = log(1 + exp(x)), is a smooth relative of ReLU used in gating and probabilistic-output layers. Computed literally, exp(x) overflows to inf for x much above ~88 in float32, and log(1 + inf) then evaluates to inf instead of the true answer (which is just ~x for large x).
PyTorch's autograd can usually differentiate through whatever forward code you write, but sometimes you need a custom torch.autograd.Function anyway: to hand-derive a backward pass that's cheaper or more stable than what autograd would build automatically, or to wrap a forward that isn't itself differentiable end to end. This problem is that exercise in miniature — implement both forward and backward by hand.
Source: src/27_custom_autograd_softplus.py
class StableSoftplus(torch.autograd.Function):
@staticmethod
def forward(ctx, x: torch.Tensor) -> torch.Tensor: ...
@staticmethod
def backward(ctx, grad_output: torch.Tensor) -> torch.Tensor: ...
def stable_softplus(x: torch.Tensor) -> torch.Tensor: ...
>>> x = torch.tensor([-1.0, 0.0, 1.0])
>>> stable_softplus(x)
tensor([0.3133, 0.6931, 1.3133])
>>> x = torch.tensor([100.0]) # naive log(1+exp(x)) would overflow to inf
>>> stable_softplus(x)
tensor([100.])
Step-by-Step Approach
- Recall the numerically stable softplus identity:
softplus(x) = max(x, 0) + log1p(exp(-|x|)). Sinceexp(-|x|)is always in(0, 1]no matter how large|x|gets, this form never overflows. - In
forward, compute the output with that identity instead of the literallog(1+exp(x))formula, and save whatever tensor(s)backwardwill need viactx.save_for_backward. - Work out the derivative by hand:
d/dx softplus(x) = sigmoid(x). This is a standard calculus fact (differentiatelog(1+exp(x))and simplify) worth having memorized going in. - In
backward, retrieve the saved input viactx.saved_tensorsand returngrad_output * torch.sigmoid(x)— the chain rule applied to the upstream gradient. - Notice that
sigmoiditself is already numerically stable for all inputs (it saturates toward 0 or 1 rather than overflowing), so unlike forward, backward needs no special-casing. - Wrap the
autograd.Functionin a plain function (stable_softplus) that calls.apply(x)—Functionsubclasses are invoked via.apply, never by callingforwarddirectly.
The key insight is that forward-pass stability and backward-pass stability are separate problems with separate fixes: the exponential in the forward formula needed rewriting to avoid overflow, but its derivative (sigmoid) was already safe, so the custom backward ends up simpler than the custom forward.
Reference solution
import torch
class StableSoftplus(torch.autograd.Function):
@staticmethod
def forward(ctx, x: torch.Tensor) -> torch.Tensor:
# softplus(x) = log(1 + exp(x))
# Stable identity: max(x, 0) + log1p(exp(-|x|))
# - for large positive x: max(x,0)=x dominates, exp(-|x|)->0, log1p(~0)=~0 -> ~x (correct)
# - for large negative x: max(x,0)=0, exp(-|x|)=exp(x)->0 -> ~0 (correct)
# exp(-|x|) is always in (0, 1], so it never overflows regardless of x's magnitude.
ctx.save_for_backward(x)
return torch.clamp(x, min=0) + torch.log1p(torch.exp(-torch.abs(x)))
@staticmethod
def backward(ctx, grad_output: torch.Tensor) -> torch.Tensor:
# d/dx softplus(x) = sigmoid(x); sigmoid itself is numerically stable
# for all x (it saturates to 0/1 rather than overflowing), so no
# special-casing is needed here even though forward needed it.
(x,) = ctx.saved_tensors
return grad_output * torch.sigmoid(x)
def stable_softplus(x: torch.Tensor) -> torch.Tensor:
return StableSoftplus.apply(x)
TEST_CASES = [
{
"name": "matches F.softplus on moderate values",
"x": torch.tensor([-2.0, -0.5, 0.0, 0.5, 2.0]),
},
{
"name": "large positive input stays finite (naive formula would overflow)",
"x": torch.tensor([50.0, 88.0, 100.0, 500.0]),
},
{
"name": "large negative input stays finite and near zero",
"x": torch.tensor([-50.0, -100.0, -500.0]),
},
{
"name": "gradient matches sigmoid(x)",
"x": torch.tensor([-3.0, -0.2, 0.0, 0.2, 3.0]),
},
]
def main():
import torch.nn.functional as F
torch.manual_seed(0)
for i, case in enumerate(TEST_CASES[:3]):
x = case["x"]
print(f"Test {i}: {case['name']}, x.shape={tuple(x.shape)}")
y = stable_softplus(x)
print(f" forward output: {y}")
assert torch.isfinite(y).all(), f"non-finite output: {y}"
expected = F.softplus(x.double()).float()
torch.testing.assert_close(y, expected, atol=1e-4, rtol=1e-4)
print("PASSED")
case = TEST_CASES[3]
x = case["x"].clone().requires_grad_(True)
print(f"Test 3: {case['name']}, x.shape={tuple(x.shape)}")
y = stable_softplus(x)
y.sum().backward()
print(f" grad: {x.grad}")
expected_grad = torch.sigmoid(x.detach())
torch.testing.assert_close(x.grad, expected_grad, atol=1e-5, rtol=1e-5)
print("PASSED")
if __name__ == "__main__":
main()
Key Functions & Tricks
torch.autograd.Function— base class for defining custom forward/backward pairs; subclass it and implementforwardandbackwardas@staticmethods.ctx.save_for_backward(x)— stashes tensors during forward so backward can retrieve them later without keeping the whole computation graph around.ctx.saved_tensors— retrieves whatever was stashed withsave_for_backward, as a tuple..apply(x)— the correct way to invoke a customFunction— callingStableSoftplus.forward(x)directly would skip autograd bookkeeping entirely.torch.log1p(y)— computeslog(1+y)directly, more accurate thantorch.log(1+y)whenyis close to zero.torch.clamp(x, min=0)— equivalent tomax(x, 0)elementwise, used as the stable identity's first term.torch.sigmoid(x)— the closed-form derivative of softplus; also happens to be its own numerically-stable building block.
How to Recognize This Pattern
The signal is any elementwise function whose textbook formula involves an exponential or logarithm that can overflow/underflow at realistic input magnitudes — softmax, log-sum-exp, softplus, and sigmoid all share this shape. The standard fix is an algebraically equivalent rewrite that factors out the largest term before exponentiating (here, max(x,0) pulled out front). A common variation asks for a second custom op in the same interview, testing whether you reach for the same identity twice. The most common pitfall is deriving the backward incorrectly by forgetting the chain rule's incoming grad_output factor — backward must return grad_output * local_derivative, not just the local derivative on its own.