Identifiability¶
A parameter the data cannot determine shows up as a wide interval, or as starts that end apart with equal losses. This page shows how to find out why: which parameters the model and the data determine, which they do not, and which combination of parameters the data determine in their place. It is for anyone who fits more than one parameter.
Quick path¶
k1 and alpha enter the model only as their product k1 * alpha, so the
data determine the product and not each one.
from commol import (
Aggregate,
CalibrationParameter,
CalibrationProblem,
Calibrator,
Collinearity,
ModelBuilder,
NelderMeadConfig,
ObservedDataPoint,
ParameterSample,
SampleIdentifiability,
SampleMethod,
Simulation,
StartsIdentifiability,
StructuralRank,
)
def basic_model(k1: float | None, alpha: float | None, k2: float | None):
return (
ModelBuilder(name="Basic Model")
.add_bin(id="A", name="State A")
.add_bin(id="B", name="State B")
.add_bin(id="C", name="State C")
.add_parameter(id="k1", value=k1)
.add_parameter(id="alpha", value=alpha)
.add_parameter(id="k2", value=k2)
.add_transition(
id="t_ab", source=["A"], target=["B"], rate="k1 * alpha * A * B / total"
)
.add_transition(id="t_bc", source=["B"], target=["C"], rate="k2 * B")
.build(
Aggregate(bin_amounts={"A": 990, "B": 10, "C": 0}),
engine="DifferenceEquations",
)
)
truth = Simulation(basic_model(0.4, 1.0, 0.1)).run(num_steps=60)
problem = CalibrationProblem(
observed_data=[
# B with a measurement error of ±5 %.
ObservedDataPoint(at=step, output="B", value=truth["B"][step] * (1 + 0.05 * (-1) ** step))
for step in range(5, 61, 5)
],
parameters=[
CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.1, max_bound=1.0),
CalibrationParameter(id="alpha", parameter_type="parameter", min_bound=0.5, max_bound=2.0),
CalibrationParameter(id="k2", parameter_type="parameter", min_bound=0.01, max_bound=0.5),
],
loss_function="sse",
optimization_config=NelderMeadConfig(max_iterations=2000),
seed=1,
)
calibrator = Calibrator(Simulation(basic_model(None, None, None)), problem)
# Before a fit: the structure of the model and the design.
structural = calibrator.identifiability(StructuralRank(points=5, design="observations"))
for name, verdicts in structural.verdicts.items():
print(name, verdicts.structural.verdict, verdicts.structural.combinations)
# k1 in_combination ['k1 · alpha']
# alpha in_combination ['k1 · alpha']
# k2 identifiable []
# After a fit: the starts and the collinearity at the best point.
result = calibrator.run(starts=6)
starts = calibrator.identifiability(
StartsIdentifiability(result=result, parameter_tolerance=0.05, loss_tolerance=0.01)
)
print(starts.starts[0].flagged) # ['k1', 'alpha']
collinearity = calibrator.identifiability(
Collinearity(at=result, max_subset_size=3, threshold=10)
)
print([subset.parameters for subset in collinearity.collinearity[0].admissible])
# [['k1', 'k2'], ['alpha', 'k2']]
# One report with every piece of evidence.
report = structural.merge(starts, collinearity)
for name, verdicts in report.verdicts.items():
print(name, verdicts.structural.verdict, verdicts.practical.verdict)
# k1 in_combination poorly_determined
# alpha in_combination poorly_determined
# k2 identifiable determined
Fix alpha (or k1) at a value, or calibrate their product as one
parameter: the data determine k1 · alpha, and the fit says nothing about
how it splits.
Structural against practical¶
| Kind | Question | Depends on | Methods |
|---|---|---|---|
| Structural | Can any data from this design determine the parameter? | The model and the positions and outputs observed, not the observed values | StructuralRank |
| Practical | Do these data determine it? | The observed values, their noise and the fit | Collinearity, SampleIdentifiability, StartsIdentifiability, the profiles of a ProfileLikelihood |
A parameter that is not structurally identifiable is never practically identifiable: no amount of data of the same design fixes it. A parameter that is structurally identifiable can still be poorly determined by the data at hand.
The methods¶
Every method runs through Calibrator.identifiability(method) and returns an
IdentifiabilityReport. A threshold or a tolerance has no default: it is a
modelling judgment, and the table gives the range the literature uses.
| Method | Needs | Answers | Engine |
|---|---|---|---|
StructuralRank(points, design) |
The problem only | The rank of the sensitivities, the non-identifiable parameters and the identifiable combinations | Deterministic |
Collinearity(at, max_subset_size, threshold) |
A fit (CalibrationResult) or a sample |
Which subsets of parameters can be fitted together; threshold from 5 to 20 |
Deterministic |
SampleIdentifiability(sample, reference, correlation_threshold, priors) |
Any ParameterSample |
How much the sample contracts each parameter against its bounds or its prior, which pairs move together, which marginals have several modes; correlation_threshold from 0.8 to 0.95 |
Any |
StartsIdentifiability(result, parameter_tolerance, loss_tolerance) |
A result of Calibrator.run(starts=n) with two or more converged starts |
Which parameters differ between starts of equal loss | Any |
Structural rank¶
StructuralRank evaluates the sensitivities of the predicted observations at
points rows of a Latin hypercube over the bounds, drawn from the seed of
the calibrator (Calibrator.seed), so problem.seed repeats them. The
sensitivity of observation i to parameter j is scaled to a relative
change of both, S_ij = (∂y_i/∂θ_j) · θ_j / s_i, where s_i is the root mean
square of the series of observation i, so the answer does not depend on
units.
design |
Positions and outputs | Question |
|---|---|---|
"observations" |
Those of the problem's observations; their values are ignored | Can this design determine the parameters? |
"dense" |
Every observed series, with its outputs, factor and window, at every unit position from 0 to the last observation | Can denser data of the same series determine them? |
At each point the rank counts the singular values of S above the norm of the
estimated error of S. The generic rank is the rank at most points; each
point's singular values are in report.structural[0].points. A parameter is:
| Verdict | Meaning |
|---|---|
identifiable |
No combination of the other parameters undoes a change of it |
in_combination |
Only the combinations in combinations are identifiable |
non_identifiable |
No observation changes with it |
Only the design of the observations gives the structural verdict of a parameter; a dense design keeps its verdicts in its section.
dense = calibrator.identifiability(StructuralRank(points=5, design="dense"))
print(dense.structural[0].generic_rank, dense.structural[0].verdicts["k1"])
# 2 in_combination
Denser data of B do not split the product either: the deficit is in the
model, not in the design.
How to read a combination¶
A combination is a product of powers of the parameters that the data
determine: k1 · alpha means that every pair (k1, alpha) with the same
product fits the data equally well. k1 / k2^2 means that k1 and k2² can
grow together. A parameter whose bounds allow negative values reads as its
absolute value, |k1|. The exponents are rounded to the nearest half.
Collinearity¶
Collinearity computes, after Brun et al. (2001), the sensitivity measure of
each parameter and the collinearity index γ of each subset: 1 when the
sensitivities of the subset are orthogonal, infinite when one is a combination
of the others. A subset with γ below threshold can be fitted together;
admissible lists the largest such subsets. With a sample, the index is
computed at every draw and summarized by its weighted median, and
share_below gives the share of the draws below the threshold.
A parameter is poorly_determined when a pair that holds it has an index at
or above the threshold, or no observation is sensitive to it.
A sample¶
SampleIdentifiability reads any sample, such as the draws of an uncertainty
method. Here the sample is the ends of the starts.
names = calibrator.parameter_names
sample = ParameterSample.equally_weighted(
names,
[(p.min_bound, p.max_bound) for p in problem.parameters],
[[start.end[name] for name in names] for start in result.starts],
SampleMethod(name="starts"),
)
from_sample = calibrator.identifiability(
SampleIdentifiability(sample=sample, reference="bounds", correlation_threshold=0.9)
)
pair = from_sample.sample[0].correlated_pairs[0]
print(pair.first, pair.second, round(pair.correlation, 1)) # k1 alpha -1.0
| Field | Meaning |
|---|---|
contraction |
1 − Var(sample) / Var(reference): near 0 when the sample spreads over the bounds, near 1 when it sits on one value |
correlated_pairs |
The pairs whose absolute correlation is above correlation_threshold |
modes |
The modes of each marginal: the maxima of a kernel density above a tenth of the highest |
The reference of the contraction is "bounds", the uniform distribution on
the bounds, or "prior" with priors, the prior of each parameter: the
sample of a Gibbs posterior or ABC contracts its priors by how much
the data inform each parameter, and a sample drawn from the priors themselves
has a contraction near 0.
from commol import Priors, Uniform
against_priors = calibrator.identifiability(
SampleIdentifiability(
sample=sample,
reference="prior",
priors=Priors(distributions={name: Uniform() for name in names}),
correlation_threshold=0.9,
)
)
section = against_priors.sample[0]
print(section.reference, section.contraction == from_sample.sample[0].contraction) # prior True
A parameter is poorly_determined when it is in a correlated pair or its
marginal has two or more modes.
Starts¶
StartsIdentifiability compares the converged starts whose loss exceeds the
best by at most loss_tolerance of the best loss, and flags a parameter whose
ends among them differ by more than parameter_tolerance of its bound width.
A loss near zero, as with data made by the model itself, leaves no start
within a relative tolerance but the best; use data with noise.
The report¶
An IdentifiabilityReport holds, for each parameter, a structural and a
practical verdict, and the sections of the methods that ran
(structural, collinearity, sample, starts, profile). The profiles of
a ProfileLikelihood give their report with ProfileResult.identifiability(); a flat
profile gives the structural verdict in_combination when resetting the
other parameters to their fitted values breaks it, and non_identifiable
when nothing the data read depends on the parameter
(Profile Likelihood). merge joins reports: a
verdict the reports agree on stays, with every reason; one they disagree on is
conflicting, with both reasons. report.report() gives it as plain data
(Reports).
Limits¶
- Local. The sensitivities are derivatives at points: a parameter can be identifiable near every point and still have two distant values that fit equally well. Symbolic methods give a global answer for differential equations; they are outside commol.
- Numerical. The derivatives are finite differences; a singular value
counts only when it is above their estimated error. The error grows with
the tolerance of an ODE solver (
rtol). - Generic points. A point where the rank is lower or higher than at most points is special; the generic rank is the rank at most points.
- Deterministic engines. The runs of a stochastic engine change in jumps
with the parameters, so
StructuralRankandCollinearityrefuse one;SampleIdentifiabilityandStartsIdentifiabilityaccept it.
Checklist¶
- [ ] You checked the structural rank of the design before you fitted.
- [ ] Each parameter
in_combinationis fixed, or replaced by its combination. - [ ] The thresholds you chose are in the report you share.
Next steps¶
| To learn | Read |
|---|---|
| Several starts of an optimizer | Optimizers |
| Scale factors, which often pair with a rate | Observations |
| What a sample is | Uncertainty |
| Intervals and verdicts from profiles | Profile Likelihood |