pulseqzero.math: differentiable math helpers
This documents src/pulseqzero/math.py, the small set of differentiable
helpers pulseq-zero uses internally (and exposes to users) wherever plain torch/numpy
would otherwise break the optimization pipeline: rounding operations that would zero out
gradients, and gradient/waveform interpolation that pypulseq itself does with numpy.
ceil, floor, round, and round_half_up are re-exported at the top level
(pulseqzero.ceil, etc. — see the README’s “Differentiable rounding” section). interp is
an internal helper used by wrapper/grad_funcs.py and is not re-exported; import it
explicitly with from pulseqzero.math import interp if you need it directly.
Straight-through rounding: ceil, floor, round
- Forward pass: behaves exactly like the corresponding
torch/numpyrounding operation. - Backward pass: returns the incoming gradient unchanged (a straight-through estimator) — these functions act as the identity for autograd, even though the forward value is discrete.
- Use case: rounding a timing (or any other sequence quantity) that is derived from an
optimized
requires_grad=Trueparameter, without killing its gradient. - Plain Python numbers /
numpyarrays are also accepted: pulseq-zero falls back tonp.ceil/np.floor/np.roundwhen the input isn’t a tensor autograd can attach to.
import pulseqzero as pp
y = pp.ceil(x) # differentiable version of torch.ceil
y = pp.floor(x) # differentiable version of torch.floor
y = pp.round(x) # differentiable version of torch.round
For plain numeric rounding outside of an optimization (nothing needs a gradient through it),
np.round / torch.round are simpler and fine to use instead.
round_half_up(n, decimals=0)
Differentiable rounding that rounds halves away from zero instead of using torch.round’s /
np.round’s banker’s rounding (round-half-to-even). Implemented as
floor(abs(n) * 10**decimals + 0.5) / 10**decimals, so it inherits floor’s straight-through
backward pass.
Known bug (matches pypulseq): because the sign is stripped before rounding and never restored,
round_half_upreturns the wrong sign for negative inputs (e.g.round_half_up(-2.5)does not give-3). This mirrors a bug in pypulseq’s ownround_half_up, kept here intentionally for parity — do not rely on this function for negative values.
interp(x, xp, fp, left=None, right=None, tol=None)
Autograd-compatible 1D linear interpolation mirroring numpy.interp — differentiable in x,
xp, and fp (unlike numpy.interp/torch, which have no autograd support for this at
all). Unlike ceil/floor/round above, this is not a straight-through estimator: the
forward value and its gradient both come from the real linear-interpolation formula.
- Linear within
[xp[0], xp[-1]]; outside that range it returnsleft(forx < xp[0]) andright(forx > xp[-1]), defaulting to the edge valuesfp[0]/fp[-1]likenumpy. Passleft=right=0to zero-fill outside the support. xpmust be sorted ascending.tolis a slack on the boundary test, in addition to whatnumpy.interpdoes: the default (a few ULP relative toxp’s magnitude) lets a query landing exactly on a boundary survive float rounding, e.g. whenxpandxare computed in different dtypes. Passtol=0for a strict boundary when the query grid is built from the exactxp.
This is what lets pulseq-zero superimpose and re-sample gradient waveforms (arbitrary
gradients, add_gradients, split_gradient_at, …) while keeping gradients flowing through
both the waveform amplitudes and the timing.