Implement residual sensitivity aggregation for PEtab v2 - #3218
Conversation
`PetabSimulationResult.sres` was always `None` so far. It now contains the sensitivities of the aggregated residuals with respect to the estimated PEtab problem parameters, in the same row order as `PetabSimulationResult.res()` and with columns in the order of `Problem.x_free_ids`. Sensitivities with respect to several model parameters that map to the same problem parameter (output parameter placeholders) are summed up. The model-parameter-index to problem-parameter-index mapping that was previously inlined in `_aggregate_s2llh` is factored out into `PetabSimulator._get_plist_to_problem_par_ix` and shared with the new `_aggregate_sres`. Along the way, make the aggregation robust for cases where the respective quantities are not computed, instead of raising: * In `RDataReporting.residuals` mode -- the mode a least-squares optimizer would use -- the likelihood and its sensitivities are not computed, so `simulate()` used to fail in `_aggregate_sllh`. `sllh`/`s2llh` are `None` now, and `res`/`sres` are available. * For non-Gaussian noise models, no residuals and no FIM are computed, so `simulate()` used to fail in `_aggregate_s2llh`. `s2llh`, `res` and `sres` are `None` now, while `llh`/`sllh` are still aggregated. * Experiments without measurements have no timepoints and therefore no residuals. Those are skipped now, instead of turning `res()` into `None` for the whole problem. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F52fCqHwmLeAm1TDNUYCCc
There was a problem hiding this comment.
Pull request overview
This PR extends the PEtab v2 SUNDIALS simulator to aggregate residual sensitivities across experiments, aligning PetabSimulationResult.sres with the concatenated residuals returned by res(), and improving behavior for edge cases (e.g., non-Gaussian noise models and experiments without measurements).
Changes:
- Implement aggregated residual sensitivity computation (
PetabSimulationResult.sres) across experiments. - Factor out reusable parameter-index mapping logic via
_get_plist_to_problem_par_ix(). - Improve edge-case handling for residual-only reporting mode, non-Gaussian noise models, and experiments without measurements; update docs/changelog accordingly.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| python/tests/petab_/test_petab_v2.py | Adds coverage for aggregated residual sensitivities, reporting modes, and non-Gaussian noise behavior. |
| python/sdist/amici/sim/sundials/petab/_v2.py | Implements residual sensitivity aggregation and refactors parameter mapping; adjusts residual aggregation behavior for edge cases. |
| doc/examples/example_petab/petab_v2.ipynb | Documents availability of aggregated residuals and residual sensitivities for least-squares problems. |
| CHANGELOG.md | Announces new residual-sensitivity aggregation feature and related edge-case fixes. |
Suppressed comments (1)
python/sdist/amici/sim/sundials/petab/_v2.py:782
- The placeholder and
plistindex mapping uses repeatedlist.index(...)lookups (model_par_ids.index(...)andplist.index(...)). This is O(n^2) and can be avoided by buildingdict-based index maps once. It also makes the intent clearer.
# still needs experiment-specific parameter mapping for placeholders
experiment = self._petab_problem[rdata.id]
placeholder_mappings = self._exp_man._get_placeholder_mapping(
experiment
)
for model_pid, problem_pid in placeholder_mappings.items():
try:
ix_map[model_par_ids.index(model_pid)] = x_free_ids.index(
problem_pid
)
except ValueError:
# mapped-to parameter is not estimated
pass
# translate model parameter index to plist index
plist = tuple(rdata.plist)
return {
plist.index(model_par_ix): problem_par_ix
for model_par_ix, problem_par_ix in ix_map.items()
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| model_par_ids = self._model.get_free_parameter_ids() | ||
| x_free_ids = self._petab_problem.x_free_ids | ||
|
|
||
| # Model parameter index to problem parameter index map for estimated | ||
| # parameters except placeholders. | ||
| # This is the same for all experiments. | ||
| ix_map: dict[int, int] = { | ||
| model_ix: x_free_ids.index(model_pid) | ||
| for model_ix, model_pid in enumerate(model_par_ids) | ||
| if model_pid in x_free_ids | ||
| } |
* `PetabSimulationResult.res` returns `None` if any experiment failed to simulate. AMICI does not invalidate `res`/`sres` in `ReturnData::invalidate`, so the residuals of the failed timepoints stayed at 0.0, i.e., a failed simulation looked like a perfect fit -- while `sres`, `sllh` and `s2llh` were `None` and `llh` was NaN. * Cache the plist-index to problem-parameter-index mapping per experiment. Extracting it from `_aggregate_s2llh` moved the construction of the (experiment-independent) model-parameter part into the per-experiment loop, and `_aggregate_sres` added another pass over the measurements of each experiment for the placeholder mapping. The mapping only depends on the PEtab problem, the model, and `rdata.plist`, so it is computed once per experiment now. Also avoid the quadratic `list.index` lookups while at it. * Document that `res()` returns an empty array if no experiment has measurements. Tests: a failed simulation reports no residuals; the log-likelihood in `RDataReporting.residuals` mode (which is computed from the residuals) matches the one from full reporting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F52fCqHwmLeAm1TDNUYCCc
…ual-aggregation-vslsid # Conflicts: # CHANGELOG.md
The residual tests shared one model module, with only the first one forcing its generation, so the others depended on the on-disk state left by earlier runs. Generate the module once in a module-scoped fixture instead, and give each test its own simulator (and thus model and solver instance) built from it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F52fCqHwmLeAm1TDNUYCCc
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3218 +/- ##
==========================================
- Coverage 78.49% 77.96% -0.54%
==========================================
Files 318 318
Lines 21013 21090 +77
Branches 1487 1487
==========================================
- Hits 16494 16442 -52
- Misses 4511 4640 +129
Partials 8 8
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
…property Follow-up to review comments: * `ReturnData::invalidate` NaN'ed `x`, `y`, `w`, `sx`, `sy` and the (sensitivities of the) log-likelihood and chi2, but left `res`, `sres` and `FIM` untouched. The residuals of the timepoints that were not reached therefore stayed at their initial value of 0.0, i.e., a failed simulation looked like a perfect fit, and the FIM contained a partial sum. Invalidate the residuals and their sensitivities from the failed timepoint on (including the error residuals, if any), and the FIM completely, since it is accumulated over all timepoints. * `PetabSimulationResult.res` is a property now, for consistency with `PetabSimulationResult.llh`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F52fCqHwmLeAm1TDNUYCCc
The residual tests wrote their model modules to the shared model root under their module name, so concurrent pytest processes (or pytest-xdist workers) could compile into the same directory and interfere with each other. Generate them in a temporary directory instead, which each process/worker gets its own of. Model modules are loaded from an explicit path and not registered in `sys.modules`, so the fixed module names are unproblematic. Verified with `pytest -k residual -n 4` and with two concurrent pytest invocations; nothing is written to the model root anymore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F52fCqHwmLeAm1TDNUYCCc
Summary
This PR implements aggregation of residual sensitivities for PEtab v2 simulations and improves handling of edge cases like non-Gaussian noise models and experiments without measurements.
Key Changes
Implement residual sensitivity aggregation: Added
_aggregate_sres()method to compute sensitivities of concatenated residuals w.r.t. estimated PEtab parameters. ThePetabSimulationResult.sresfield now contains the aggregated residual sensitivities instead ofNone.Extract parameter mapping logic: Created
_get_plist_to_problem_par_ix()helper method to map simulation result parameter indices to problem parameter indices. This handles both regular parameters and output parameter placeholders, and is now reused by both_aggregate_s2llh()and_aggregate_sres().Improve edge case handling:
Nonefor unavailable results instead of raising errorsRDataReporting.residuals) is now properly supported_has_timepoints()helper to distinguish between experiments without measurements and missing computed resultsEnhanced documentation: Updated docstrings for
PetabSimulationResult.sresandres()property to clarify the structure and ordering of aggregated results.Implementation Details
np.add.at()to sum their contributionsFIM == sres.T @ sresandsllh == -res @ sres) are verified to hold for parameter-independent noise models in the test suitehttps://claude.ai/code/session_01F52fCqHwmLeAm1TDNUYCCc