Skip to content

Implement residual sensitivity aggregation for PEtab v2 - #3218

Merged
FFroehlich merged 7 commits into
mainfrom
claude/petab-v2-residual-aggregation-vslsid
Aug 5, 2026
Merged

Implement residual sensitivity aggregation for PEtab v2#3218
FFroehlich merged 7 commits into
mainfrom
claude/petab-v2-residual-aggregation-vslsid

Conversation

@FFroehlich

Copy link
Copy Markdown
Member

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. The PetabSimulationResult.sres field now contains the aggregated residual sensitivities instead of None.

  • 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:

    • Experiments without measurements (no timepoints) are now correctly skipped when aggregating residuals and their sensitivities
    • Non-Gaussian noise models now gracefully return None for unavailable results instead of raising errors
    • Residual-only reporting mode (RDataReporting.residuals) is now properly supported
    • Added _has_timepoints() helper to distinguish between experiments without measurements and missing computed results
  • Enhanced documentation: Updated docstrings for PetabSimulationResult.sres and res() property to clarify the structure and ordering of aggregated results.

Implementation Details

  • Residual sensitivities are aggregated by concatenating sensitivities from individual experiments in order, matching the row order of concatenated residuals
  • Multiple model parameters mapping to the same problem parameter (e.g., output parameter placeholders) are correctly handled using np.add.at() to sum their contributions
  • The implementation validates that all experiments have consistent availability of sensitivities before aggregation
  • Least-squares identities (FIM == sres.T @ sres and sllh == -res @ sres) are verified to hold for parameter-independent noise models in the test suite

https://claude.ai/code/session_01F52fCqHwmLeAm1TDNUYCCc

`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
@FFroehlich
FFroehlich requested a review from a team as a code owner August 4, 2026 11:00
Copilot AI lite review requested due to automatic review settings August 4, 2026 11:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 plist index mapping uses repeated list.index(...) lookups (model_par_ids.index(...) and plist.index(...)). This is O(n^2) and can be avoided by building dict-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.

Comment on lines +751 to +761
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
}
Comment thread python/tests/petab_/test_petab_v2.py Outdated
* `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

@dweindl dweindl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks

Comment thread python/sdist/amici/sim/sundials/petab/_v2.py
Comment thread python/sdist/amici/sim/sundials/petab/_v2.py Outdated
claude added 2 commits August 4, 2026 13:26
…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

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.96%. Comparing base (cd38627) to head (2250723).

Additional details and impacted files

Impacted file tree graph

@@            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              
Flag Coverage Δ
cpp 71.97% <7.14%> (-0.16%) ⬇️
cpp_python 36.59% <7.14%> (-0.06%) ⬇️
petab 48.27% <100.00%> (+1.12%) ⬆️
petab_sciml 16.22% <7.14%> (-0.02%) ⬇️
petab_sciml_benchmarks 14.78% <7.14%> (-0.02%) ⬇️
python 70.34% <75.00%> (-0.03%) ⬇️
sbmlsuite-jax ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
python/sdist/amici/sim/sundials/petab/_v2.py 96.23% <100.00%> (+2.63%) ⬆️
python/tests/petab_/test_petab_v2.py 100.00% <100.00%> (ø)

... and 6 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

claude and others added 3 commits August 4, 2026 13:44
…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
@FFroehlich
FFroehlich added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit 607bc50 Aug 5, 2026
29 of 30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants