Error bars and diagnostics¶
Error bars on inferred tensions, with their measured calibration, and identifiability diagnostics.
infer_tensions_with_uncertainty returns the tensions of infer_tensions (it never changes a
point estimate) together with an interval and the diagnostics needed to decide whether to trust it;
compute_inference_diagnostics returns the diagnostics alone. Neither has a formulation for crop
faces yet: both raise ValueError on a cropped mesh.
Large tissues. Both compute the diagnostics from the dense force-balance operator, three rows per mesh
vertex and one column per interface and per cell, and its singular value decomposition. Past
DENSE_OPERATOR_ENTRY_LIMIT entries (2**31 - 1, the most scipy's LAPACK can index) that decomposition cannot
run, so both raise ValueError at once, before building anything; a mesh of the default reconstruction reaches it at
about 1 300 cells of real size. Below the limit the cost grows with the operator: on a generated 1 200-cell foam (0.8
of the limit) infer_tensions_with_uncertainty(mesh, 1, YoungDupre) took about 30 minutes and 46 GiB.
Read this before using an error bar from here. No interval of this module is a calibrated
error bar on a reconstructed mesh. The first-order interval of the YoungDupre tensions
(AnglePropagation) is a lower bound on the error: measured on 2 400 reconstructions with
ground truth, one reconstruction's +- std contained the true tension for 26-54 % of interfaces
(nominal 68 %), because the error is mostly the reconstruction's bias, which no propagated scatter
measures. The calibration figures below are for the simulator's reference meshes.
The goal here is not "add error bars" — it is produce an interval whose error/std is
O(1), or state plainly that we cannot. error/std (the ratio of the actual error against
ground truth to the reported standard deviation, gauge-matched) is the calibration
metric: 1 is calibrated, >>1 is optimistic (dangerous), <<1 is conservative (safe but
uninformative). Measured over the 42 equilibrium cases of benchmarking-dataset
(foambryo_benchmarks/run_uncertainty_calibration.py):
| model | estimator | median error/std |
worst case | verdict |
|---|---|---|---|---|
ProbeResidual |
force balance | 0.233 | 0.93 | O(1), and conservative on 42/42 |
CoherentResidual |
force balance | 1.33 | 29.9 | O(1) at the median, not uniformly |
EdgeBootstrap |
YoungDupreLocal |
0.27 | 1.57 | ~4x conservative (measured on all 47 |
| cases) | ||||
BayesianMap |
force balance | 0.21 | 0.61 | O(1) by choice of prior, not by measurement |
AnglePropagation |
YoungDupre |
0.015 | 0.078 | ~65x conservative here; too narrow on reconstructions |
ClusterRobust |
force balance | 204 | 1.5e4 | optimistic; not recommended |
ClusterBootstrap |
force balance | 216 | 3e4 | optimistic; not recommended |
IidGls |
force balance | 246 | 5.4e3 | optimistic; not recommended (the naive posterior) |
LineBootstrap |
YoungDupre |
1.3e-05 | — | ~1e5 conservative, weight-confounded; diagnostic only |
AnglePropagation's row is on the reference meshes, whose angles are nearly exact. On 2 400
reconstructions of five tissue populations with ground truth, one reconstruction's +- std
contains the true tension for only 26-54 % of interfaces (nominal 68 %), +- 2 std for 48-79 %
(nominal 95 %): it is not a calibrated interval there, and no single factor makes it one.
So that goal is met: ProbeResidual is an interval with error/std = O(1), and it
errs on the safe side on every one of the 42 cases.
Why the naive posterior fails by 250x, and why the fix is one factor. The i.i.d. model
says the per-component vertex force residual is N(0, sigma_f^2) with sigma_f estimated
as ||M beta|| / sqrt(dof). On a 11 000-vertex mesh that is ~34 000 rows, so the estimator
averages the noise down by sqrt(34 000) ~ 185 and claims to know each tension to ~1e-4.
It does not: the residual was measured to be systematic — the meshes are the
equilibrium of the generator's volume-constraint projection, not of M beta = 0 — so it
does not average down at all. Measured, the implied n_eff (the number of independent
observations the rows are actually worth, dof / (error/std)^2) has median 0.57 on the
42 and is below 1 on 29 of them: the ~33 000 rows are worth less than one independent
observation.
CoherentResidual replaces the probabilistic assumption with a deterministic one, and it
is the same linear algebra with sigma_f -> ||M beta||: treat the residual as one
coherent perturbation of measured norm and unknown direction. The worst-case-direction
first-order bound on the estimator is then
|d gamma_i| <= sigma * sqrt(Sigma_unit_ii),
with Sigma_unit the constrained-GLS shape matrix of
foambryo.validation.force_balance.constrained_covariance at sigma_f = 1. That is the
n_eff = 1 extreme, and it moves the median calibration from 246 to 1.33 — the whole
250x gap is that one sqrt(dof) factor, nothing else. But it is not uniform: 10 of the
42 cases still come out above 10, worst 29.9.
ProbeResidual closes that, and the mechanism is measured rather than guessed. The
remaining failure is self-fitting: ||M beta_hat|| is the residual at the estimate, and
beta_hat was chosen to minimise it. The residual at the truth is a median 5.4x and up
to 87x larger, and that ratio is what predicts the failures (Spearman 0.80). Worse, the
bound fails worst on the best meshes — a mesh close to M's equilibrium lets the fit
drive ||M beta_hat|| to almost nothing while the true error stays put (Spearman against
rho_free: -0.81). ProbeResidual therefore takes sigma from the residual at an
independent tension estimate (PROBE_METHOD, YoungDupre, which never touches M).
Measured: the probe residual reproduces the residual at the ground truth to 0.1 % on every
case, so this is the oracle bound without an oracle — and probe error can only inflate
sigma, so it fails safe. See _probe_residual_norm.
Why resampling does not rescue the i.i.d. model. ClusterRobust (a cluster-robust
sandwich) and ClusterBootstrap (resampling whole cells) both model correlation; neither
can model a bias. Measured, they move 246 to 204 and 216 — a factor 1.2, not 250. That is
the honest reason to prefer the deterministic bound: the dominant term is not variance, so
there is nothing for a resampling scheme to find.
Why ForceBalanceMAP's apparently-good 0.21 is not an answer. Its interval's width is
set by MAP_PRIOR_TENSION_STD, a hyperparameter documented as "deliberately broad" and
never derived. Sweeping it over 0.03-1.0 moves error/std over 2.93-0.061, a log-log slope
of -1.03: the interval is the prior. Any calibration it shows was chosen. Both
CoherentResidual and ProbeResidual have no such knob — their scale is a measured residual
norm.
The negative control behaves differently, and that is a check on the mechanism. On the
five non-equilibrium cases excluded from every accuracy claim, the residual is genuinely large and spread over
interface interiors, so the implied n_eff is 370 rather than 0.57, IidGls improves to
9.0, and ProbeResidual/CoherentResidual become ~20x conservative. The models fail in the
safe direction exactly where the mesh is bad — which is the behaviour one wants and not
something that was fitted.
Why the angle family is conservative rather than optimistic. YoungDupre reads
triple-line angles and never touches M. Propagating the measured along-line angle
spread through the operator over-predicts its error by ~2 orders, because (as measured) the
dataset's angle deviations from Neumann lie nearly orthogonal to the Young-Dupre operator's column
space. So its interval is safe and nearly useless, and its point estimate (2.3e-05) is far
better than any interval we can defend around it.
One caveat, and it is load-bearing. YoungDupre's accuracy is
weighting-critical: a factor-~4 change in relative inter-line weights costs two orders
of magnitude. LineBootstrap resamples whole triple lines, which is such a reweighting
(n copies of a line of weight w act as one row of weight w sqrt(n)), so its width is
dominated by weight perturbation rather than by angle noise. It is reported for that reason,
not despite it. Nothing here changes the junction-length weighting of any shipped
method — every point estimate returned by this module is bit-identical to the
corresponding infer_tensions call.
UncertaintyModel ¶
Bases: Enum
How the interval around an inferred tension is produced.
See the module docstring for the measured error/std of each. ProbeResidual is the one
recommended for reporting — O(1) and conservative on every one of the 42 equilibrium
cases. The others are kept because a calibration claim that cannot be reproduced against
its alternatives is not a calibration claim, and because two of them (IidGls,
ClusterRobust) are the models a reader would otherwise reach for.
Attributes:
| Name | Type | Description |
|---|---|---|
ProbeResidual |
|
|
CoherentResidual |
Deterministic worst-case-direction bound, |
|
IidGls |
the naive constrained-GLS posterior, |
|
ClusterRobust |
Cluster-robust (sandwich) covariance, clusters = cells. |
|
ClusterBootstrap |
Nonparametric bootstrap resampling whole cells. |
|
AnglePropagation |
First-order propagation of the measured along-line angle spread
through the Young-Dupre solve. Angle family ( |
|
LineBootstrap |
Nonparametric bootstrap resampling whole triple lines. Angle family; weight-confounded, see the module docstring. |
InferenceDiagnostics
dataclass
¶
InferenceDiagnostics(n_interfaces: int, n_cells: int, n_rows: int, rho: float | None, rho_free: float, sigma_gauge: float, sigma_soft: float, soft_mode_fractions: tuple[float, float, float], condition_number: float)
Per-mesh diagnostics saying whether any tension inference on it can be trusted.
These are properties of the mesh and the operator, not of a method, which is what makes
them usable on dw3d output where no ground truth exists. rho_free is the one to read
first: it was measured that the force-balance / Variational family's error is
limited by it (median rho_free 0.012 against a median error 0.021 on the 42), while
YoungDupre's is not.
Attributes:
| Name | Type | Description |
|---|---|---|
n_interfaces |
int
|
Number of interfaces (tension unknowns). |
n_cells |
int
|
Number of cells (pressure unknowns; the exterior is the gauge). |
n_rows |
int
|
Rows of |
rho |
float | None
|
|
rho_free |
float
|
The same quantity with the tensions themselves free — the
distance of this mesh from any equilibrium of |
sigma_gauge |
float
|
Smallest singular value of the column-normalised |
sigma_soft |
float
|
Second-smallest singular value: the worst-determined physical
direction. |
soft_mode_fractions |
tuple[float, float, float]
|
How much of that soft direction sits in the (surface tension, line tension, pressure) blocks. The line entry is always 0 here — the harness operator carries no line-tension block, by the package author's design choice. |
condition_number |
float
|
|
identifiability_report ¶
identifiability_report() -> str
Human-readable identifiability and equilibrium summary.
Returns:
| Name | Type | Description |
|---|---|---|
str |
str
|
A short multi-line report, safe to print next to an inferred tension table. |
TensionUncertaintyResult
dataclass
¶
TensionUncertaintyResult(interfaces: list[tuple[int, int]], tensions: NDArray[float64], tension_std: NDArray[float64], method: TensionComputationMethod, model: UncertaintyModel, sigma_scale: float, residual_norm: float, n_rows: int, diagnostics: InferenceDiagnostics, calibration_note: str = '')
An inferred tension field with an interval whose calibration is stated, not assumed.
Attributes:
| Name | Type | Description |
|---|---|---|
interfaces |
list[tuple[int, int]]
|
Interface keys, in the order of |
tensions |
NDArray[float64]
|
Inferred tensions — bit-identical to the
corresponding |
tension_std |
NDArray[float64]
|
Per-interface interval half-width, in the unit of
|
method |
TensionComputationMethod
|
Which estimator produced |
model |
UncertaintyModel
|
Which model produced |
sigma_scale |
float
|
The residual scale the interval was built on. |
residual_norm |
float
|
|
n_rows |
int
|
Rows of the operator inverted. |
diagnostics |
InferenceDiagnostics
|
Mesh-level identifiability and equilibrium. |
calibration_note |
str
|
The measured |
compute_inference_diagnostics ¶
compute_inference_diagnostics(mesh: DcelData, dict_tensions: dict[tuple[int, int], float] | None = None) -> InferenceDiagnostics
Equilibrium and identifiability diagnostics for a mesh, readable without ground truth.
The rho / sigma_soft diagnostics of the benchmark harness, as a public function, so they
can be computed on a mesh with no ground truth (i.e. on dw3d output). Everything here is a
property of the mesh and of the force-balance operator; nothing is fitted to a ground-truth
tension.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mesh
|
DcelData
|
Mesh to analyse, in any length unit. |
required |
dict_tensions
|
dict[tuple[int, int], float] | None
|
Tensions at which to
evaluate |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
InferenceDiagnostics |
InferenceDiagnostics
|
See that class; call |
Raises:
| Type | Description |
|---|---|
ValueError
|
on a mesh with crop faces, for which these diagnostics have no formulation
yet ( |
KeyError
|
if |
infer_tensions_with_uncertainty ¶
infer_tensions_with_uncertainty(mesh: DcelData, mean_tension: float = 1, method: TensionComputationMethod = TensionComputationMethod.ForceBalance, model: UncertaintyModel | None = None, n_resamples: int = DEFAULT_N_RESAMPLES, seed: int = 0) -> TensionUncertaintyResult
Infer tensions and an interval whose calibration has been measured and is reported.
A companion to foambryo.infer_tensions: the point estimate is that of the corresponding
infer_tensions call -- bit-identical for ForceBalance and YoungDupre (pinned by
tests/test_uncertainty.py), to rounding (within 1e-12 on benchmark case 000) for Variational,
whose interval is built on the ForceBalance operator. The YoungDupre tensions are
normalised over the whole mesh at once, so they match infer_tensions on a mesh whose
junction graph is in one piece. The returned calibration_note states the measured
error/std of the interval rather than leaving the reader to assume it is 1.
The interval is not a calibrated error bar on a reconstructed mesh. The first-order
AnglePropagation interval of YoungDupre is a lower bound on the error: one
reconstruction's +- std held the true tension for 26-54 % of interfaces (nominal 68 %) and
+- 2 std for 48-79 % (nominal 95 %), measured on 2 400 reconstructions with ground truth.
The force-balance family's error is floored by rho_free on reconstructed meshes (see the
module docstring and InferenceDiagnostics).
Only the estimators whose intervals have been calibrated are accepted. That is deliberate: an interval for a method nobody measured would be exactly the failure mode this module exists to close.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mesh
|
DcelData
|
Mesh to analyse, in any length unit; not cropped. |
required |
mean_tension
|
float
|
The normalisation: the mean of the returned tensions, met exactly. Defaults to 1. |
1
|
method
|
TensionComputationMethod
|
The estimator. One of |
ForceBalance
|
model
|
UncertaintyModel | None
|
How to build the interval. Defaults to
|
None
|
n_resamples
|
int
|
Bootstrap draws, for the bootstrap models
( |
DEFAULT_N_RESAMPLES
|
seed
|
int
|
Bootstrap RNG seed, recorded so any number is reproducible. Defaults to 0. |
0
|
Returns:
| Name | Type | Description |
|---|---|---|
TensionUncertaintyResult |
TensionUncertaintyResult
|
Tensions (keyed by interface |
Raises:
| Type | Description |
|---|---|
NotImplementedError
|
If |
ValueError
|
If |
error_over_std ¶
error_over_std(result: TensionUncertaintyResult, dict_truth_tensions: dict[tuple[int, int], float]) -> dict[str, float]
The calibration metric used throughout this module: |error| / std, gauge-matched, per interface.
Gauge matching is mandatory (the rule every accuracy comparison in this package follows): only tension ratios are physical, so the inferred tensions are rescaled by the single factor making their mean match the truth's, and the same factor is applied to the interval — an interval is in the same units as the quantity it bounds, so rescaling one without the other would be a units error dressed up as a calibration result.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
TensionUncertaintyResult
|
The result to score. |
required |
dict_truth_tensions
|
dict[tuple[int, int], float]
|
Ground-truth tension per interface. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, float]
|
dict[str, float]: |