Experiment Design and Identifiability#
difflow.estimation fits parameters to experiments that have already been run.
This page covers the two questions that come before that:
Can these parameters be told apart at all from the measurements available? (
check_identifiability)Which experiments should be run next, and what confidence intervals would they buy? (
design_experiments,predicted_covariance)
Both rest on the sensitivity matrix
which is one jax.jacobian call on a differentiable model — exact, and about
as cheap as a single model evaluation. Computing \(S\) by finite differences is
what makes model-based design of experiments expensive elsewhere. The API
follows Pyomo.DoE (Wang & Dowling, AIChE
J. 68 (2022) e17813), which does the same thing for Pyomo models; see
Franceschini & Macchietto, Chem. Eng. Sci. 63 (2008) 4846,
doi:10.1016/j.ces.2007.11.034 for
the wider method.
Table of Contents#
difflow is not the only place design of experiments lives, and for many problems it is not the right one. The
discopt-doeplugin is a far larger DoE package — continuous design optimization, profile likelihood, model discrimination, classical and screening designs, Bayesian optimization. What is here is the narrow thing it cannot do: design against a model that only exists as a differentiable JAX function, such as a difflow flowsheet. See Related tools before you start.
The ordering is not optional#
Check identifiability first. If the sensitivity matrix is rank deficient — if some direction \(v\) in parameter space satisfies \(Sv = 0\) — then moving the parameters along \(v\) changes no prediction. The Fisher information \(S^T\Sigma^{-1}S\) is singular, the covariance is infinite along \(v\), every design criterion is degenerate, and the fitted parameters you get back are whatever the optimizer happened to stop at.
More data does not fix this. Additional experiments add rows to \(S\), and every new row is orthogonal to \(v\) by the same structural argument, so the null space survives any amount of data. The fixes are structural:
measure something else — a new kind of measurement that responds to \(v\),
reparameterize on the identifiable combination (fit \(k = AB\) rather than \(A\) and \(B\)),
fix one of the parameters from independent knowledge.
Because it is so easy to skip this step and then spend a month collecting data
that cannot possibly answer the question, design_experiments and
predicted_covariance run the rank test themselves and raise
IdentifiabilityError rather than returning a design built on a singular
matrix. You can pass require_identifiable=False to inspect a degenerate case
deliberately; you then get infinite standard errors, which is the honest answer.
import jax.numpy as jnp
from difflow.estimation import (
Experiment, check_identifiability, design_experiments, predicted_covariance,
)
# A toy extraction model: distribution coefficient D(T, pH) splits 0.1 mol/L
def model_fn(theta, exp):
T, pH = exp.inputs['T'], exp.inputs['pH']
lnD = theta['lnK'] - theta['dH'] * (1 / T - 1 / 298.0) / 8.314 + theta['n'] * pH
D = jnp.exp(lnD)
return {'C_aq': 0.1 / (1 + D), 'C_org': 0.1 * D / (1 + D)}
theta = {'lnK': -4.0, 'dH': 2.0e4, 'n': 1.5}
candidates = [
Experiment.candidate({'T': T, 'pH': pH}, measures=['C_aq', 'C_org'],
uncertainties={'C_aq': 1e-4, 'C_org': 1e-4})
for T in (298.0, 318.0, 338.0)
for pH in (1.0, 2.0, 3.0, 4.0)
]
report = check_identifiability(model_fn, theta, candidates) # 1. structural
print(report.summary())
report.raise_if_unidentifiable()
design = design_experiments(model_fn, theta, candidates, n=8) # 2. which runs
ci = predicted_covariance(model_fn, theta, design.selected) # 3. what they buy
# ... run the experiments, then Estimator.fit(), and iterate.
Candidate experiments#
Experiment doubles as the record of a run and as a candidate for a run that
has not happened yet. Everything the Fisher information needs — the inputs,
which outputs are measured, and their 1-sigma uncertainties — is known before
the run; only the measured values are not, and the FIM does not depend on them.
pool = [
Experiment.candidate({'T': T, 'pH': pH}, measures=['C_aq', 'C_org'],
uncertainties={'C_aq': 1e-4, 'C_org': 1e-4},
name=f'T{T:.0f}-pH{pH:.1f}')
for T in (298.0, 318.0, 338.0)
for pH in (1.0, 2.0, 3.0, 4.0)
]
candidate leaves observed empty and sets measured; measured_names falls
back to the keys of observed for a recorded experiment, so both kinds flow
through the same functions. sigma_array gives the 1-sigma vector, defaulting
to 1.0 for any output with no stated uncertainty.
Structural identifiability#
report = check_identifiability(model_fn, theta, candidates, param_names=None,
rank_tol=None, scale='theta')
Linearizes the model at theta and takes the numerical rank of the (weighted,
column-scaled) sensitivity matrix by SVD. IdentifiabilityReport carries:
field |
meaning |
|---|---|
|
|
|
the counts behind the verdict |
|
the full spectrum, so a marginal case can be looked at |
|
the threshold used, and how clean the gap is |
|
\(s_\max/s_\min\); large means practically unidentifiable |
|
parameters implicated in a null-space direction |
|
the offending directions, and a readable rendering |
|
diagnosis and enforcement |
Columns are scaled by \(|\theta_j|\) by default, so the test is about relative
sensitivity and does not change when a parameter is re-expressed in different
units. That is the same equilibration
difflow.reconciliation.structure.classify applies before its rank test, and
the SVD-based rank routine is literally reused from there: observability of a
reconciliation problem and identifiability of a parameter set are the same
linear-algebra question asked of different Jacobians.
Two classic failures, both detected:
def product(theta, exp): # A and B only ever appear as A*B
return {'y': theta['A'] * theta['B'] * exp.inputs['x']}
line_pool = [Experiment.candidate({'x': x}, ['y']) for x in (1.0, 2.0, 3.0)]
report = check_identifiability(product, {'A': 2.0, 'B': 3.0}, line_pool)
report.identifiable # False
report.unidentifiable # ['A', 'B']
report.combinations # ['- 0.707*A + 0.707*B ~ 0']
A full-rank report with a huge condition_number is a different diagnosis:
the parameters are separable in principle, but only weakly, and that is the
case experiment design is for.
Fisher information#
with \(\Sigma_i\) the diagonal measurement-error covariance built from each
experiment’s uncertainties.
from difflow.estimation import fisher_information
fim = fisher_information(model_fn, theta, candidates[:6], prior_fim=None)
Two properties do all the work:
It does not depend on the measured values, only on the conditions, the model and
theta. So a campaign can be scored before it runs.It is additive over experiments. The FIM of any subset is the sum of the per-experiment contributions, which is what makes greedy selection and exchange cheap: each is computed once and then added and subtracted.
prior_fim folds in information already in hand — from previously run
experiments, or a Bayesian prior precision. design_experiments also accepts
existing=[...] experiments directly.
Design criteria#
inv(FIM) is the asymptotic parameter covariance, i.e. the confidence
ellipsoid. Each criterion is a different scalar summary of that ellipsoid:
criterion |
value |
direction |
geometry |
|---|---|---|---|
|
\(\log\det \mathrm{FIM}\) |
maximize |
shrink the ellipsoid’s volume |
|
\(\mathrm{tr}(\mathrm{FIM}^{-1})\) |
minimize |
shrink the average axis (sum of variances) |
|
\(\lambda_{\min}(\mathrm{FIM})\) |
maximize |
shrink the longest axis (worst direction) |
|
\(\lambda_{\max}/\lambda_{\min}\) |
minimize |
round the ellipsoid out (conditioning) |
design_criterion(fim, criterion) returns the conventional value, so 'D' and
'E' are better when larger and 'A' and 'ME' better when smaller.
D-optimality is the default, because it is the only one of the four that is
invariant to rescaling the parameters. A- and E-optimality compare variances of
quantities with different units, so switching a rate constant from s⁻¹ to h⁻¹
can change the design they recommend. Use 'A' when the parameters really are
commensurate and you care about the total variance; 'E' when one poorly
determined direction is the whole problem; 'ME' when correlated parameters
are making the fit ill-conditioned.
Choosing a run list#
design = design_experiments(model_fn, theta, candidates, n=8, criterion='D',
method='greedy', replace=True,
existing=None, prior_fim=None)
print(design.summary())
Greedy (default) repeatedly adds the candidate that most improves the criterion given everything already chosen. For D-optimality this is the standard construction and is usually within a few percent of the optimum.
method='exchange'then runs Fedorov-style swaps: try replacing each selected run with each unselected one, keep the best improvement, repeat. It costsn * n_candidatesevaluations per sweep and never returns a worse design than the greedy start.replace=True(default) allows replicates. Replicating an informative condition is often genuinely optimal — for a straight line the D-optimal design is half the runs at each end of the range, and nothing in between.
DesignResult carries selected, indices, fim, covariance,
std_errors, criterion_value, criterion_history (so the point of
diminishing returns is visible), the identifiability report, and summary().
The design is local: it is computed at the current theta, and a different
theta can give a different design. That is not a defect of the method, it is
why the loop is design → run → refit → design again.
Predicted confidence intervals#
ci = predicted_covariance(model_fn, theta, design.selected, alpha=0.05)
ci.std_errors, ci.ci_lower, ci.ci_upper, ci.correlation
This returns the same ConfidenceResult type as
Estimator.confidence_intervals, so the intervals you predicted and the
intervals you achieved can be compared field by field. The one difference is
where the error model comes from: fisher_confidence_intervals estimates the
residual variance from data that exist, while predicted_covariance takes the
uncertainties declared on each experiment as the assumed error model — the
only thing available before the run.
The intervals are a linearization about theta. For a nonlinear model they are
exact only to the extent the model is locally linear over the interval, and they
are only as good as the theta used. They are, in the authors’ experience,
still the right thing to put in front of an experimental collaborator: a ranked
run list with the confidence-interval shrinkage attached is a concrete,
checkable claim, and the check is one refit away.
Numerical notes#
Log-determinant via Cholesky, never
det. For an \(n \times n\) FIM the determinant scales like the \(n\)-th power of the information and overflows or underflows long before its logarithm does.log_det(fim)equilibrates the matrix by its own diagonal, \(M = D\tilde{M}D\) with \(D = \mathrm{diag}(\sqrt{M_{ii}})\), and returns \(2\sum\log d + 2\sum\log\mathrm{diag}(\mathrm{chol}(\tilde M))\). The equilibration is exact, and it keeps every logarithm on a sane scale when the parameters differ by ten orders of magnitude — a pre-exponential factor and an activation energy, say, which is the normal case, not a pathology.A singular FIM is not an error.
log_detreturns-inf,trace(FIM^-1)returns+inf, \(\lambda_\min\) returns 0 and the condition number+inf. Those are the correct limits: infinite variance in some direction.Cholesky success is not a rank test, and neither is the relative size of its pivots. A FIM that is singular in exact arithmetic often factors anyway with a tiny pivot; \(\begin{bmatrix}1 & 1-2\epsilon\\ 1-2\epsilon & 1\end{bmatrix}\) factors cleanly and
slogdetreports a perfectly finite \(-35.35\). Worse, in a badly scaled FIM the pivots inherit the spread of the diagonal, so the small one is not small relative to the largest and a rank-deficient design is scored as merely mediocre. Singularity is therefore decided on the spectrum: an eigenvalue below \(n\,\epsilon\,\lambda_\max\) is zero.The two rank tolerances agree by construction.
check_identifiabilitycalls a singular value of \(S\) zero below \(\sqrt{\epsilon}\) times the largest, followingdifflow.reconciliation.structure. The FIM’s eigenvalues are the squares of those singular values, so the matching threshold here is \(\epsilon\), not \(\sqrt{\epsilon}\), and the two tests then flag exactly the same degenerate problems. Using \(\sqrt{\epsilon}\) on the FIM would be an \(\epsilon^{1/4}\) test on \(S\): an informative but ill-conditioned design — an Arrhenius pair correlated to one part in \(10^5\) — would be called singular, and greedy selection would walk away from the only pairs in the pool that determine both parameters.Selection under a singular FIM. Early in a greedy selection, fewer runs have been chosen than there are parameters, so the FIM is always singular and the criterion is
-inffor every candidate. Selection therefore ranks on the pair(rank, pseudo-value), where the pseudo-value applies the criterion to the nonzero eigenvalues only: information is first added in as many independent directions as possible, and only then optimized. Once the FIM is nonsingular this is exactly the criterion, so nothing changes for the picks that matter.float64 throughout, as everywhere in difflow.
fisher_information,sensitivity_matrixanddesign_criterionarejit- andgrad-safe intheta, so the D-criterion can itself be the objective of a continuous design optimization over the input space.
Worked example#
A straight line, where the answer is known analytically — the D-optimal design puts half the runs at each end of the range:
import numpy as np
from difflow.estimation import Experiment, design_experiments, predicted_covariance
def model(theta, exp):
return {'y': theta['a'] * exp.inputs['x'] + theta['b']}
pool = [Experiment.candidate({'x': float(x)}, ['y'], {'y': 0.5})
for x in np.linspace(0.0, 10.0, 11)]
design = design_experiments(model, {'a': 2.0, 'b': 1.0}, pool, n=8)
sorted(e.inputs['x'] for e in design.selected)
# [0.0, 0.0, 0.0, 0.0, 10.0, 10.0, 10.0, 10.0]
ci = predicted_covariance(model, {'a': 2.0, 'b': 1.0}, design.selected)
ci.std_errors # what those eight runs would buy
Those eight runs buy std_errors == {'a': 0.0354, 'b': 0.25}. Eight runs bunched
in the middle of the range instead (pool[2:10]) give 0.0772 on the slope —
2.2 times worse, for the same eight runs and the same cost. The gap widens as
the pool widens, because the endpoints move apart and the middle does not.
The motivating case is rare-earth solvent extraction, where each equilibrium experiment takes days, the design space (pH, temperature, extractant loading) is continuous, and a mechanistic model already exists. Handing an experimental collaborator a ranked run list with predicted confidence intervals attached is exactly what this module is for.
Limitations#
Candidate-pool selection only. The design space must be discretized into candidates; there is no continuous optimization over the input space. The criterion is differentiable in
theta, but the selection itself is combinatorial. (jax.gradthrough the criterion w.r.t. inputs is possible if the model is written to expose them, but no driver is provided.)discopt.doe.optimal_experimentoptimizes over a continuous design box instead — use it when the conditions are continuous and the model can be written indiscopt.modeling.Local design. Everything is evaluated at one
theta; robust and Bayesian-average designs over a parameter distribution are not implemented. A cheap approximation is to design at several plausiblethetavalues and take the runs the designs agree on.Diagonal \(\Sigma\). Measurement errors are assumed independent, with the 1-sigma values declared per output. Correlated measurement error is not supported.
Structural identifiability is tested numerically, at one
theta, by rank. That is local structural identifiability, not the global symbolic result a differential-algebra tool would give. In exchange it works on any model that JAX can differentiate, including a flowsheet with recycles.No cost model. Candidates are ranked purely by information, so a run list cannot yet trade information against the cost or duration of a run.
One model at a time, and no profile likelihood. There is no model discrimination criterion (which experiment best tells two rival mechanisms apart) and no likelihood-profile confidence interval, so the intervals here are always the linearized ones. Both live in
discopt-doe(discriminate_design,profile_likelihood) — see Related tools.No estimability ranking. When the parameters are identifiable but badly correlated, this module tells you that (the condition number) but will not choose a subset to fit and fix the rest;
discopt.doe.estimability_rankandd_optimal_subsetwill.