Statistical estimation¶
This page provides an overview of fitting the learnable parameters of a
model to observed data as a statistical estimation problem with
ds.inference.InferenceProblem.
For maximum likelihood estimation, we assume the data, \(u_{\text{data}}\), is distributed under a known distribution, \(u_{\text{data}} \sim p(u_{\text{data}})\), such that our loss function is replaced with a likelihood \(p(u_{\text{data}} \mid \theta)\), where \(\theta\) are the model parameters. For maximum a posteriori estimation, we also assume the model parameters are probabilistic unknowns, distributed as \(\theta \sim p(\theta)\). Throughout, we fit the synthetic data of fitting parameters, generated with additive Gaussian noise by solving the heat equation
with \(u(0, t) = u(1, t) = 0\), \(u(x, 0) = x(1 - x)\), and \(D = 0.3\), sampled at five times with noise of scale \(10^{-3}\):
import jax
jax.config.update("jax_enable_x64", True)
import jax.numpy as jnp
import numpy as np
import diff_soup as ds
from diff_soup.domains import cartesian_grid_1d
n = 32
spec = ds.problem("heat")
spec.domain("rod", cartesian_grid_1d(n, 0.0, 1.0, method="fvm"))
spec.field("u", mass="differential", scale=1.0, init=lambda x: x * (1.0 - x))
spec.param("D", value=0.3) # the generating diffusivity
@spec.equation(writes="u", reads=("u",))
def heat(q):
return q.p.D * q.ops.laplacian(q.u)
spec.bc.dirichlet("u", node_indices=np.array([0, n - 1]), value=0.0)
problem_true = spec.build()
y0 = spec.assemble_state({})
solver = ds.JIDA()
ts = jnp.linspace(0.02, 0.1, 5)
sol_true = ds.pdesolve(problem_true, solver, (0.0, 0.1), y0, saveat=ds.SaveAt(ts=ts))
data = sol_true.ys + 1e-3 * np.random.default_rng(0).standard_normal(sol_true.ys.shape)
The estimation problem¶
Here we parameterize the solution of the heat equation, \(\hat u(\theta)\), with a learnable diffusivity denoted by the parameter \(\theta\), as in the least-squares problem. The vanilla least-squares problem treats the data as exact and fits \(\theta\) so that the solution matches the data as closely as possible. In contrast, a statistical estimation problem treats the data as uncertain, since every measurement carries noise, and under Bayesian assumptions also treats the model as uncertain, since \(\theta\) is not known before the data and what we know of it is incomplete. Both uncertainties are stated as probability densities.
The uncertainty in the data is the likelihood \(p(u_{\text{data}} \mid \theta)\), the probability of observing the data if the diffusivity were \(\theta\). Under the assumption of additive noise, it comes from the observation model
with \(\varepsilon\) the measurement noise, so the likelihood is the density of \(\varepsilon\) evaluated at the misfit \(u_{\text{data}} - \hat u(\theta)\). For independent Gaussian noise of scale \(\sigma\) at every point,
with \(\mathcal{L}\) the least-squares loss of fitting parameters.
In the Bayesian setting, we introduce the uncertainty in the model as the distribution \(p(\theta)\), the prior, what we believe about the diffusivity before the data, for example a normal density of mean \(\theta_0\) and scale \(\tau\). We then use Bayes' rule to describe the posterior as follows:
where the posterior encodes how likely our model is given the measured data. Maximum a posteriori estimation (MAP) then picks the most probable diffusivity given the data,
In a Bayesian setting, maximum likelihood estimation (MLE) can be interpreted as the MAP under an uninformative prior, \(p(\theta) \propto 1\),
with \(\ell\) the negative log likelihood. The posterior around the estimate is what standard errors and covariance, identifiability, and posterior sampling describe. The full maximum-likelihood fit:
spec = ds.problem("heat")
spec.domain("rod", cartesian_grid_1d(n, 0.0, 1.0, method="fvm"))
spec.field("u", mass="differential", scale=1.0, init=lambda x: x * (1.0 - x))
D = spec.param("D", value=0.1, learnable=True, bounds=(0.01, 2.0),
prior=ds.inference.NormalPrior(mean=0.25, scale=0.1)) # the learnable diffusivity and its prior
@spec.equation(writes="u", reads=("u",))
def heat(q):
return q.p.D * q.ops.laplacian(q.u)
spec.bc.dirichlet("u", node_indices=np.array([0, n - 1]), value=0.0)
problem = spec.build()
y0 = spec.assemble_state({})
measurement = ds.inference.Measurement("u", times=ts, values=data,
likelihood=ds.inference.Gaussian(sigma=1e-3)) # declare the data and its noise model
experiment = ds.inference.Experiment(model=problem, solver=solver, t_span=(0.0, 0.1),
initial_state=y0, measurements=(measurement,)) # bind the data to one solve
est = ds.inference.InferenceProblem(experiment, parameters=D, objective=ds.inference.MLE()) # pose the maximum-likelihood problem
fit = ds.inference.optsolve(est, ds.inference.LevenbergMarquardt()) # solve it
fit[D] # the maximum-likelihood diffusivity
Solving the estimation problem¶
The fit of fitting parameters
carries over with two additions. likelihood= on the measurement states
the noise model of the data, and objective= on the inference problem
selects the maximum-likelihood or the maximum-a-posteriori estimate.
spec.param also takes prior=, the prior on the parameter
(priors and MAP). ds.inference.optsolve and its
options are unchanged.
Declare the measurement¶
ds.inference.Measurement(name, times, values, *,
likelihood=ds.inference.Gaussian(sigma=1.0), observe=None, mask=None)
declares one stream of observed values together with its noise model
(declare the data).
measurement = ds.inference.Measurement("u", times=ts, values=data,
likelihood=ds.inference.Gaussian(sigma=1e-3))
likelihood=is the noise model of the measurement. The default isds.inference.Gaussian(sigma=1.0). The families are covered under likelihoods.
Declare the experiment¶
ds.inference.Experiment(model, solver, t_span, initial_state,
measurements, id=None, trajectory=None) binds the measurements to one
solve of the model (declare the experiment).
experiment = ds.inference.Experiment(model=problem, solver=solver, t_span=(0.0, 0.1),
initial_state=y0, measurements=(measurement,))
Pose the estimation problem¶
ds.inference.InferenceProblem(experiments, *, parameters,
objective=ds.inference.MLE(), ...) pairs the experiment with its
objective and is the problem ds.inference.optsolve solves
(pose the inference problem).
est = ds.inference.InferenceProblem(experiment, parameters=D, objective=ds.inference.MLE())
objective=isds.inference.MLE(), the default, ords.inference.MAP().ds.inference.MAP()requires a prior on every fitted parameter and is refused without one (priors and MAP).est.with_start(fit)returns the same problem started from an earlier fit of it.
Analyze the result¶
The result is the ds.inference.OptResult of
fitting parameters with its statistical
fields filled. fit[D] returns the fitted value of the parameter D
by handle.
fit[D] # the fitted value by handle
fit.objective # MLE()
fit.log_likelihood # the log likelihood at the fit
fit.log_prior # None for a maximum-likelihood fit
The fit is the starting point of the analysis below, goodness of fit, standard errors and covariance, and prediction bands, and of the identifiability analysis and posterior sampling.
Likelihoods¶
The likelihood is the noise model of a measurement, the density of \(\varepsilon\) in the observation model. Independent Gaussian noise of a known scale is the default and the textbook choice, and it is right whenever the errors are additive, symmetric, and of a similar size everywhere. The other families cover errors that are correlated across the values of one time, errors with outliers, counts, and errors that grow with the value. The families:
ds.inference.Gaussian(sigma=1e-3)
ds.inference.CorrelatedGaussian(cholesky=1e-3 * jnp.eye(n))
ds.inference.StudentT(df=4.0, scale=1e-3)
ds.inference.Poisson(exposure=1.0)
ds.inference.LogNormal(scale=0.1)
ds.inference.MultiplicativeGaussian(scale=0.1)
ds.inference.Gaussian(sigma)is independent Gaussian noise with standard deviationsigma, one value for every entry or one per entry. The fit is then the least-squares fit of fitting parameters with the weight \(1/\sigma\), and the scale sets the size of the standard errors.ds.inference.CorrelatedGaussian(cholesky)is Gaussian noise correlated across the values of one time, with covariance \(\Sigma = LL^\top\) andcholeskythe lower factor \(L\). It is the choice when neighbouring sensors share their errors, at the price of one factor of the size of a frame.ds.inference.StudentT(df, scale)is heavy-tailed noise withdfdegrees of freedom and scalescale. It tolerates outliers, which a Gaussian fit follows, and approaches the Gaussian asdfgrows.ds.inference.Poisson(exposure=1.0)is the noise of counts, with the predicted value timesexposureas the expected count. It is the choice for event counts, whose spread grows with the count.ds.inference.LogNormal(scale)is multiplicative noise, with the logarithm of each value Gaussian of standard deviationscaleand the predicted value its mean. It is the choice for a positive quantity whose errors grow in proportion to it.ds.inference.MultiplicativeGaussian(scale)is Gaussian noise of standard deviationscaletimes the predicted value. An observed value of zero is refused under it, and such times are masked out.
Every family is a normalized density, so fit.log_likelihood is the
log probability of the data under the model and compares across
families. Whether the chosen family describes the data is checked after
the fit under goodness of fit. For example, the
same fit under heavy-tailed noise:
student_measurement = ds.inference.Measurement(
"u", times=ts, values=data, likelihood=ds.inference.StudentT(df=4.0, scale=1e-3))
student_experiment = ds.inference.Experiment(
model=problem, solver=solver, t_span=(0.0, 0.1), initial_state=y0,
measurements=(student_measurement,))
student_fit = ds.inference.optsolve(
ds.inference.InferenceProblem(student_experiment, parameters=D),
ds.inference.LevenbergMarquardt())
student_fit[D] # the heavy-tailed estimate
Goodness of fit¶
ds.inference.residual_report(problem, fit, *, experiment_id=None)
checks the likelihood against the data after the fit. It standardizes
the residual of every measurement by its own likelihood, so that under
the declared noise model the standardized residuals have mean zero,
standard deviation one, and no correlation from one time to the next,
and it flags the departures in words:
report = ds.inference.residual_report(est, fit)
report.channels["u"].std # near one when the noise scale is right
report.channels["u"].mean # near zero
report.channels["u"].lag1_autocorrelation # near zero for independent errors
report.flags # the departures from the noise model, empty here
- A standard deviation far from one says the declared scale is off by that factor, and the standard errors with it.
- A mean far from zero says the model misses the data on one side.
- A lag-1 autocorrelation far from zero says the errors are correlated in time.
Each entry of channels also carries n_rows, cost, reduced_cost,
and the standardized residuals themselves. experiment_id= selects the
experiment when several are fitted together.
Priors and MAP¶
A prior states what we know about a parameter before the data, a
plausible value and how far from it we would be surprised. With a prior
declared on every fitted handle, ds.inference.MAP() minimizes the
negative log likelihood plus the negative log prior, and the fit is the
maximum-a-posteriori estimate. The prior matters when the data pin the
parameter down weakly, and it is negligible against informative data.
It is also what makes the posterior a distribution one can sample
(posterior sampling). The prior is declared on the handle,
spec.param(..., prior=) or spec.function(..., prior=), and the
families:
ds.inference.NormalPrior(mean=0.25, scale=0.1)
ds.inference.LogNormalPrior(median=0.25, log_sd=0.5)
ds.inference.GaussianCoefficientPrior(scale=1.0, smoothness=2.0)
ds.inference.NormalPrior(mean, scale)is a normal prior with the given mean and standard deviation. It is the textbook choice, and with the Gaussian likelihood the MAP fit is the ridge-regularized least-squares fit of regularization, centered onmean.ds.inference.LogNormalPrior(median, log_sd)is a log-normal prior on a positive parameter, withmedianits median andlog_sdthe standard deviation of its logarithm. It is the choice for a rate or a diffusivity that is positive and uncertain over orders of magnitude. It is refused on a function handle.ds.inference.GaussianCoefficientPrior(scale, smoothness=2.0)is a normal prior on the coefficients of a learnable function, centered on the declared coefficients, with a standard deviation that shrinks with the degree at the ratesmoothness(function approximation).
The MAP fit of the same study uses the prior declared on D:
est_map = ds.inference.InferenceProblem(experiment, parameters=D, objective=ds.inference.MAP())
fit_map = ds.inference.optsolve(est_map, ds.inference.LevenbergMarquardt())
fit_map[D] # the maximum-a-posteriori diffusivity
fit_map.objective # MAP()
fit_map.log_prior # the log prior density at the fit
Standard errors and covariance¶
Near the fit, the negative log likelihood is approximately quadratic,
with \(I\) the information matrix, the curvature of the negative log
likelihood at the fit. It measures how sharply the data pin down each
parameter. Its inverse is the covariance of the estimate, and the
square roots of its diagonal are the standard errors. For a MAP fit the
curvature of the prior adds to \(I\).
ds.inference.information(problem, *, at, kind="observed")
assembles the information matrix at a point:
info = ds.inference.information(est, at=fit)
info.observed # the information matrix, here 1 by 1
info.rank() # the number of directions the data determine
at=is the point, a fit result or a mapping from handle to value,{D: 0.3}.kind=selects the curvature."observed"is the curvature of the negative log likelihood at the data actually seen, and is the default."expected"is the expected information, the curvature averaged over data drawn from the model, which for Gaussian noise is the Gauss-Newton curvature. Any other name is refused.info.observedandinfo.expectedhold the matrix that was assembled. For a MAP problem,info.prior_precisionis the curvature of the prior andinfo.posterior_precisionthe sum of both.info.rank()counts the directions the data determine. The full spectrum is the subject of identifiability.
ds.inference.laplace(problem, fit, *, curvature="observed",
on_singular="raise") builds the Laplace approximation, the Gaussian
centered on the fit with the inverse of the information matrix as its
covariance:
posterior = ds.inference.laplace(est, fit)
posterior[D].sd # the standard error of D
posterior[D].interval(0.95) # the 95% interval of the Gaussian
posterior.cov[D, D] # the variance of D
posterior.sample(jax.random.PRNGKey(0), 1000) # draws from the Gaussian around the fit
fitis the fit result the Gaussian is centered on, or a mapping from handle to value in its place.curvature=is"observed"or"expected", as forkind=above. The default is"observed". Any other name is refused.on_singular=says what happens when the data leave a direction undetermined. The default"raise"refuses the approximation."subspace"returns it on the determined directions (identifiability). Any other name is refused.posterior[D]is the marginal of one parameter, withmean,sd, andinterval(level).posterior.cov[D, D]is the covariance of two parameters.posterior.marginal_sd()gives every standard error at once,posterior.covariance_dense()the full covariance, andposterior.sample(key, n)draws.- For a MAP fit
posterior.prior_spectrumholds the eigenvalues of the prior curvature. - A multiple-shooting fit that fits its window
starts has them integrated out, so
information,laplaceandidentifiabilityreport the parameters alone.
For a MAP fit, ds.inference.evidence(problem, fit, *,
curvature="observed") uses the same Gaussian to approximate the log
evidence, the logarithm of the normalizer \(p(u_{\text{data}})\) of
Bayes' rule:
with \(d\) the number of parameters and \(H\) the information matrix plus the curvature of the prior. Between priors or models fitted to the same data, the larger evidence is preferred.
ds.inference.evidence(est_map, fit_map).log_evidence # the log evidence of the MAP fit
ds.inference.laplace(est_map, fit_map).log_evidence # the same value
- The priors are the ones declared on the handles. A maximum-likelihood fit has no normalized prior and is refused.
- A fit that has not reached a stationary point is refused. The check
tolerates a Newton decrement of 400 times the solver
rtol, between 0.1 and 0.5, and credits a float32 solve with anrtolof at least 1e-3.
Uncertainty across the experiments of a population fit is estimated by
ds.inference.cluster_covariance and ds.inference.bootstrap
(hierarchical fits).
Prediction bands¶
ds.inference.predict(problem, fit, *, experiment_id=None, kind="latent",
level=0.95) carries the spread of the parameters through to the
predicted measurements. Each measurement is linearized at the fit and
the covariance of the Laplace approximation is propagated, giving one
band per measurement name:
latent = ds.inference.predict(est, fit)["u"] # from the parameter uncertainty alone
predictive = ds.inference.predict(est, fit, kind="predictive")["u"] # with the measurement noise added
latent.times, latent.mean, latent.sd, latent.lower, latent.upper # over the observation times
kind="latent"is the spread of the predicted values from the uncertainty of the parameters alone.kind="predictive"adds the noise of the measurement, the spread of a new observation.level=is the level of the intervallowertoupper.experiment_id=selects the experiment when several are fitted together.- The draws of posterior sampling are accepted in place of the fit, and the band is then the spread over the draws.