Multivariate Derivative Estimation#
Some discovery problems need derivatives of a surface rather than of a trajectory. Two examples:
PDE-style discovery. The target is
u_tand the candidate library containsu,u_x,u_xx, … — every one of them a partial derivative of the same measured field.Transform / shift laws. Time–temperature superposition rests on the identity
y(x, T) = f(x + s(T)) => y_T = s'(T) * y_x
with
x = log(omega)andy = log(G). Both partials have to come from one smoothed surface over(x, T)before the symbolic stage can sees'(T)at all.
jaxsr.dynamics.estimate_derivatives differentiates along a single axis: X is
(n_times, n_states) and t is a 1-D vector. That covers ODE discovery, but not
either case above. SurfaceDerivatives fills the gap.
Quick start#
import numpy as np
from jaxsr import SurfaceDerivatives
# A surface sampled on a rectangular grid
x = np.linspace(0.0, 2.0, 25)
T = np.linspace(-1.0, 1.0, 20)
xx, TT = np.meshgrid(x, T, indexing="ij")
Y = np.sin(2 * xx) * np.exp(0.5 * TT)
sigma = 0.01
Y_obs = Y + np.random.default_rng(0).normal(0, sigma, Y.shape)
est = SurfaceDerivatives(method="tensor_spline").fit([x, T], Y_obs, sigma=sigma)
y, dy = est.derivatives(est.coords_, order=[(1, 0), (0, 1)])
y_x, y_T = dy[:, 0], dy[:, 1]
print(est.summary())
SurfaceDerivatives
========================================
method : tensor_spline
data : 500 points, 2 dimensions
penalty lambda : 0.158489 (chosen by gcv)
effective dof : 97.89
residual std : 0.00988053
noise std used : 0.01
basis per dim : [12, 12] (degree 3)
Gridded data is passed as a list of axis arrays plus an N-D value array; scattered data
as an (n_points, n_dims) coordinate array plus a flat value array. Either way,
est.coords_ holds the flattened sample locations, which is usually what you want to
evaluate at.
The one-call form:
from jaxsr import estimate_partial_derivatives
y, dy = estimate_partial_derivatives(
[x, T], Y_obs, order=[(1, 0), (0, 1)], method="tensor_spline", sigma=sigma
)
Reading the API#
derivatives() returns a tuple. The first element is the smoothed surface; the second
is a column per requested order, in the order you asked for them (continuing from the
quick start above):
y, dy = est.derivatives(est.coords_, order=[(1, 0), (0, 1)]) # dy.shape == (n, 2)
y, dy = est.derivatives(est.coords_, order=(2, 0)) # dy.shape == (n, 1)
y, dy, dy_se = est.derivatives(est.coords_, order=[(1, 1)], return_std=True)
Each order is a tuple with one entry per coordinate: (1, 0) is the first partial with
respect to dimension 0, (0, 1) the first partial with respect to dimension 1,
(2, 0) the second partial in dimension 0, and (1, 1) the mixed partial. sigma is a
standard deviation (scalar or per point), not a variance.
predict() is the order-zero case, with optional standard errors:
mean, std = est.predict(est.coords_, return_std=True)
Choosing a smoother#
SurfaceDerivatives(method="tensor_spline") # default
SurfaceDerivatives(method="local_poly")
SurfaceDerivatives(method="gp")
Method |
Best for |
Cost |
Uncertainty |
|---|---|---|---|
|
the default; gridded or scattered data in any dimension |
fast — one penalized least-squares solve |
posterior of the penalized fit |
|
irregular sampling, local structure |
moderate — one weighted fit per query point |
sandwich variance of the local fit |
|
irregular sampling, honest uncertainty, |
cubic in |
exact posterior; grows away from the data |
All three return analytic partials of the fitted smoother. None of them finite-differences the raw data, which is what makes second derivatives usable at all under noise.
degree bounds what you can ask for: for "tensor_spline" no single dimension may be
differentiated more than degree times; for "local_poly" the total order may not
exceed degree. The default degree=3 covers u_xx and mixed second partials.
Choosing the smoothing level#
This is the part that decides whether the downstream symbolic result is trustworthy.
Never select the smoothing hyperparameter by the downstream symbolic score. A smoother tuned against the regression that consumes it can manufacture whichever law the regression prefers — the fit looks excellent and the failure is silent. JAXSR therefore only offers criteria that are blind to the symbolic stage:
|
How the level is chosen |
|---|---|
|
generalized cross-validation for |
|
from the noise you supply to |
a float |
used verbatim — the penalty |
Replicates are the cleanest source of sigma: pool the within-replicate variance and
pass its square root.
Reporting and diagnosing smoothing bias#
Smoothing flattens derivatives, and a flatter derivative reports a smaller coefficient. The bias tracks the noise level and the smoothing level, and is roughly flat in the amount of data — so more data does not remove it. It is predictable and calibratable, but only if the smoothing level is visible (continuing from the quick start above):
est.smoothing_ # λ, bandwidth, or noise variance actually used
est.smoothing_source_ # "gcv", "marginal_likelihood", "sigma", or "fixed"
est.effective_dof_ # effective degrees of freedom of the smoother
est.residual_std_ # residual scatter at the sample points
est.noise_std_ # noise level backing the reported uncertainties
print(est.summary()) # all of the above, formatted
smoothing_scale multiplies the selected level, which turns “how much does my answer
depend on the derivative stage?” into a three-line experiment:
for scale in (1.0, 3.0, 10.0):
est = SurfaceDerivatives(smoothing_scale=scale).fit([x, T], Y_obs, sigma=sigma)
_, dy = est.derivatives(est.coords_, order=[(1, 0), (0, 1)])
... # rerun the symbolic stage, record the coefficient
Report the spread across scales alongside the point estimate. A coefficient that moves
by 10% between ×1 and ×10 is telling you where its error bar really comes from.
Worked example: PDE-style discovery#
Recovering the heat equation u_t = 0.1 * u_xx from a noisy field:
import numpy as np
from jaxsr import BasisLibrary, SurfaceDerivatives, SymbolicRegressor
x = np.linspace(0, 2 * np.pi, 40)
t = np.linspace(0, 1.0, 30)
xx, tt = np.meshgrid(x, t, indexing="ij")
u = np.exp(-0.1 * tt) * np.sin(xx) + 0.5 * np.exp(-0.9 * tt) * np.sin(3 * xx)
noise = 0.002
u_obs = u + np.random.default_rng(0).normal(0, noise, u.shape)
# One smoothed surface -> every partial the library needs
est = SurfaceDerivatives().fit([x, t], u_obs, sigma=noise)
values, d = est.derivatives(est.coords_, order=[(1, 0), (2, 0), (0, 1)])
u_x, u_xx, u_t = d[:, 0], d[:, 1], d[:, 2]
library = (
BasisLibrary(n_features=3, feature_names=["u", "u_x", "u_xx"])
.add_linear()
.add_interactions(max_order=2)
)
model = SymbolicRegressor(basis_library=library, max_terms=1).fit(
np.column_stack([values, u_x, u_xx]), u_t
)
print(model.expression_)
y = 0.09583*u_xx
The right term, with a coefficient about 4% low. Raising smoothing_scale to 10.0
moves it to 0.09043 — the derivative stage, not the symbolic stage, is what sets that
digit.
Worked example: a shift law#
For y(x, T) = f(x + s(T)), the slope ratio is the derivative of the shift law. Here
the shift is Arrhenius, so s'(T) = E / (R ln(10) T**2) and the activation energy E
falls out of the ratio:
import numpy as np
from jaxsr import SurfaceDerivatives
# Synthetic time-temperature superposition data
gas_r, energy, t_ref = 8.314e-3, 55.85, 350.0 # kJ/mol/K, kJ/mol, K
x_axis = np.linspace(-2.0, 4.0, 20) # log frequency
T_axis = np.linspace(320.0, 400.0, 12) # temperature, K
xx, TT = np.meshgrid(x_axis, T_axis, indexing="ij")
shift = -(energy / (gas_r * np.log(10.0))) * (1.0 / TT - 1.0 / t_ref)
sigma = 0.01
Y_grid = 2.0 + 1.5 * np.tanh(0.8 * (xx + shift - 1.0))
Y_grid = Y_grid + np.random.default_rng(0).normal(0, sigma, Y_grid.shape)
# Both partials from one smoothed surface
est = SurfaceDerivatives(smoothing="sigma").fit([x_axis, T_axis], Y_grid, sigma=sigma)
_, d = est.derivatives(est.coords_, order=[(1, 0), (0, 1)])
y_x, y_T = d[:, 0], d[:, 1]
# The ratio is ill-posed wherever the master curve is flat
keep = np.abs(y_x) > 0.15 * np.abs(y_x).max()
s_prime = y_T[keep] / y_x[keep]
T_keep = est.coords_[keep, 1]
E_eff = np.median(s_prime * gas_r * np.log(10.0) * T_keep**2)
print(f"E_eff = {E_eff:.2f} kJ/mol") # E_eff = 55.93 kJ/mol (true 55.85)
s_prime versus T_keep is also an ordinary symbolic regression problem in its own
right — fit it with a SymbolicRegressor over a library containing 1/T**2 instead of
assuming the Arrhenius form, and let the selection decide.
Pitfalls#
Boundaries. Every smoother is weakest at the edge of its data. Drop a margin before reading derivatives, or expect the largest errors there.
Ratios of partials. Mask points where the denominator is near zero, as above.
High orders. Accuracy degrades with each order. Second partials need a well-sampled surface; third and higher rarely survive realistic noise.
GP size.
method="gp"is cubic in the number of points and raises abovemax_points=800rather than hanging. Subsample, or use"tensor_spline".Basis resolution. The spline uses at most 12 basis functions per dimension by default, because GCV picks the penalty that is best for the fit and tends to under-smooth derivatives. Pass
n_basis=when the surface genuinely has finer structure.Irregular sampling. Rectangular grids are the best-tested case. For strongly irregular sampling prefer
"gp"(or"local_poly"), and check the reported uncertainty rather than assuming it.