Hierarchical fits¶
This page provides an overview of fitting several experiments together, with parameters shared across all of them, tied within clusters of experiments, or local to each experiment. Throughout, we fit synthetic data from three electrode particles, each run under a potentiostatic and a galvanostatic protocol. We recover a shared diffusivity and a per-particle rate constant.
The particle model¶
Each particle is a sphere of radius \(R\) in which the lithium filling fraction \(c \in (0, 1)\) diffuses,
with the intercalation flux into the particle at the surface given by Butler-Volmer kinetics against the ideal-solution open-circuit potential, in units of the thermal voltage,
where \(c_s\) is the surface fraction, \(V\) the applied potential, and \(k\) the rate constant. Under the potentiostatic protocol \(V\) is held at a fixed value. Under the galvanostatic protocol the total current \(4 \pi R^2 j\) is held at a fixed value and \(V\) is the algebraic unknown that closes it (constraints). Simplifying the expression, the flux is \(j = k\,[(1 - c_s)\,e^{-V/2} - c_s\,e^{V/2}]\), the form the recipe evaluates. The recipe works in nanometers and seconds, and is a template (recipe templates) whose arguments are the radius and the protocol, so that one declaration serves every particle:
n = 24
def intercalation_flux(c_s, V, k):
return k * ((1.0 - c_s) * jnp.exp(-V / 2) - c_s * jnp.exp(V / 2)) # Butler-Volmer against the ideal OCV
@ds.recipe
def particle(R=100.0, protocol="potentiostatic"):
spec = ds.problem(f"particle_{protocol}")
spec.domain("particle", spherical_grid_1d(n, R, method="fvm"))
spec.field("c", mass="differential", scale=1.0)
spec.param("D", value=3e3, learnable=True, bounds=(1e2, 1e6)) # nm^2 / s, shared by every particle
spec.param("k", value=10.0, learnable=True, bounds=(0.1, 1e3)) # nm / s, one value per particle
@spec.equation(writes="c", reads=("c",))
def diffusion(q):
return q.p.D * q.ops.laplacian(q.c)
if protocol == "potentiostatic":
spec.control.potentiostatic(name="V", fn=lambda t: -2.0)
spec.bc.flux("c", node_indices=np.array([n - 1]), fn=lambda q, t: intercalation_flux(q.c, q.control.V, q.p.k))
else:
spec.global_var("V", initial=-1.0)
spec.bc.flux("c", node_indices=np.array([n - 1]), fn=lambda q, t: intercalation_flux(q.c, q.V, q.p.k))
spec.constraint.galvanostatic(
name="current", closes="V",
current_fn=lambda q: 4 * jnp.pi * R**2 * intercalation_flux(q.c[n - 1], q.V, q.p.k),
target_fn=lambda t: 4 * jnp.pi * R**2 * R / 12.0)
return spec
D = particle.handle("D")
k = particle.handle("k")
Particle A has \(R = 100\) nm and \(k = 30\) nm/s, particle B has \(R = 200\) nm and \(k = 15\) nm/s, particle C has \(R = 150\) nm and \(k = 20\) nm/s, and all three share \(D = 10^4\) nm\(^2\)/s. The six experiments are solved at those values and sampled at ten times with noise of scale \(10^{-3}\). Every particle starts at \(c = 0.2\):
solver = ds.JIDA()
ts = jnp.linspace(0.2, 2.0, 10)
particles = {"A": 100.0, "B": 200.0, "C": 150.0}
k_true = {"A": 30.0, "B": 15.0, "C": 20.0}
experiments = []
for label, R in particles.items():
for protocol in ("potentiostatic", "galvanostatic"):
problem = particle.build(R=R, protocol=protocol)
y0 = problem.assemble_state({"c": 0.2})
theta_true = ds.default_theta(problem, {D: 1e4, k: k_true[label]})
sol = ds.pdesolve(problem, solver, (0.0, 2.0), y0, theta=theta_true, saveat=ds.SaveAt(ts=ts))
data = sol.ys + 1e-3 * np.random.default_rng(len(experiments)).standard_normal(sol.ys.shape)
experiments.append(ds.inference.Experiment(
particle, inputs={"R": R, "protocol": protocol}, id=f"{label}-{protocol}",
solver=solver, t_span=(0.0, 2.0), initial_state=y0, parameters={"particle": label},
measurements=(ds.inference.Measurement("c", ts, data, likelihood=ds.inference.Gaussian(sigma=1e-3)),)))
The hierarchical least-squares problem¶
Here we parameterize the solution of experiment \(i\), \(\hat u_i(\theta_s, \theta_{k(i)}, \theta_i)\), with three tiers of learnable parameters: \(\theta_s\) shared across every experiment, \(\theta_k\) tied within the cluster \(k(i)\) that experiment \(i\) belongs to, and \(\theta_i\) local to experiment \(i\). The shared tier holds the physics common to every experiment, such as a material's diffusivity. A cluster holds what a group of experiments has in common, such as the geometry of one particle run under several protocols. The local tier holds what belongs to one experiment alone, such as a quantity specific to its protocol. Given the observed data \(u_{\text{data}, i}\) of each experiment, we fit every tier at once by minimizing the joint least-squares loss
In our example \(\theta_s = D\), the clusters are the three particles with \(\theta_A = k_A\), \(\theta_B = k_B\), and \(\theta_C = k_C\), each cluster holding one potentiostatic and one galvanostatic experiment, and the local tier is empty. The joint decision vector is ordered \([\theta_s \mid \theta_A\, \theta_B \cdots \mid \theta_1 \cdots \theta_N]\).
Each experiment couples only to the shared block and to its own
cluster, so the Jacobian and the Hessian of the loss have an arrowhead
structure, one block per cluster bordered by the shared block. ds.inference.TrustRegion factors that structure cluster by cluster
and solves the shared block last, so the joint Hessian is never
assembled.
The full fit:
hierarchy = ds.inference.Hierarchy(levels=("particle",), shared=(D,), per={"particle": (k,)})
study = ds.inference.Study(experiments, parameters=hierarchy)
lowered = ds.lower(study)
result = ds.inference.optsolve(lowered, ds.inference.TrustRegion())
result[D] # the shared diffusivity
result[k]["A"] # the rate constant of particle A
result[k]["B"] # the rate constant of particle B
result[k]["C"] # the rate constant of particle C
Solving the hierarchical least-squares problem¶
To solve this problem, we declare one ds.inference.Experiment per
experiment, tie their parameters together in a ds.inference.Study
through a ds.inference.Hierarchy, lower the study with ds.lower, and
solve it using ds.inference.optsolve with
ds.inference.TrustRegion().
Declare the experiments¶
Each experiment is declared as on
fitting parameters, with the
template in place of a built problem and its arguments under inputs=.
experiments.append(ds.inference.Experiment(
particle, inputs={"R": R, "protocol": protocol}, id=f"{label}-{protocol}",
solver=solver, t_span=(0.0, 2.0), initial_state=y0, parameters={"particle": label},
measurements=(ds.inference.Measurement("c", ts, data, likelihood=ds.inference.Gaussian(sigma=1e-3)),)))
modelis the template.inputs=gives the arguments of this experiment's build, and the experiment builds the recipe for them.inputs=may also name a declared parameter, fixing its value for this experiment alone. A parameter that is fixed here and fitted throughparameters=is refused.id=names the experiment in the result and in the reports. The default is built frominputs=.parameters=names the experiment's member of each level of the hierarchy, here the particle it belongs to.- A built problem is accepted in place of the template, with the handles
of any one build. A handle is matched to each experiment's declaration
by name, and a declaration whose size or
learnable=flag differs between experiments is refused.
Pose the joint inference problem¶
ds.inference.Hierarchy declares which parameters are shared and which
take one value per member of a level, ds.inference.Study ties the
experiments to it, and ds.lower prepares the study for fitting (pose
the inference problem).
hierarchy = ds.inference.Hierarchy(levels=("particle",), shared=(D,), per={"particle": (k,)})
study = ds.inference.Study(experiments, parameters=hierarchy)
lowered = ds.lower(study)
levels=names the levels between the study and its experiments, outermost first. Here the one level is the particle.shared=lists the handles with one value for the whole study.per=maps a level name to the handles that take one value per member of that level. The key"experiment"places a handle with one value per experiment.- The experiments that name the same member share its value. The joint
coordinates are named by member,
k@Aandk@B, in the reports. - Bounds are those declared on the handle in each experiment, and a tied handle declares the same bounds in every experiment (box bounds).
layout=onds.lowerplaces the experiments across several devices (multi-GPU parallelism).lowered.n_paramsis the length of the joint decision vector.
Run the fit¶
ds.inference.TrustRegion() is the recommended optimizer for a
hierarchical study. It is a trust-region Gauss-Newton method solved
level by level. The curvature is assembled one cluster at a time from
that cluster's experiments, and each step is solved from the clusters up
to the shared parameters.
result = ds.inference.optsolve(lowered, ds.inference.TrustRegion())
The options, with their defaults:
hessian="gauss-newton"uses the Gauss-Newton curvature, and"true"the exact Hessian.max_steps=30bounds the number of steps.target_accepted_steps=None, when set, ends the run after that many accepted steps with the statusACCEPTED_STEPS.nested_at=Nonenames a level at which the fit splits into two loops. The levels above it take the outer step, and each member of that level then refits its own parameters.radius_init=,radius_max=,eta=,grad_tol=,step_tol=,cost_tol=, andx_scale=are the trust-region options ofds.inference.NewtonTrustRegion(Newton trust region).weights=Nonegives one nonnegative weight per cluster, multiplying that cluster's contribution to the loss.constraints=()andgauges=()add linear equality constraints on the joint vector,LinearConstraintandGaugefromdiff_soup.inference.statistics.parameters. A gauge pins a direction the data leave free, such as the mean of a spatial field.fixed_indices=()freezes the joint coordinates at those indices.callbacks=()attaches callbacks to the solver (run the fit).ds.inference.optsolve(lowered, ..., checkpoint=directory)saves the fit after every attempt and, when the directory already holds one, resumes from the newest.
ds.inference.NewtonTrustRegion() and
ds.inference.LevenbergMarquardt() also fit a lowered study, by dense
steps over the whole joint vector. TrustRegion is the best choice when
the joint vector is large, since its curvature stays cluster-local.
Analyze the result¶
The result object carries the fields of a single fit (analyze the result) with the joint structure on top.
result[D] # the shared diffusivity
result[k] # {"A": ..., "B": ..., "C": ...}, one value per cluster
result.experiments["A-potentiostatic"][k] # the value that experiment used
result.experiments["A-potentiostatic"].opt_params
result[D]returns the fitted value of a shared handle, andresult[k]a dictionary from cluster label to the fitted value of a varying handle.result.experimentsholds one entry per experiment by itsid, with[handle]the value that experiment used, andopt_paramsthe experiment's parameters in the formds.pdesolvetakes throughtheta=.result.paramsis the joint decision vector, shared handles first.
Uncertainty across the population¶
The uncertainty methods of statistical estimation and identifiability take the joint problem and its fit, and index their results by handle and by cluster label. The population methods estimate the spread of a parameter across the clusters:
posterior = ds.inference.laplace(lowered, result)
posterior[D].sd # the standard error of the shared diffusivity
posterior[k]["A"].sd # the standard error of the rate constant of particle A
posterior.cov[D, k][None, "A"] # the covariance of D with k of particle A
robust = ds.inference.cluster_covariance(lowered, result, cluster="particle")
boot = ds.inference.bootstrap(lowered, result, replicates=100, key=jax.random.key(0))
boot.interval(D) # the bootstrap interval of the shared diffusivity
prof = ds.inference.profile(lowered, result, k, owner="A", optimizer=ds.inference.TrustRegion(), max_steps=8)
prof.interval(0.95) # the profile interval of the rate constant of particle A
ds.inference.laplace(problem, fit)is the Laplace approximation of the joint posterior.posterior[D]is the marginal of a shared handle,posterior[k]["A"]the marginal of a varying handle in one cluster, andposterior.cov[D, k]a dictionary of covariances keyed by the pair of cluster labels,Nonefor a shared handle.ds.inference.cluster_covariance(problem, fit, *, cluster)estimates the covariance of the fit from the spread of the residuals between the clusters ofcluster, without trusting the declared noise scale.ds.inference.bootstrap(problem, fit, *, replicates, key)refits the studyreplicatestimes with the clusters reweighted, andboot[handle]returns the fitted values across the replicates. It refuses fewer than three clusters, naming the count and the minimum.steps=3is the number of accepted trust-region steps each replicate takes from the fit, andsteps="refit"refits each replicate to convergence.checkpoint=saves the run to a directory, and a later call with the same arguments resumes from it.ds.inference.profile(problem, fit, handle, *, owner=, optimizer=)walks one cluster's value of a varying handle, named byowner=, with every other parameter refitted byds.inference.TrustRegion(the profile likelihood).