# Commol Documentation > A high-performance compartment modelling library for mathematical modeling Commol is a Python library, with a Rust engine, to build, simulate and calibrate compartment models. Models are built with ModelBuilder, simulated with Simulation and fitted with Calibrator. Examples use abstract names (bins A, B, C; parameters k1, k2). # Getting started # Commol Commol builds, simulates and calibrates compartment models: amounts move between states (compartments) at rates you write as formulas. An amount can be a count, a mass or a concentration. You describe the process once, in Python, and choose the engine that runs it: difference equations, differential equations or a stochastic process of whole units. A Rust engine expands the model over your stratifications, compiles every rate to machine code, runs it, fits it to your data and measures how sure the fit is. Before version 1.0 The API can change between minor versions (`0.2` → `0.3`). The [changelog](https://munqu.github.io/commol/latest/about/changelog/index.md) marks every change that breaks code as **Breaking**. ## Where to go | You are… | Start with | Then | | ------------------------------ | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | New to Commol | [Quick Start](https://munqu.github.io/commol/latest/getting-started/quickstart/index.md) | [Core Concepts](https://munqu.github.io/commol/latest/guide/core-concepts/index.md) | | Building a model | [Building Models](https://munqu.github.io/commol/latest/guide/building-models/index.md) | [Transitions](https://munqu.github.io/commol/latest/guide/transitions/index.md), [Mathematical Expressions](https://munqu.github.io/commol/latest/guide/mathematical-expressions/index.md) | | Running a model | [Running Simulations](https://munqu.github.io/commol/latest/guide/simulations/index.md) | [Stochastic Simulation](https://munqu.github.io/commol/latest/guide/stochastic/index.md) | | Fitting a model to data | [Model Calibration](https://munqu.github.io/commol/latest/guide/calibration/index.md) | [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) | | Looking up a class or argument | [API Reference](https://munqu.github.io/commol/latest/api/index.md) | [Model Structure](https://munqu.github.io/commol/latest/advanced/model-structure/index.md) | | Contributing to Commol | [Codebase Guide](https://munqu.github.io/commol/latest/codebase/index.md) | [Development Workflow](https://munqu.github.io/commol/latest/development/workflow/index.md) | | An AI agent using Commol | [`llms.txt`](https://munqu.github.io/commol/latest/llms.txt): an index of the site | [`llms-full.txt`](https://munqu.github.io/commol/latest/llms-full.txt): the whole site in one file | | An AI agent changing Commol | [`AGENTS.md`](https://github.com/MUNQU/commol/blob/main/AGENTS.md) | [Codebase Guide](https://munqu.github.io/commol/latest/codebase/index.md) | ## How Commol works ``` flowchart LR A[ModelBuilder: the process] -->|build with a structure and an engine| B[Model] B --> C[Simulation] C -->|run, replicates| D[Results: read, plot, report] B --> E[Calibrator] E -->|run| F[CalibrationResult] F -->|apply_calibration_parameters| B E -->|uncertainty| G[ParameterSample] G --> H[Predictive, Scenarios] H --> D ``` 1. **Build** the process once with `ModelBuilder`: bins, parameters, transitions, stratifications and accumulators. `build` joins it with a structure (the amount each bin starts with) and an engine, and checks the whole definition. 1. **Run** it with `Simulation`. The same process runs as difference equations, as differential equations or as a stochastic process of whole units; only the engine changes. 1. **Read** the results by output name, and draw or report them with their own methods (`results.plot`, `results.report`). 1. **Calibrate** the unknown values against observations with `Calibrator`, and write the result back into the model. 1. **Quantify** how sure the fit is: an uncertainty method gives a sample of the parameters, and its predictive gives bands and scores. 1. **Compare** runs: `Scenarios` runs a sample under other parameter values, and `plot_compared` draws several results on the same panels. Every parallel step (the starts of a calibration, the replicates, the draws of a predictive) runs in Rust on the threads you choose. ## Quick example One process, run by two engines: ```python from commol import Aggregate, ModelBuilder, Simulation process = ( 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=0.3) .add_parameter(id="k2", value=0.1) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total") .add_transition(id="t_bc", source=["B"], target=["C"], rate="k2") ) structure = Aggregate(bin_amounts={"A": 990, "B": 10, "C": 0}) steps = Simulation(process.build(structure, engine="DifferenceEquations")).run(num_steps=100) flows = Simulation(process.build(structure, engine="DifferentialEquations")).run(num_steps=100) print(round(steps["C"][-1]), round(flows["C"][-1])) # the amount of C in each model ``` ## Features | Feature | Guide | | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | Fluent, checked model building | [Building Models](https://munqu.github.io/commol/latest/guide/building-models/index.md) | | Stratifications, conditional stratifications, per-compartment initial conditions | [Core Concepts](https://munqu.github.io/commol/latest/guide/core-concepts/#stratifications) | | Rate formulas compiled with Cranelift | [Mathematical Expressions](https://munqu.github.io/commol/latest/guide/mathematical-expressions/index.md) | | Pulses, windows, ramps, sinusoids and schedules per subgroup | [Axis Patterns](https://munqu.github.io/commol/latest/guide/axis-patterns/index.md) | | Unit checking, and dates on the axis | [Units](https://munqu.github.io/commol/latest/guide/units-and-equations/index.md) | | A description of a model: equations, state graphs, the ODD protocol (text, Markdown, LaTeX) | [Describing a Model](https://munqu.github.io/commol/latest/guide/describing-models/index.md) | | One process run as difference equations or differential equations; results read, plotted and compared | [Running Simulations](https://munqu.github.io/commol/latest/guide/simulations/index.md) | | The same process as a stochastic process of whole units, with seeds and replicates | [Stochastic Simulation](https://munqu.github.io/commol/latest/guide/stochastic/index.md) | | Calibration of parameters, initial conditions and scale factors | [Model Calibration](https://munqu.github.io/commol/latest/guide/calibration/index.md) | | Parameter samples, predictive bands, scenarios and scores | [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) | | JSON-ready reports | [Reports](https://munqu.github.io/commol/latest/guide/reports/index.md) | | Parallel starts, replicates, particles and draws, on the threads you choose | [Threads and Parallelism](https://munqu.github.io/commol/latest/guide/parallelism/index.md) | ## License and authors Commol is licensed under the MIT License; see [License](https://munqu.github.io/commol/latest/about/license/index.md). It is developed by the MUNQU team: Rafael J. Villanueva Micó, Carlos Andreu Vilarroig and David Martínez Rodríguez. # Installation [Back to Home](https://munqu.github.io/commol/latest/index.md) Install Commol from PyPI, or build it from source to work on it. ## Quick path ```bash pip install commol ``` The figures need matplotlib, the optional extra `commol[plot]`: ```bash pip install 'commol[plot]' ``` | Requirement | Version | | -------------------------------- | ---------------------------------------------------- | | Python | 3.12 or later | | Platforms with ready-made wheels | Linux and macOS (x86_64 and arm64), Windows (x86_64) | On other platforms, `pip` builds Commol from the source distribution, which needs a Rust toolchain. ## Check the installation ```python import commol print(commol.__version__) print(commol.build_info()) ``` `commol.__version__` is the version you installed. `commol.build_info()` gives it with the full commit of the source; save it with a result to name the code that made it. | Installed from | `__version__` | `build_info().commit` | | -------------------------------------------- | ------------------------------------------- | -------------------------------------- | | A wheel of a release on PyPI | The release, such as `0.9.0` | The commit of the release | | The source distribution of a release | The release | `None`: the source has no git metadata | | A git commit, or a clone (`maturin develop`) | `0.1.0+g` | The full commit | A build that is not a release has the placeholder version `0.1.0` of the source, so its local version (`+g…`) is what names its commit. The metadata of the package (`pip show commol`) gives the placeholder alone. ## Build from source To work on Commol, or to use unreleased code from `main`: 1. Install Rust: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`. 1. Install [uv](https://docs.astral.sh/uv/), at the version of `required-version` in `py-commol/pyproject.toml`; another version of uv refuses to run in the project. A standalone uv moves to it with `uv self update `. 1. Clone, install the dependencies, and build the extension: ```bash git clone https://github.com/MUNQU/commol.git cd commol/py-commol uv sync --extra dev --extra docs source .venv/bin/activate # On Windows: .venv\Scripts\activate maturin develop --release --features test-kind pytest ``` | Step | Why | | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `uv sync --extra dev --extra docs` | Creates `.venv` with the test, lint and documentation tools. It does not build Commol (`package = false` in `pyproject.toml`), and a later `uv sync` removes the install of Commol. | | `source .venv/bin/activate` | `maturin develop` installs into the active environment. | | `maturin develop --release --features test-kind` | Compiles the Rust extension and installs Commol in editable mode. The `test-kind` feature adds the model kind that the tests need; a release never has it. Run it again after every Rust change and every `uv sync`. | Paths with spaces Maturin may fail when the project path contains spaces. Clone into a path without them. See [Development Workflow](https://munqu.github.io/commol/latest/development/workflow/index.md) for branches, checks and pull requests. ## Next steps | To learn | Read | | ---------------- | ---------------------------------------------------------------------------------------- | | Your first model | [Quick Start](https://munqu.github.io/commol/latest/getting-started/quickstart/index.md) | | The concepts | [Core Concepts](https://munqu.github.io/commol/latest/guide/core-concepts/index.md) | # Quick Start [Back to Home](https://munqu.github.io/commol/latest/index.md) Build a model, run it, read and plot its results, run the same process with another engine, and fit it to data. Each step takes a few lines. This page assumes Commol is installed with its figures (`pip install 'commol[plot]'`, see [Installation](https://munqu.github.io/commol/latest/getting-started/installation/index.md)). ## Quick path 1. Write the process with `ModelBuilder`: bins, parameters and transitions. 1. Build it with a structure (`Aggregate`, the amount each bin starts with) and an engine. 1. Run it with `Simulation`, read a series by its output name, and draw it with `results.plot`. ```python from commol import Aggregate, ModelBuilder, Simulation process = ( ModelBuilder(name="Basic Model", version="1.0") .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=0.3) .add_parameter(id="k2", value=0.1) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total") .add_transition(id="t_bc", source=["B"], target=["C"], rate="k2") ) structure = Aggregate(bin_amounts={"A": 990, "B": 10, "C": 0}) model = process.build(structure, engine="DifferenceEquations") simulation = Simulation(model) results = simulation.run(num_steps=100) print(simulation.simulation_outputs) # ['A', 'B', 'C'] print(results.positions[-1], round(results["C"][-1])) # 100.0 and the amount of C results.plot.series(output_file="quickstart.png") # one panel per bin ``` `results` maps each output name to its values, one per position from step 0 to step 100; `results.positions` holds the positions. `results.plot.series` returns the matplotlib `Figure`, and saves it when you give `output_file`. ## What each call does | Call | What it does | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `ModelBuilder(name=..., version=...)` | Starts a model definition. Every method returns the builder, so calls chain. | | `.add_bin(id="A", name="State A")` | Adds a compartment (a *bin*): a state that holds an amount, in the unit of the bin. | | `.add_parameter(id="k1", value=0.3)` | Adds a named value for rate formulas. `value=None` leaves it for calibration. | | `.add_transition(..., rate="k1 * A * B / total")` | Moves an amount from `source` to `target` along the axis. This rate names compartments, so it is the flow itself (*absolute*). | | `.add_transition(..., rate="k2")` | This rate names no compartment, so it is *proportional*: the flow is `k2 × B`. | | `Aggregate(bin_amounts=...)` | The *structure*: the amount each bin starts with. | | `.build(structure, engine=...)` | Joins the *process* (what you wrote) with the structure and an engine, checks the definition and returns a `Model`. | | `Simulation(model).run(num_steps=100)` | Runs the model in the Rust engine and returns its `SimulationResults`. | | `results["B"]`, `results.positions` | The values of one output, and the positions they were read at. | | `results.plot.series(...)` | Draws each series along the axis. | See [Core Concepts](https://munqu.github.io/commol/latest/guide/core-concepts/index.md) for bins, transitions and [what a rate means](https://munqu.github.io/commol/latest/guide/core-concepts/#what-a-rate-means) in each engine. ## One process, several engines The process is written once. The same builder and structure build a model of another engine: differential equations in continuous time, or a stochastic process of whole units. `plot_compared` draws the runs on the same panels: ```python from commol import plot_compared differential = Simulation(process.build(structure, engine="DifferentialEquations")) stochastic = Simulation(process.build(structure, engine="StochasticEvents")) continuous = differential.run(num_steps=100) replicates = stochastic.replicates(50, seed=1).run(num_steps=100) print(len(replicates)) # 50 runs of whole units, one seed each plot_compared( {"difference": results, "differential": continuous, "stochastic": replicates}, series=["B"], output_file="engines.png", ) ``` A difference model applies each flow once per step; a differential model integrates the flows; a stochastic engine moves whole units at random, so `replicates` holds one run per seed and the figure shows their mean and band. See [Running Simulations](https://munqu.github.io/commol/latest/guide/simulations/index.md) and [Stochastic Simulation](https://munqu.github.io/commol/latest/guide/stochastic/index.md). ## Check units Give every bin and parameter a unit, and the unit of one step, and Commol checks that each rate gives the bin unit per step unit: ```python model_with_units = ( ModelBuilder(name="Model with Units", version="1.0", bin_unit="item", step_unit="day") .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=0.3, unit="1/day") .add_parameter(id="k2", value=0.1, unit="1/day") .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total") .add_transition(id="t_bc", source=["B"], target=["C"], rate="k2 * B") .build(structure, engine="DifferenceEquations") ) model_with_units.check_unit_consistency() # raises UnitConsistencyError on a mismatch ``` See [Unit checking](https://munqu.github.io/commol/latest/guide/units-and-equations/#unit-checking). ## Calibrate unknown parameters Set the unknown parameters to `None`, describe the observations and the unknowns in a `CalibrationProblem`, and let an optimizer find the values: ```python from commol import ( CalibrationParameter, CalibrationProblem, Calibrator, NelderMeadConfig, ObservedDataPoint, ) observed_data = [ ObservedDataPoint(at=step, output="B", value=value) for step, value in [(10, 45.2), (20, 78.5), (30, 62.3), (40, 38.1)] ] calibration_model = ( ModelBuilder(name="Calibration Model", version="1.0") .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=None) .add_parameter(id="k2", value=None) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total") .add_transition(id="t_bc", source=["B"], target=["C"], rate="k2 * B") .build(structure, engine="DifferenceEquations") ) problem = CalibrationProblem( observed_data=observed_data, parameters=[ CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0), CalibrationParameter(id="k2", parameter_type="parameter", min_bound=0.0, max_bound=1.0), ], loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=2000), seed=42, ) # A simulation of a model with a parameter at None can be calibrated, not run. calibration = Simulation(calibration_model) result = Calibrator(calibration, problem).run() print({name: round(value, 3) for name, value in result.best_parameters.items()}) calibration_model.apply_calibration_parameters(result, problem) fitted = calibration.run(num_steps=60) # the simulation follows its model fitted.plot.series(series=["B"], observed_data=observed_data, output_file="fit.png") ``` | Object | Role | | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `ObservedDataPoint` | One observation: a position (`at`), an output and a value. | | `CalibrationParameter` | One unknown, with its bounds. | | `CalibrationProblem` | The observations, the unknowns, the loss, the optimizer and the `seed` of its random numbers. | | `Calibrator(...).run()` | Runs the optimizer in Rust and returns a `CalibrationResult` (`best_parameters`, `final_loss`, `converged`, …). | | `Model.apply_calibration_parameters(result, problem)` | Writes the calibrated values into the model. | See [Model Calibration](https://munqu.github.io/commol/latest/guide/calibration/index.md) for initial conditions, constraints, scale factors and the optimizers. ## Log messages Commol logs with the standard `logging` module, to the logger `commol` and its children, such as `commol.api.simulation` and `commol.api.calibrator`. Commol adds no handler of its own: your application chooses where the messages go. Python shows only warnings and errors until you configure logging. To see the messages of commol and of every other library, configure the root logger once, at the start of your program: ```python import logging logging.basicConfig(level=logging.INFO) Simulation(model).run(num_steps=10) ``` Each message shows its level and its logger, such as `INFO:commol.api.simulation:Running simulation for 10 steps.` | To | Call | | --------------------------------------------------- | --------------------------------------------------------------------------- | | See the messages of every library | `logging.basicConfig(level=logging.INFO)` | | Set a level for commol only | `logging.getLogger("commol").setLevel(logging.DEBUG)` | | Send the messages of commol to a place of their own | `logging.getLogger("commol").addHandler(logging.FileHandler("commol.log"))` | | Read the messages of one part only | `logging.getLogger("commol.api.calibrator")`, with a level or a handler | See the [logging HOWTO](https://docs.python.org/3/howto/logging.html) for handlers, formats and levels. ## Checklist - [ ] The model builds without errors. - [ ] `Simulation(model).run(...)` returns one series per output, and `results.plot.series()` draws them. - [ ] Another engine runs the same process when you change only `engine`. - [ ] `check_unit_consistency()` passes when you give units. - [ ] Calibration returns `best_parameters` within the bounds you set. ## Next steps | To learn | Read | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | The concepts behind models | [Core Concepts](https://munqu.github.io/commol/latest/guide/core-concepts/index.md) | | Every builder option | [Building Models](https://munqu.github.io/commol/latest/guide/building-models/index.md) | | Running, reading and plotting runs, and comparing them | [Running Simulations](https://munqu.github.io/commol/latest/guide/simulations/index.md) | | Calibration in depth | [Model Calibration](https://munqu.github.io/commol/latest/guide/calibration/index.md) | | How sure a fit is: samples, predictive bands, scenarios | [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) | | How many threads the parallel work uses | [Threads and Parallelism](https://munqu.github.io/commol/latest/guide/parallelism/index.md) | | Complete models | [Examples](https://munqu.github.io/commol/latest/guide/examples/index.md) | # User guide # Core Concepts [Back to Home](https://munqu.github.io/commol/latest/index.md) A Commol model is a set of **compartments** that each hold an **amount**, and **transitions** that move amounts between them along the **axis**, the independent variable of the model (time is the most common one). The amounts of every compartment together are the **state** of the model, and their sum is the **total**. An amount can be a count, a mass or a concentration. This page explains each building block once, with a runnable example. For every option of every method, see [Building Models](https://munqu.github.io/commol/latest/guide/building-models/index.md). ## The concepts at a glance | Concept | What it is | Builder method | | ----------------- | ----------------------------------------------------------------------------------------------------- | -------------------------- | | Compartment (bin) | A state that holds an amount | `add_bin` | | Stratification | A dimension that splits every compartment into categories | `add_stratification` | | Parameter | A named constant or formula used in rates | `add_parameter` | | Transition | A flow between compartments, with a rate | `add_transition` | | Accumulator | A counter of what a transition moved; not part of the state | `add_accumulator` | | Process | The bins, stratifications, transitions and accumulators: what changes and how, written once | the `add_*` methods | | Structure | How the process is laid out; `Aggregate` holds amounts per compartment and says how much starts where | `Aggregate(...)` | | Model | The validated definition: a header, a process, a structure and an engine | `build(structure, engine)` | | Simulation | Runs a model for a number of steps | `Simulation(model).run` | ## Compartments A compartment (a *bin*) holds an amount, in the unit of the bin. A model can have any number of them, with any ids. ```python from commol import Aggregate, ModelBuilder, Simulation builder = ( ModelBuilder(name="Concepts") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_bin(id="C", name="State C") ) ``` ## Parameters A parameter is a named value that rates can use. Its value is a number, a formula (a string that can use other parameters and the [special variables](https://munqu.github.io/commol/latest/guide/mathematical-expressions/index.md)), or `None` when calibration will find it. ```python builder = ( builder .add_parameter(id="k1", value=0.3, description="A to B rate") .add_parameter(id="k2", value=0.1, description="B to C rate") ) ``` ## Transitions A transition moves an amount from its `source` compartments to its `target` compartments along the axis. How much it moves depends on its rate: | The rate… | Kind | Flow | Example | | ---------------------------------- | ---------------- | --------------- | ---------------------- | | names a compartment or a bin total | **Absolute** | the rate itself | `"k1 * A * B / total"` | | names no compartment | **Proportional** | rate × source | `"k2"` moves `k2 × B` | ```python builder = ( builder .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total") .add_transition(id="t_bc", source=["B"], target=["C"], rate="k2") ) ``` The engine decides the kind when the model is built, and [`model.describe()`](https://munqu.github.io/commol/latest/guide/describing-models/index.md) shows the result: the proportional rate above shows as `k2 * B`. [Transitions](https://munqu.github.io/commol/latest/guide/transitions/#absolute-and-proportional-rates) gives the exact rule, with its edge cases. ## Accumulators An accumulator counts what a transition has moved since step 0. It is an output of the simulation, not part of the state: nothing is ever taken from it, and it is not part of the total. ```python builder = builder.add_accumulator(id="cum_ab", name="Total moved from A to B") builder = builder.add_transition( id="t_ab_counted", source=["A"], target=["B"], rate="0.01 * A", accumulators=["cum_ab"], ) ``` With stratifications, an accumulator expands like a bin (`cum_ab_g1`, `cum_ab_g2`, …). Resetting a simulation sets it back to 0. ## Initial conditions: the structure The calls above write the **process**: what changes and how. The **structure** lays the process out. `Aggregate` holds an amount per compartment, and gives the amount that each bin starts with, in the unit of the bin. An amount is a real number, finite and not negative: ```python structure = Aggregate(bin_amounts={"A": 900, "B": 100, "C": 0}) ``` Every bin needs an amount. An amount may be `None` when calibration will find it ([Model Calibration](https://munqu.github.io/commol/latest/guide/calibration/index.md)). [The structure](https://munqu.github.io/commol/latest/guide/building-models/#the-structure-aggregate) lists every argument of `Aggregate` and when each rule is checked. ## Build and run `build` joins the process with the structure and an engine, validates the definition and returns a `Model`. `Simulation` runs it in the Rust engine: ```python model = builder.build(structure, engine="DifferenceEquations") results = Simulation(model).run(num_steps=50) print(sorted(results)) # ['A', 'B', 'C', 'cum_ab'] print(len(results["A"])) # 51: step 0 to step 50 ``` A `Model` has a header (its name, parameters and step unit), the `process` (bins, stratifications, transitions and accumulators), the `structure` that lays it out, and the `engine` that runs it. The process is written once; the structure is what changes between kinds of model, so `ModelBuilder` builds every kind its process fits. Here `model.structure` is an `Aggregate`: ```python print(model.engine.type) # DifferenceEquations print(model.structure.kind) # aggregate print([b.id for b in model.process.bins]) # ['A', 'B', 'C'] print(model.structure.bin_amounts) # {'A': 900.0, 'B': 100.0, 'C': 0.0} ``` Four engines run an aggregate model, with the same process and structure: | Engine | Time | Crate | | ----------------------- | ----------------------------------------------------------------------- | --------------------- | | `DifferenceEquations` | Advances in whole steps; each step applies every flow once. | `commol-difference` | | `DifferentialEquations` | Continuous: the engine integrates `dX/dt = Σ flows` with an ODE solver. | `commol-differential` | | `StochasticEvents` | Continuous, at random: whole units move one event at a time. | `commol-stochastic` | | `StochasticSteps` | Whole steps, at random: each step draws how many units each flow moves. | `commol-stochastic` | See [Differential equations](https://munqu.github.io/commol/latest/guide/simulations/#differential-equations) and [Stochastic Simulation](https://munqu.github.io/commol/latest/guide/stochastic/index.md). ### What a rate means A flow is per unit of the axis in every engine, and the unit check is the same for every engine. What its value means is the engine's (`rate_meaning` of the engine's list, `EngineType` in `crates/commol-core/src/types/engine.rs`): | Engine | `rate_meaning` | A flow of value `f` | Equation | | ----------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------- | | `DifferenceEquations` | `per_step` | Moves `f` in one step: the flow of an absolute rate, or `f × source` for a proportional rate | `X(n+1) = X(n) + Σ flows` | | `DifferentialEquations` | `continuous` | Is the instantaneous rate of change: over a short span `Δ` it moves about `f × Δ` | `dX/dt = Σ flows` | | `StochasticEvents` | `continuous` | Is the propensity of its events: the expected number of units it moves per unit of the axis | `dX/dt = Σ flows`, in the mean | | `StochasticSteps` | `per_step` | Is the expected number of units it moves in one step; a proportional rate is the probability that one unit moves | `X(n+1) = X(n) + Σ flows`, in the mean | So one rate gives two different dynamics: a proportional rate `k` keeps `(1 - k)^n` of its source after `n` steps in a difference model, and `exp(-k n)` in a differential model. To find the rate that gives a mean span or a probability over a period in the engine of a model, use [`AxisUnits`](#units-of-the-axis): it asks the engine. ## Stratifications A stratification splits every compartment into one compartment per category. The expanded name is `{bin}_{category}`, with one suffix per stratification, in the order the stratifications are declared: ```text Bins: A, B Stratification group = [g1, g2] A -> A_g1, A_g2 B -> B_g1, B_g2 4 compartments (2 bins x 2 categories) ``` With several stratifications, the expansion is the product of all their categories: ```text Stratifications group = [g1, g2] and type = [t1, t2] A -> A_g1_t1, A_g1_t2, A_g2_t1, A_g2_t2 8 compartments for 2 bins ``` The initial amount of each compartment is the amount of its bin times the product of the fractions of its categories: ```python stratified = ( ModelBuilder(name="Stratified") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_stratification(id="group", categories=["g1", "g2"]) .add_parameter(id="k1", value=0.1) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1") .build( Aggregate( bin_amounts={"A": 9000, "B": 1000}, stratification_fractions={"group": {"g1": 0.6, "g2": 0.4}}, ), engine="DifferenceEquations", ) ) for compartment in stratified.structure.initial_state(stratified.process): print(compartment.name, compartment.amount) ``` | Compartment | Calculation | Initial amount | | ----------- | ----------- | -------------- | | `A_g1` | 9000 × 0.6 | 5400 | | `A_g2` | 9000 × 0.4 | 3600 | | `B_g1` | 1000 × 0.6 | 600 | | `B_g2` | 1000 × 0.4 | 400 | `model.structure.initial_state(model.process)` returns this table as the engine builds it: each compartment with its bin, its categories and its amount. Use a stratification when subgroups have different rates, when you need output per subgroup, or when the data you fit is broken down by category. ### Conditional stratifications A stratification with `conditions` only splits the compartments whose earlier categories satisfy all of them. A condition may only name a stratification declared before it. ```text Stratifications group = [g1, g2] and subtype = [s1, s2] (only where group = g2) A -> A_g1, A_g2_s1, A_g2_s2 3 compartments per bin instead of 4 ``` ```python conditional = ( ModelBuilder(name="Conditional") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_stratification(id="group", categories=["g1", "g2"]) .add_stratification( id="subtype", categories=["s1", "s2"], conditions=[{"stratification": "group", "category": "g2"}], ) .add_parameter(id="k1", value=0.1) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1") .build( Aggregate( bin_amounts={"A": 9000, "B": 1000}, stratification_fractions={ "group": {"g1": 0.6, "g2": 0.4}, "subtype": {"s1": 0.5, "s2": 0.5}, }, ), engine="DifferenceEquations", ) ) print([c.name for c in conditional.structure.initial_state(conditional.process)]) # ['A_g1', 'A_g2_s1', 'A_g2_s2', 'B_g1', 'B_g2_s1', 'B_g2_s2'] ``` The fractions of a conditional stratification are fractions of the subgroup it splits, not of the whole bin, so they multiply along the chain. `subgroup_total` sums the initial state over the compartments of a subgroup, and `get_conditioning_categories` names the subgroup: ```python aggregate, process = conditional.structure, conditional.process print(aggregate.subgroup_total(process)) # 10000 print(aggregate.subgroup_total(process, ["g2"])) # 10000 x 0.4 = 4000 print(aggregate.subgroup_total(process, ["g2", "s1"])) # 4000 x 0.5 = 2000 print(aggregate.get_conditioning_categories(process, "subtype")) # ('g2',) ``` See [Conditional Stratifications](https://munqu.github.io/commol/latest/guide/building-models/#stratifications) for the full API. ### Per-compartment overrides When the product of fractions cannot express the starting state, for example a state that is empty in only one category, `compartment_amounts` overrides single expanded compartments. Each amount is in the unit of the bin of its compartment and replaces the product for that compartment only: ```python overridden = ( ModelBuilder(name="Override") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_stratification(id="group", categories=["g1", "g2"]) .add_parameter(id="k1", value=0.1) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1") .build( Aggregate( bin_amounts={"A": 9000, "B": 1000}, stratification_fractions={"group": {"g1": 0.5, "g2": 0.5}}, compartment_amounts={"B_g1": 0}, ), engine="DifferenceEquations", ) ) print({c.name: c.amount for c in overridden.structure.initial_state(overridden.process)}) # B_g1 starts empty; the other compartments keep their product ``` | Rule | Checked when | | -------------------------------------- | ------------------ | | Each amount is finite and not negative | `Aggregate(...)` | | Each name is an expanded compartment | The model is built | An override may hold more than the product gave its compartment: the bins keep no sum. `model.structure.update_compartment_amounts(model.process, {"B_g1": 50})` changes or adds overrides on a built model; the engine checks them against the expansion of the process before they are stored. ### Updating the initial state after building ```python overridden.structure.update_bin_amounts({"A": 8500, "B": 1500}) overridden.structure.update_stratification_fractions({"g1": 0.7}) # g2 becomes 0.3 ``` | Method | Rule | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `update_bin_amounts` | Each amount is finite and not negative, or `None` for calibration. The other bins keep their amounts. | | `update_stratification_fractions` | Within each stratification you may omit one category; it receives what remains of 1, or 0 when the given fractions pass 1. Omitting two raises. Each fraction is from 0 to 1 and the fractions sum to 1 within 1e-6, with the rule of the engine; a rejected call changes no fraction. | A category name that two stratifications declare must be qualified with its stratification, as in `{"group:g1": 0.5}`. Calibration writes its results back through these same methods. ## Several sources or targets A transition can have several sources or several targets. It then moves **one** flow, which each source loses and each target gains, so its rate must be absolute. For independent flows of one formula from several compartments, the `$compartment` placeholder makes one transition per compartment. See [Several sources or targets](https://munqu.github.io/commol/latest/guide/transitions/#several-sources-or-targets). ## Units of the axis A model knows only steps: a rate is per step, and a position or a period is a number of steps. `AxisUnits` converts values in units to steps, and gives the rate that the engine of a model reads. `Model.axis_units()` gives the one of a model: its `step_unit` (or `step` when it declares none), its `origin` and its engine. ```python from commol import AxisUnits hourly = AxisUnits(step_unit="hour") print(hourly.steps(1, "day")) # 24.0 print(hourly.steps(3, "day")) # 72.0 print(hourly.steps(30, "minute")) # 0.5 along_a_line = AxisUnits(step_unit="meter") print(along_a_line.steps(2, "km")) # 2000.0 ``` `steps` works for any dimension, and raises `ValueError` for a unit of another dimension than the step, naming both units. It returns a `float`: whether a position must be a whole number of steps is the engine's check, made where the position is used. A result within `1e-9` of an integer is that integer. The rate conversions ask the engine what a rate means ([What a rate means](#what-a-rate-means)): | Method | `DifferenceEquations` (`per_step`) | `DifferentialEquations` (`continuous`) | | ---------------------------------------- | ---------------------------------------------------------- | -------------------------------------- | | `rate_from_mean_span(span, unit)` | `1 - exp(-1 / n)`, with `span` equal to `n` steps | `1 / n` | | `rate_from_probability(p, period, unit)` | `1 - (1 - p) ** (1 / n)`, with `period` equal to `n` steps | `-ln(1 - p) / n` | ```python difference = AxisUnits(step_unit="day", engine="DifferenceEquations") differential = AxisUnits(step_unit="day", engine="DifferentialEquations") print(difference.rate_from_mean_span(4, "day")) # 0.2212 = 1 - exp(-1/4) print(differential.rate_from_mean_span(4, "day")) # 0.25 print(difference.rate_from_probability(0.3, 1, "week")) print(differential.rate_from_probability(0.3, 1, "week")) ``` With either rate, one outflow moves `p` of its bin over the period, and the bin keeps `exp(-n / span)` at each step `n`. In the difference model the mean residence is then `1 / rate` steps (`4.52` for a span of 4), not the span: a per-step rate cannot keep both the survival and the mean of the continuous process, and these conversions keep the survival. An axis of time with an origin also converts between dates and positions, and gives the ends of calendar days and weeks; see [Dates on the axis](https://munqu.github.io/commol/latest/guide/units-and-equations/#dates-on-the-axis). The axis also gives the window grid. A window has a width, a span in a unit, which must be a whole number of steps; any other width raises `ValueError`, naming the span, its unit and the step unit. A width in steps is the span in the step unit, such as `(7, "day")` for 7 steps of a day, or `(7, "step")` when the model declares no step unit. | Method | Returns | | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `window_start(i, span, unit)`, `window_end(i, span, unit)` | The positions that bound window `i`: `i · width` and `(i + 1) · width`, with the width in steps | | `window_index(at, span, unit)` | The window that holds a position, `max(ceil(at / width) − 1, 0)`; the inverse of `window_end`. Reports place observations with this rule. | | `window_ends(span, unit, end)` | Every complete window end up to the position `end` | ```python print(hourly.window_start(0, 1, "week"), hourly.window_end(0, 1, "week")) # 0.0 168.0 print(hourly.window_end(2, 1, "week")) # 504.0 print(hourly.window_index(168, 1, "week")) # 0 print(hourly.window_index(168.5, 1, "week")) # 1 print(hourly.window_ends(1, "day", 100)) # [24.0, 48.0, 72.0, 96.0] ``` A window covers the positions `(start, end]`, the same interval that [windowed observations](https://munqu.github.io/commol/latest/guide/calibration-observations/#counts-per-period-windows) use. [Windowed totals](https://munqu.github.io/commol/latest/guide/simulations/#windowed-totals) reads the gain of a series over each window. ## Next steps | To learn | Read | | -------------------------------- | --------------------------------------------------------------------------------------------------------- | | Every builder option | [Building Models](https://munqu.github.io/commol/latest/guide/building-models/index.md) | | What rate formulas can contain | [Mathematical Expressions](https://munqu.github.io/commol/latest/guide/mathematical-expressions/index.md) | | Rates that change along the axis | [Axis Patterns](https://munqu.github.io/commol/latest/guide/axis-patterns/index.md) | | Running and reading simulations | [Running Simulations](https://munqu.github.io/commol/latest/guide/simulations/index.md) | # Building Models [Back to Home](https://munqu.github.io/commol/latest/index.md) `ModelBuilder` writes the **process** of a model: its bins, parameters, stratifications, accumulators and transitions. `build` joins the process with a **structure**, which lays it out, and an **engine**, which runs it, and returns a validated `Model` ([Build and run](https://munqu.github.io/commol/latest/guide/core-concepts/#build-and-run)). The structure of an aggregate model is `Aggregate`: amounts per compartment. This page documents every builder method except transitions, which have their own page: | Topic | Page | | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Bins, parameters, stratifications, accumulators, the structure (`Aggregate`), build, save and load | This page | | Transitions and their rates | [Transitions](https://munqu.github.io/commol/latest/guide/transitions/index.md) | | Units | [Units](https://munqu.github.io/commol/latest/guide/units-and-equations/index.md) | | The equations and a description of a model | [Describing a Model](https://munqu.github.io/commol/latest/guide/describing-models/index.md) | | What the concepts mean | [Core Concepts](https://munqu.github.io/commol/latest/guide/core-concepts/index.md) | ## Quick path ```python from commol import Aggregate, Model, ModelBuilder, Simulation model = ( ModelBuilder(name="Four-State Model", version="1.0") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_bin(id="C", name="State C") .add_bin(id="D", name="State D") .add_parameter(id="k1", value=0.4, description="A to B rate") .add_parameter(id="k2", value=0.2, description="B to C rate") .add_parameter(id="k3", value=0.1, description="C to D rate") .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total") .add_transition(id="t_bc", source=["B"], target=["C"], rate="k2") .add_transition(id="t_cd", source=["C"], target=["D"], rate="k3") .build( Aggregate(bin_amounts={"A": 900, "B": 100, "C": 0, "D": 0}), engine="DifferenceEquations", ) ) results = Simulation(model).run(num_steps=200) ``` 1. Create a builder. 1. Add bins, parameters and transitions, and optionally stratifications and accumulators: the process. 1. Call `build` with a structure, such as `Aggregate` with the initial amount of each bin, and an engine. ## The builder ```python builder = ModelBuilder( name="My Model", version="1.0", description="Optional description", bin_unit="item", ) ``` | Argument | Type | Default | Meaning | | ------------- | -------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | `str` | required | The model name. | | `description` | `str \| None` | `None` | A free-text description. | | `version` | `str \| None` | `None` | A version label for your own tracking. | | `bin_unit` | `str \| None` | `None` | The default unit of every bin. It enables [unit checking](https://munqu.github.io/commol/latest/guide/units-and-equations/#unit-checking). | | `step_unit` | `str \| None` | `None` | The unit of one step of the axis, such as `"day"` or `"meter"`. The unit check reads each flow as `/`. `None` means `step`. See [Units](https://munqu.github.io/commol/latest/guide/units-and-equations/index.md). | | `origin` | `date \| datetime \| None` | `None` | The calendar moment of position 0, with no time zone, for an axis of time. See [Dates on the axis](https://munqu.github.io/commol/latest/guide/units-and-equations/#dates-on-the-axis). | Every `add_*` method returns the builder, so calls chain. The other methods: | Method | Returns | What it does | | ----------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `build(structure, engine, settings=None)` | `Model` of the class of `structure` | Joins the process with a [structure](#the-structure-aggregate) and an engine, validates the model and returns it. The engine must run the kind of the structure: `"DifferenceEquations"` (discrete time), `"DifferentialEquations"` (continuous time), `"StochasticEvents"` and `"StochasticSteps"` ([at random](https://munqu.github.io/commol/latest/guide/stochastic/index.md)) run `Aggregate`. `settings` are the settings of that engine (`ODESolver` for `"DifferentialEquations"`, `StochasticEventsSettings` and `StochasticStepsSettings` for the stochastic engines); None uses its defaults. One builder builds the model for each engine and each structure. See [Differential equations](https://munqu.github.io/commol/latest/guide/simulations/#differential-equations). | | `process()` | `Process` | The bins, accumulators, stratifications and transitions written so far. | | `clone()` | `ModelBuilder` | A deep copy of everything, accumulators included, to build variants of one model. | | `reset()` | `ModelBuilder` | Removes bins, accumulators, stratifications, transitions and parameters; keeps the name, description, version, `bin_unit`, `step_unit` and `origin`. | | `get_summary()` | `dict` | The name, description, version, and the count and ids of the bins, stratifications, transitions and parameters added so far. | ```python variant = builder.clone() print(builder.get_summary()["name"]) # My Model ``` ## Bins ```python builder = ( builder .add_bin(id="A", name="State A") .add_bin(id="B", name="State B", unit="item") ) ``` | Argument | Type | Default | Meaning | | -------- | ------------- | -------- | ------------------------------------------------------------- | | `id` | `str` | required | The identifier used in rates, initial conditions and results. | | `name` | `str` | required | A display name. | | `unit` | `str \| None` | `None` | The unit of this bin; `None` uses the builder's `bin_unit`. | A bin id is reserved: no parameter may use it. With stratifications, a bin id in a rate means the total of all its expanded compartments. ## Parameters A parameter is a named value for rate formulas: ```python builder = ( builder .add_parameter(id="k1", value=0.3, description="A constant") .add_parameter(id="k2", value="k1 / 2", description="A formula") .add_parameter(id="k3", value=None, description="Found by calibration") ) ``` | Argument | Type | Default | Meaning | | ------------- | ----------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- | | `id` | `str` | required | The identifier. It must be unique and must not be a [name the engine sets](#names-the-engine-sets). | | `value` | `float`, `str`, `list[tuple[float, float]]` or `None` | required | See the table below. | | `description` | `str \| None` | `None` | A free-text description. | | `unit` | `str \| None` | `None` | The unit, for [unit checking](https://munqu.github.io/commol/latest/guide/units-and-equations/#unit-checking). | | `mode` | `str \| None` | `None` | How a series is read; required for a series and rejected otherwise. | | `value` | Kind | Behavior | | ------------------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0.3` | Constant | The same value at every step. | | `"k1 / 2"` | Formula | Evaluated at every step, in declaration order, so it can use earlier formulas, `total`, `step` and the other [special variables](https://munqu.github.io/commol/latest/guide/mathematical-expressions/index.md). | | `[(position, value), …]` | Series | Values at positions on the axis, read with `mode` (below). | | `None` | Uncalibrated | Calibration must set it before a simulation runs. | ### Names the engine sets The engine sets these variables, so `build` rejects a parameter with one of these ids (`Aggregate.check_model` and `check_model` in `crates/commol-model/src/builder.rs`): | Name | Example | | ------------------------------------------------------------------------ | --------------------------------------------- | | `total`, `step`, `t`, `pi`, `e` | `total` | | `neighbors` and `neighbors_`, the [neighbors of a unit](#neighbors) | `neighbors_A` | | A bin id | `A` | | An expanded compartment | `A_g1` | | A subgroup total | `total_g1`, `A_t1` (with two stratifications) | A bin or an accumulator cannot take an id of the first two rows either, because the engine variable would hide it. A bin whose names read as a subgroup total is rejected too: a bin `total_y` stratified by `group = [y, o]` and `type = [y, o]` expands into `total_y_y`, which is also the total of the `y` of both stratifications. An id that starts with `total_` but names no subgroup total, such as `total_max`, is accepted. `N` is not reserved, so a bin or a parameter may use it. ### Series parameters A series gives a parameter its value from `(position, value)` points on the axis. `mode` says how the positions between the points are read, and whether the series reads `step` or `t` (`SeriesParameter::evaluate` in `crates/commol-model/src/types.rs`): | `mode` | Reads | Before the first point | At a point | Between points | After the last point | | ----------------- | ------ | ---------------------- | --------------------------- | --------------------------- | --------------------------- | | `"pulse"` | `step` | 0 | the value on the whole step | 0 | 0 | | `"step_function"` | `t` | 0 | the value | the value of the last point | the value of the last point | | `"linear"` | `t` | 0 | the value | linear interpolation in `t` | 0 | A series has at least one point, each position finite, not negative and in one point only, and each value finite. `SeriesValue(...)` checks its points with the rule of the engine (`check_series` in `crates/commol-model/src/rules.rs`), which the model compiler also reads. The engine decides which positions its model type accepts. `ModelBuilder.build` and `Model.update_parameters` reject a position the engine cannot run with `ValueError`, whose message names the parameter (`check_model` in `crates/commol-model/src/builder.rs`). A rejected update leaves the model unchanged. | Model | Accepted positions | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `DifferenceEquations`, `StochasticSteps` | Integer steps only, for every mode. `t` equals `step`, so `"linear"` is read at each step. | | `DifferentialEquations`, `StochasticEvents` | Any non-negative position for `"step_function"` and `"linear"`; the engine stops and restarts at each point. A `"pulse"` keeps integer positions: it is a rate on one unit step ([Time inside a step](https://munqu.github.io/commol/latest/guide/simulations/#time-inside-a-step)). | ```python import numpy as np def inflow_per_step(mode: str) -> list[float]: model = ( ModelBuilder(name="Series") .add_bin(id="A", name="State A") .add_parameter(id="k_in", value=[(2, 1.0), (5, 4.0)], mode=mode) .add_transition(id="inflow", source=[], target=["A"], rate="k_in") .build( Aggregate( bin_amounts={"A": 100}, ), engine="DifferenceEquations", ) ) series = Simulation(model).run(num_steps=7)["A"] return np.diff(series).round(3).tolist() print(inflow_per_step("pulse")) # [0.0, 0.0, 1.0, 0.0, 0.0, 4.0, 0.0] print(inflow_per_step("step_function")) # [0.0, 0.0, 1.0, 1.0, 1.0, 4.0, 4.0] print(inflow_per_step("linear")) # [0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 0.0] ``` `Model.update_parameters` changes a series of a built model, such as a schedule of a scenario. A list of points keeps the mode of the series; a `SeriesValue` sets the points and the mode. The model takes every value or none: a value it cannot take raises `ValueError` at the call, with the id of the parameter, and the model stays as it was (`tests/building/test_parameters.py::test_update_parameters_rejects_a_value_it_cannot_take`): ```python from commol import SeriesValue schedule = ( ModelBuilder(name="Schedule") .add_bin(id="A", name="State A") .add_parameter(id="k_in", value=[(2, 1.0)], mode="pulse") .add_transition(id="inflow", source=[], target=["A"], rate="k_in") .build(Aggregate(bin_amounts={"A": 100}), engine="DifferenceEquations") ) schedule.update_parameters({"k_in": [(3, 2.0)]}) # still a pulse print(np.diff(Simulation(schedule).run(num_steps=5)["A"]).tolist()) # [0.0, 0.0, 0.0, 2.0, 0.0] schedule.update_parameters( {"k_in": SeriesValue(data=[(3, 2.0)], mode="step_function")} ) print(np.diff(Simulation(schedule).run(num_steps=5)["A"]).tolist()) # [0.0, 0.0, 0.0, 2.0, 2.0] ``` `Model.to_json` writes a long series on one line, so model files stay readable. ## Stratifications ```python builder = builder.add_stratification( id="group", categories=["g1", "g2"], description="Primary grouping", ) builder = builder.add_stratification( id="subtype", categories=["s1", "s2"], conditions=[{"stratification": "group", "category": "g2"}], ) ``` | Argument | Type | Default | Meaning | | ------------- | -------------------- | -------- | ------------------------------------------------------------------------------- | | `id` | `str` | required | The identifier, used in initial conditions and conditions. | | `categories` | `list[str]` | required | The categories. They must be unique and not empty. | | `description` | `str \| None` | `None` | A free-text description. | | `conditions` | `list[dict] \| None` | `None` | Splits only the compartments whose earlier categories match **all** conditions. | Each condition is `{"stratification": , "category": }`, and may only name a stratification declared before this one; otherwise the call raises `ValueError`. | Rule | Effect | | -------------------------- | ------------------------------------------------------------------------------------------ | | Expansion | Every bin and accumulator gets one compartment per category: `A_g1`, `A_g2`. | | Several stratifications | The expansion is the product of their categories, in declaration order: `A_g1_s1`. | | Conditional stratification | Only matching compartments are split: with the code above, `A_g1`, `A_g2_s1`, `A_g2_s2`. | | Order | Changing the order of `add_stratification` calls changes the compartments and their names. | | Case | Names are case-sensitive: `A_g1` is not `A_G1`. | See [Core Concepts](https://munqu.github.io/commol/latest/guide/core-concepts/#stratifications) for worked examples, and [Transitions](https://munqu.github.io/commol/latest/guide/transitions/#stratified-rates) for rates that depend on the category. ## Accumulators An [accumulator](https://munqu.github.io/commol/latest/guide/core-concepts/#accumulators) counts what one or more transitions move. ```python counted = ( ModelBuilder(name="Counted") .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=0.2) .add_parameter(id="k2", value=0.1) .add_accumulator(id="cum_ab", name="Total moved from A to B") .add_accumulator(id="total_out", name="Total that left A") .add_transition( id="t_ab", source=["A"], target=["B"], rate="k1", accumulators=["cum_ab", "total_out"] ) .add_transition(id="t_ac", source=["A"], target=["C"], rate="k2", accumulators=["total_out"]) .build( Aggregate( bin_amounts={"A": 1000, "B": 0, "C": 0}, ), engine="DifferenceEquations", ) ) results = Simulation(counted).run(num_steps=10) print(round(results["total_out"][-1], 3) == round(1000 - results["A"][-1], 3)) # True ``` | Argument | Type | Meaning | | -------- | ----- | ------------------------------- | | `id` | `str` | The identifier and output name. | | `name` | `str` | A display name. | A transition adds its flow to every accumulator in its `accumulators` list, and several transitions can share one accumulator. With stratifications, an accumulator expands like a bin. ## The structure: `Aggregate` `Aggregate` lays the process out as amounts per compartment: the bins expand over the stratifications into compartments, and each compartment holds an amount. It says how much starts where and who the neighbors of a unit are. Each bin starts at an amount in its own unit, so there is no total over bins of different units. ```python structure = Aggregate( bin_amounts={"A": 9000, "B": 1000}, stratification_fractions={ "group": {"g1": 0.3, "g2": 0.7}, "subtype": {"s1": 0.8, "s2": 0.2}, }, compartment_amounts={"B_g1": 0}, ) ``` | Argument | Type | Default | Meaning | | -------------------------- | ----------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `bin_amounts` | `dict[str, float \| None]` | required | The amount of each bin at the start, in the unit of the bin: a count, a mass or a concentration. An amount may be `None` when calibration finds it. | | `stratification_fractions` | `dict[str, dict[str, float]]` | `{}` | For each stratification, the fraction in each category: the split of the amount of every bin it applies to. For a conditional stratification, the split of the subgroup it splits. | | `compartment_amounts` | `dict[str, float]` | `{}` | Overrides single expanded compartments with an amount in the unit of their bin. | | `neighbors` | `Complete \| Groups \| None` | `None` | Who the [neighbors](#neighbors) of a unit are. Required when a rate reads them. | | Rule | Checked when | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | | Every amount is finite and not negative; `0` is an amount. The engine holds the rule (`check_bin_amount` and `check_compartment_amount` in `crates/commol-model/src/rules.rs`) | `Aggregate(...)` and each update of an amount | | Every bin has an amount, and no other id | `build` | | Every stratification has a fraction for each of its categories, and no other category | `build` | | Every fraction is from 0 to 1, and the fractions of a stratification sum to 1 within 1e-6. The engine holds the rule (`check_fractions` in `crates/commol-model/src/rules.rs`) | `Aggregate(...)` and each `update_stratification_fractions` | | Override names are expanded compartments | `build` | | With a stochastic engine, the amount of each bin is a whole number | `build` | The initial amount of a compartment is `bin amount × the fraction of each of its categories`, unless an override names it. `model.structure.initial_state(model.process)` shows the result. To change amounts or fractions on a built model, see [Core Concepts](https://munqu.github.io/commol/latest/guide/core-concepts/#updating-the-initial-state-after-building). A structure that states `total`, `bin_fractions` or `compartment_fractions` is rejected, and the message names `bin_amounts` and `compartment_amounts`. ### Bins of different units A bin may hold a count while another holds a concentration. Each bin states its amount in its own unit: ```python mixed = ( ModelBuilder(name="Mixed units", step_unit="day") .add_bin(id="A", name="State A", unit="item") .add_bin(id="B", name="State B", unit="item") .add_bin(id="C", name="State C", unit="mg/l") .add_parameter(id="k1", value=0.5, unit="1/day") .add_parameter(id="k2", value=1e6, unit="mg/l") .add_transition( id="t_ab", source=["A"], target=["B"], rate="k1 * A * C / (C + k2)" ) .build( Aggregate(bin_amounts={"A": 990, "B": 10, "C": 1e5}), engine="DifferenceEquations", ) ) print({c.name: c.amount for c in mixed.structure.initial_state(mixed.process)}) # {'A': 990.0, 'B': 10.0, 'C': 100000.0} mixed.check_unit_consistency() ``` A rate that reads `total`, a subgroup total or `neighbors` adds bins of different units here, so the [unit check](https://munqu.github.io/commol/latest/guide/units-and-equations/index.md) rejects it. A model whose bins all start at `0` builds and runs: its state stays at `0` when nothing flows in. ### Neighbors A rate may read the neighbors of a unit: `neighbors_` is the amount of a bin among them, and `neighbors` is their total. A rate that reads them and no compartment is **proportional**, a rate per unit of its source, so the same process carries over to structures where units have their own neighbors. Under `Aggregate`, the structure says who they are (`resolve_neighbors` in `crates/commol-model/src/flows.rs`): | `neighbors=` | `neighbors_` in a compartment of category `g` | `neighbors` | | -------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------ | | `Complete()` | The bin total: every unit is a neighbor of every other | `total` | | `Groups(stratification, weights=None)` | The sum over the categories `g'` of `w[g][g']` times the amount of the bin in `g'` | The same sum, of the amount of every bin in `g'` | `weights[g][g']` is the weight of category `g'` for a unit in `g`: one number of at least 0 for every pair of categories. No weights is the identity: a unit sees only its own category. A rate that reads the neighbors needs `neighbors`; there is no default, and `build` lists the options. Here `B` starts only in `g1` (the override pins `B_g2` to 0), and the flow into `B_g2` depends on whom the units of `g2` see: ```python from commol import Complete, Groups def into_b_g2(neighbors: Complete | Groups) -> list[float]: model = ( ModelBuilder(name="Neighbors") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_stratification(id="group", categories=["g1", "g2"]) .add_parameter(id="k1", value=0.3) .add_transition( id="t_ab", source=["A"], target=["B"], rate="k1 * neighbors_B / neighbors" ) .build( Aggregate( bin_amounts={"A": 990, "B": 10}, stratification_fractions={"group": {"g1": 0.5, "g2": 0.5}}, compartment_amounts={"B_g2": 0}, neighbors=neighbors, ), engine="DifferenceEquations", ) ) return [round(value, 3) for value in Simulation(model).run(num_steps=3)["B_g2"]] half = {"g1": {"g1": 1.0, "g2": 0.5}, "g2": {"g1": 0.5, "g2": 1.0}} print(into_b_g2(Complete())) # [0.0, 0.746, 1.714, 2.967] print(into_b_g2(Groups("group"))) # [0.0, 0.0, 0.0, 0.0] print(into_b_g2(Groups("group", weights=half))) # [0.0, 0.498, 1.194, 2.151] ``` Under `Complete()`, `k1 * neighbors_B / neighbors` from `A` moves `k1 × A × B / total`, the flow of the absolute rate `k1 * A * B / total`. Under `Groups("group")`, a unit of `g2` sees only `g2`, where there is no `B`. ## Build ```python builder = ( builder .add_parameter(id="k_out", value=0.05) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k_out") ) stratified_model = builder.build(structure, engine="DifferenceEquations") print([c.name for c in stratified_model.structure.initial_state(stratified_model.process)]) # ['A_g1', 'A_g2_s1', 'A_g2_s2', 'B_g1', 'B_g2_s1', 'B_g2_s2'] ``` `build` raises `ValueError` (or Pydantic's `ValidationError`, a subclass) when the definition is not valid: | Check | Example of a rejected definition | | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | | The engine runs the kind of the structure | An `Aggregate` with an engine of another kind; the message lists the engines of `Aggregate` | | At least one bin and one transition | A model with no transition | | The structure follows the rules above | A bin with no amount in `bin_amounts` | | Parameter ids are unique and are not [names the engine sets](#names-the-engine-sets) | A parameter named `A`, `total` or `A_g1` | | Every `total_…` variable names a subgroup total | `total_t1_g1` when `group` is declared before `type` | | A rate that reads the neighbors has them | `k1 * neighbors_B` with no `neighbors=` | | Every rate expression passes the security check | A formula with `__import__` | | Every transition can be planned by the engine | See [Transitions](https://munqu.github.io/commol/latest/guide/transitions/#what-building-checks) | ## Save and load ```python stratified_model.to_json("model.json") reloaded = Model.from_json("model.json") print(reloaded == stratified_model) # True ``` | Method | What it does | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Model.to_json(file_path, indent=2)` | Writes every field, including nulls, so an uncalibrated model reloads too: the header, the `process` and the `structure`, with its `kind` and `variant`. Long numeric arrays are written on one line. | | `Model.from_json(file_path)` | Reads and validates a model file. | ## Next steps | To learn | Read | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------- | | Transitions and rates | [Transitions](https://munqu.github.io/commol/latest/guide/transitions/index.md) | | Units | [Units](https://munqu.github.io/commol/latest/guide/units-and-equations/index.md) | | The equations and a description of a model | [Describing a Model](https://munqu.github.io/commol/latest/guide/describing-models/index.md) | | What rate formulas can contain | [Mathematical Expressions](https://munqu.github.io/commol/latest/guide/mathematical-expressions/index.md) | | Running a model | [Running Simulations](https://munqu.github.io/commol/latest/guide/simulations/index.md) | # Transitions [Back to Building Models](https://munqu.github.io/commol/latest/guide/building-models/index.md) A transition moves an amount at every step: from its `source` compartments to its `target` compartments, at the rate you give. This page documents `ModelBuilder.add_transition` completely: rates, several sources, transitions between categories, stratified rates, impulses, amounts below zero, and what `build` checks. ## Quick path ```python from commol import Aggregate, ModelBuilder, Simulation model = ( ModelBuilder(name="Transitions") .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=0.3) .add_parameter(id="k2", value=0.1) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total") .add_transition(id="t_bc", source=["B"], target=["C"], rate="k2") .build( Aggregate( bin_amounts={"A": 900, "B": 100, "C": 0}, ), engine="DifferenceEquations", ) ) print(model.describe().render("text")) # every flow, as the engine computes it ``` ## `add_transition` | Argument | Type | Default | Meaning | | ------------------ | --------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `str` | required | The identifier. | | `source` | `list[str]` | required | Bins (or one category) that lose the flow. `[]` means the flow enters the system. | | `target` | `list[str]` | required | Bins (or one category) that gain the flow. `[]` means the flow leaves the system. | | `rate` | `str`, `float`, `AxisPattern` or `None` | `None` | The rate formula; see [Rates](#rates). With `stratified_rates`, the fallback rate. `None` with no stratified rate gives a transition with no flows. | | `stratified_rates` | `list[dict] \| None` | `None` | Rates for some categories; see [Stratified rates](#stratified-rates). | | `per_compartment` | `bool \| None` | `None` | Replaces bin names in the rate with each flow's own compartment; see [per_compartment](#per_compartment). | | `accumulators` | `list[str] \| None` | `None` | Accumulators that count the flow; see [Building Models](https://munqu.github.io/commol/latest/guide/building-models/#accumulators). | | `impulse_at` | `list[float] \| None` | `None` | Makes the transition an impulse at these steps; see [Impulses](#impulses). | | `impulse_every` | `tuple[float, float] \| None` | `None` | `(period, offset)`: an impulse at `offset`, `offset + period`, …; see [Impulses](#impulses). | ## Rates A rate is a formula (a string), a number, or a [`AxisPattern`](https://munqu.github.io/commol/latest/guide/axis-patterns/index.md). A formula can use parameters, compartments, bin totals, `total`, `step`, constants and functions; see [Mathematical Expressions](https://munqu.github.io/commol/latest/guide/mathematical-expressions/index.md). ### Absolute and proportional rates The engine decides for each flow whether its rate is the flow itself or a rate per member of the source: | Case | Kind | Flow | | ----------------------------------------------------------------- | ------------ | --------------- | | The rate names a compartment, a bin total or a `total_…` variable | Absolute | `rate` | | No source | Absolute | `rate` | | A stratified rate sets `"absolute": True` or `False` | As set | — | | Otherwise | Proportional | `rate × source` | A rate that reads `total` and no compartment, such as `k1 / total`, is proportional (`plan_transition_flows` in `crates/commol-model/src/flows.rs`). | Rate | Kind | Flow from `B` to `C` | | ------------------------ | ------------ | ----------------------- | | `"k2"` | Proportional | `k2 * B` | | `"k2 * B"` | Absolute | `k2 * B` | | `"k1 * A * B / total"` | Absolute | `k1 * A * B / total` | | `"0.5"` with `source=[]` | Absolute | `0.5` enters the target | The [description](https://munqu.github.io/commol/latest/guide/describing-models/index.md) of the model shows every flow after this decision, so a proportional rate appears multiplied by its source. ### Rates that change along the axis A formula may use `step` (or `t`), the current step: ```python periodic = ( ModelBuilder(name="Periodic") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_parameter(id="k1", value=0.3) .add_transition( id="t_ab", source=["A"], target=["B"], rate="k1 * (1 + 0.3 * sin(2 * pi * step / 365)) * A * B / total", ) .build( Aggregate( bin_amounts={"A": 990, "B": 10}, ), engine="DifferenceEquations", ) ) ``` For pulses, windows, ramps and schedules per group, use [Axis Patterns](https://munqu.github.io/commol/latest/guide/axis-patterns/index.md). ## Several sources or targets A transition with several sources or targets moves **one** flow: every entry in `source` loses it and every entry in `target` gains it. A bin listed twice loses or gains it twice. The rate must be absolute. ```python merge = ( ModelBuilder(name="Merge") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_bin(id="C", name="State C") .add_parameter(id="k", value=0.001) .add_transition(id="t1", source=["A", "B"], target=["C", "C"], rate="k * A * B") .build( Aggregate( bin_amounts={"A": 500, "B": 500, "C": 0}, ), engine="DifferenceEquations", ) ) print(merge.describe().render("text")) # A loses k * A * B, B loses k * A * B, C gains 2 * (k * A * B) ``` ### The `$compartment` placeholder For **independent** flows with one formula, write `$compartment` in the rate. The builder creates one transition per source (or per target), named `{id}__{compartment}`, with `$compartment` replaced by that compartment: ```python outflow = ( ModelBuilder(name="Outflow") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_bin(id="C", name="State C") .add_parameter(id="mu", value=0.01) .add_transition( id="outflow", source=["A", "B", "C"], target=[], rate="mu * $compartment * (1 + 0.1 * $compartment / total)", ) .build( Aggregate( bin_amounts={"A": 400, "B": 300, "C": 300}, ), engine="DifferenceEquations", ) ) for transition in outflow.process.transitions: print(transition.id, transition.rate) # outflow__A mu * A * (1 + 0.1 * A / total) # outflow__B mu * B * (1 + 0.1 * B / total) # outflow__C mu * C * (1 + 0.1 * C / total) ``` | Rule | Otherwise | | ------------------------------------------------------------------------------- | ------------------------------------------------ | | Several sources with one or no target, or several targets with one or no source | `ValueError`: the mapping would be ambiguous | | At least two sources or two targets | `ValueError`: write the compartment name instead | | A `rate` is given | `ValueError` | `stratified_rates` may also use `$compartment`; each generated transition gets its own copy. Each generated transition keeps `per_compartment`, `accumulators` and the impulse schedule of the call. With `per_compartment=True`, `$compartment` reads the compartment that flows: in a flow from `A_g1`, `"k * $compartment"` is `k * A_g1`. ## Transitions between categories When `source` and `target` are categories of one stratification, the transition moves amounts between them **in every bin**: ```python moving = ( ModelBuilder(name="Moving") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_stratification(id="group", categories=["g1", "g2"]) .add_parameter(id="k", value=0.1) .add_transition(id="move", source=["g1"], target=["g2"], rate="k") .build( Aggregate( bin_amounts={"A": 500, "B": 500}, stratification_fractions={"group": {"g1": 0.5, "g2": 0.5}}, ), engine="DifferenceEquations", ) ) print(moving.describe().render("text")) # move (g1 -> g2): A_g1 -> A_g2: k * A_g1 and B_g1 -> B_g2: k * B_g1 ``` A category transition needs exactly one source and one target category. If the target category is split by a conditional stratification, every flow must say which of its categories it enters, with a `to` condition (see [Redirecting the target with `to`](#redirecting-the-target-with-to)); otherwise `build` raises. ## `per_compartment` With stratifications, a bin id in a rate means the **total** of the bin. In a flow from `A_g1`, `"k * A"` uses `A_g1 + A_g2`. `per_compartment=True` replaces each source and target bin id with the flow's own compartment: | Flow | `rate="k * A"` | with `per_compartment=True` | | -------------- | ------------------- | --------------------------- | | `A_g1 -> B_g1` | `k * A` (the total) | `k * A_g1` | | `A_g2 -> B_g2` | `k * A` (the total) | `k * A_g2` | ```python own_compartment = ( ModelBuilder(name="Per compartment") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_stratification(id="group", categories=["g1", "g2"]) .add_parameter(id="k", value=0.1) .add_transition(id="t", source=["A"], target=["B"], rate="k * A", per_compartment=True) .build( Aggregate( bin_amounts={"A": 500, "B": 500}, stratification_fractions={"group": {"g1": 0.5, "g2": 0.5}}, ), engine="DifferenceEquations", ) ) print(own_compartment.describe().render("text")) # A_g1 -> B_g1: k * A_g1 and A_g2 -> B_g2: k * A_g2 ``` It applies to the fallback rate and to every stratified rate, and only to transitions between bins. Any other bin in the rate keeps its total: in the flow `A_g1 -> B_g1`, `"k * A * C / total"` reads `A_g1` and the total of `C`. ## Stratified rates `stratified_rates` gives some categories their own rate. Each entry has `conditions` and a `rate`, and may set `absolute`: ```python group_rates = ( ModelBuilder(name="Group rates") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_stratification(id="group", categories=["g1", "g2", "g3"]) .add_parameter(id="k_g1", value=0.15) .add_parameter(id="k_g2", value=0.10) .add_parameter(id="k_default", value=0.05) .add_transition( id="t_ab", source=["A"], target=["B"], rate="k_default", stratified_rates=[ {"conditions": [{"stratification": "group", "category": "g1"}], "rate": "k_g1"}, {"conditions": [{"stratification": "group", "category": "g2"}], "rate": "k_g2"}, ], ) .build( Aggregate( bin_amounts={"A": 900, "B": 0}, stratification_fractions={"group": {"g1": 0.3, "g2": 0.3, "g3": 0.4}}, ), engine="DifferenceEquations", ) ) print(group_rates.describe().render("text")) # A_g1 -> B_g1: k_g1 * A_g1, A_g2 -> B_g2: k_g2 * A_g2, A_g3 -> B_g3: k_default * A_g3 ``` | Entry key | Type | Meaning | | ------------ | ---------------- | ----------------------------------------------- | | `conditions` | `list[dict]` | All must match the source compartment (AND). | | `rate` | `str` or `float` | The rate for matching compartments. | | `absolute` | `bool`, optional | Forces the rate to be absolute or proportional. | | Condition key | Meaning | | ---------------- | -------------------------------------------------------------- | | `stratification` | The stratification id. | | `category` | Optional. The category the **source** must have. | | `to` | Optional. The category the **target** gets instead; see below. | **Which rate applies:** for each source compartment, the entry that matches with the **most** conditions wins; on a tie, the first one in the list. With no match, the fallback `rate` applies. With three stratifications and one entry for `g2`, `t1`, `v2`: | Source compartment | Conditions matched | Rate | | ------------------ | ------------------ | --------- | | `A_g2_t1_v2` | 3 of 3 | The entry | | `A_g2_t1_v1` | not all | Fallback | | `A_g1_t1_v1` | not all | Fallback | ### Redirecting the target with `to` A condition with `category` filters the **source**. A condition with `to` changes the **target** category. A condition with only `to` filters nothing, so it can send the flow into a stratification that the source does not have: ```python redirect = ( ModelBuilder(name="Redirect") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_stratification(id="group", categories=["g1", "g2"]) .add_stratification( id="subtype", categories=["s1", "s2"], conditions=[{"stratification": "group", "category": "g2"}], ) .add_parameter(id="k", value=0.1) .add_transition( id="move", source=["g1"], target=["g2"], stratified_rates=[{"conditions": [{"stratification": "subtype", "to": "s1"}], "rate": "k"}], ) .build( Aggregate( bin_amounts={"A": 500, "B": 500}, stratification_fractions={ "group": {"g1": 0.5, "g2": 0.5}, "subtype": {"s1": 0.5, "s2": 0.5}, }, ), engine="DifferenceEquations", ) ) print(redirect.describe().render("text")) # A_g1 -> A_g2_s1: k * A_g1 and B_g1 -> B_g2_s1: k * B_g1 ``` A transition without a source starts from the compartments it enters, so its `category` conditions choose them and `build` rejects a `to`. ## Limiting when a transition runs A transition runs at every step. To limit a flow, write the condition in the rate with `if`, as in `"if(step > 30, k, 0)"`, or use a [`AxisPattern`](https://munqu.github.io/commol/latest/guide/axis-patterns/index.md), a [series parameter](https://munqu.github.io/commol/latest/guide/building-models/#series-parameters) or a [stratified rate](#stratified-rates). A comparison alone is a boolean, so `"k * (step > 30)"` is rejected. ## Impulses An impulse moves its amount at once, at given steps, and nothing between them. Give the steps with `impulse_at=[…]` or `impulse_every=(period, offset)`. The rate is then a fraction of the source (proportional) or an amount (absolute), with the same [rules](#absolute-and-proportional-rates), sources, targets, stratified rates and accumulators as any transition. ```python impulse_model = ( ModelBuilder(name="Impulses") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_accumulator(id="moved", name="Moved by the impulse") .add_parameter(id="p", value=0.25) .add_parameter(id="k", value=0.1) .add_transition(id="jump", source=["A"], target=["B"], rate="p", impulse_at=[1], accumulators=["moved"]) .add_transition(id="flow", source=["A"], target=["B"], rate="k") .build( Aggregate( bin_amounts={"A": 1000, "B": 0}, ), engine="DifferenceEquations", ) ) jumped = Simulation(impulse_model).run(num_steps=2) print([round(a, 1) for a in jumped["A"]]) # [1000.0, 675.0, 607.5] print(jumped["moved"][2]) # 225.0: a quarter of the 900 left at step 1 ``` | Rule | In every engine | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Order | At step `n` the state jumps first; then the flows of the step run from the new state. The output at `n` shows the state after the jump (`CompiledModel::apply_impulses` in `crates/commol-model/src/system.rs`). | | Several impulses at one step | Every jump is computed from the state before any of them. | | Steps | A `DifferentialEquations` or `StochasticEvents` model takes any steps, such as `0.5`: the engine stops there, applies the jump and restarts. A `DifferenceEquations` or `StochasticSteps` model needs integer steps: `ModelBuilder.build` raises `ValidationError`, which names the transition. | | Units | The rate of an impulse is an amount, ``, not a flow per step ([Units](https://munqu.github.io/commol/latest/guide/units-and-equations/#impulses)). | | `describe()` | Lists impulses apart from the equations, with their steps ([Describing a Model](https://munqu.github.io/commol/latest/guide/describing-models/#the-equations)). | An impulse is not a pulse rate. An [`AxisPattern.pulse`](https://munqu.github.io/commol/latest/guide/axis-patterns/index.md) is a rate, which acts with the other flows for one step, and in a differential model moves less than `p` ([Time inside a step](https://munqu.github.io/commol/latest/guide/simulations/#time-inside-a-step)). An impulse moves exactly `p` of its source in both deterministic engines; a [stochastic engine](https://munqu.github.io/commol/latest/guide/stochastic/index.md) moves each unit with probability `p`. ## Amounts below zero A `DifferenceEquations` step never leaves a compartment below zero. After the impulses of a step and after its flows, the engine checks every compartment. An amount below `−10⁻¹² × total` (the total at the start of the step) stops the run with `InfeasibleStateError`, a `ValueError`. Its message gives the compartment, the step and the amount before the step, and lists the flows out of the compartment, largest first. The tolerance lets a flow that empties a compartment exactly pass. Accumulators are not checked: a net flow may count negative (`DifferenceEquations::step` in `crates/commol-difference/src/simulation.rs`). ```python from commol import InfeasibleStateError too_fast = ( ModelBuilder(name="Too fast") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_parameter(id="k", value=1.5) .add_transition(id="t", source=["A"], target=["B"], rate="k") .build( Aggregate( bin_amounts={"A": 1000, "B": 0}, ), engine="DifferenceEquations", ) ) try: Simulation(too_fast).run(num_steps=3) except InfeasibleStateError as error: print(error) # The flows of step 0 leave compartment 'A' at -500, ... ``` A step moves what the flows give, so the usual causes are in the rates: | Cause | Example | Fix | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | | Proportional rates that sum above 1 | `rate="k"` with `k = 1.5` moves 1.5 times its source in one step | A smaller rate per step, or a shorter step | | An absolute rate on a bin total | With stratifications, `"k1 * A * B / total"` moves from each compartment of `A` an amount computed from the total of `A` | `per_compartment=True`, so each compartment loses an amount from its own amount ([`per_compartment`](#per_compartment)) | | A rate that turns negative | `"k * B * (1 - C / capacity)"` once `C > capacity`: the flow runs backwards and drains its target | Bound the factor: `"k * B * max(0, 1 - C / capacity)"` | A calibration gives a run that stops this way a penalty and goes on; see [Calibration](https://munqu.github.io/commol/latest/guide/calibration/#losses). A `DifferentialEquations` model is not checked: its solver may step below zero by its tolerance. ## What building checks `build` plans every flow in the engine and rejects a transition it cannot simulate as written: | Rejected | Message names | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | | A source or target that is neither a bin nor a category of one stratification | The transition | | A category transition without exactly one source and one target | The transition | | A rate variable that is not a parameter, bin, compartment or special variable | The variable and the available names | | Several sources with a proportional rate | The transition; use an absolute rate or `$compartment` | | A stratified rate whose `category` conditions match no source compartment | The transition; use `to` to reach another stratification | | A `to` condition on a transition without a source | The transition; use `category` to choose the compartment it enters | | A flow that ends in categories no compartment has | The transition; add a `to` condition | | `impulse_at` and `impulse_every` together, an `impulse_at` with no step, a step that is negative or not finite, a period that is not positive, or a negative offset; the `Transition` fails when it is made, with the rule of the engine (`check_impulse` in `crates/commol-model/src/rules.rs`) | The transition | | A non-integer impulse step in a `DifferenceEquations` or `StochasticSteps` model | The transition and the step | ## Next steps | To learn | Read | | -------------------------------- | --------------------------------------------------------------------------------------------------------- | | Rates that change along the axis | [Axis Patterns](https://munqu.github.io/commol/latest/guide/axis-patterns/index.md) | | Everything a formula can contain | [Mathematical Expressions](https://munqu.github.io/commol/latest/guide/mathematical-expressions/index.md) | | Units | [Units](https://munqu.github.io/commol/latest/guide/units-and-equations/index.md) | | The equations of a model | [Describing a Model](https://munqu.github.io/commol/latest/guide/describing-models/index.md) | # Mathematical Expressions [Back to Building Models](https://munqu.github.io/commol/latest/guide/building-models/index.md) Rates and formula parameters are written as expressions, such as `"k1 * A * B / total"`. This page lists everything an expression can contain: operators, variables, functions, and the security limits. The engine checks each expression against this language, compiles it to machine code (Cranelift) once, and reuses it at every step. ## Quick path ```python from commol import Aggregate, ModelBuilder, Simulation model = ( ModelBuilder(name="Expressions") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_parameter(id="k1", value=0.3) .add_parameter(id="k1_periodic", value="k1 * (1 + 0.3 * sin(2 * pi * step / 365))") .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1_periodic * A * B / total") .add_transition(id="t_ba", source=["B"], target=["A"], rate="if(step > 30, 0.05, 0)") .build( Aggregate( bin_amounts={"A": 990, "B": 10}, ), engine="DifferenceEquations", ) ) results = Simulation(model).run(num_steps=60) ``` | Where | Example | Evaluated | | ----------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------- | | Transition `rate` | `"k1 * A * B / total"` | For every flow, at every step | | Formula parameter `value` | `"k1 * 2"` | At every step, in declaration order, before the rates | | Stratified rate `rate` | `"k_g1 * A"` | Like a transition rate | | Calibration constraint `expression` | `"k1 - k2"` | See [Model Calibration](https://munqu.github.io/commol/latest/guide/calibration/index.md) | The variables a rate names decide whether it is absolute or proportional; see [Absolute and proportional rates](https://munqu.github.io/commol/latest/guide/transitions/#absolute-and-proportional-rates). ## Operators | Operator | Meaning | Example | Value | | -------------------------------- | ------------------------------- | --------------------------- | ------ | | `+`, `-` | Addition, subtraction, negation | `"k1 - k2"`, `"-k1"` | | | `*`, `/` | Multiplication, division | `"k1 * B / total"` | | | `%` | Remainder | `"step % 7"` | 0 to 6 | | `**` or `^` | Power | `"2 * 3 ** 4"` | 162 | | `( )` | Grouping | `"(2 + 3) * 4"` | 20 | | `>`, `<`, `>=`, `<=`, `==`, `!=` | Comparison, only inside `if` | `"if(step > 30, k1, 0)"` | | | `!` | Not, only inside `if` | `"if(!(step > 30), k1, 0)"` | | Precedence, from high to low: parentheses, power, the sign `-`, multiplication, division and remainder, addition and subtraction. So `"2 ** 3 * 4"` is 32, `"2 + 3 * 4"` is 14 and `"-2 ** 2"` is -4. Operators of one level apply from the left: `"8 - 4 - 2"` is 2 and `"8 / 4 * 2"` is 4. A chain of powers applies from the right, as in Python: `"2 ** 3 ** 2"` is `"2 ** (3 ** 2)"`, 512. A comparison gives a boolean, not a number, so it cannot be multiplied: `"k1 * (step > 30)"` is rejected. Write `"if(step > 30, k1, 0)"`. Rates do not accept `&&` or `||`; nest `if` calls instead: ```python in_window = "if(step >= 10, if(step <= 20, 0.05, 0), 0)" # 0.05 for steps 10 to 20 outside = "if(step < 10, 0.05, if(step > 20, 0.05, 0))" # 0.05 outside that window ``` ## Variables An id starts with a letter or `_` and can hold digits after it, such as `e1` or `c1star`. A number such as `1e5` or `2.5E-3` is a number: its exponent is never a variable. | Variable | Value | | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A parameter id | The parameter's value at this step | | A bin id, without stratifications | The bin's amount | | A compartment name, such as `A_g1` | That compartment's amount | | A bin id, with stratifications | The total of all its compartments | | `total` | The total: the sum of every compartment, accumulators excluded | | `neighbors_`, `neighbors` | In a transition rate only: what the neighbors of a unit hold, of one bin or of every bin. The structure says who they are; a rate that reads them and no compartment is proportional. See [Neighbors](https://munqu.github.io/commol/latest/guide/building-models/#neighbors) | | `t` | The current position on the axis. A difference model reads it at each step, so there it equals `step`; a differential model reads it anywhere | | `step` | The step that holds the current position, `floor(t)`: 0, 1, 2, … It is constant on each unit interval of a differential model; see [Time inside a step](https://munqu.github.io/commol/latest/guide/simulations/#time-inside-a-step) | | `pi`, `e` | π and e | ### Subgroup totals With stratifications, the engine also provides sums of compartments, the subgroup totals. Category suffixes must follow the order in which the stratifications were declared. `commol_rs.model.subgroup_total_names` (in `crates/commol-model/src/builder.rs`) lists them for a process. | Variable | Sum of | Example (`group` then `type`) | | -------------- | -------------------------------------------- | ----------------------------------------------------------------------- | | `total_…` | Every compartment with these categories | `total_g1`, `total_t1`, `total_g1_t1` | | `` | Every compartment of the bin | `A` | | `_…` | The bin's compartments with these categories | `A_g1`, `A_t1`; with every category, the compartment itself (`A_g1_t1`) | ```python from commol import ModelBuilder def value_of(expression: str) -> float: """The value of an expression at step 0, read through a formula parameter.""" model = ( ModelBuilder(name="Variables") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_stratification(id="group", categories=["g1", "g2"]) .add_stratification(id="type", categories=["t1", "t2"]) .add_parameter(id="probe", value=expression) .add_transition(id="probe_flow", source=[], target=["B"], rate="probe") .build( Aggregate( bin_amounts={"A": 600, "B": 400}, stratification_fractions={ "group": {"g1": 0.25, "g2": 0.75}, "type": {"t1": 0.5, "t2": 0.5}, }, ), engine="DifferenceEquations", ) ) results = Simulation(model).run(num_steps=1) inflow = sum(series[1] - series[0] for series in results.values()) return inflow / 4 # the probe flows into each of the 4 B compartments print(value_of("total + 0")) # 1000.0 print(value_of("total_g1 + 0")) # 250.0 = 1000 x 0.25 print(value_of("total_g1_t1 + 0")) # 125.0 = 1000 x 0.25 x 0.5 print(value_of("A + 0")) # 600.0 print(value_of("A_g1 + 0")) # 150.0 = 600 x 0.25 print(value_of("A_t1 + 0")) # 300.0 = 600 x 0.5 ``` | Pitfall | Result | | ------------------------------------------------------------- | --------------------------------------------------- | | Suffixes out of declaration order, as in `A_t1_g1` | Error: the variable is not found | | `total_…` out of order, as in `total_t1_g1` | `build` fails and lists the valid `total_…` names | | A combination that a conditional stratification never creates | `build` fails for `total_…`; `_…` is not found | ## Functions | Group | Functions | | ------------------------- | ---------------------------------------------------------------------------------------------- | | Trigonometric | `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2(y, x)` | | Hyperbolic | `sinh`, `cosh`, `tanh`, `asinh`, `acosh`, `atanh` | | Exponential and logarithm | `exp`, `log` (natural), `ln` (natural), `log10`, `log2` | | Power and root | `sqrt`, `cbrt`, `pow(x, y)`, `hypot(x, y)` | | Comparison | `min(a, b, ...)`, `max(a, b, ...)` (two arguments or more), `abs` | | Rounding | `floor`, `ceil`, `round` (halves round away from zero: `round(2.5)` is 3, `round(-2.5)` is -3) | | Conditional | `if(condition, value_if_true, value_if_false)` | No other function is in the language: `erf(x)` or `trunc(x)` is rejected. For rates that change along the axis, [`AxisPattern`](https://munqu.github.io/commol/latest/guide/axis-patterns/index.md) builds tested expressions for pulses, windows, ramps and sinusoids. ## Common patterns ```python patterns = { "product of two amounts": "k1 * A * B", "frequency dependent": "k1 * A * B / total", "saturation": "k1 * B / (1 + B)", "linear growth along the axis": "0.1 + 0.001 * step", "exponential decay along the axis": "k1 * exp(-0.01 * step)", "cycle of 365 steps": "k1 * (1 + 0.3 * sin(2 * pi * step / 365))", "never negative": "max(0, k1 - 0.001 * step)", "capped above a threshold": "k1 * min(1, 100 / max(B, 1))", "only after step 30": "if(step > 30, k1, 0)", "safe division": "k1 * B / max(total, 1)", } ``` To keep a rate readable, move part of it into a formula parameter: `add_parameter(id="k1_periodic", value="k1 * (1 + 0.3 * sin(2 * pi * step / 365))")` and then `rate="k1_periodic * A * B / total"`. ## Rejected expressions The engine checks each expression when it is added: a transition rate, a stratified rate, a formula parameter, a calibration constraint or an axis pattern. An expression outside the language of this page fails with a `ValueError` that names the expression and its first part outside the language: an unknown function, a call with the wrong number of arguments, `&&` or `||`, or a comparison or `!` outside the condition of an `if`. ```python try: ModelBuilder(name="Rejected").add_parameter(id="k2", value="k1 * (step > 30)") except ValueError as error: print(error) # ... Parameter 'k2': Formula 'k1 * (step > 30)': the comparison '>' ... ``` When the model is built, the engine checks the variables each rate and each formula parameter reads. A variable that no parameter, bin, compartment, subgroup total or engine variable defines fails the build, with a message that names the transition or the parameter, the expression, the undefined variables, and the parameters and bins of the model: `Undefined variables in transition 'bc' rate 'k9 * B': k9. Available parameters: k1, k2. Available bins: A, B, C.` A category id, such as `g1`, is not a variable: write `total_g1` for its compartments. ## Security Every expression is also checked before it reaches the engine (`commol.utils.security.validate_expression_security`). An expression that is not plain mathematics is rejected when the transition is added: ```python try: ModelBuilder(name="Unsafe").add_bin(id="A", name="State A").add_transition( id="t", source=["A"], target=[], rate="__import__('os')" ) except ValueError as error: print("rejected") ``` | Limit (`SecurityConfig`) | Value | | --------------------------------------------------------------------------------------------------- | ---------------- | | Expression length | 10000 characters | | Nesting depth | 10 levels | | Function calls | 500 | | Variable name length | 50 characters | | Numeric literal | 1e12 | | Complexity score: the nodes of the tree of the expression (numbers, variables, operators and calls) | 1000 | The engine has one limit of its own, which no configuration changes: a formula nests at most 100 levels deep. Each pair of parentheses, each call, each sign and each power is one level; a sum or a product of any number of terms is one. A deeper formula fails with `it nests deeper than 100 levels of parentheses, calls, signs and powers`. ## Next steps | To learn | Read | | ------------------------------------------- | --------------------------------------------------------------------------------------- | | Ready-made rates that change along the axis | [Axis Patterns](https://munqu.github.io/commol/latest/guide/axis-patterns/index.md) | | How rates become flows | [Transitions](https://munqu.github.io/commol/latest/guide/transitions/index.md) | | Running a model | [Running Simulations](https://munqu.github.io/commol/latest/guide/simulations/index.md) | # Axis Patterns [Back to Transitions](https://munqu.github.io/commol/latest/guide/transitions/index.md) `AxisPattern` builds rates that change along the axis: pulses, periodic events, intervals, sinusoids, ramps and bell curves, and schedules that give each subgroup its own pattern. Pass a pattern as `rate=` to `add_transition`. Every pattern is a tested, parenthesised expression, so patterns combine safely. ## Quick path ```python from commol import Aggregate, AxisPattern, ModelBuilder, Simulation def flow_per_step(rate: AxisPattern, num_steps: int = 10) -> list[float]: """The flow into B at each step, for a transition with no source.""" model = ( ModelBuilder(name="Pattern") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_transition(id="inflow", source=[], target=["B"], rate=rate) .build( Aggregate( bin_amounts={"A": 100, "B": 0}, ), engine="DifferenceEquations", ) ) series = Simulation(model).run(num_steps=num_steps)["B"] return [round(series[i + 1] - series[i], 4) for i in range(num_steps)] print(flow_per_step(AxisPattern.interval(start=3, end=6, value=1.0))) # [0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0] ``` A transition with no source uses the pattern as the flow. With a source, a pattern that names no compartment is proportional: `value=0.1` moves 10% of the source. See [Absolute and proportional rates](https://munqu.github.io/commol/latest/guide/transitions/#absolute-and-proportional-rates). ## Single patterns Each pattern reads `step` or `t` (see [Patterns in a differential model](#patterns-in-a-differential-model)). In a difference model both are the step `s`. | Pattern | Reads | Value | Arguments | | -------------------------------------------------- | ------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `pulse(at, value)` | `step` | `value` when `step == at`, else 0 | integer `at >= 0` | | `pulses(at, value)` | `step` | `value` at each step in `at`, else 0 | unique integer steps, not empty; `value` may be one value per step | | `periodic(period, value, offset=0)` | `step` | `value` when `step = offset, offset + period, …`, else 0 | integer `period > 0`, `0 <= offset < period` | | `interval(start, end, value)` | `t` | `value` for `start <= t < end`, else 0 | `start < end`, any finite positions | | `linear_ramp(start, end, start_value, end_value)` | `t` | From `start_value` at `start` towards `end_value`, for `start <= t < end`; else 0 | `start < end`, any finite positions | | `sinusoid(amplitude, period, phase=0, baseline=0)` | `t` | `baseline + amplitude × sin(2π (t − phase) / period)` | numeric `period != 0` | | `gaussian_pulse(center, width, peak)` | `t` | `peak × exp(−(t − center)² / (2 width²))` | `width > 0` | | `from_formula(expr)` | What it names | The expression, wrapped in parentheses | not empty; passes the security check | An invalid argument raises `pydantic.ValidationError` (a `ValueError`). These arguments also accept a parameter name, so calibration can fit them: `value` of `pulse`, `pulses`, `periodic` and `interval`; every argument of `sinusoid`; and `peak` of `gaussian_pulse`. ```python print(flow_per_step(AxisPattern.pulse(at=3, value=1.0))) # [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] print(flow_per_step(AxisPattern.pulses(at=[2, 5], value=1.0))) # [0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0] print(flow_per_step(AxisPattern.periodic(period=4, value=1.0, offset=1))) # [0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] print(flow_per_step(AxisPattern.linear_ramp(start=2, end=6, start_value=0.0, end_value=1.0))) # [0.0, 0.0, 0.0, 0.25, 0.5, 0.75, 0.0, 0.0, 0.0, 0.0] print(flow_per_step(AxisPattern.sinusoid(amplitude=1.0, period=8, baseline=1.0))) # [1.0, 1.7071, 2.0, 1.7071, 1.0, 0.2929, 0.0, 0.2929, 1.0, 1.7071] print(flow_per_step(AxisPattern.gaussian_pulse(center=5.0, width=1.0, peak=1.0))) # [0.0, 0.0003, 0.0111, 0.1353, 0.6065, 1.0, 0.6065, 0.1353, 0.0111, 0.0003] ``` `str(pattern)` and `pattern.formula` give the expression: ```python print(AxisPattern.pulse(at=10, value=0.05).formula) # (if(step == 10, 0.05, 0)) print(f"2 * {AxisPattern.from_formula('k1 + k2')}") # 2 * (k1 + k2) ``` The parentheses make composition safe: the `2 *` applies to the whole expression. ## Combining patterns `AxisPattern.combine(*patterns, mode=AxisPattern.SUM)` merges patterns: | `mode` | Value | | --------------------------- | ----------------------------------- | | `AxisPattern.SUM` (default) | The sum of the patterns | | `AxisPattern.MAX` | The largest value at each position | | `AxisPattern.MIN` | The smallest value at each position | ```python peak_or_interval = AxisPattern.combine( AxisPattern.pulse(at=3, value=2.0), AxisPattern.interval(start=2, end=5, value=1.0), mode=AxisPattern.MAX, ) print(flow_per_step(peak_or_interval)) # [0.0, 0.0, 1.0, 2.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0] ``` | Rule | Otherwise | | -------------------------------------------------------------------------------------------------- | ------------ | | At least one pattern | `ValueError` | | All patterns have the same group binding (or none); the result keeps it | `ValueError` | | The result is not longer than `SecurityConfig.max_expression_length` (10000 characters by default) | `ValueError` | `combine(p)` with one pattern returns `p`. ## Patterns per subgroup With stratifications, a schedule gives each subgroup its own pattern. Start with the class method `AxisPattern.add_group(...)` and chain more groups on the result: ```python def stratified_flows(rate: AxisPattern, num_steps: int = 7) -> dict[str, list[float]]: """The change of each compartment at each step, for a transition A -> B.""" model = ( ModelBuilder(name="Groups") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_stratification(id="group", categories=["cat1", "cat2"]) .add_transition(id="flow", source=["A"], target=["B"], rate=rate) .build( Aggregate( bin_amounts={"A": 1000, "B": 0}, stratification_fractions={"group": {"cat1": 0.5, "cat2": 0.5}}, ), engine="DifferenceEquations", ) ) results = Simulation(model).run(num_steps=num_steps) return { name: [round(series[i + 1] - series[i], 2) for i in range(num_steps)] for name, series in results.items() if name.startswith("B") } schedule = ( AxisPattern.add_group( conditions=[{"stratification": "group", "category": "cat1"}], schedule=AxisPattern.periodic(period=3, value=0.1), ) .add_group( conditions=[{"stratification": "group", "category": "cat2"}], schedule=AxisPattern.interval(start=2, end=4, value=0.2), ) ) print(stratified_flows(schedule)) # {'B_cat1': [50.0, 0.0, 0.0, 45.0, 0.0, 0.0, 40.5], # 'B_cat2': [0.0, 0.0, 100.0, 80.0, 0.0, 0.0, 0.0]} ``` | Method | What it does | | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `AxisPattern.add_group(conditions, schedule, *, source_compartment=None, absolute=None)` | Class method: starts a schedule with its first group. On a schedule: adds a group. On a single pattern: `TypeError`. | | `schedule.set_default(pattern)` | The pattern for compartments that match no group. At most once; the pattern may not have conditions or a `source_compartment`. | | `pattern.for_group(conditions, *, source_compartment=None)` | A single pattern for one subgroup only. | | Rule | Behavior | | ---------------------------------------------------- | ---------------------------------------------------------------------------- | | A compartment that matches no group, with no default | No flow | | A compartment that matches several groups | The group with the most conditions wins | | `conditions` | Not empty; each entry has `stratification` and `category`, and may have `to` | | Two groups with the same conditions | `ValueError` | ```python only_cat1 = AxisPattern.add_group( conditions=[{"stratification": "group", "category": "cat1"}], schedule=AxisPattern.pulse(at=1, value=0.1), ) with_default = only_cat1.set_default(AxisPattern.from_formula("0.01")) print(stratified_flows(with_default, num_steps=3)) # {'B_cat1': [0.0, 50.0, 0.0], 'B_cat2': [5.0, 4.95, 4.9]} print(stratified_flows(AxisPattern.pulse(at=1, value=0.1).for_group( [{"stratification": "group", "category": "cat1"}] ), num_steps=3)) # {'B_cat1': [0.0, 50.0, 0.0], 'B_cat2': [0.0, 0.0, 0.0]} ``` ### Absolute flows and `source_compartment` | Argument of `add_group` | Effect | | ----------------------------- | -------------------------------------------------------------------------------- | | `absolute=None` (default) | Inferred: a pattern that names a compartment is absolute, otherwise proportional | | `absolute=False` | Proportional: `value=0.1` moves 10% of each matching source compartment | | `absolute=True` | Absolute: `value=100.0` moves exactly 100 | | `source_compartment="A_cat1"` | Multiplies the pattern by that compartment, which makes it absolute | ```python mixed = ( AxisPattern.add_group( conditions=[{"stratification": "group", "category": "cat1"}], schedule=AxisPattern.pulse(at=1, value=0.1), absolute=False, ) .add_group( conditions=[{"stratification": "group", "category": "cat2"}], schedule=AxisPattern.pulse(at=1, value=100.0), absolute=True, ) ) print(stratified_flows(mixed, num_steps=3)) # {'B_cat1': [0.0, 50.0, 0.0], 'B_cat2': [0.0, 100.0, 0.0]} ``` ### Moving between categories with `to` A condition with `to` sends the flow to another category of the same stratification. With the same bin as source and target, the bin's total stays the same: ```python routing = ( ModelBuilder(name="Routing") .add_bin(id="A", name="State A") .add_stratification(id="status", categories=["s0", "s1"]) .add_transition( id="flow", source=["A"], target=["A"], rate=AxisPattern.add_group( conditions=[{"stratification": "status", "category": "s0", "to": "s1"}], schedule=AxisPattern.pulse(at=1, value=0.5), ), ) .build( Aggregate( bin_amounts={"A": 1000}, stratification_fractions={"status": {"s0": 0.5, "s1": 0.5}}, ), engine="DifferenceEquations", ) ) routed = Simulation(routing).run(num_steps=2) print(routed["A_s0"], routed["A_s1"]) # [500.0, 500.0, 250.0] [500.0, 500.0, 750.0] ``` ## Patterns in a differential model In a `DifferentialEquations` model `step` is constant on each unit interval `[n, n + 1)` and `t` moves continuously. | Pattern | In a differential model | | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pulse`, `pulses`, `periodic` (read `step`) | A rate that holds for one unit of the axis ([Time inside a step](https://munqu.github.io/commol/latest/guide/simulations/#time-inside-a-step) says what it moves). To move a fraction at once in both engines, use an [impulse](https://munqu.github.io/commol/latest/guide/transitions/#impulses). | | `interval`, `linear_ramp` (read `t`) | Open and close at any position, such as `interval(start=2.5, end=4, …)`; the ramp is continuous. The engine restarts its integration at both bounds. | | `sinusoid`, `gaussian_pulse` (read `t`) | Continuous in `t`. | ```python def remaining_a(rate: AxisPattern, at: float) -> float: """A of a proportional A -> B transition, in a differential model.""" model = ( ModelBuilder(name="Pattern ODE") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_transition(id="flow", source=["A"], target=["B"], rate=rate) .build( Aggregate( bin_amounts={"A": 1000, "B": 0}, ), engine="DifferentialEquations", ) ) return Simulation(model).run_at([at])["A"][0] # The rate 0.5 acts on [2.5, 4): A keeps exp(-0.75) of its start. print(round(remaining_a(AxisPattern.interval(start=2.5, end=4, value=0.5), 6), 2)) # 472.37 ``` See [Time inside a step](https://munqu.github.io/commol/latest/guide/simulations/#time-inside-a-step). ## How patterns are stored Numeric `pulse` and `pulses` patterns become [series parameters](https://munqu.github.io/commol/latest/guide/building-models/#series-parameters) instead of long formulas, so equations stay readable and many pulses stay fast: | Pattern | Generated parameter id | | -------------- | -------------------------------------------------------------------------------------------------------------------- | | Not in a group | `_series_` | | In a group | `{transition}_{source bin}_{source categories}_to_{target bin}_{target categories}`, such as `flow_A_cat1_to_B_cat1` | A generated id must not clash with a parameter of yours: the second of the two calls (`add_parameter` or `add_transition`) raises `ValueError`. ## Next steps | To learn | Read | | -------------------------------- | --------------------------------------------------------------------------------------------------------- | | Everything a formula can contain | [Mathematical Expressions](https://munqu.github.io/commol/latest/guide/mathematical-expressions/index.md) | | How rates become flows | [Transitions](https://munqu.github.io/commol/latest/guide/transitions/index.md) | | Signatures | [API reference: AxisPattern](https://munqu.github.io/commol/latest/api/axis-patterns/index.md) | # Units [Back to Building Models](https://munqu.github.io/commol/latest/guide/building-models/index.md) Commol checks that every rate of a built `Model` has the right units. To see the equations the engine simulates, with their units, see [Describing a Model](https://munqu.github.io/commol/latest/guide/describing-models/index.md). ## Quick path ```python from commol import Aggregate, ModelBuilder model = ( ModelBuilder(name="Model with Units", bin_unit="item", step_unit="day") .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=0.5, unit="1/day") .add_parameter(id="k2", value=0.1, unit="1/day") .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total") .add_transition(id="t_bc", source=["B"], target=["C"], rate="k2 * B") .build( Aggregate( bin_amounts={"A": 900, "B": 100, "C": 0}, ), engine="DifferenceEquations", ) ) model.check_unit_consistency(verbose=True) # Unit consistency check passed successfully. ``` ## Unit checking `Model.check_unit_consistency(verbose=False)` checks that every flow has the unit `/`, every [impulse](https://munqu.github.io/commol/latest/guide/transitions/#impulses) the unit ``, and that every function receives a plain number. It checks the models of every engine alike: in each, a rate is per unit of the axis, and only what its value means differs (see [What a rate means](https://munqu.github.io/commol/latest/guide/core-concepts/#what-a-rate-means)). It reads the flows the engine plans (`transition_flows`), so a proportional rate is checked as its flow, the rate times its source: `k2` in `1/day` from a bin in `item` gives `item/day`. The bin unit of a flow is the unit of the bin it leaves, or of the bin it enters when it has no source; so a category transition in a model of bins in `item` and `kg` moves `item/day` in one bin and `kg/day` in the other. In such a model `total`, a subgroup total `total_` and `neighbors` add amounts of different units, so they have none, and a rate that reads one fails the check; read the bins or the compartments instead. A rate that reads the [neighbors of a unit](https://munqu.github.io/commol/latest/guide/building-models/#neighbors) is checked as the sums they are. It raises `UnitConsistencyError` (from `commol.utils.equations`) and lists every transition that fails. ### What needs a unit | Item | How it gets a unit | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------ | | Bins | `ModelBuilder(bin_unit=...)`, or `add_bin(unit=...)` for one bin. Required: the check names every bin without one. | | `total`, `total_…` | The bin unit, automatically | | Constant parameters | `add_parameter(unit=...)`. Required: the check raises if one is missing. | | Formula parameters | Inferred from the formula; a unit is optional | | `t` | The step unit, automatically: it is a position on the axis | | `step`, `pi`, `e` | Dimensionless, automatically: `step` counts increments | | Unit | Use for | | ------------------------------------------------------ | ------------------------------------------------- | | `"1/day"`, `"1/week"`, `"1/step"` | Rates | | `"dimensionless"` | Ratios, fractions, amplitudes, function arguments | | Any word that `pint` does not define, such as `"item"` | A unit of its own dimension; Commol registers it | A unit means what [`pint`](https://pint.readthedocs.io/) defines, for time as for any other quantity: `month`, `year` and `fortnight` are units of time, but `quarter` is a unit of mass and `decade` is a number. A word that `pint` does not define, such as `mo`, becomes a unit of its own dimension, which no other unit converts to. ### The step unit The engine converts no value between units. So one step has one unit, and every rate must use it (`UnitChecker._step_unit` in `py-commol/commol/context/_model/units.py`). The axis of a model is its independent variable: the quantity the model steps along or differentiates over. Time is one case; a length, a mass or any other quantity is another. So the step unit may have any single dimension. Declare it with `ModelBuilder(..., step_unit="day")` (`Model.step_unit`); `check_step_unit` accepts one unit of one dimension with exponent 1, such as `day` or `meter`, and rejects `1/day` or `meter**2`. A model that declares no step unit has the step unit `step`, the default increment of any axis, of its own dimension `[step]`. The check never guesses the step unit from the parameters. | Model | Step unit | Flows must be | | ----------------------------------------- | --------- | --------------------------------------------------------------------------------- | | `step_unit="day"` | `day` | `/day`: a rate in `1/d` passes, a rate in `1/week` or `1/quarter` fails | | `step_unit="meter"` | `meter` | `/meter` | | No `step_unit`, rates in `1/step` or none | `step` | `/step` | | No `step_unit`, rates in `1/day` | `step` | Fails; the error says to declare `step_unit` | Two names of one unit, such as `d` and `day`, are one unit. A flow must have exactly the expected unit: `item/week` does not match `item/day`, because the engine would read a weekly rate as a daily one. ```python along_a_line = ( ModelBuilder(name="Along a line", bin_unit="item", step_unit="meter") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_parameter(id="k1", value=0.01, unit="1/meter") .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A") .build( Aggregate( bin_amounts={"A": 1000, "B": 0}, ), engine="DifferentialEquations", ) ) along_a_line.check_unit_consistency(verbose=True) # Unit consistency check passed successfully. ``` `Model.axis_units()` converts values in units of the step dimension to steps, and gives the rates the engine reads; see [Units of the axis](https://munqu.github.io/commol/latest/guide/core-concepts/#units-of-the-axis). ### Positions and numbers in a rate `t` is a position on the axis, so it has the step unit: with `step_unit="day"`, `k * t * A` passes when `k` is in `1/day**2`. `step` counts increments and is dimensionless. When a rate adds a number to a quantity, subtracts it or compares it, the number has the unit of that quantity, as the engine reads it: `if(t >= 10, k, 0)` compares `t` with 10 days, and `t - 3` is 3 days before `t`. The two branches of `if` and the arguments of `min` and `max` must share a unit, and a written `0` takes the unit of the other branch. A number that a function computes, such as `exp(2)`, stays dimensionless. ### Impulses An impulse moves an amount at once, so its rate must have the bin unit: `p * A` with `p` in `"dimensionless"` is an amount of ``; `k * A` with `k` in `"1/day"` is a flow and fails with `expected ''`. ### What the check catches ```python from commol.utils.equations import UnitConsistencyError wrong_units = ( ModelBuilder(name="Wrong units", bin_unit="item", step_unit="day") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_parameter(id="k1", value=0.5, unit="day") # should be "1/day" .add_parameter(id="k2", value=0.1, unit="1/day") .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total") .add_transition(id="t_ba", source=["B"], target=["A"], rate="k2 * sin(B) * B") .build( Aggregate( bin_amounts={"A": 900, "B": 100}, ), engine="DifferenceEquations", ) ) try: wrong_units.check_unit_consistency() except UnitConsistencyError as error: print(error) ``` | Error | Cause | | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `Unit mismatch: equation '…' has unit 'day * item' but expected 'item/day'` | A rate with the wrong unit | | `Cannot convert from 'item' to 'dimensionless'` | A function such as `sin`, `exp` or `log` received a quantity with a unit | | `The following constant parameters are missing units: …` | A constant parameter has no unit | | `The following bins have no unit: …` | A bin has no unit (set `bin_unit`) | | `The model declares no step unit, so it is 'step'` | Rates in a unit such as `1/day` without `step_unit`; see [The step unit](#the-step-unit) | | `Variable '…' … has no defined unit` | A variable of a rate has no unit | To pass a quantity to a function, make it dimensionless first, for example `sin(2 * pi * step / 365)` or `exp(-decay * step)` with `decay` in `"dimensionless"`, or `sin(2 * pi * t / period)` with `period` in the step unit. ## Dates on the axis An axis of time can start at a calendar moment: `ModelBuilder(origin=...)` takes a `datetime.date` (read as its midnight) or a `datetime.datetime` with no time zone. `Model.axis_units()` then converts between dates and positions, and gives the positions where calendar days and weeks end. ```python import calendar from datetime import date, datetime dated = ( ModelBuilder(name="Dated", step_unit="hour", origin=date(2020, 1, 1)) .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_parameter(id="k1", value=0.01) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A") .build(Aggregate(bin_amounts={"A": 1000, "B": 0}), engine="DifferenceEquations") ) axis = dated.axis_units() print(axis.position_of(date(2020, 1, 2))) # 24.0 print(axis.position_of(datetime(2020, 1, 1, 6))) # 6.0 print(axis.moment_of(36)) # 2020-01-02 12:00:00 print(axis.day_ends(6, 72)) # [24.0, 48.0, 72.0] # 2020-01-01 is a Wednesday: the first week that starts on Sunday ends on 5 January. print(axis.week_ends(0, 400, first_weekday=calendar.SUNDAY)) # [96.0, 264.0] ``` | Method | Gives | | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `position_of(moment)` | `(moment - origin)` in steps; negative before the origin. | | `moment_of(position)` | `origin + position` steps, a `datetime` with no time zone. The inverse of `position_of`. | | `day_ends(start, end)` | The positions of the midnights in `(start, end]`. | | `week_ends(start, end, first_weekday)` | The positions in `(start, end]` where a week starts, at midnight of `first_weekday`: 0 (Monday) to 6 (Sunday), as `date.weekday()` and `calendar.SUNDAY` number them. | A period ends where the next one starts, so its end is the midnight that starts the next day or week. The range `(start, end]` is the range of a [window](https://munqu.github.io/commol/latest/guide/calibration-observations/#counts-per-period-windows): a period that ends at `start` is not in it, and the first end can close a partial period that starts before `start`. The number of a period (a week number of a national rule) is not part of the axis: label a period from its end with your own function. Rules of the origin, each a `ValueError`: | Rule | Why | | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A conversion needs an origin. | An axis without one has no dates. | | The moment has no time zone. | A step has no time zone, so `(moment - origin) / step` stays exact through a change of summer time. Convert an aware moment with `moment.astimezone(zone).replace(tzinfo=None)`. | | The step unit is a unit of time of a whole number of days, or a day divided by a whole number, such as `hour`, `day` or `week`. | `month` and `year` are units of a fixed length (a month is 1/12 of a year, about 30.44 days), not the length of a calendar month or year, so their positions would not fall on calendar dates. | With an origin, [`results.plot.series()`](https://munqu.github.io/commol/latest/guide/simulations/#plotting) labels its ticks with dates when it has no `position_label`. ## Next steps | To learn | Read | | ---------------------------------- | --------------------------------------------------------------------------------------------------------- | | The equations the engine simulates | [Describing a Model](https://munqu.github.io/commol/latest/guide/describing-models/index.md) | | What a formula can contain | [Mathematical Expressions](https://munqu.github.io/commol/latest/guide/mathematical-expressions/index.md) | | Running the model | [Running Simulations](https://munqu.github.io/commol/latest/guide/simulations/index.md) | # Describing a Model [Back to Building Models](https://munqu.github.io/commol/latest/guide/building-models/index.md) `Model.describe()` returns a description of a model as data: sections of tables, equations, state graphs and text. You can read it in Python, render it as text, Markdown, LaTeX or JSON, save it to a file, or draw its state graph. The description works for every kind of model, and it is built from what the engine simulates, so the equations it shows are the equations that run. Use this page to document a model, to check the equations Commol derived, or to write the methods section of a paper. ## Quick path ```python from commol import Aggregate, ModelBuilder model = ( ModelBuilder(name="Described", bin_unit="item", step_unit="day") .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=0.5, unit="1/day") .add_parameter(id="k2", value=0.1, unit="1/day") .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total") .add_transition(id="t_bc", source=["B"], target=["C"], rate="k2 * B") .build( Aggregate(bin_amounts={"A": 900, "B": 100, "C": 0}), engine="DifferenceEquations", ) ) description = model.describe() print(description.render("text")) # every section, in plain text description.save("model.md") # Markdown, with the state graph as a Mermaid diagram description.save("model.tex") # a LaTeX document description.save("model.dot") # every state graph, in Graphviz DOT ``` In a Jupyter notebook, `model.describe()` as the last line of a cell shows the Markdown rendering, with the state graph drawn (JupyterLab draws Mermaid from version 4.1). ## What a description holds A `ModelDescription` has a `title`, a `layout` and a list of `sections`. Each section has a `key`, a `title` and `blocks`. `Model.describe()` builds the sections every kind shares from the model, and the structure of the model adds the sections of its kind (`ModelStructure.describe`): | Key | Content | For an `Aggregate` model | | ---------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | | `overview` | Name, description, version, kind, engine, its settings and the step unit | From `Model` | | `entities` | What holds the state | Bins, accumulators, stratifications and their categories, the expanded compartments | | `dynamics` | How the state changes | The state graphs, the compact and expanded forms of the equations, the impulses, the neighbors | | `schedule` | How the engine advances, and the settings it advances with | From the engine (`EngineType.schedule`) and `Model.engine.settings` | | `initialization` | The state the model starts from | Bin amounts, category fractions, overrides, and the initial state the engine builds | | `parameters` | Every parameter | Value, unit, whether calibration sets it, description | | `outputs` | What a run returns | The outputs of each bin and accumulator | A section with no blocks is left out of every rendering. ```python dynamics = description.section("dynamics") print([type(block).__name__ for block in dynamics.blocks]) # ['StateGraph', 'StateGraph', 'Equations', 'Text', 'Equations'] ``` The blocks are a closed set (`commol.context.description`): | Block | Holds | | ------------ | ------------------------------------------------------------------------ | | `Text` | A paragraph | | `Table` | Columns and rows of cells | | `Equations` | Groups of equations, each in plain text and in LaTeX | | `StateGraph` | Nodes and edges; `expanded` marks the graph of the expanded compartments | | `Matrix` | Labelled rows and columns of numbers, such as the weights of neighbors | ## Formats `render(format)` returns a string; `save(path, format=None)` writes it and, without `format`, takes the format from the suffix of the path. | Format | Suffix | Equations | Diagrams | | ------------ | ------- | ---------------------------------------- | ------------------------------------------------------------------------ | | `"text"` | `.txt` | Compact and expanded forms in plain text | None | | `"markdown"` | `.md` | LaTeX math | The state graph of the model as a Mermaid diagram | | `"latex"` | `.tex` | `align*` environments in a full document | None | | `"json"` | `.json` | As data | The graphs as data; `ModelDescription.model_validate_json` reads it back | | `"dot"` | `.dot` | None | Every state graph, the expanded one included | The Markdown rendering does not draw the graph of the expanded compartments: a stratified model can have hundreds of edges, more than a Mermaid diagram draws. It gives the size of the graph instead. The DOT file holds every graph; `dot -Tpdf model.dot -o model.pdf` draws them with [Graphviz](https://graphviz.org/). The LaTeX document uses only standard packages (`amsmath`, `amssymb`, `longtable`, `fontenc`, `geometry`), so `latexmk -pdf model.tex` builds it. ## The equations The `dynamics` section of an aggregate model holds the flows that the engine plans, after it has decided which rates are proportional (see [Transitions](https://munqu.github.io/commol/latest/guide/transitions/#absolute-and-proportional-rates)): | Part | Content | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Compact form | Each transition and its flows, `source -> target: rate`. With units, each symbol shows its unit and each flow its resulting unit. | | Expanded form | One equation per compartment. The left-hand side comes from the engine: `[A(t+Dt) - A(t)] / Dt` for `DifferenceEquations`, `dA/dt` for `DifferentialEquations`. | | Events | For a stochastic engine, instead of the expanded form: one row per flow, with the change of the state of one event and its propensity (`StochasticEvents`) or its draw in a step (`StochasticSteps`). See [Stochastic Simulation](https://munqu.github.io/commol/latest/guide/stochastic/#how-a-rate-becomes-an-event). | | Impulses | Each impulse transition, the positions where it acts and the amounts it moves; an impulse is not part of the equations. | For the model above, the text rendering holds: ```text Bin Transitions: t_ab (A -> B): A -> B: k1(1/day) * A(item) * B(item) / total(item) [item / day] t_bc (B -> C): B -> C: k2(1/day) * B(item) [item / day] Total System: 3 coupled equations (3 bins) ``` ```text [A(t+Dt) - A(t)] / Dt = - (k1 * A * B / total) [B(t+Dt) - B(t)] / Dt = (k1 * A * B / total) - (k2 * B) [C(t+Dt) - C(t)] / Dt = (k2 * B) ``` Units appear only when every bin and every constant parameter has one; with some units missing, the equations show none. To check the units, use [`check_unit_consistency`](https://munqu.github.io/commol/latest/guide/units-and-equations/#unit-checking). ## The ODD layout `model.describe(layout="odd")` follows the **ODD protocol** (Overview, Design concepts, Details), the standard description of individual-based and agent-based models, used for other simulation models too (Grimm et al. 2006, updated in 2010 and 2020). The same layout works for every kind of model, so a paper can describe an aggregate model and an agent model in one format. ```python odd = model.describe(layout="odd") print([section.title for section in odd.sections]) # ['Purpose and patterns', 'Entities, state variables and scales', # 'Process overview and scheduling', 'Design concepts', 'Initialization', # 'Input data', 'Submodels'] odd.save("model_odd.tex") ``` | Element | What Commol fills for an `Aggregate` model | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Purpose and patterns | `Model.description`; without one, the marker | | Entities, state variables and scales | Bins, accumulators, stratifications, compartments; the step unit and how the engine advances | | Process overview and scheduling | Each transition, where it moves, whether it is an impulse; the schedule of the engine and its settings | | Design concepts | Interaction (when the structure has [neighbors](https://munqu.github.io/commol/latest/guide/building-models/#neighbors)), stochasticity (from the engine) and observation (the outputs); the marker for the others | | Initialization | The bin amounts and their units, the category fractions, the overrides and the initial state | | Input data | The series parameters, or "None" | | Submodels | The state graphs and equations, the parameters and the outputs | An element Commol cannot fill says "To be written by the modeller.": write it in the saved file. The layout of a description changes which sections it has and their order; the format of `render` is independent of it. References: - Grimm, V., et al. (2006). A standard protocol for describing individual-based and agent-based models. *Ecological Modelling*, 198, 115–126. [doi:10.1016/j.ecolmodel.2006.04.023](https://doi.org/10.1016/j.ecolmodel.2006.04.023) - Grimm, V., et al. (2010). The ODD protocol: A review and first update. *Ecological Modelling*, 221, 2760–2768. [doi:10.1016/j.ecolmodel.2010.08.019](https://doi.org/10.1016/j.ecolmodel.2010.08.019) - Grimm, V., et al. (2020). The ODD protocol for describing agent-based and other simulation models: A second update to improve clarity, replication, and structural realism. *Journal of Artificial Societies and Social Simulation*, 23(2), 7. [doi:10.18564/jasss.4259](https://doi.org/10.18564/jasss.4259) ## Checklist - [ ] `model.describe().render("text")` shows the equations you expect. - [ ] Saved files use a suffix of the table above, or pass `format=`. - [ ] For a paper, `layout="odd"` and the markers it leaves are filled in. ## Next steps | To learn | Read | | ---------------------- | --------------------------------------------------------------------------------------- | | How rates become flows | [Transitions](https://munqu.github.io/commol/latest/guide/transitions/index.md) | | Unit checking | [Units](https://munqu.github.io/commol/latest/guide/units-and-equations/index.md) | | Running the model | [Running Simulations](https://munqu.github.io/commol/latest/guide/simulations/index.md) | # Running Simulations [Back to Home](https://munqu.github.io/commol/latest/index.md) `Simulation` runs a built `Model` in the Rust engine and returns its outputs: one series per compartment and per accumulator, with the positions on the axis they were read at. This page covers running a model, reading and aggregating its outputs, running it in continuous time as differential equations, and plotting them. ## Quick path ```python from commol import Aggregate, ModelBuilder, Simulation model = ( 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=0.3) .add_parameter(id="k2", value=0.1) .add_accumulator(id="cum_ab", name="Total moved from A to B") .add_transition( id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total", accumulators=["cum_ab"] ) .add_transition(id="t_bc", source=["B"], target=["C"], rate="k2") .build( Aggregate( bin_amounts={"A": 900, "B": 100, "C": 0}, ), engine="DifferenceEquations", ) ) simulation = Simulation(model) results = simulation.run(num_steps=100) print(simulation.simulation_outputs) # ['A', 'B', 'C', 'cum_ab'] print(f"C at step 100: {results['C'][-1]:.0f}") ``` ## `Simulation` | Member | Returns | What it does | | ------------------------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `Simulation(model)` | — | Builds the engine for the model. A model with uncalibrated parameters can be built, but not run. | | `run(num_steps, *, seed=None)` | `SimulationResults` | Runs from the initial state, with the model as it is now, and reports at the positions `0, 1, …, num_steps`. Raises `ValueError` if a parameter or a bin amount is still `None`, `InfeasibleStateError` if a difference step leaves an amount below zero ([Amounts below zero](https://munqu.github.io/commol/latest/guide/transitions/#amounts-below-zero)), and `RuntimeError` that names the flow and the position if a rate is not a number. | | `run_at(at, *, seed=None)` | `SimulationResults` | Runs from the initial state and reports at the given sorted positions only. The engine decides which positions its model type accepts: a `DifferentialEquations` or `StochasticEvents` model any non-negative position, such as `0.5`; a `DifferenceEquations` or `StochasticSteps` model only integer steps. Otherwise `ValueError`, with the engine's message. See [Differential equations](#differential-equations). | | `replicates(count, *, seed=None)` | `ReplicateRuns` | For a stochastic engine: an object whose `run` and `run_at` return `SimulationReplicates`, `count` runs with one seed each. See [Randomness, seeds and replicates](#randomness-seeds-and-replicates). | | `is_stochastic` | `bool` | Whether the engine draws random numbers. | | `simulation_outputs` | `list[str]` | The output names, in engine order: compartments first, then accumulators. | | `outputs_for(id)` | `list[str]` | The outputs of one bin or accumulator. | | `group_outputs(ids)` | `dict[str, list[str]]` | The outputs of several bins or accumulators, keyed by id. | | `total_series(results, ids)` | `list[float]` | The sum of the given outputs at each position of the results. A bin or accumulator id stands for all its outputs; an output name stands for itself. | | `windowed_totals(results, ids, window, at=None)` | `list[float]` | What the given accumulators, or outputs of them, gained in each window. | | `sample(results, ids, at)` | `list[float]` | The sum of the given outputs at each position of `at`, such as a bin at the end of each window. | | `engine` | engine | The underlying Rust engine, for the model as it is now: the engine `model.engine.type` names (`engine.engine_type`). `engine.step()` advances it one step; `engine.reset()` returns it to the initial state. | Every `run` starts again from the initial state, so two calls with the same model give the same result (`Simulation.run` resets the engine first). To continue a run step by step, use the engine: ```python first = simulation.run(num_steps=10) again = simulation.run(num_steps=10) print(again["A"] == first["A"]) # True: it started again simulation.engine.step() # step 11, from where the last run stopped print(simulation.engine.state[0] < first["A"][-1]) # True: A keeps falling ``` The engine follows the model. When the model changed after the `Simulation` was created, for example with `update_parameters` or `apply_calibration_parameters`, `run` and `engine` rebuild the engine from the model as it is now (`Simulation._sync_engine` compares `model_dump_json()` with the JSON the engine was built from): ```python model.update_parameters({"k1": 0.0}) frozen = simulation.run(num_steps=10) print(frozen["A"][10] == frozen["A"][0]) # True: k1 = 0 stops A → B model.update_parameters({"k1": 0.3}) ``` ## Results `run` and `run_at` return `SimulationResults` (`py-commol/commol/api/results.py`): a `dict` from output name to one value per position, so `results["A"]`, `json.dumps(results)` and `pandas.DataFrame(results)` work as for any mapping. The positions the values were read at are in `results.positions`. | Member | Shape | Use for | | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | | `results[name]` | `[value at positions[0], …]` | Reading one output, plotting | | `results.positions` | `[0.0, 1.0, …, n]` for `run(n)`; the requested positions for `run_at` | Reading a value at a position, windows, reports | | `results.rows()` | `[[every output at positions[0]], …]`, in `simulation_outputs` order | Matrix operations, export | | `results.model` | The `Model` of the run, which `run` and `run_at` set; `None` for results built by hand | The outputs of each bin and accumulator, read by reports | | `results.report(positions, window, windows, …)` | The report of the run, as plain data ([Reports](https://munqu.github.io/commol/latest/guide/reports/#the-report-of-a-run)) | JSON export next to the observations | | `results.quantities(quantities)` | `{label: value}`, the quantities of interest of the run, as a propagation reduces each run ([The quantities of one run](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/#the-quantities-of-one-run)) | The numbers a run is judged on, such as the point estimate of a fit | | `results.plot.series(…)`, `results.plot.cumulative(…)`, `results.plot.observations(…)` | A matplotlib `Figure` ([Plotting](#plotting)) | Figures of the run | `run(num_steps)` holds `num_steps + 1` positions: position 0 is the initial state. ```python rows = results.rows() print(results.positions[:3]) # [0.0, 1.0, 2.0] print(len(rows)) # 101 print(len(rows[0])) # 4: A, B, C and cum_ab print([round(value) for value in rows[0]]) # [900, 100, 0, 0] ``` Two results are equal (`==`) when their values, `positions`, `seed` and `model` are equal, a NaN equal to a NaN. A plain `dict` is not a result, so it is not equal to one even with the same values: compare `dict(results)` to read the values only. `SimulationReplicates` compare their runs, their seed, their positions and their model in the same way. Results can change, so they have no hash ([Comparing results](https://munqu.github.io/commol/latest/guide/uncertainty/#comparing-results)). ```python print(results == dict(results), dict(results) == dict(results)) # False True ``` ## Randomness, seeds and replicates A stochastic engine draws random numbers, so its runs depend on a seed; a deterministic engine, such as `DifferenceEquations` and `DifferentialEquations`, gives the same run for every seed (`EngineType.is_stochastic` in `crates/commol-core/src/types/engine.rs`). | Call | Deterministic engine | Stochastic engine | | -------------------------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `run(n, seed=s)`, `run_at(at, seed=s)` | Ignores the seed; `results.seed` is `None` | One run from seed `s`; `results.seed` is `s` | | `run(n)` without a seed | `results.seed` is `None` | The engine draws a seed and keeps it in `results.seed`, so the run can be repeated | | `replicates(count, seed=s).run(…)` | Raises `ValueError` that names the engine: every replicate would be equal | `count` runs, in parallel in the engine, each with its own seed; without `seed`, the engine draws one and keeps it in `replicates.seed` | ```python print(simulation.is_stochastic) # False: a difference engine print(simulation.run(num_steps=5, seed=1).seed) # None: the run depends on no seed try: simulation.replicates(10) except ValueError as error: print(error) # The DifferenceEquations engine is deterministic: ... ``` Replicate `i` takes the seed derived from `seed` and `i`, the SplitMix64 of the SplitMix64 of `seed` plus `i` (`replicate_seed` in `crates/commol-random/src/generator.rs`), so two seeds share no replicate, a replicate gives the same run however many replicates run, and `simulation.run(…, seed=replicates[i].seed)` repeats it. `SimulationReplicates` (`py-commol/commol/api/results.py`) is a list of `SimulationResults`: | Member | Returns | | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `replicates[i]`, `len(replicates)` | One run, with its `seed`; the number of runs | | `seed`, `seeds`, `positions`, `model` | The seed the replicate seeds derive from, the seed of each replicate, the positions, the `Model` of the replicates | | `mean()` | `SimulationResults` of the mean of every output at each position | | `quantiles(levels)` | `{level: SimulationResults}`, at each position, by [the one quantile](https://munqu.github.io/commol/latest/codebase/decisions/results/#one-confidence-interval-convention) (NumPy's `method="hazen"`) | | `member_statistics(output, confidence_level)` | `mean`, `median`, `ci_lower`, `ci_upper`, `min`, `max` of one output at each position, reduced replicate by replicate with [the one quantile](https://munqu.github.io/commol/latest/codebase/decisions/results/#one-confidence-interval-convention) | | `report(confidence_level, positions, window, windows, …)` | The report of a run, with each number reduced replicate by replicate ([Reports](https://munqu.github.io/commol/latest/guide/reports/#the-report-of-an-ensemble)) | | `quantities(quantities)` | One `{label: value}` per replicate, in seed order, with no mean ([The quantities of one run](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/#the-quantities-of-one-run)) | | `plot.series(…)`, `plot.cumulative(…)`, `plot.observations(…)` | The figure of the mean and of a band of the replicates, the figure of the running total of the mean, and the figure of each observed series ([Plotting](#plotting)) | ```python stochastic = Simulation( ModelBuilder(name="Stochastic Model") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_parameter(id="k", value=0.1) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k") .build(Aggregate(bin_amounts={"A": 1000, "B": 0}), engine="StochasticEvents") ) replicates = stochastic.replicates(200, seed=42).run(num_steps=50) mean = replicates.mean() band = replicates.member_statistics("B", confidence_level=0.9) replicates.plot.series() # the mean and a 95 % band print(replicates.seeds[:2] == [run.seed for run in replicates[:2]]) # True ``` The stochastic engines and what they draw are in [Stochastic Simulation](https://munqu.github.io/commol/latest/guide/stochastic/index.md). The model and the seed of a run repeat it on the same platform with the same commol version: | Where | The same seed gives | | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The same machine and commol version | The same run | | Another platform, the same commol version | The same random numbers (`Xoshiro256PlusPlus` and samplers written in commol, `crates/commol-random/`), but the rate functions (`exp`, `sin`, `log`, …) come from the system's math library, which can differ in the last bit between operating systems. The runs have the same distribution, and a draw seldom differs | | Another commol version | A run that may change; the changelog says so | ## Accumulators An accumulator output starts at 0 and only grows: it is the total that its transitions have moved since step 0. Here `cum_ab` is everything that has moved from `A` to `B`, so `A` plus `cum_ab` stays at the initial `A`: ```python print(round(results["A"][-1] + results["cum_ab"][-1])) # 900 ``` ## Stratified outputs With stratifications, every bin and accumulator has one output per combination of categories, named `{id}_{category}_{category}…` in the order the stratifications were declared. ```python stratified = ( ModelBuilder(name="Stratified") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_stratification(id="group", categories=["g1", "g2"]) .add_stratification(id="type", categories=["t1", "t2"]) .add_accumulator(id="cum_ab", name="Total moved from A to B") .add_parameter(id="k", value=0.1) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k", accumulators=["cum_ab"]) .build( Aggregate( bin_amounts={"A": 1000, "B": 0}, stratification_fractions={ "group": {"g1": 0.5, "g2": 0.5}, "type": {"t1": 0.5, "t2": 0.5}, }, ), engine="DifferenceEquations", ) ) stratified_simulation = Simulation(stratified) stratified_results = stratified_simulation.run(num_steps=50) print(stratified_simulation.outputs_for("B")) # ['B_g1_t1', 'B_g1_t2', 'B_g2_t1', 'B_g2_t2'] print(stratified_simulation.group_outputs(["A", "cum_ab"])["cum_ab"]) # ['cum_ab_g1_t1', 'cum_ab_g1_t2', 'cum_ab_g2_t1', 'cum_ab_g2_t2'] ``` ### Totals by bin `total_series` sums every output of the bin and accumulator ids you give, at each position: ```python total_b = stratified_simulation.total_series(stratified_results, ["B"]) total_ab = stratified_simulation.total_series(stratified_results, ["A", "B"]) print(round(total_ab[-1])) # 1000: A and B hold the whole total ``` Do not select outputs by name prefix `[k for k in results if k.startswith("B_")]` also matches an accumulator named `B_events`, and, with conditional stratifications, outputs may have fewer suffixes than their siblings. `outputs_for`, `group_outputs` and `total_series` use the model structure, so neither case can go wrong. ### Totals by category `total_series` also takes output names, and each one stands for itself. To select compartments by category, use their labels from `model.structure.initial_state(model.process)`: each entry has `name`, `bin` and `categories`. ```python in_g1 = [ compartment.name for compartment in stratified.structure.initial_state(stratified.process) if compartment.categories.get("group") == "g1" ] print(in_g1) # ['A_g1_t1', 'A_g1_t2', 'B_g1_t1', 'B_g1_t2'] total_g1 = stratified_simulation.total_series(stratified_results, in_g1) print(round(total_g1[-1])) # 500: g1 started with half of every bin ``` ## Windowed totals `windowed_totals` turns a cumulative output into the amount it gained in each window of length `window` on the axis, the windows of the [window grid](https://munqu.github.io/commol/latest/guide/core-concepts/#units-of-the-axis). Each value is the output at the end of the window minus the output at its start. Both must be positions of the results. ```python per_week = stratified_simulation.windowed_totals( stratified_results, ["cum_ab"], window=7 ) print(len(per_week)) # 7: the complete windows of a 50-step run ``` | Argument | Meaning | | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ids` | Accumulator ids, each for all its outputs, and output names, each for itself, such as some strata of an accumulator. Their outputs are summed first. An id that is neither raises `KeyError`, with the bins, accumulators and outputs of the model. | | `window` | The window length on the axis: a finite number greater than 0. | | `at` | The positions where windows close. By default, every complete window of the results. Pass observation positions to get the values a calibration compares against. | The axis of a model gives the window grid of a width in a unit (`Model.axis_units()`; [Units of the axis](https://munqu.github.io/commol/latest/guide/core-concepts/#units-of-the-axis)), and `windowed_totals` of `py-commol/commol/api/windows.py` gives the same reduction for any series: ```python from commol import AxisUnits, windowed_totals daily = AxisUnits(step_unit="day") print(daily.window_ends(1, "week", 30)) # [7.0, 14.0, 21.0, 28.0] print(daily.window_end(0, 1, "week"), daily.window_index(7, 1, "week")) # 7.0 0 print(windowed_totals([0, 1, 3, 6, 10, 15, 21, 28], range(8), 3)) # [6.0, 15.0] ``` A window of the grid is a whole number of steps. The `window` of `windowed_totals` need not be: see [Reporting at any position](#reporting-at-any-position). The engine computes every window that results, reports and figures read, and checks its length (`window_ends` and the other functions of `crates/commol-calibration/src/observation.rs`), so a calibration and a report read the same windows. ## Values at positions A bin holds an amount at a position, so it is read at the position, not over the window before it. `sample` reads the given bins and accumulators at each position of `at`; the ids are the ones of `total_series`, and their outputs are summed first. Next to `windowed_totals`, it gives the amount in a bin at the end of each window and what an accumulator gained over that window: ```python ends = stratified.axis_units().window_ends(7, "step", 21) # [7.0, 14.0, 21.0] in_a = stratified_simulation.sample(stratified_results, ["A"], ends) gained = stratified_simulation.windowed_totals(stratified_results, ["cum_ab"], 7, ends) print([round(value) for value in in_a]) # [478, 229, 109] print([round(value) for value in gained]) # [522, 250, 119] ``` `sample(series, positions, at)` of `py-commol/commol/api/results.py` reads any series: ```python from commol import sample print(sample([0, 1, 3, 6, 10], range(5), [1, 4])) # [1, 10] ``` Each position of `at` must be a position of the results, within a relative `1e-9`; otherwise `ValueError` asks you to run the simulation there with `run_at`. ## Differential equations A model built with `engine="DifferentialEquations"` runs in continuous time. The engine (`crates/commol-differential/`) integrates `dX/dt = Σ flows(t, X)` with the ODE solver [diffsol](https://github.com/martinjrobins/diffsol). The flows are the same as in a difference model: the same transitions, rates, accumulators, outputs, calibration, reports and plots. Only the way the engine advances changes. ```python import math from commol import ODESolver ode_model = ( ModelBuilder(name="Decay") .add_bin(id="A", name="A") .add_bin(id="B", name="B") .add_parameter(id="k1", value=0.1) .add_accumulator(id="moved", name="Moved from A to B") .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1", accumulators=["moved"]) .build( Aggregate( bin_amounts={"A": 1000, "B": 0}, ), engine="DifferentialEquations", settings=ODESolver(method="tsit45", rtol=1e-8, atol=1e-8), ) ) ode = Simulation(ode_model) at = ode.run_at([0, 0.5, 2.25]) print(round(at["A"][2], 3), round(1000 * math.exp(-0.1 * 2.25), 3)) # 798.516 798.516 print(round(ode.run(num_steps=10)["moved"][10], 1)) # 632.1: the integral of the flow ``` ### Solver settings `ODESolver` holds the settings of the `DifferentialEquations` engine. They are part of the model (`Model.engine.settings`), so a saved model reproduces its runs. Pass them where you choose the engine, `build(engine="DifferentialEquations", settings=...)`, so one builder can build the model for each engine with its own settings; without them the defaults apply. The `DifferenceEquations` engine takes no settings: the engine rejects settings of another engine with an error that names both (`ValidationError`). | Field | Default | Meaning | | -------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `method` | `"tsit45"` | `"tsit45"`: explicit Runge–Kutta 5(4), for non-stiff models. `"bdf"`: backward differentiation formulas, for stiff models (rates of very different speeds). | | `rtol` | `1e-6` | Relative tolerance of each state component. | | `atol` | `1e-6` | Absolute tolerance of each state component, in the unit of the bins. | The defaults are the engine's (`commol_rs.engine.ODE_DEFAULT_RTOL` and `ODE_DEFAULT_ATOL`), and the engine checks the settings when they are built: `ODESolver(rtol=0.0)` raises `ValidationError` with the message of the engine, `rtol is a finite number greater than 0, got 0`. `bdf` builds its Jacobian from a finite-difference Jacobian-vector product. It uses a dense matrix below `SPARSE_THRESHOLD` state components and a sparse one from there (`crates/commol-differential/src/solve.rs`). A run stops with `RuntimeError` when the solver cannot take a step, or when a step leaves the state not finite, as when the solution blows up. The message names the last position the solver reached, with either method. ### Time inside a step On `[n, n + 1)` a rate sees `step = n` and the position `t`, which moves continuously. A rate that reads `step`, a `"pulse"` series or a pulse pattern (`AxisPattern.pulse`, `pulses`, `periodic`) is constant on its unit interval, and the engine restarts the integration at each integer where such a rate can jump. A proportional pulse `p` on step `n` acts as the constant rate `p` for one unit: it moves `1 − e^(−p)` of its source, not `p` as in a difference model. To move a fraction at once, use an [impulse](https://munqu.github.io/commol/latest/guide/transitions/#impulses). A `"step_function"` or `"linear"` [series](https://munqu.github.io/commol/latest/guide/building-models/#series-parameters) and the patterns `AxisPattern.interval` and `linear_ramp` read `t`, so they change at any position, and the engine restarts the integration at their points and bounds. In a difference model `t` equals `step`, so they give the same results as a rate that reads `step`. A rate can also jump where `t` reaches a value that it compares `t` with, such as `if(t >= t_on, k1, 0)`. The engine restarts the integration there too, so the solver cannot step over a short window. This works when the value reads only numbers, `pi`, `e` and parameters with a constant or calibrated value, and `t` is one side of the comparison: write `t >= 10`, not `t - 10 >= 0`. A jump that depends on the state, such as `if(t >= A, ...)`, is not found. A rate of a differential model is continuous, not a fraction per step; see [What a rate means](https://munqu.github.io/commol/latest/guide/core-concepts/#what-a-rate-means). `Model.axis_units()` gives the rate that the engine of a model reads from a mean span or a probability over a period; see [Units of the axis](https://munqu.github.io/commol/latest/guide/core-concepts/#units-of-the-axis). ```python from commol import AxisPattern pulse_model = ( ModelBuilder(name="Pulse") .add_bin(id="A", name="A") .add_bin(id="B", name="B") .add_transition(id="t_ab", source=["A"], target=["B"], rate=AxisPattern.pulse(at=2, value=0.2)) ) all_in_a = Aggregate(bin_amounts={"A": 1000, "B": 0}) ode_pulse = Simulation( pulse_model.build(all_in_a, engine="DifferentialEquations") ).run(num_steps=3) print(round(ode_pulse["A"][2] - ode_pulse["A"][3], 1)) # 181.3 = 1000 (1 − e^−0.2) ``` ### Reporting at any position `run_at(at)` reports at sorted positions on the axis, from the dense output of the solver. [Observations](https://munqu.github.io/commol/latest/guide/calibration-observations/index.md) (`ObservedDataPoint.at`), windows (`ObservedDataPoint.window`) and constraint positions (`CalibrationConstraint.at`) may lie between integers for a differential model: the calibration reads the engine at those positions ([Observations](https://munqu.github.io/commol/latest/guide/calibration-observations/#positions-between-integers)). A difference model reports only at integer steps; the engine rejects any other position and names the object that holds it. Windows, reports and plots read a value at a position of the results, so a window of `2.5` works once the results hold its ends: ```python ends = [2.5, 5.0, 7.5, 10.0] ode_windows = ode.run_at([0.0, *ends]) gained = ode.windowed_totals(ode_windows, ["moved"], window=2.5) print([round(value, 1) for value in gained]) # [221.2, 172.3, 134.2, 104.5] ``` ## Plotting A figure is read from the results it draws. It needs matplotlib, the optional extra `commol[plot]`: ```bash pip install 'commol[plot]' ``` `import commol` loads no plotting library. Without matplotlib, a figure raises `ImportError` with this install line. | Results | Figures | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `SimulationResults` | `results.plot.series(…)`: each series along the axis. `results.plot.cumulative(…)`: the running total of each series. `results.plot.observations(…)`: each [observed series](#a-panel-per-observed-series), read through its observation operator. | | `SimulationReplicates` | `replicates.plot.series(…)`: the mean and a band of `confidence_level` (default 0.95), each replicate reduced to the series first. `replicates.plot.cumulative(…)`: the running total of the mean. `replicates.plot.observations(…)`: the mean and the band of each observed series. | | [`Predictive`](https://munqu.github.io/commol/latest/guide/uncertainty/#the-predictive) | `predictive.plot.series(…)` and `predictive.plot.cumulative(…)`: the weighted median of the draws and their band of `confidence_level`, with no other run. `predictive.plot.observations(…)`: the median and the band of each observed series. | | The results of [sensitivity methods](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/#plots) and of [profiles](https://munqu.github.io/commol/latest/guide/uncertainty/profiles/#plots) | `result.plot(…)` | | Several runs, replicates or predictives | `plot_compared({label: result}, …)`: each result on the same panels, one per series or, with `panels="observations"`, one per observed series ([compare results](#compare-results-on-the-same-panels)). | A figure draws one panel per series and returns the matplotlib `Figure`. It reads the name and the unit of each series from the model of the results (`results.model`). Results built by hand have no model: their figures draw only their outputs, and a bin or accumulator id raises `ValueError`. ```python from commol import PlotConfig results.plot.series(output_file="series.png") # the results of the first run results.plot.cumulative(output_file="cumulative.png") results.plot.series( output_file="custom.png", config=PlotConfig(figsize=(16, 10), dpi=150, style=["seaborn-v0_8-whitegrid", "seaborn-v0_8-talk"]), series=["B", "C"], x_label="Week", tick_every=10, position_label=lambda at: f"w{int(at) // 7}", linewidth=2.5, alpha=0.8, ) ``` | Argument | Default | Meaning | | ----------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `output_file` | `None` | Where to save the figure. `None` only returns it. | | `series` | `None` (every output) | What to draw, one panel each: an output name (`B_g1`) or a bin or accumulator id (`B`), which draws the sum of its outputs (`ModelStructure.outputs_by_source`). An exact output name wins. An unknown entry raises `ValueError`. | | `observed_data` | `None` | [`ObservedDataPoint`](https://munqu.github.io/commol/latest/guide/calibration-observations/index.md)s to draw on the panel of their `label`, at their positions (a differential model may observe between integers). When the observations of a series share one sum of outputs and one factor and have no window, the model line covers every position of the results. Otherwise the line joins the model value of each observation at its position, read through its own outputs, `window` and factor as the loss reads it (`ValueError` when the results have no value at a position or a window start). A predictive reads each draw through the same operator. | | `calibration_result` | `None` | Runs and replicates only. A [`CalibrationResult`](https://munqu.github.io/commol/latest/guide/calibration/#results) gives the values of the [factors](https://munqu.github.io/commol/latest/guide/calibration-observations/#partial-observations-factors) that scale the model. A predictive reads the factors of each draw. | | `confidence_level` | `0.95` | Replicates and predictives only. The level of the band. | | `prediction_intervals` | `None` | `plot.series` only. The `PredictionIntervals` of a [conformal method](https://munqu.github.io/commol/latest/guide/uncertainty/conformal/index.md): the panel of each of their series, by label, shows the band from `lower` to `upper` and the point prediction, dashed. An unbounded interval is left out of the band. | | `axes` | `None` | A `Figure` of commol to draw on ([several results on one figure](#several-results-on-one-figure)). | | `config` | `PlotConfig()` | Figure size, dpi, layout and matplotlib style. | | `show_legend` | `True` | `plot.series` only. | | `x_label`, `tick_every`, `position_label` | `"Step"`, every plotted position, the position | `plot.series` only. Label the x axis, for example in weeks. The x axis shows `results.positions`. With no `position_label`, a model with an [origin](https://munqu.github.io/commol/latest/guide/units-and-equations/#dates-on-the-axis) labels each tick with its date (`2020-01-08`, with the time when it is not midnight): at the plotted positions `tick_every` apart, or at the ticks matplotlib places when `tick_every` is `None`. | | other keyword arguments | — | Passed to `Axes.plot` for the line of the model, such as `linewidth` or `alpha`. | | `PlotConfig` field | Default | Values | | ------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `figsize` | `(12, 8)` | Inches (width, height) of a figure of running totals. The other figures get 4.5 by 3.5 inches per panel. | | `dpi` | `100` | Resolution | | `layout` | `None` (automatic) | `(rows, columns)` | | `style` | `None` | A [matplotlib style](https://matplotlib.org/stable/gallery/style_sheets/style_sheets_reference.html) or a list of styles, applied in order: a name of `matplotlib.style.available`, such as `"ggplot"` or `"seaborn-v0_8-whitegrid"`, or the path of a style file | `PlotConfig.presentation()`, `publication()` and `notebook()` give a size, a dpi and styles for each use. The style applies to the figure of one call only. Each figure draws in a copy of `matplotlib.rcParams` (`_scoped_style` in `py-commol/commol/api/plotting/figures.py`), so the figures that you draw with matplotlib after a figure of commol keep your own style. To compare with observations: ```python from commol import ObservedDataPoint observed = [ ObservedDataPoint(at=10, output="B", value=45.2), ObservedDataPoint(at=20, output="B", value=78.5), ] results.plot.series(output_file="with_data.png", observed_data=observed) ``` ### A panel per observed series `plot.observations(observed_data, …)` checks a model against its data: one panel per `label` of the observations, in the order of its first observation. The model is read only at the observations, through the observation operator of each one (the sum of its `outputs`, its `window` and its factor): the values the loss compares. An observed series that sums some outputs, such as `B + C`, has its own panel, which no series of `plot.series` gives. ```python observed = [ ObservedDataPoint(at=at, output="cum_ab", value=value, window=7) for at, value in [(7, 120.0), (14, 160.0), (21, 190.0)] ] + [ ObservedDataPoint(at=at, outputs=["B", "C"], label="B_and_C", value=value) for at, value in [(10, 300.0), (20, 520.0)] ] + [ ObservedDataPoint(at=at, output="B", value=value) for at, value in [(10, 240.0), (20, 380.0)] ] figure = results.plot.observations(observed, output_file="observations.png") print([ax.get_label() for ax in figure.axes if ax.get_visible()]) # ['cum_ab', 'B_and_C', 'B'] results.plot.series(series=["B"], axes=figure) # the run along the whole axis ``` | Drawn | How | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A value with a window | A total over `(at - window, at]`: a step over its window, with a marker at `at`. Each point has its own window. | | A value with no window | A marker at `at`. A dotted line joins these markers only as a guide. | | The band of replicates or of a predictive | A rectangle over the window of each value with a window, and a band filled between the values with no window. Its legend says that it is a band of the model, with no observation noise, not a prediction interval. | | The observed values | Red points. | | Results | Model | Band | | ---------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `SimulationResults` | The run | None | | `SimulationReplicates` | The mean of the replicates | The interval of `confidence_level` across the replicates, each read through the operator of each observation first | | `Predictive` | The weighted median of the draws | The band of `predictive.at_observations(observed_data, NoNoise())`, each draw with its own values of the factors | `calibration_result`, for runs and replicates, gives the values of the factors. The other arguments (`output_file`, `config`, `show_legend`, `x_label`, `tick_every`, `position_label`, `confidence_level`, `axes`) are those of `plot.series`. The other keyword arguments go to `Axes.plot` for the steps and the guide of the model. A label that names a series, such as `B`, also has the panel of that series: `plot.series(series=["B"], axes=figure)` draws the run along the whole axis, after the data too. An empty `observed_data`, or results with no value at the position of an observation or at the start of its window, raise `ValueError`. ### Several results on one figure Each panel holds the id of its series as the label of its `Axes` (`ax.get_label()`). Give the `Figure` of an earlier figure as `axes=`, and a figure draws each of its series on the panel of that series. A series with no panel raises `ValueError` that names it. `config` then sets only the style: the size, dpi and layout are those of the given figure. ```python faster_model = model.model_copy(deep=True) faster_model.update_parameters({"k1": 0.5}) faster = Simulation(faster_model).run(num_steps=100) figure = results.plot.series(series=["B", "C"]) faster.plot.series(series=["B", "C"], axes=figure, linestyle="--") print([ax.get_label() for ax in figure.axes if ax.get_visible()]) # ['B', 'C'] ``` ### Compare results on the same panels `plot_compared({label: result}, …)` draws several results of the same model on the same panels and returns the `Figure`. A value is a `SimulationResults`, a `SimulationReplicates` or a [`Predictive`](https://munqu.github.io/commol/latest/guide/uncertainty/#the-predictive). It is a function, not a method: a comparison describes no single result. ```python from commol import plot_compared figure = plot_compared({"k1 = 0.3": results, "k1 = 0.5": faster}, series=["B", "C"]) print([line.get_label() for line in figure.axes[0].lines]) # ['k1 = 0.3', 'k1 = 0.5'] ``` | What | How it is drawn | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Each result | One line per panel, labelled with its key, in the same color in every panel: `C0` of the matplotlib style for the first key, `C1` for the second, and so on. | | Replicates | Their mean and the band of `confidence_level` of the replicates, in the color of their key. | | A predictive | The weighted median of its draws and their band of `confidence_level`, in the color of their key. The legend of a band starts with the key: `fit: 95% of the draws`. | | `observed_data` | Drawn once, as points on the panel of their label. Without `window`, they also set how each line is read, as in `plot.series`. | | `window` | Each line is the gain of the series over each complete window, at the end of the window, as `Simulation.windowed_totals` reads it. A band is the band of the gains of the replicates or of the draws. | The first result names the series and gives the title and the unit of each panel. Every result has the outputs of the first; otherwise `plot_compared` raises `ValueError` that names the key of the result and the outputs that differ. `axes=` draws on the panels of an earlier figure, and `series`, `config`, `show_legend`, `x_label`, `tick_every`, `position_label`, `output_file` and other keyword arguments (for `Axes.plot`, on every line) work as in `plot.series`. ```python weekly = plot_compared({"k1 = 0.3": results, "k1 = 0.5": faster}, series=["cum_ab"], window=7) print([float(at) for at in weekly.axes[0].lines[0].get_xdata()][:3]) # [7.0, 14.0, 21.0] ``` `panels="observations"` draws one panel per `label` of `observed_data`, as [`plot.observations`](#a-panel-per-observed-series) draws one result: each result is read only at the observations, through the observation operator of each one (the sum of its `outputs`, its `window` and its factor). Each result has the steps, markers and band of `plot.observations` in the color of its key, and the observations are drawn once. The default, `panels="series"`, draws one panel per entry of `series`. ```python checked = plot_compared( {"k1 = 0.3": results, "k1 = 0.5": faster}, observed_data=observed, panels="observations" ) print([ax.get_label() for ax in checked.axes if ax.get_visible()]) # ['cum_ab', 'B_and_C', 'B'] ``` With `panels="observations"`, `series` and `window` raise `ValueError`: the labels of `observed_data` set the panels, and each observation sets its own window. An empty `observed_data`, or a result with no value at the position of an observation or at the start of its window, also raises `ValueError`. `plot_compared` takes no `calibration_result`: a run or replicates read an observation with a factor unscaled, and log a warning. A predictive reads the factors of each draw. ## Next steps | To learn | Read | | -------------------------------- | ------------------------------------------------------------------------------------------ | | Runs at random, with whole units | [Stochastic Simulation](https://munqu.github.io/commol/latest/guide/stochastic/index.md) | | Fitting a model to data | [Model Calibration](https://munqu.github.io/commol/latest/guide/calibration/index.md) | | Complete models | [Examples](https://munqu.github.io/commol/latest/guide/examples/index.md) | | Signatures | [API reference: Simulation](https://munqu.github.io/commol/latest/api/simulation/index.md) | # Stochastic Simulation [Back to Home](https://munqu.github.io/commol/latest/index.md) A stochastic engine runs the process of a model as whole units that move at random, with no change to the process: the rates of the model become the probabilities or the hazards of its events. Use it when the variability between runs, an extinction or the behaviour of small amounts matters. This page covers the two stochastic engines of an aggregate model, how a rate becomes an event, whole units, the methods and their cost, and calibration with replicates. ## Quick path ```python from commol import Aggregate, ModelBuilder, Simulation builder = ( ModelBuilder(name="Stochastic 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=0.3) .add_parameter(id="k2", value=0.1) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total") .add_transition(id="t_bc", source=["B"], target=["C"], rate="k2") ) structure = Aggregate(bin_amounts={"A": 999, "B": 1, "C": 0}) model = builder.build(structure, engine="StochasticEvents") simulation = Simulation(model) one_run = simulation.run(num_steps=100, seed=1) print(one_run["C"][-1] == int(one_run["C"][-1])) # True: whole units replicates = simulation.replicates(100, seed=2).run(num_steps=100) print(len(replicates)) # 100 runs, one seed each died_out = sum(run["C"][-1] < 10 for run in replicates) print(died_out) # about a third of the runs: the one unit of B leaves before it spreads ``` The same `builder` and `structure` build the deterministic model with `engine="DifferentialEquations"`: only the engine changes. ## When to use it | You need | Engine | | ------------------------------------------------------------------- | ------------------------------------------------ | | The mean behaviour of a large amount | `DifferentialEquations` or `DifferenceEquations` | | How much runs vary, and the spread of an output | `StochasticEvents` or `StochasticSteps` | | The chance that an amount reaches zero and stays there (extinction) | `StochasticEvents` or `StochasticSteps` | | Small amounts, where a unit more or less changes the outcome | `StochasticEvents` or `StochasticSteps` | ## The two engines | Engine | Time | Method | Rate meaning | Positions | Crate | | ------------------ | -------------- | ------------------------------------------------------------------------------------ | ------------------------------------------- | --------------------------------- | ------------------- | | `StochasticEvents` | Continuous | Gillespie's direct method (the stochastic simulation algorithm, SSA), or tau-leaping | `continuous`: a hazard per unit of the axis | Any finite, non-negative position | `commol-stochastic` | | `StochasticSteps` | Discrete steps | The chain binomial | `per_step`: a probability per step | Integer steps | `commol-stochastic` | Their mean-field limits are the deterministic engines: the expected change of the state of `StochasticEvents` is the right-hand side of `DifferentialEquations`, and the expected step of `StochasticSteps` is the step of `DifferenceEquations` while no cap acts (see below). A rate keeps its meaning: a `per_step` rate of a difference model is the probability of the chain binomial, and a `continuous` rate of a differential model is the hazard of the events ([What a rate means](https://munqu.github.io/commol/latest/guide/core-concepts/#what-a-rate-means)). ## How a rate becomes an event Each flow of the model is one kind of event. An event moves one unit: it takes one from each source of the flow, and adds one to each target and to each accumulator that counts the flow (`CompiledModel::flow_changes` in `crates/commol-model/src/system.rs`). | Rate | `StochasticEvents` (propensity) | `StochasticSteps` (draw of a step) | | ----------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ | | Proportional, `k` | Each unit of the source leaves at hazard `k`: propensity `k × source` | Each unit leaves with probability `k`: `Binomial(source, k)` | | Absolute, `r` | Events at rate `r` | `Poisson(r)`, capped by what the sources still hold | | No source (inflow), `r` | Events at rate `r` | `Poisson(r)` | - `StochasticEvents`: a flow has no events while a source holds fewer units than an event takes. A negative propensity is an error that names the flow. - `StochasticSteps`: every draw reads the state at the start of the step. The units of one compartment leave by its proportional flows in one multinomial draw (each flow with its probability, staying with `1 − Σ k`), so a unit never leaves twice and an amount never goes below zero. The absolute flows then draw in the order of the model, each capped by what its sources still hold. A negative rate, a proportional rate above 1, or a sum above 1 of the proportional rates out of one compartment raises `InfeasibleStateError`, which names the compartment, the step and the flows (`StochasticSteps::step` in `crates/commol-stochastic/src/steps.rs`). ```python from commol import InfeasibleStateError too_likely = ( ModelBuilder(name="Too likely") .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=0.7) .add_parameter(id="k2", value=0.5) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1") .add_transition(id="t_ac", source=["A"], target=["C"], rate="k2") .build(structure, engine="StochasticSteps") ) try: Simulation(too_likely).run(num_steps=5, seed=1) except InfeasibleStateError as error: print(error) # At step 0, the proportional rates out of compartment 'A': flows 't_ab' 0.7, 't_ac' 0.5 sum to 1.2, above 1. ... ``` `Model.describe()` shows the events in its `dynamics` section: one row per flow with its change of the state and its propensity or its draw ([Describing a Model](https://munqu.github.io/commol/latest/guide/describing-models/index.md)): ```python print(model.describe().render("markdown").split("### Events")[1].split("\n\n")[1]) # | Transition | Change | Propensity | # |---|---|---| # | t_ab | A -1, B +1 | k1 * A * B / total | # | t_bc | B -1, C +1 | k2 * B | ``` ## Whole units A stochastic engine moves whole units, so it starts from whole amounts (`check_model` in `crates/commol-model/src/builder.rs`): | Initial amounts | What happens | | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | | The amount of a bin is not a whole number | `build` raises `ValueError` and names the engine and the bin | | Every amount is whole | Nothing more is needed | | The amount of a compartment is not whole (a bin amount times its category fractions) | `build` raises `ValueError`, unless the settings of the engine name a `rounding`; the message lists both | There is no default rounding: with small amounts the rounding decides which compartments start with a unit, so it is a modelling choice. With half a unit in each of ten groups, five groups start with one unit and the others empty. | `rounding` | Start of each run | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `"largest_remainder"` | The same whole amounts in every run: in each bin, each amount rounded down, and the units left over to the largest remainders, so they sum to the amount of the bin | | `"random"` | In each run, the amount of each bin split by a multinomial draw with the fractions of the model, so the mean start equals the amounts of the model | Both round each bin on its own: no unit goes from one bin to another, such as from a bin of a concentration to a bin of a count. ```python from commol import StochasticEventsSettings groups = ( ModelBuilder(name="Groups") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_stratification(id="group", categories=[f"g{i}" for i in range(10)]) .add_parameter(id="k1", value=0.3) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * B / total") ) seeded = Aggregate( bin_amounts={"A": 995, "B": 5}, stratification_fractions={"group": {f"g{i}": 0.1 for i in range(10)}}, ) try: groups.build(seeded, engine="StochasticEvents") except ValueError as error: print("largest_remainder" in str(error), "random" in str(error)) # True True grouped = groups.build( seeded, engine="StochasticEvents", settings=StochasticEventsSettings(rounding="random"), ) start = Simulation(grouped).run_at([0.0], seed=3) print(sum(start[f"B_g{i}"][0] for i in range(10)) + sum(start[f"A_g{i}"][0] for i in range(10))) # 1000.0 ``` Initial conditions that calibration sets go through the same rounding. With no rounding, a calibrated value that is not whole is an error that lists the two options. ## Seeds and replicates A run is reproducible from its model and its seed: `simulation.run(…, seed=s)` gives one run, and `simulation.replicates(count, seed=s).run(…)` gives `count` runs with one seed each, in parallel. A run does not depend on the positions it reports at: the output at a position is the state after the last event before it. See [Randomness, seeds and replicates](https://munqu.github.io/commol/latest/guide/simulations/#randomness-seeds-and-replicates) for the seeds, `SimulationReplicates` and its statistics, plots of the mean and a band, and reports. The draws of a sample that the uncertainty methods run follow a tree of these seeds ([The predictive](https://munqu.github.io/commol/latest/guide/uncertainty/#the-predictive)). ```python again = simulation.run(num_steps=100, seed=1) print(again == one_run) # True print(replicates.member_statistics("C", confidence_level=0.9)["ci_lower"][-1] >= 0) # True ``` ## Methods and their cost `StochasticEvents` takes `StochasticEventsSettings`, and `StochasticSteps` takes `StochasticStepsSettings` (only `rounding`): | Setting | Default | Meaning | | ----------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- | | `method` | `"direct"` | `"direct"`: one event at a time, exact. `"tau_leaping"`: a Poisson number of events of each flow in a leap, an approximation | | `tau` | `None` | The length of a leap of `"tau_leaping"`; `None` chooses each leap as Cao, Gillespie and Petzold (2006) do | | `epsilon` | `0.03` | How much a propensity may change in a chosen leap, relative to its value | | `tolerance` | `1e-8` | Relative tolerance of the time of an event when a rate reads `t` | | `rounding` | `None` | See [Whole units](#whole-units) | The defaults are the engine's, and the engine checks the settings when they are built: `StochasticEventsSettings(epsilon=1.5)` raises `ValidationError` with the message of the engine, `epsilon is strictly between 0 and 1, got 1.5`. - **Cost of the direct method.** The number of events grows with the total and with the rates: every unit that moves is one event. For a large total, use `"tau_leaping"` or a deterministic engine. - **Tau-leaping.** A chosen leap is as long as the propensities allow while they change by less than `epsilon`; a leap that would hold fewer than ten events is one exact event instead, and a leap that leaves an amount below zero is halved and drawn again. A fixed `tau` is first-order accurate: a smaller `tau` is closer to the direct method. - **Rates that read `t`.** Between events the state is fixed, but a rate that reads `t`, such as `k * (1 + sin(t))`, still changes. The direct method then integrates the total propensity from the last event until it reaches an exponential draw (a time change, Anderson 2007), within `tolerance`. A model whose rates do not read `t` pays nothing for it. - **Breakpoints.** The engine stops at every position where a rate can jump (the impulses, the values a rate compares `t` with, the points of a series read at `t`, and every integer when a rate reads `step`), so no wait and no leap crosses one (`CompiledModel::breakpoints`). - **Impulses.** At its positions, before the events there, a proportional impulse moves a binomial draw of its source (its rate is the probability that one unit moves, between 0 and 1); an absolute impulse moves its rate, a whole number of units. Each moves at most what its sources still hold. - **A new seed between two steps.** `simulation.engine.set_seed(seed)` acts from the current position: the state there stays, and every event after it comes from the new seed. A wait that was pending is drawn again from the position, and a pending leap keeps only its events up to the position. ```python leaping = builder.build( structure, engine="StochasticEvents", settings=StochasticEventsSettings(method="tau_leaping"), ) leaped = Simulation(leaping).replicates(100, seed=2).run(num_steps=100) print(abs(leaped.mean()["C"][-1] - replicates.mean()["C"][-1]) < 150) # True ``` ## Calibration with replicates A calibration of a stochastic model runs `replicates` runs per evaluation with common random numbers, and compares the observations with the summary of the replicates you choose: `"mean"`, `"median"`, `Quantile(q)` or `"crps"` ([Stochastic engines](https://munqu.github.io/commol/latest/guide/calibration/#stochastic-engines)). ```python from commol import ( CalibrationParameter, CalibrationProblem, Calibrator, NelderMeadConfig, ObservedDataPoint, ) mean = replicates.mean() observed = [ ObservedDataPoint(at=at, output="C", value=mean["C"][at]) for at in (40, 70, 100) ] unknown = builder.build(structure, engine="StochasticEvents") unknown.update_parameters({"k2": None}) problem = CalibrationProblem( observed_data=observed, parameters=[ CalibrationParameter(id="k2", parameter_type="parameter", min_bound=0.01, max_bound=0.5) ], loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=100), replicates=50, replicate_summary="mean", seed=7, ) result = Calibrator(Simulation(unknown), problem).run() print(0.05 < result.best_parameters["k2"] < 0.2) # True: near 0.1 ``` ## Checklist - [ ] The engine is `StochasticEvents` (continuous time) or `StochasticSteps` (per-step probabilities), as the rates of the model mean. - [ ] The amount of each bin is whole, and the settings name a `rounding` when the amount of a compartment is not. - [ ] Every run has a seed, or keeps the one commol drew (`results.seed`). - [ ] A large total uses `"tau_leaping"` or a deterministic engine. - [ ] A calibration sets `replicates`, `replicate_summary` and `seed`. ## Next steps | To learn | Read | | ------------------------------------ | --------------------------------------------------------------------------------------------------------------- | | Seeds, replicates, plots and reports | [Running Simulations](https://munqu.github.io/commol/latest/guide/simulations/#randomness-seeds-and-replicates) | | Calibration of a stochastic model | [Model Calibration](https://munqu.github.io/commol/latest/guide/calibration/#stochastic-engines) | | Signatures | [API reference: Simulation](https://munqu.github.io/commol/latest/api/simulation/index.md) | # Model Calibration [Back to Home](https://munqu.github.io/commol/latest/index.md) Calibration finds the parameter values (and, if you want, initial conditions and observation scale factors) that make a model reproduce observed data. You describe the observations and the unknowns in a `CalibrationProblem`, and a `Calibrator` runs an optimizer in the Rust engine. | Topic | Page | | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | The workflow, the problem, losses and results | This page | | Maximum likelihood with a model of the noise of each series | [Observation Models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md) | | Calibrating initial conditions | [Calibrating Initial Conditions](https://munqu.github.io/commol/latest/guide/calibration-initial-conditions/index.md) | | Several outputs, aggregates, weights, scale factors, windowed data | [Observations](https://munqu.github.io/commol/latest/guide/calibration-observations/index.md) | | Nelder-Mead and particle swarm settings | [Optimizers](https://munqu.github.io/commol/latest/guide/calibration-optimizers/index.md) | | Relations the result must respect | [Constraints](https://munqu.github.io/commol/latest/guide/calibration-constraints/index.md) | | Reports of a calibrated model and run | [Reports](https://munqu.github.io/commol/latest/guide/reports/index.md) | | Samples, predictive bands and scores | [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) | ## Quick path ```python from commol import ( Aggregate, CalibrationParameter, CalibrationProblem, Calibrator, ModelBuilder, NelderMeadConfig, ObservedDataPoint, Simulation, ) def basic_model(k1: float | None, k2: float | None, engine="DifferenceEquations"): 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="k2", value=k2) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * 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=engine, ) ) # Observations generated from k1 = 0.4, k2 = 0.1 truth = Simulation(basic_model(0.4, 0.1)).run(num_steps=50) observed_data = [ ObservedDataPoint(at=step, output="B", value=truth["B"][step]) for step in range(5, 51, 5) ] # 1. A model whose unknowns are None model = basic_model(None, None) # 2. The problem: observations, unknowns, loss and optimizer problem = CalibrationProblem( observed_data=observed_data, parameters=[ CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0), CalibrationParameter(id="k2", parameter_type="parameter", min_bound=0.0, max_bound=1.0), ], loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=2000), seed=42, ) # 3. Calibrate result = Calibrator(Simulation(model), problem).run() print({name: round(value, 3) for name, value in sorted(result.best_parameters.items())}) # {'k1': 0.4, 'k2': 0.1} # 4. Write the values back, and simulate model.apply_calibration_parameters(result, problem) calibrated = Simulation(model).run(num_steps=100) ``` A `Simulation` follows its model: after `apply_calibration_parameters` or `update_parameters`, its next `run` (and its `engine`) uses the new values, also when it was created while a parameter was `None`. See [Simulations](https://munqu.github.io/commol/latest/guide/simulations/#simulation). ## The problem | `CalibrationProblem` field | Type | Default | Meaning | | ----------------------------- | ------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `observed_data` | `list[ObservedDataPoint]` | required | What the model must reproduce. See [Observations](https://munqu.github.io/commol/latest/guide/calibration-observations/index.md). | | `parameters` | `list[CalibrationParameter]` | required | The unknowns and their bounds. | | `loss_function` | `str \| None` | `None` | How the misfit is measured; see [Losses](#losses). A problem has `loss_function` or `observation_models`, exactly one. | | `observation_models` | `dict[str, ObservationModel] \| None` | `None` | One observation model per series label: the fit maximizes their likelihood. See [Observation Models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md). | | `optimization_config` | `NelderMeadConfig` or `ParticleSwarmConfig` | required | The optimizer and its settings. See [Optimizers](https://munqu.github.io/commol/latest/guide/calibration-optimizers/index.md). | | `constraints` | `list[CalibrationConstraint]` | `[]` | Relations the result must respect. See [Constraints](https://munqu.github.io/commol/latest/guide/calibration-constraints/index.md). | | `normalize_observations` | `bool` | `False` | Divides each residual by the RMS of its observation series, so series of very different size weigh the same. Needs a loss. | | `initial_condition_remainder` | `str \| None` | `None` | The compartment or bin that absorbs the difference when initial conditions are calibrated. See [Calibrating Initial Conditions](https://munqu.github.io/commol/latest/guide/calibration-initial-conditions/index.md). | | `replicates` | `int \| None` | `None` | How many replicates of a stochastic engine each evaluation runs. Required with a stochastic engine. See [Stochastic engines](#stochastic-engines) and [Stochastic Simulation](https://munqu.github.io/commol/latest/guide/stochastic/index.md). | | `replicate_summary` | `"mean"`, `"median"`, `"crps"`, `Quantile(q)` or `None` | `None` | What each observation is compared with when an evaluation runs replicates. Required with a stochastic engine. See [Stochastic engines](#stochastic-engines). | | `seed` | `int \| None` | `None` | The seed of every random number of a calibration: the replicates of a stochastic engine, the starts, a particle swarm and the uncertainty methods. Without it, the engine draws one, which `Calibrator.seed` gives. See [Reproducibility](https://munqu.github.io/commol/latest/guide/calibration-optimizers/#reproducibility). | ### Observations An `ObservedDataPoint(at=..., output=..., value=...)` is one observed value of an output at a position on the axis. Its other fields observe a sum of outputs, weigh it, scale the prediction by a factor or compare a gain over a window: [Observations](https://munqu.github.io/commol/latest/guide/calibration-observations/index.md) lists them all. ### Unknowns | `CalibrationParameter` field | Type | Default | Meaning | | ---------------------------- | --------------- | -------- | ----------------------------------------------------------------- | | `id` | `str` | required | What to calibrate; depends on `parameter_type`. | | `parameter_type` | `str` | required | `"parameter"`, `"initial_condition"`, `"factor"` or `"noise"`. | | `min_bound`, `max_bound` | `float` | required | The search interval. | | `initial_guess` | `float \| None` | `None` | Where the optimizer starts; the midpoint of the bounds if `None`. | | `parameter_type` | `id` names | The value is | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `"parameter"` | A model parameter with a number, or one declared with `value=None`; not a formula or a series parameter, which the engine computes at each step | The parameter value | | `"initial_condition"` | A bin, a category, or an expanded compartment | An amount in the unit of its bin, or the split of a category; see [Calibrating Initial Conditions](https://munqu.github.io/commol/latest/guide/calibration-initial-conditions/index.md) | | `"factor"` | Any new id, used by observations through `factor_id` | A factor on the predictions; not part of the model | | `"noise"` | Any new id, named by an [observation model](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/#noise-parameters) | An argument of the model, such as its standard deviation; not part of the model | `"parameter"`, `"factor"` and `"noise"` work with every kind of model. The other types are values of the model kind, and the kind declares which it accepts (`model.structure.calibration_value_types()`): an aggregate model accepts `"initial_condition"`. A type the kind does not accept raises a `ValueError` that names the kind when you create the `Calibrator`. A `"parameter"` that the model does not have, or that a formula or a series sets, raises a `ValueError` with the engine's message at the same time. ## Losses A problem has a loss or observation models, exactly one, and neither has a default: what to fit is a modelling choice. A loss measures the residuals; [observation models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md) say how each series scatters, and the fit maximizes their likelihood, so its loss is `−log L`. `CalibrationProblem` raises a `ValueError` with both or neither. | `loss_function` | Formula (residual `r = observed − predicted`, weight `w`) | Use when | | --------------- | --------------------------------------------------------- | ------------------------------------- | | `"sse"` | `Σ w·r²` | The usual choice | | `"rmse"` | `√(Σ w·r² / Σ w)` | You want the error in the data's unit | | `"mae"` | \`Σ w· | r | How weights count Every loss uses a weight the same way: `weight=2.0` makes an observation count twice as much as one with weight 1.0. With every weight at its default of 1.0, the losses are the plain sum of squares, root mean square and mean absolute error. `LossConfig` in `crates/commol-calibration/src/types.rs` computes them; the discrepancy (`crates/commol-calibration/src/discrepancy.rs`) applies them. With `normalize_observations=True`, each residual is first divided by the RMS of the observations of its series (its `label`). A run the model does not allow gets a penalty instead of a loss: a run with values that are not finite, or a difference step that leaves an amount below zero (`InfeasibleStateError`, see [Amounts below zero](https://munqu.github.io/commol/latest/guide/transitions/#amounts-below-zero)). The penalty is much larger than any fit, and the run is not recorded in the history, so the optimizer moves away from it and goes on. Any other error of a run, such as a rate that cannot be evaluated or that is not a number, stops the calibration with its message (`OptimizationObjective` in `crates/commol-calibration/src/objective.rs`, the only place that turns an infeasible point into a penalty). ## Stochastic engines With a stochastic engine, one run per evaluation would make the loss change from one evaluation to the next for the same parameters, and Nelder-Mead and particle swarm would chase the noise. So each evaluation runs `replicates` replicates with **common random numbers**: the same replicate seeds at every evaluation, derived from `seed`. The loss is then a deterministic function of the parameters (`CalibrationProblem::with_replicates` in `crates/commol-calibration/src/calibration_problem.rs`). You choose what each observation is compared with; there is no default, because what to fit is a modelling choice: | `replicate_summary` | Compared with each observation | Loss | | ------------------- | -------------------------------------------- | -------------------------------------------- | | `"mean"` | The mean of the replicates | `loss_function` | | `"median"` | The median | `loss_function` | | `Quantile(q)` | The quantile `q`, a level between 0 and 1 | `loss_function` | | `"crps"` | The empirical distribution of the replicates | The continuous ranked probability score, \`E | ```python stochastic = basic_model(None, 0.1, engine="StochasticSteps") runs = Simulation(basic_model(0.4, 0.1)).run(num_steps=50) problem = CalibrationProblem( observed_data=[ ObservedDataPoint(at=step, output="B", value=runs["B"][step]) for step in range(5, 51, 5) ], parameters=[CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0)], loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=200), replicates=50, replicate_summary="crps", # or "mean", "median", Quantile(0.9) seed=7, ) result = Calibrator(Simulation(stochastic), problem).run() print(round(result.best_parameters["k1"], 1)) # 0.4 ``` | Rule | Where | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | A stochastic engine needs `replicates`, `replicate_summary` and `seed`; without one, `Calibrator` raises a `ValueError` that names the engine, and the message lists the summaries | `CalibrationProblem::with_replicate_settings` in `crates/commol-calibration/src/calibration_problem.rs`; `SimulationEngine::is_stochastic` | | A deterministic engine does not use `replicates` and `replicate_summary` | The same | | A stochastic engine needs a loss: with `observation_models`, `Calibrator` raises a `ValueError` | `CalibrationProblem::new`; `Discrepancy::set_summary` | | The summaries and the CRPS are computed in `LossConfig`, and the observation weights apply to every option | `LossConfig::replicate_loss` in `crates/commol-calibration/src/types.rs` | | The median and `Quantile(q)` are the one quantile of commol, the one of `weighted_quantiles` and `SimulationReplicates.quantiles` (NumPy's `method="hazen"`): `Quantile(0.25)` of the replicates 1, 2, 3, 4, 5 is 1.75 | `ReplicateSummary::summarize`; `statistics::quantiles` in `crates/commol-calibration/src/statistics.rs` | | A constraint at positions is checked on every replicate | `CalibrationProblem::evaluate` | With one replicate the CRPS is the absolute error. The [predictive](https://munqu.github.io/commol/latest/guide/uncertainty/#the-predictive) of a sample runs every replicate of each draw. ## Results `Calibrator(simulation, problem)` checks the problem against the model, and `run()` returns a `CalibrationResult`: | `CalibrationResult` field | Meaning | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `best_parameters` | `dict[str, float]`: the best value of every unknown, in the order of `problem.parameters` | | `final_loss` | The loss at `best_parameters`; with observation models, the negative log-likelihood | | `iterations` | The optimizer iterations performed | | `converged` | `True` only when the optimizer met its convergence test (a stop rule for a particle swarm); a run that used all `max_iterations` is `False`. See [Optimizers](https://munqu.github.io/commol/latest/guide/calibration-optimizers/#converged-and-termination_reason). | | `termination_reason` | Why it stopped | | `starts`, `failed` | Every start that finished, and each start that failed with its reason. See [Several starts](https://munqu.github.io/commol/latest/guide/calibration-optimizers/#several-starts). | | `seed` | The seed of the calibration's random numbers (`Calibrator.seed`). See [Reproducibility](https://munqu.github.io/commol/latest/guide/calibration-optimizers/#reproducibility). | ```python print(result.converged, result.termination_reason) print(f"loss = {result.final_loss:.3g} after {result.iterations} iterations") ``` | `Calibrator` member | What it does | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `run(starts=1)` | Runs the optimizer from `starts` start points and returns the best `CalibrationResult`. See [Several starts](https://munqu.github.io/commol/latest/guide/calibration-optimizers/#several-starts). | | `run_with_history()` | Returns a `CalibrationHistory`: the result and every evaluation of a feasible point. | | `uncertainty(method)` | Runs an uncertainty method. See [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/#the-entry-point). | | `identifiability(method)` | Runs an identifiability method. See [Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/index.md). | | `seed` | The seed of the calibration's random numbers: `problem.seed`, or a seed drawn once and logged. | | `num_observations`, `num_parameters`, `parameter_names` | Facts about the problem. | ## Writing the result back `Model.apply_calibration_parameters(result, problem)` writes every calibrated value into the model, routed by its `parameter_type`: | `parameter_type` | Written to | | --------------------- | ------------------------------------------------------------------------------------ | | `"parameter"` | The model parameter with that id | | `"initial_condition"` | The initial conditions, so the model rebuilds exactly the state the optimizer scored | | `"factor"`, `"noise"` | Nothing: they describe the observations, not the model | It accepts a `CalibrationResult` or a plain `{id: value}` mapping, such as one of `sample.draws()`; a `ParameterSample` has no single set of values and raises `TypeError`. It computes the new definition first, so a rejected value leaves the model unchanged. To set values by hand, use `Model.update_parameters({...})`. ## Checklist - [ ] Every unknown is `None` in the model and has a `CalibrationParameter`. - [ ] Every observation names an output of the model (`Simulation.simulation_outputs`). - [ ] The bounds are realistic, and the result is not stuck at a bound. - [ ] `result.converged` is `True`, or you know why not. - [ ] You simulate the calibrated model (`apply_calibration_parameters`, then `run`). ## Next steps | To learn | Read | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | Fitting initial conditions | [Calibrating Initial Conditions](https://munqu.github.io/commol/latest/guide/calibration-initial-conditions/index.md) | | Fitting several or partial observations | [Observations](https://munqu.github.io/commol/latest/guide/calibration-observations/index.md) | | Tuning the optimizer | [Optimizers](https://munqu.github.io/commol/latest/guide/calibration-optimizers/index.md) | | Uncertainty | [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) | # Calibrating Initial Conditions [Back to Model Calibration](https://munqu.github.io/commol/latest/guide/calibration/index.md) An `initial_condition` unknown fits how the state starts: the amount of a bin in its unit, the split of a category, or the amount of one expanded compartment. The Rust engine applies the values in a fixed order, and a **remainder** compartment keeps the sum of the amounts the model states. ## Quick path ```python from commol import ( Aggregate, CalibrationParameter, CalibrationProblem, Calibrator, ModelBuilder, NelderMeadConfig, ObservedDataPoint, Simulation, ) def model_with_b(b_amount: float | None): # A holds what B does not, so A and B keep 1000 between them. a_amount = 1000 if b_amount is None else 1000 - b_amount return ( ModelBuilder(name="Initial B") .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=0.3) .add_parameter(id="k2", value=0.1) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total") .add_transition(id="t_bc", source=["B"], target=["C"], rate="k2 * B") .build( Aggregate(bin_amounts={"A": a_amount, "B": b_amount, "C": 0}), engine="DifferenceEquations", ) ) truth = Simulation(model_with_b(20)).run(num_steps=40) observed_data = [ ObservedDataPoint(at=step, output="B", value=truth["B"][step]) for step in range(5, 41, 5) ] model = model_with_b(None) problem = CalibrationProblem( observed_data=observed_data, parameters=[ CalibrationParameter( id="B", parameter_type="initial_condition", min_bound=0.0, max_bound=100.0 ) ], initial_condition_remainder="A", loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=1000), ) result = Calibrator(Simulation(model), problem).run() print(round(result.best_parameters["B"], 2)) # 20.0 model.apply_calibration_parameters(result, problem) print({c.name: round(c.amount, 1) for c in model.structure.initial_state(model.process)}) # {'A': 980.0, 'B': 20.0, 'C': 0.0} ``` Here `B` is calibrated, and `A`, the remainder, takes what `B` leaves of the 1000 that the model states for `A` and `B`. ## What an id can name | `id` | Example | The value is | | ----------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | A bin | `"B"` | The amount of the bin, in its unit. Its compartments share it in the proportions of the initial state, also when the `Simulation` has run. | | A bin prefix (a bin and some categories) | `"B_g1"` | The amount in those compartments, shared in the same way. Compartments are matched by label, so `B_g1` never covers a bin `B_g1x`. | | An expanded compartment | `"B_g1_t2"` | The amount of that compartment alone, in the unit of its bin. | | A category of a **binary** stratification | `"g1"` or `"group:g1"` | The split: every `(g1, g2)` pair of compartments keeps its total and gives `g1` this fraction. | A category that two stratifications share is written with its stratification, as in `"group:g1"` ([the same rule](https://munqu.github.io/commol/latest/guide/core-concepts/#updating-the-initial-state-after-building) as for the initial state); a bare name is rejected as ambiguous. The engine resolves every id when you create the `Calibrator` (`InitialConditionPlan` in `crates/commol-model/src/initial_conditions.rs`), so a wrong id fails before the optimization starts: | `id` | Error | | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | | Names nothing in the model | `Invalid bin, compartment or category 'X'`, with the compartments | | A category of a stratification with three or more categories, such as `"g1"` of `group = [g1, g2, g3]` | `Initial condition category 'group:g1' needs a binary stratification` | | A category name that two stratifications declare | `… is ambiguous`, with the qualified names | ## The remainder Without help, calibrated values change the sum of the amounts. Name a compartment or bin in `initial_condition_remainder`, and it takes what the values leave of the amounts the model states: | Rule | Detail | | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | It is not calibrated | It takes the sum of the amounts the model states, less every other compartment. | | It keeps the sum of what the values and it cover | A compartment that no value covers keeps its amount, so it does not change the remainder. A bin left `None` for calibration counts as 0, so the remainder states the sum: `A` holds 1000 while `B` is unknown. | | It has the unit of every amount it gives | The `Calibrator` rejects a calibrated bin, prefix or compartment whose unit is not the unit of the remainder, and names both. A category split has no unit. | | It keeps its own split | A bin remainder shares what it takes across its compartments in the proportions of the initial state. | | It must not overlap a calibrated id | Building the problem rejects it otherwise. | | A value that would make it negative is penalized | The optimizer moves away from it. | Without a remainder, values are applied as given, and the other compartments keep their amounts. So a model of a count in `A` and `B` and of a concentration in `C` can fit the amount of `C` alone, or fit `B` with `A` as the remainder. ## Order of application Whatever their order in `parameters`, values are written in layers, like a model definition: 1. bins and bin prefixes; 1. category splits; 1. expanded compartments; 1. the remainder. Within a layer, the order of `parameters` applies. So an expanded compartment always wins over the bin that contains it. ## One expanded compartment Fitting a single compartment of a stratified model, such as a state that is empty in one group only, needs the remainder: ```python def stratified(b_g1: float | None): return ( ModelBuilder(name="Composite") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_stratification(id="group", categories=["g1", "g2"]) .add_parameter(id="k1", value=0.2) .add_transition(id="t_ba", source=["B"], target=["A"], rate="k1") .build( Aggregate( bin_amounts={"A": 900, "B": 100}, stratification_fractions={"group": {"g1": 0.5, "g2": 0.5}}, compartment_amounts={} if b_g1 is None else {"B_g1": b_g1, "A_g1": 500 - b_g1}, ), engine="DifferenceEquations", ) ) truth = Simulation(stratified(10)).run(num_steps=20) problem = CalibrationProblem( observed_data=[ ObservedDataPoint(at=step, output="A_g1", value=truth["A_g1"][step]) for step in range(2, 21, 2) ], parameters=[ CalibrationParameter( id="B_g1", parameter_type="initial_condition", min_bound=0.0, max_bound=100.0, initial_guess=30.0, # Nelder-Mead is local: start near the answer ) ], initial_condition_remainder="A_g1", loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=1000), ) composite = stratified(None) result = Calibrator(Simulation(composite), problem).run() print(round(result.best_parameters["B_g1"], 2)) # 10.0 composite.apply_calibration_parameters(result, problem) print({c.name: round(c.amount, 1) for c in composite.structure.initial_state(composite.process)}) # {'A_g1': 490.0, 'A_g2': 450.0, 'B_g1': 10.0, 'B_g2': 50.0} ``` ## Writing back `apply_calibration_parameters` writes the values so that the model rebuilds the state the optimizer scored: 1. Each bin whose every compartment a value or the remainder sets gets the amount the engine gave it, in the unit of the bin, and each category gets the split the engine applied, so the definition stays readable. 1. Every compartment whose amount still differs from the scored one is pinned as a `compartment_amounts` override. So an expanded-compartment id always becomes an override, and so does a bin or category value that overlaps an existing override. A bin of which a value sets only some compartments keeps its amount. Reloading the saved model gives the scored state exactly. A calibrated category split is written with `update_stratification_fractions`: the named category gets the value and its complement the rest. ## Checklist - [ ] Each unknown amount is `None` in the model, and the bounds are amounts in the unit of its bin (a category split is in `[0, 1]`). - [ ] A remainder is named when the calibrated values should keep a sum, and it holds that sum in the model. - [ ] The remainder has the unit of every amount it gives. - [ ] The remainder is not also calibrated. - [ ] Category ids name a binary stratification, qualified when the name is shared. ## Next steps | To learn | Read | | ----------------------------------- | ---------------------------------------------------------------------------------------------------------- | | The rules of initial conditions | [Building Models](https://munqu.github.io/commol/latest/guide/building-models/#the-structure-aggregate) | | Reporting fitted initial conditions | [Reports](https://munqu.github.io/commol/latest/guide/reports/index.md) | | The design behind this page | [Initial conditions](https://munqu.github.io/commol/latest/codebase/decisions/initial-conditions/index.md) | # Observations [Back to Model Calibration](https://munqu.github.io/commol/latest/guide/calibration/index.md) An `ObservedDataPoint` says what the model must reproduce: an output at a position on the axis. This page shows how to observe several outputs, sums of outputs, partial observations (factors), counts per period (windows) and positions between integers, and how to weigh them. ## Quick path ```python from commol import ( Aggregate, CalibrationParameter, CalibrationProblem, Calibrator, ModelBuilder, NelderMeadConfig, ObservedDataPoint, Simulation, ) def model(k1: float | None): return ( ModelBuilder(name="Observed") .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="k2", value=0.1) .add_accumulator(id="cum_ab", name="Total moved from A to B") .add_transition( id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total", accumulators=["cum_ab"] ) .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(model(0.4)).run(num_steps=70) def calibrate_k1(observed_data, **problem_options) -> float: problem = CalibrationProblem( observed_data=observed_data, parameters=[ CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0) ], loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=2000), **problem_options, ) result = Calibrator(Simulation(model(None)), problem).run() return round(result.best_parameters["k1"], 3) # Observe B and C at the same steps observed = [ ObservedDataPoint(at=step, output=output, value=truth[output][step]) for step in range(10, 51, 10) for output in ("B", "C") ] print(calibrate_k1(observed)) # 0.4 ``` Each observation compares the model output named in `output` with `value` at the position `at`. Any output works: a compartment (`B`, `B_g1`) or an accumulator (`cum_ab`). The observation's `label` names its series in reports, plots and the per-series normalization; it defaults to `output`. | Field | Type | Default | Meaning | | ----------- | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `at` | `float` | required | The position on the axis. The engine decides which positions its model type accepts. | | `output` | `str \| None` | `None` | The observed output. Give `output` or `outputs`, not both. | | `outputs` | `list[str] \| None` | `None` | Outputs summed before the comparison; needs a `label`. | | `label` | `str` | `output` | The name of the observed series. | | `value` | `float` | required | The observed value, zero or more. | | `weight` | `float` | `1.0` | Above zero. How much the observation counts in every loss, and the power of its term of a likelihood; see [Losses](https://munqu.github.io/commol/latest/guide/calibration/#losses). | | `factor_id` | `str \| None` | `None` | A `factor` unknown that multiplies the prediction. | | `window` | `float \| None` | `None` | Compares with the gain over `(at − window, at]` instead of the value. | | `trials` | `int \| None` | `None` | The trials of the observation, for a `BinomialObservations()` model that reads them from each observation. See [Observation Models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md). | An unknown field, such as a misspelt name, raises `ValidationError` and names it. ## Sums of outputs When one measurement covers several outputs, list them in `outputs`. They are summed before the comparison; `label` names the sum for plots and reports: ```python b_and_c = [ ObservedDataPoint( at=step, label="B_plus_C", outputs=["B", "C"], value=truth["B"][step] + truth["C"][step], ) for step in range(10, 51, 10) ] print(calibrate_k1(b_and_c)) # 0.4 ``` `outputs` may name compartments and accumulators, and works with `factor_id` and `window`. It must not be empty. ## Weights and normalization | Option | Where | Effect | | ----------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `weight=2.0` | `ObservedDataPoint` | The observation counts twice as much, in every loss and in a [likelihood](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/#the-likelihood) ([How weights count](https://munqu.github.io/commol/latest/guide/calibration/#losses)). | | `normalize_observations=True` | `CalibrationProblem` | Divides each residual by the RMS of its series ([Losses](https://munqu.github.io/commol/latest/guide/calibration/#losses)), so a series of thousands does not drown a series of tens. | ```python weighted = [ ObservedDataPoint(at=10, output="B", value=truth["B"][10], weight=2.0), ObservedDataPoint(at=30, output="B", value=truth["B"][30], weight=1.0), ObservedDataPoint(at=30, output="cum_ab", value=truth["cum_ab"][30], weight=1.0), ] print(calibrate_k1(weighted, normalize_observations=True)) # 0.4 ``` ## Partial observations: factors When the data sees only part of an output, for example a fixed share of it, a `factor` unknown multiplies the prediction before the comparison. Link observations to it with `factor_id`: ```python seen = 0.3 partial = [ ObservedDataPoint(at=step, output="B", value=seen * truth["B"][step], factor_id="share_seen") for step in range(5, 51, 5) ] problem = CalibrationProblem( observed_data=partial, parameters=[ CalibrationParameter(id="share_seen", parameter_type="factor", min_bound=0.01, max_bound=1.0), ], loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=2000), ) scaled_result = Calibrator(Simulation(model(0.4)), problem).run() print(round(scaled_result.best_parameters["share_seen"], 3)) # 0.3 ``` | Fact | Detail | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Several factors | Each observation names its own `factor_id`, so outputs can have different shares. | | Not part of the model | `apply_calibration_parameters` skips `factor` values. | | Plots | Pass `calibration_result=` to `results.plot.series` or `results.plot.observations`; it applies the calibrated factors to the comparison. | | Identifiability | A rate and a share can explain the same data. Fit them together only when the data can tell them apart: [Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/index.md) checks it. | ## Counts per period: windows Data often counts what happened **in** each period, such as the amount that entered a bin in each interval of 7 steps. Observe a cumulative output (an accumulator) with `window`, and the model's prediction becomes the gain over the window: ```text predicted(at) = output(at) - output(at - window) ``` ```python from commol import windowed_totals ends = model(0.4).axis_units().window_ends(7, "step", 70) counts = windowed_totals(truth["cum_ab"], truth.positions, 7, ends) windowed = [ ObservedDataPoint(at=end, output="cum_ab", value=count, window=7) for end, count in zip(ends, counts) ] print(calibrate_k1(windowed)) # 0.4 ``` | Rule | Detail | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The window starts at 0 or later | `at − window`, read as 0 within the rounding of the window; otherwise the observation is rejected when it is created, with the rule of the engine (`check_window_end` in `crates/commol-calibration/src/observation.rs`). | | Any output | Usually an accumulator, but any compartment works. | | Mixed windows | Observations may use different windows, and positions need not be multiples of the window. | | Combinations | Works with `factor_id` and `outputs`. | `windowed_totals` (module function) and `Simulation.windowed_totals` apply the same reduction outside calibration, so a report shows exactly what the loss compared. See [Windowed totals](https://munqu.github.io/commol/latest/guide/simulations/#windowed-totals). An observation without `window` reads its output at `at`; `sample` and `Simulation.sample` read a run there in the same way ([Values at positions](https://munqu.github.io/commol/latest/guide/simulations/#values-at-positions)). ## Positions between integers An observation is at a position that the engine of the model reports at, as for [`Simulation.run_at`](https://munqu.github.io/commol/latest/guide/simulations/#simulation): a model in continuous time (`DifferentialEquations`, `StochasticEvents`) at any position, such as `2.5`, with windows of any length; a model of whole steps (`DifferenceEquations`, `StochasticSteps`) at integer steps only. The calibration reads the engine at those positions (`SimulationEngine::run_at_positions_into_buffer`), and the engine checks each one (`SimulationEngine::check_position`): `Calibrator` raises `ValueError` for a position the engine does not accept, and the engine's message names the observation, or the constraint, that holds it (`check_positions` in `crates/commol-calibration/src/calibration_problem.rs`). ```python def ode_model(k1: float | None): return ( ModelBuilder(name="Observed in continuous time") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_parameter(id="k1", value=k1) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1") .build( Aggregate( bin_amounts={"A": 1000, "B": 0}, ), engine="DifferentialEquations", ) ) positions = [0.5, 1.75, 3.25, 6.5] observed_b = Simulation(ode_model(0.2)).run_at(positions)["B"] problem = CalibrationProblem( observed_data=[ ObservedDataPoint(at=at, output="B", value=value) for at, value in zip(positions, observed_b) ], parameters=[CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0)], loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=500), ) print(round(Calibrator(Simulation(ode_model(None)), problem).run().best_parameters["k1"], 3)) # 0.2 ``` ## Stratified models Observe expanded outputs by name, such as `B_g1`, or sum them with `outputs`. `Simulation.outputs_for("B")` lists the names. ## Checklist - [ ] Every `output` (and every entry of `outputs`) is an output name; a sum has a `label`. - [ ] Weights say how much each observation counts, in every loss. - [ ] Every `factor_id` has a `CalibrationParameter` with `parameter_type="factor"`. - [ ] Windowed observations use a cumulative output and a window that starts at 0 or later. ## Next steps | To learn | Read | | ---------------------------- | ----------------------------------------------------------------------------------------- | | Tuning the optimizer | [Optimizers](https://munqu.github.io/commol/latest/guide/calibration-optimizers/index.md) | | Reports of what was compared | [Reports](https://munqu.github.io/commol/latest/guide/reports/index.md) | # Optimizers [Back to Model Calibration](https://munqu.github.io/commol/latest/guide/calibration/index.md) The class of `optimization_config` chooses the optimizer: `NelderMeadConfig` for a fast local search, `ParticleSwarmConfig` for a global one. Both run in Rust on the [argmin](https://github.com/MUNQU/argmin) library, and both keep every unknown inside its bounds. ## Quick path ```python from commol import ( Aggregate, CalibrationParameter, CalibrationProblem, Calibrator, ModelBuilder, NelderMeadConfig, ObservedDataPoint, ParticleSwarmConfig, Simulation, ) def model(k1: float | None, k2: float | None): return ( ModelBuilder(name="Optimizers") .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="k2", value=k2) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * 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(model(0.4, 0.1)).run(num_steps=50) observed_data = [ ObservedDataPoint(at=step, output="B", value=truth["B"][step]) for step in range(5, 51, 5) ] def calibrate(optimization_config) -> dict[str, float]: problem = CalibrationProblem( observed_data=observed_data, parameters=[ CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0), CalibrationParameter(id="k2", parameter_type="parameter", min_bound=0.0, max_bound=1.0), ], loss_function="sse", optimization_config=optimization_config, seed=7, ) result = Calibrator(Simulation(model(None, None)), problem).run() return {name: round(value, 2) for name, value in sorted(result.best_parameters.items())} print(calibrate(NelderMeadConfig(max_iterations=2000))) # {'k1': 0.4, 'k2': 0.1} print(calibrate(ParticleSwarmConfig(particles=30, max_iterations=300))) # {'k1': 0.4, 'k2': 0.1} ``` ## Choosing | Optimizer | Strength | Weakness | Use when | | -------------- | ---------------------------------------------- | --------------------------------------------- | ---------------------------------------- | | Nelder-Mead | Few evaluations; precise near the answer | Local: can stop at a bound or a local minimum | Few unknowns, a good `initial_guess` | | Particle swarm | Searches the whole box; robust to local minima | Many evaluations | Several unknowns, no good starting point | A common pattern: run particle swarm first, then Nelder-Mead from its result with `initial_guess`. ## Nelder-Mead ```python nelder_mead = NelderMeadConfig( max_iterations=1000, sd_tolerance=1e-6, initial_simplex_size=0.1, alpha=1.0, gamma=2.0, rho=0.5, sigma=0.5, verbose=False, header_interval=100, ) print(calibrate(nelder_mead)) # {'k1': 0.4, 'k2': 0.1} ``` | Field | Default | Meaning | | -------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | | `max_iterations` | `1000` | The iteration limit. | | `sd_tolerance` | `1e-6` | Stops when the spread of the simplex values falls below it. | | `initial_simplex_size` | `0.1` | The size of the first simplex: each vertex moves one unknown by this fraction of its bound width, towards the farther bound. In (0, 1\]. | | `alpha`, `gamma`, `rho`, `sigma` | `None` (argmin defaults) | Reflection, expansion, contraction and shrink coefficients. | | `verbose` | `False` | Writes a progress table to `sys.stdout` (a notebook shows it). It shows one start, so it needs `run(starts=1)`. | | `header_interval` | `100` | With `verbose`, repeats the table header every this many iterations. | The search starts at `initial_guess`, or at the midpoint of the bounds; a start of 0 works. Nelder-Mead moves in an unbounded coordinate `u`, and the value it scores is `lo + (hi − lo)·(sin u + 1)/2` (the transform of MINUIT and lmfit), so every value it tries is inside the bounds (`SineTransform` of the argmin fork). An optimum exactly at a bound is reached only asymptotically, so it can take more iterations. Nelder-Mead needs finite bounds: a run with an infinite bound fails with the message `Nelder-Mead needs finite bounds`. ## `converged` and `termination_reason` | `termination_reason` | `converged` | Meaning | | ----------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ | | `Terminated(SolverConverged)` | `True` | Nelder-Mead: the spread of the simplex values fell below `sd_tolerance`. Particle swarm: a [stop rule](#stop-rules) held | | `Terminated(MaxItersReached)` | `False` | The run used all `max_iterations` | | Any other reason | `False` | The run stopped for another reason that argmin reports | `converged` comes from `converged` in `crates/commol-calibration/src/optimization.rs`. A particle swarm without a [stop rule](#stop-rules) always stops at `max_iterations`, so it reports `converged=False`; judge it by `final_loss`. ## Particle swarm ```python swarm = ( ParticleSwarmConfig(particles=40, max_iterations=300, initialization="latin_hypercube") .inertia("chaotic", w_min=0.4, w_max=0.9) .acceleration("time_varying", c1_initial=2.5, c1_final=0.5, c2_initial=0.5, c2_final=2.5) .velocity(clamp_factor=0.2, mutation_threshold=0.005) .mutation("gaussian", scale=0.1, probability=0.05, application="global_best") ) print(calibrate(swarm)) # {'k1': 0.4, 'k2': 0.1} ``` | Field | Default | Meaning | | ---------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `particles` | `20` | The swarm size. More particles search more widely and cost more evaluations. | | `max_iterations` | `1000` | The iteration limit. | | `initialization` | `"uniform"` | Where particles start: `"uniform"`, `"latin_hypercube"` (spread evenly) or `"opposition_based"` (each random point with its mirror). | | `verbose` | `False` | Writes progress to `sys.stdout`; needs `run(starts=1)`. | Each method returns the configuration, so they chain: | Method | Arguments | Effect | | ----------------------------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `.inertia("constant", factor=0.72135)` | `factor` | A fixed inertia weight. The default is the weight of canonical PSO, 1/(2 ln 2), which a swarm with no `.inertia` also uses. | | `.inertia("chaotic", w_min=…, w_max=…)` | `w_min < w_max` | An inertia weight that varies chaotically between the two. | | `.acceleration("constant", cognitive=1.19315, social=1.19315)` | `cognitive`, `social` | Fixed pulls towards each particle's best and the swarm's best. The default of each is the coefficient of canonical PSO, 0.5 + ln 2, which a swarm with no `.acceleration` also uses. | | `.acceleration("time_varying", c1_initial=…, c1_final=…, c2_initial=…, c2_final=…)` | four factors | Pulls that change from explore (high `c1`) to exploit (high `c2`). | | `.velocity(clamp_factor=None, mutation_threshold=None)` | fractions | `clamp_factor` limits speed to a share of the search box (0.1–0.2 is typical); `mutation_threshold` restarts particles slower than it (0.001–0.01). | | `.mutation(strategy, *, scale, probability, application)` | see right | Random jumps to escape local minima: `strategy` `"gaussian"` or `"cauchy"` (larger jumps), `probability` per iteration, `application` `"global_best"`, `"all_particles"` or `"below_average"`. | | `.improvement_stop(*, window, tol_abs, tol_rel)` | see [Stop rules](#stop-rules) | Stops when the best loss stops improving. | | `.radius_stop(tol)` | see [Stop rules](#stop-rules) | Stops when the swarm has gathered around its best position. | ### Stop rules A swarm stops at `max_iterations` unless a stop rule holds first. The two rules come from the argmin fork; both are off until you set them, and neither has a default value. A run that stops by a rule reports `converged=True` and `termination_reason` `Terminated(SolverConverged)`. | Rule | Stops when | Arguments | | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | | `.improvement_stop(window=…, tol_abs=…, tol_rel=…)` | The best loss improved by at most \`tol_abs + tol_rel· | best loss | | `.radius_stop(tol)` | Every particle is within `tol` of the best position, as a fraction of the size of the search box (the length of its diagonal) (Zielinski and Laur, 2007) | `tol` ≥ 0 | ```python stopping = ParticleSwarmConfig(particles=30, max_iterations=2000).improvement_stop( window=30, tol_abs=1e-12, tol_rel=1e-9 ) problem = CalibrationProblem( observed_data=observed_data, parameters=[ CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0), CalibrationParameter(id="k2", parameter_type="parameter", min_bound=0.0, max_bound=1.0), ], loss_function="sse", optimization_config=stopping, seed=7, ) result = Calibrator(Simulation(model(None, None)), problem).run() print(result.converged, result.iterations < 2000) # True True ``` A swarm can stall for some iterations and then improve again, so a short `window` can stop it early. Mutation and velocity mutation scatter the particles again, and unknowns that the data cannot tell apart keep them spread, so with them the radius can stay above `tol`. Neither rule draws random numbers: with the same seed, a run with a rule is the run without it up to the iteration where it stops. In a [profile](https://munqu.github.io/commol/latest/guide/uncertainty/profiles/index.md), the improvement rule also sets the tolerance of a swarm. ## Several starts `Calibrator.run(starts=n)` runs the optimizer from `n` start points in parallel and returns the best result. Start 0 is the initial guess of each parameter (its `initial_guess`, or the midpoint of its bounds). Start `i > 0` is a row of a Latin hypercube over the bounds, drawn from the problem's `seed`: with `n − 1` rows, each parameter has one start in each of `n − 1` equal strata of its bounds, so the starts cover the parameter space (`start_points` in `crates/commol-calibration/src/starts.rs`). A particle swarm draws its own population; each start takes its own seed, also drawn from the problem's `seed`, so start 0 of `run(starts=n)` is the result of `run()`, with or without `verbose`. A particle swarm also scores the particles of each iteration in parallel, in the order of the particles, so the result does not depend on the number of threads. `commol.threads` chooses the threads and whether the particles run in parallel: see [Threads and Parallelism](https://munqu.github.io/commol/latest/guide/parallelism/index.md). ```python problem = CalibrationProblem( observed_data=observed_data, parameters=[ CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0), CalibrationParameter(id="k2", parameter_type="parameter", min_bound=0.0, max_bound=1.0), ], loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=2000), seed=7, ) result = Calibrator(Simulation(model(None, None)), problem).run(starts=4) print(len(result.starts), result.failed) # 4 [] print(result.starts[0].start) # {'k1': 0.5, 'k2': 0.5} print({name: round(value, 2) for name, value in result.best_parameters.items()}) # {'k1': 0.4, 'k2': 0.1} ``` | `CalibrationResult` field | Meaning | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `starts` | One `StartSummary` per start that finished, in order: `start`, `end`, `loss`, `converged` | | `failed` | Each start that failed, `"Start failed: "`; each is also logged as a warning (`logging`, logger `commol.api.calibrator`) | The result is the start of least loss, the first of them on a tie; if every start fails, `run` raises a `RuntimeError` with the reason of each start. Starts that end at distinct points with equal losses are a sign that the data do not identify the parameters; `StartsIdentifiability` names the parameters that differ (see [Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/#starts)). ## Reproducibility Set `CalibrationProblem(seed=...)`: the starts, a particle swarm and the replicates of a stochastic engine then make the same random choices on every run. Without a seed, the engine draws one when the calibrator is built; `Calibrator.seed` gives it and the calibrator logs it. The result keeps it in `CalibrationResult.seed`, and the sample of each uncertainty method in `sample.method.settings["seed"]`: set it as `problem.seed` to repeat the run, also from a result read back from JSON. ```python again = problem.model_copy(update={"seed": result.seed}) print(Calibrator(Simulation(model(None, None)), again).run(starts=4) == result) # True ``` ## History of evaluations `Calibrator.run_with_history()` optimizes from the initial guess and returns a `CalibrationHistory`: the `result`, and every evaluation of a feasible point the optimizer made (`CalibrationEvaluation`: its `parameters` and `loss`), in order. A point the objective penalizes is not kept. ## Troubleshooting | Symptom | Try | | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A result sits at a bound | Check the bounds, give a better `initial_guess`, or use particle swarm | | `converged=False` | Check `termination_reason`: with `MaxItersReached`, raise `max_iterations`; a particle swarm needs a [stop rule](#stop-rules) to converge | | Different results on each run | Set `seed`; use more particles or iterations | | A local minimum | `run(starts=n)` from several points | | A good loss but implausible values | Unknowns that the data cannot tell apart; fix one, or add observations | | Slow | Fewer observation steps or particles; Nelder-Mead from a good start; check `commol.get_threads()` ([Threads and Parallelism](https://munqu.github.io/commol/latest/guide/parallelism/index.md)) | ## Next steps | To learn | Read | | --------------------------------- | ------------------------------------------------------------------------------------------- | | Relations the result must respect | [Constraints](https://munqu.github.io/commol/latest/guide/calibration-constraints/index.md) | | Uncertainty | [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) | # Constraints [Back to Model Calibration](https://munqu.github.io/commol/latest/guide/calibration/index.md) A constraint is an expression that must be **zero or more** for a calibration result to be acceptable, such as `"3.0 - k1 / k2"` for `k1 / k2 <= 3`. A constraint on unknowns is checked before each simulation; a constraint on outputs is checked at the steps you list. ## Quick path ```python from commol import ( Aggregate, CalibrationConstraint, CalibrationParameter, CalibrationProblem, Calibrator, ModelBuilder, ObservedDataPoint, ParticleSwarmConfig, Simulation, ) def model(k1: float | None, k2: float | None): return ( ModelBuilder(name="Constrained") .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="k2", value=k2) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * 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", ) ) # The data comes from k1 / k2 = 4 truth = Simulation(model(0.4, 0.1)).run(num_steps=50) observed_data = [ ObservedDataPoint(at=step, output="B", value=truth["B"][step]) for step in range(5, 51, 5) ] def calibrate(constraints: list[CalibrationConstraint]) -> dict[str, float]: problem = CalibrationProblem( observed_data=observed_data, parameters=[ CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0), CalibrationParameter(id="k2", parameter_type="parameter", min_bound=0.01, max_bound=1.0), ], constraints=constraints, loss_function="sse", optimization_config=ParticleSwarmConfig(particles=30, max_iterations=300), seed=3, ) result = Calibrator(Simulation(model(None, None)), problem).run() return result.best_parameters best = calibrate([CalibrationConstraint(id="ratio", expression="3.0 - k1 / k2")]) print(round(best["k1"] / best["k2"], 2)) # 3.0: the best fit that respects k1 / k2 <= 3 ``` ## `CalibrationConstraint` | Field | Type | Default | Meaning | | ------------- | --------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `id` | `str` | required | A unique identifier, used in error messages. | | `expression` | `str` | required | Must evaluate to `>= 0` when the constraint holds. | | `description` | `str \| None` | `None` | A free-text description. | | `weight` | `float` | `1.0` | Above zero. Multiplies the amount of a violation in the penalty. | | `at` | `list[float] \| None` | `None` | The positions on the axis at which to evaluate it. Required when the expression names an output. The engine accepts the positions it accepts for [observations](https://munqu.github.io/commol/latest/guide/calibration-observations/#positions-between-integers). | ## Writing the expression Move every term to one side, so that the expression is `>= 0` when it holds: | Condition | `expression` | | ----------------------------- | ------------------------------------- | | `k1 <= 0.5` | `"0.5 - k1"` | | `k1 >= k2` | `"k1 - k2"` | | `k1 / k2 <= 5` | `"5.0 - k1 / k2"` | | `2 <= k1 / k2` | `"k1 / k2 - 2.0"` | | `k1 + k2 + k3 <= 1` | `"1.0 - (k1 + k2 + k3)"` | | `B <= 300` at steps 0 to 50 | `"300 - B"` with `at=list(range(51))` | | `A + B <= 100` at position 30 | `"100 - (A + B)"` with `at=[30]` | The expression uses the [expression syntax](https://munqu.github.io/commol/latest/guide/mathematical-expressions/index.md) and passes the same security check. | Kind | Can name | Checked | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | Without `at` | The calibration unknowns only | Once per evaluation, before the simulation | | With `at` | The unknowns and the simulation outputs (compartments, accumulators and the total of a bin over its categories, such as `B` of a stratified model) | At each listed position of the simulation, in every replicate of a stochastic engine | A constraint cannot name a model parameter that is not calibrated, and an expression that names an output without `at` is rejected when the calibration starts. ```python best = calibrate( [CalibrationConstraint(id="peak", expression="300 - B", at=list(range(51)))] ) peak = max(Simulation(model(best["k1"], best["k2"])).run(num_steps=50)["B"]) print(round(peak)) # 300: the best fit whose B never exceeds 300 ``` ## How a violation counts A violated constraint does not add a small cost to the fit: it **replaces** the loss with a penalty much larger than any fit, so the optimizer moves away from it: ```text loss = P × (1 + Σ weight × |violation|) P = max(1000 × n × (largest observed value)², 1e10) ``` where the sum runs over the violated constraints (and, for a constraint with `at`, over its positions) and `n` is the number of observations. With [observation models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md), `P` comes from the cost of each observation at the narrowest noise the bounds allow (`Discrepancy::penalty_base` in `crates/commol-calibration/src/discrepancy.rs`). Parameter constraints are checked first, and the simulation is skipped when one fails. So constraints act almost as hard limits; `weight` only ranks violations against each other. ## Checklist - [ ] Each expression is `>= 0` when the condition holds. - [ ] Expressions that name outputs have `at`. - [ ] Expressions name only calibration unknowns (and outputs). - [ ] The constraints do not contradict each other, such as `k1 - 0.5` and `0.4 - k1`. - [ ] The search box contains points that satisfy every constraint. ## Next steps | To learn | Read | | -------------------- | ----------------------------------------------------------------------------------------- | | Tuning the optimizer | [Optimizers](https://munqu.github.io/commol/latest/guide/calibration-optimizers/index.md) | | Reporting the result | [Reports](https://munqu.github.io/commol/latest/guide/reports/index.md) | | How sure the fit is | [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) | # Uncertainty [Back to Home](https://munqu.github.io/commol/latest/index.md) A calibration gives one set of parameters. This section shows how far you can trust it: which parameters the data identify, how the inputs move the outputs, and whether the intervals you report cover what they claim. This page defines what every method returns and reads: a weighted **parameter sample**, the **predictive** of a sample, and the **scores** that check it against data. Each method has its own page, listed in the table below. ## Quick path ```python from commol import ( Aggregate, CalibrationParameter, CalibrationProblem, Calibrator, ModelBuilder, NelderMeadConfig, NoNoise, ObservedDataPoint, ParameterSample, Predictive, SampleMethod, Simulation, scores, ) def basic_model(k1: 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="k2", value=k2) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * 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, 0.1)).run(num_steps=60) observations = [ ObservedDataPoint(at=step, output="B", value=truth["B"][step]) for step in range(5, 61, 5) ] # Fit the first 40 steps; hold out the rest to check the predictive. fitted = [point for point in observations if point.at <= 40] held_out = [point for point in observations if point.at > 40] problem = CalibrationProblem( observed_data=fitted, parameters=[ CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0), CalibrationParameter(id="k2", parameter_type="parameter", min_bound=0.0, max_bound=1.0), ], loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=2000), seed=42, ) calibrator = Calibrator(Simulation(basic_model(None, None)), problem) # A sample of the parameters. A method returns one; here it is written by hand. sample = ParameterSample.equally_weighted( names=["k1", "k2"], bounds=[(0.0, 1.0), (0.0, 1.0)], values=[[0.38, 0.09], [0.40, 0.10], [0.42, 0.11]], method=SampleMethod(name="by hand"), ) print(sample.interval(0.9)["k1"]) # (0.38, 0.42) # The predictive: every draw run to step 60, and its band of the model. predictive = Predictive(calibrator, sample, positions=list(range(61))) band = predictive.band(confidence_level=0.9) print(band.lower["B"][50] <= truth["B"][50] <= band.upper["B"][50]) # True # Scores of the band of the model on the held-out observations. print(scores.coverage(predictive, held_out, NoNoise(), confidence_level=0.9).mean) print(scores.crps(predictive, held_out, NoNoise()).per_series) ``` ## The three questions | Question | What answers it | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Which parameters fit the data? | The [identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/index.md) methods; a sample of the parameters, or a [profile](https://munqu.github.io/commol/latest/guide/uncertainty/profiles/index.md) of the likelihood or the loss; several starts that end apart with equal losses show that the data do not identify the parameters | | How do the inputs move the outputs? | The predictive of a sample: the run of every draw, and its band; with no data, a [sensitivity analysis](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/index.md), which propagates the inputs and ranks them | | Are the intervals calibrated? | Scores of the predictive against observations the method did not see, and coverage studies | ## A band of the model, or a prediction interval A **band of the model** is the spread of the model's output across the draws of the parameters. It says where the model curve lies, not where the next observation will fall: an observation also carries measurement noise. A **prediction interval** adds that noise to every draw before it reduces them (the `ObservationNoise` port of `commol-inference`). Every reader of observations takes the noise as an argument with no default: `NoNoise()` gives a band of the model, `ResidualNoise(...)` adds residuals of a fit, which gives a prediction interval with no noise model ([Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/#prediction-intervals)), and `DistributionNoise()` draws from the [observation models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md) of the problem. Comparing a band of the model with data underestimates the spread, so its coverage of the data is below its level. A [conformal](https://munqu.github.io/commol/latest/guide/uncertainty/conformal/index.md) interval is a prediction interval with a coverage guarantee, from the scores of observations the fit did not use. ## Which method answers which question | Method | Question | Needs a noise model | Page | | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | Several starts, `Calibrator.run(starts=n)` | Which parameters fit the data: distinct ends with equal losses | No | [Optimizers](https://munqu.github.io/commol/latest/guide/calibration-optimizers/#several-starts) | | `StructuralRank` | Which parameters any data of this design can determine, and which combinations | No | [Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/#structural-rank) | | `Collinearity` | Which parameters can be fitted together | No | [Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/#collinearity) | | `SampleIdentifiability` | Which parameters a sample determines: contraction, correlations, modes | No | [Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/#a-sample) | | `StartsIdentifiability` | Which parameters differ between starts of equal loss | No | [Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/#starts) | | `BayesianBootstrap` | How far the parameters move when the data are reweighted by blocks | No | [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/#bayesian-bootstrap) | | `ResidualBootstrap` | How far the parameters move when the residuals of the fit are resampled by blocks | No | [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/#residual-block-bootstrap) | | `WildBootstrap` | As `ResidualBootstrap`, for noise whose size varies along the series | No | [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/#wild-block-bootstrap) | | `BlockJackknife` | The standard error and bias of each parameter, and which block of data moves it most | No | [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/#block-jackknife) | | `ResidualNoise` | Where the next observations fall: a prediction interval | No | [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/#prediction-intervals) | | `GibbsPosterior` | A posterior of the parameters from the loss and priors, with a scale `η` of the loss | No | [Sequential Monte Carlo](https://munqu.github.io/commol/latest/guide/uncertainty/smc/#gibbs-posterior) | | `ABCSMC` | A posterior of the parameters whose runs come within a tolerance of the data, with priors | No | [Sequential Monte Carlo](https://munqu.github.io/commol/latest/guide/uncertainty/smc/#abc) | | `ABCRejection` | The same, from the closest draws of the priors | No | [Sequential Monte Carlo](https://munqu.github.io/commol/latest/guide/uncertainty/smc/#abc) | | `SplitConformal` | Where new observations fall, with a guaranteed coverage, from held-out observations | No | [Conformal](https://munqu.github.io/commol/latest/guide/uncertainty/conformal/#split-conformal) | | `RollingConformal` | Where the next observations fall at each horizon ahead of the data | No | [Conformal](https://munqu.github.io/commol/latest/guide/uncertainty/conformal/#rolling-origins) | | `JackknifePlus` | Where new observations fall, with a guaranteed coverage and no held-out data | No | [Conformal](https://munqu.github.io/commol/latest/guide/uncertainty/conformal/#jackknife) | | `EnbPI` | Where the next observations of a series with dependent errors fall | No | [Conformal](https://munqu.github.io/commol/latest/guide/uncertainty/conformal/#enbpi) | | `AdaptiveConformal` | Intervals for observations as they arrive, whose share of misses tends to its level | No | [Conformal](https://munqu.github.io/commol/latest/guide/uncertainty/conformal/#adaptive-conformal-inference) | | `observation_models` with `Calibrator.run()` | The maximum-likelihood fit of the parameters and the noise | Yes | [Observation Models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/#maximum-likelihood-against-the-posterior) | | `BayesianPosterior` | The posterior of the parameters from the likelihood and priors, with its evidence | Yes | [Observation Models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/#maximum-likelihood-against-the-posterior) | | `ParametricBootstrap` | How far the parameters move in data sets drawn from the models at the fit | Yes | [Observation Models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/#maximum-likelihood-against-the-posterior) | | `DistributionNoise` | Where the next observations fall: a prediction interval of the models | Yes | [Observation Models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/#prediction-with-noise) | | `Laplace` | A normal around the fit from the curvature of `−log L`, and the band of the outputs by the delta method | Yes | [Local Approximations](https://munqu.github.io/commol/latest/guide/uncertainty/local/#laplace-approximation-and-the-delta-method) | | `Sandwich` | A covariance around the fit that holds when the noise model is wrong or the noise depends on its neighbours | No | [Local Approximations](https://munqu.github.io/commol/latest/guide/uncertainty/local/#sandwich) | | `LaplaceMixture` | A posterior from Laplace approximations at the optima of several starts, corrected by importance sampling, with `k_hat` | With `LikelihoodTarget` | [Local Approximations](https://munqu.github.io/commol/latest/guide/uncertainty/local/#laplace-mixture-and-importance-sampling) | | `Propagation` | How uncertainty in the inputs moves the outputs, with no data | No | [Sensitivity Analysis](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/#propagation) | | `PRCC` | How steadily each output rises or falls with each input, of independent inputs or a sample | No | [Sensitivity Analysis](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/#prcc) | | `Morris` | Which inputs matter, in few runs | No | [Sensitivity Analysis](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/#morris-screening) | | `Sobol` | The share of the variance of an output each input explains, alone and with interactions | No | [Sensitivity Analysis](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/#sobol-indices) | | `PolynomialChaos` | A polynomial surrogate of an output and its Sobol indices, in few runs | No | [Sensitivity Analysis](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/#polynomial-chaos) | | `ProfileLikelihood` | The interval of each parameter and whether the data determine it, from refits with the parameter held | No: `Threshold.chi_square` needs one, `beale` and `bootstrap_calibrated` do not | [Profile Likelihood](https://munqu.github.io/commol/latest/guide/uncertainty/profiles/index.md) | | `PredictionProfile` | The interval of a prediction: the values the data do not reject | No, as `ProfileLikelihood` | [Profile Likelihood](https://munqu.github.io/commol/latest/guide/uncertainty/profiles/#prediction-profiles) | ## The parameter sample A `ParameterSample` holds draws of the parameters of one problem, each with a weight. Every method with draws returns one; the plots, the reports, the predictive and the scores read it, never the result of a method. | Field | Meaning | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `names` | One name per column, in the order of the problem's parameters | | `bounds` | The bounds of each parameter; every value lies inside them | | `values` | One row per draw | | `log_weights` | The log weight of each draw, normalized so the weights sum to 1; `-inf` for a weight of zero | | `groups` | The chain, replicate or wave of each draw, or `None` | | `method` | The name and settings of the method that made it (`SampleMethod`); the settings of a method of `Calibrator.uncertainty` name the seed of the calibrator, `settings["seed"]`, which repeats the sample when set as `problem.seed` | Every statistic is weighted and computed in Rust. With equal weights it is the unweighted statistic. | Member | Returns | | ------------------------------------------------- | -------------------------------------------------------------------------------------------- | | `mean()` | The weighted mean of each parameter | | `covariance()`, `correlation()` | Weighted matrices, in the order of `names`; with equal weights, NumPy's `cov` and `corrcoef` | | `quantiles(levels)`, `interval(confidence_level)` | Weighted quantiles by [the one convention](#the-one-quantile) | | `effective_size` | Kish's effective sample size, `1 / Σ wᵢ²` (a property) | | `draws()` | One `{name: value}` mapping per draw | | `resample(count, seed)` | A sample of equal weights, drawn by weight | | `summary(confidence_level)` | A `ParameterSummary` per parameter, for a report | ```python print(round(sample.effective_size, 6)) # 3.0 print(next(sample.draws())) # {'k1': 0.38, 'k2': 0.09} ``` A sample has no single set of parameters, so `Model.apply_calibration_parameters` rejects it with a `TypeError`; apply one of `sample.draws()`. A sample saves to JSON and loads back equal (`model_dump_json`, `model_validate_json`). ### The one quantile Every interval of commol reads one weighted quantile. It inverts the empirical distribution of the weighted values, and interpolates between the midpoints of their cumulative weights. With equal weights it equals `numpy.percentile(..., method="hazen")`. A draw of weight zero does not count. `weighted_quantiles` computes it for any values, and `member_statistics` reduces ensemble members with it. ```python from commol import weighted_quantiles print(weighted_quantiles([0.0, 1.0], levels=[0.5], weights=[1.0, 3.0])) # [0.75] ``` ## The predictive `Predictive(source, sample, positions)` runs every draw at `positions`, in parallel in Rust, through the forward model of its source: a `Calibrator`, or a `SensitivityAnalysis` of the inputs of the sample. With a stochastic engine every replicate of a draw is a draw of the predictive, with an equal share of its weight. The runs follow one tree of seeds: draw `j` takes the seed `draw_seeds[j]`, derived from the seed of the source, and its replicate `i` takes the seed that replicate `i` of `Simulation.replicates(count, seed=draw_seeds[j])` takes. So every draw shows fresh noise, the predictive repeats from the seed of the source, and the runs of a draw change neither with the number of replicates nor with the number of draws. The positions are sorted and unique, and each one is a position the engine reports at; otherwise `Predictive` raises `ValueError` before any draw runs. A draw whose run leaves the values its model allows raises `InfeasibleStateError` with the index of its parameter set and the message of the run: the compartment, the step and the flows, as `Simulation.run` reports them ([Amounts below zero](https://munqu.github.io/commol/latest/guide/transitions/#amounts-below-zero)). A draw whose initial amounts leave the remainder below zero raises `InfeasibleStateError` with the index of its parameter set and the message a calibration gives for the same values: the remainder and the excess ([Calibrating initial conditions](https://munqu.github.io/commol/latest/guide/calibration-initial-conditions/index.md)). | Member | Returns | | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `band(confidence_level)` | The weighted mean, median and central interval of every output at every position (`PredictiveBand`) | | `series_band(outputs, confidence_level, cumulative=False)` | The band of the sum of some outputs, or of its running total; each draw is reduced first | | `at_observations(observed_data, noise)` | Every draw's value of each observation, read through the observation operator the loss uses (sums, windows and factors), with `noise` drawn from the calibrator's seed: `NoNoise()`, `ResidualNoise(...)` or `DistributionNoise()` (`ObservationDraws`) | | `runs()`, `weights` | The trajectory and weight of every draw | | `quantities(quantities)` | The quantities of interest of every draw, one dict per draw, as `SimulationResults.quantities` reads one run | | `draw_seeds` | The seed of each draw of the sample: `Simulation.replicates(count, seed=draw_seeds[j])` of the model at the values of draw `j` gives its runs | | `report(confidence_level, positions, window, windows, observed_data=(), accumulators=(), prediction_intervals=None)` | The report of a run, with each number reduced across the draws with their weights ([Reports](https://munqu.github.io/commol/latest/guide/reports/#the-report-of-an-ensemble)); `prediction_intervals` adds the intervals of a [conformal method](https://munqu.github.io/commol/latest/guide/uncertainty/conformal/#results-plots-and-reports) | | `plot.series(…)`, `plot.cumulative(…)`, `plot.observations(…)` | The weighted median of the draws of each series and their band, those of its running total, or those of each observed series, read through `at_observations(…, NoNoise())` ([Plotting](https://munqu.github.io/commol/latest/guide/simulations/#a-panel-per-observed-series)) | `predictive.plot.series()` and `predictive.plot.cumulative()` draw the band of the predictive, each draw read through the observation operator of its observations, and `Predictive.report` reduces its draws with their weights. `Model.applied_parameters` takes the sample itself (`sample=`, with a `confidence_level`). ## Scenarios `Scenarios(source, sample, scenarios, positions)` runs every draw of a sample under each scenario: a set of parameter values that override those of the model of the source, any value `Model.update_parameters` takes (a number, a formula, the points of a series). Each scenario holds the `Predictive` of the sample on a copy of the model with its values; the source and its model stay unchanged. `Scenarios` is a mapping from the name of a scenario to its predictive, so `plot_compared(scenarios)` draws them on the same panels ([Plotting](https://munqu.github.io/commol/latest/guide/simulations/#compare-results-on-the-same-panels)). Run `k` of every scenario is the same draw, and every scenario takes the seed of the source (`Calibrator.with_model`, `SensitivityAnalysis.with_model`), so with a stochastic engine run `k` of each scenario runs with the same seed: common random numbers, whose noise cancels in a difference. `compare(name, baseline, quantities, confidence_level)` takes the difference `name − baseline` of each quantity within each run, then reduces the differences across the runs with their weights. So the interval of a difference is the interval of the paired differences, not the difference of two intervals. ```python from commol import Peak, Scenarios, ValueAt # k1 is fitted; k2 is a value of the model that a scenario overrides. problem_k1 = CalibrationProblem( observed_data=fitted, parameters=[ CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0), ], loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=2000), seed=42, ) calibrator_k1 = Calibrator(Simulation(basic_model(None, 0.1)), problem_k1) sample_k1 = ParameterSample.equally_weighted( names=["k1"], bounds=[(0.0, 1.0)], values=[[0.38], [0.40], [0.42]], method=SampleMethod(name="by hand"), ) scenarios = Scenarios( calibrator_k1, sample_k1, {"fitted": {}, "slower": {"k2": 0.05}}, positions=list(range(61)), ) quantities = [ValueAt(series="C", at=60), Peak(series="B", positions=list(range(61)))] for summary in scenarios.compare("slower", "fitted", quantities, confidence_level=0.9): print(summary.label, round(summary.median), round(summary.lower), round(summary.upper)) # C at 60 -72 -73 -69 # peak of B 213 209 218 ``` | Member | Returns | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `scenarios[name]` | The `Predictive` of one scenario: its `runs()`, bands, plots and reports | | `summary(name, quantities, confidence_level)` | The weighted mean, median and central interval of each quantity across the runs of one scenario (`ScenarioSummary`) | | `compare(name, baseline, quantities, confidence_level)` | The same for the difference `name − baseline`, taken run by run | | `weights` | The weight of each run, the same in every scenario | A quantity is one of the quantities of interest ([Sensitivity Analysis](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/#quantities-of-interest)), reduced in Rust by `Predictive.quantities`; a `Trajectory` gives one summary per position. The runs read them at `positions`, so the positions hold `quantity_schedule(quantities)`. For anything else, `scenarios[name].runs()` gives the runs, in the order of `weights`. The point estimate of a fit is a sample of one draw: `Calibrator.point_sample(result)` gives it, with the names and bounds of the problem and `SampleMethod(name="point", settings={"seed": result.seed})`. With one run, a summary gives the value of that run and no interval (`lower` and `upper` are None). ```python fit = calibrator_k1.run() point = Scenarios( calibrator_k1, calibrator_k1.point_sample(fit), {"fitted": {}, "slower": {"k2": 0.05}}, positions=list(range(61)), ) [at_fit] = point.compare("slower", "fitted", [ValueAt(series="C", at=60)], 0.9) print(round(at_fit.median), at_fit.lower) # -72 None ``` | Case | Result | | --------------------------------------------------- | ------------------------------------------------------------------------- | | A scenario sets a parameter of the sample | `ValueError` that names the scenario and the parameter: each draw sets it | | A scenario sets a parameter the model does not have | `ValueError` that names the scenario and the parameter | | A quantity reads a position the runs lack | `ValueError` that lists each such position with its quantity | | A quantity is NaN in a run | `ValueError` that names the quantity | ## Scores The functions of `commol.scores` compare a predictive with observations, and return a `Score`: the value of each observation, the mean of each series and the overall mean. Which observations to score and the observation noise are arguments with no default: score data the method did not see, such as the held-out positions above, and say whether you score a band of the model (`NoNoise()`) or a prediction interval (`ResidualNoise(...)`, `DistributionNoise()`). | Function | Score | Better | | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `coverage(predictive, observations, noise, confidence_level)` | 1 inside the central interval, 0 outside; the mean is the empirical coverage | Close to the level | | `interval_score(predictive, observations, noise, confidence_level)` | `(u − l) + (2/α)(l − y)₊ + (2/α)(y − u)₊`, `α = 1 − c` | Lower | | `weighted_interval_score(predictive, observations, noise, confidence_levels)` | The weighted interval score of Bracher et al. (2021) | Lower | | `crps(predictive, observations, noise)` | \`Σ wᵢ | xᵢ − y | | `pit(predictive, observations, noise)` | `F(y)`, randomized where draws tie with `y`, from the seed of the source (`Calibrator.seed` or `SensitivityAnalysis.seed`): the same draws give the same values | Uniform | | `log_score(predictive, observations, DistributionNoise())` | \`log Σ_d w_d · p(y | μ_d)`, the log density of `y`under the mixture of the observation models over the draws; another noise raises a`TypeError\` | ## The entry point Every method runs through `Calibrator.uncertainty(method)`. A method is a configuration, a subclass of `UncertaintyMethod[R]` whose type parameter `R` is its result; the call returns an `R`, which a static type checker infers from the configuration's type. A configuration with no registered runner raises a `ValueError` that names the registered ones. The identifiability methods run through their own entry point, `Calibrator.identifiability(method)`, with a subclass of `IdentifiabilityMethod[R]`: they answer another question ([Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/index.md)). Both kinds subclass `InferenceMethod[R]` and share one registry; each entry point refuses the other kind with a `TypeError` that names the right one. The sensitivity methods, subclasses of `SensitivityMethod[R]`, run with no data through `SensitivityAnalysis(simulation, inputs).run(method)`, the third entry point of the registry ([Sensitivity Analysis](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/index.md)). ```python from commol import BayesianBootstrap result = calibrator.uncertainty(BayesianBootstrap(replicates=20, block_length=1)) print(type(result).__name__, len(result.sample)) # BootstrapResult 20 ``` ## Comparing results Two results of commol are equal (`==`) when they are of one class and their fields hold the same values, whatever the objects that hold them: a NaN equals a NaN, `0.0` equals `-0.0`, and the order of the keys of a dict does not count. This holds for the result of a run and of replicates (`SimulationResults` and `SimulationReplicates`, which also compare their positions, seed and model), of a calibration, of every method, of a sample, a band, a scenario summary and a score; a plain `dict` or `list` is not a result. So two runs with one seed give equal results, also when they hold a NaN, such as the PRCC of an input that the other inputs explain entirely. A frozen result, every one but the results of runs and replicates, `CalibrationResult` and `CalibrationHistory`, has a hash that agrees: equal results have equal hashes, so they can be keys of a `dict` or members of a `set`. ```python from commol import ParameterSummary again = calibrator.uncertainty(BayesianBootstrap(replicates=20, block_length=1)) print(again == result, hash(again) == hash(result)) # True True first = ParameterSummary( mean=0.5, median=0.5, std=float("nan"), lower=0.0, upper=1.0, min=0.0, max=1.0 ) second = first.model_copy(update={"std": float("nan")}) print(first == second, len({first, second})) # True 1 ``` ## Checklist - [ ] A band of the model is reported as such, never as a prediction interval. - [ ] You score observations the method did not see. - [ ] The effective size of a weighted sample is large enough for the intervals you read. - [ ] You compare scenarios with `Scenarios.compare`, run by run, not as the difference of two intervals. ## Next steps | To learn | Read | | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | Maximum likelihood, the posterior and prediction intervals of observation models | [Observation Models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md) | | Which parameters the data determine | [Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/index.md) | | Bootstraps, the jackknife and prediction intervals with no noise model | [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/index.md) | | Posteriors with no noise model: the Gibbs posterior and ABC | [Sequential Monte Carlo](https://munqu.github.io/commol/latest/guide/uncertainty/smc/index.md) | | The curvature at the fit: Laplace, the sandwich and the Laplace mixture | [Local Approximations](https://munqu.github.io/commol/latest/guide/uncertainty/local/index.md) | | Intervals and verdicts from profiles of the likelihood or the loss | [Profile Likelihood](https://munqu.github.io/commol/latest/guide/uncertainty/profiles/index.md) | | Prediction intervals with a coverage guarantee | [Conformal Prediction Intervals](https://munqu.github.io/commol/latest/guide/uncertainty/conformal/index.md) | | How the inputs move the outputs, with no data | [Sensitivity Analysis](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/index.md) | | Several starts of an optimizer | [Optimizers](https://munqu.github.io/commol/latest/guide/calibration-optimizers/#several-starts) | | Reports of a predictive | [Reports](https://munqu.github.io/commol/latest/guide/reports/index.md) | | Plots of a predictive, and several results on the same panels | [Running Simulations](https://munqu.github.io/commol/latest/guide/simulations/#plotting) | # Observation Models [Back to Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) An observation model says how an observed value scatters around the model: a normal error, a count, a proportion, an outlier. With one model per series, a calibration fits by maximum likelihood instead of a loss, and the same likelihood gives a Bayesian posterior with its evidence, prediction intervals that hold the noise, the log score and the parametric bootstrap. This page is for you when you know how your data were measured and want methods that use it; the methods of [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/index.md), [Sequential Monte Carlo](https://munqu.github.io/commol/latest/guide/uncertainty/smc/index.md) and [Conformal](https://munqu.github.io/commol/latest/guide/uncertainty/conformal/index.md) need no noise model. ## Quick path ```python import numpy as np from commol import ( Aggregate, BayesianPosterior, CalibrationParameter, CalibrationProblem, Calibrator, DistributionNoise, ModelBuilder, NegativeBinomialObservations, NelderMeadConfig, ObservedDataPoint, ParametricBootstrap, Predictive, Priors, Simulation, scores, ) def decay_model(k1: float | None): return ( ModelBuilder(name="Decay") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_parameter(id="k1", value=k1) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A") .build( Aggregate(bin_amounts={"A": 1000, "B": 0}), engine="DifferenceEquations", ) ) # Counts of the units that reach B in each step, scattered more than a # Poisson: a negative binomial with overdispersion 0.1 around the model. truth = Simulation(decay_model(0.05)).run(num_steps=40) rng = np.random.default_rng(3) counts = [] for step in range(1, 41): mean = truth["B"][step] - truth["B"][step - 1] size = 1 / 0.1 counts.append(float(rng.negative_binomial(size, size / (size + mean)))) observations = [ ObservedDataPoint(at=step, output="B", value=value, window=1) for step, value in zip(range(1, 41), counts) ] fitted, held_out = observations[:30], observations[30:] problem = CalibrationProblem( observed_data=fitted, parameters=[ CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=0.5), CalibrationParameter(id="phi", parameter_type="noise", min_bound=0.0, max_bound=2.0), ], observation_models={"B": NegativeBinomialObservations(overdispersion="phi")}, optimization_config=NelderMeadConfig(max_iterations=2000), seed=42, ) calibrator = Calibrator(Simulation(decay_model(None)), problem) # Maximum likelihood: the loss is the negative log-likelihood. fit = calibrator.run() print({name: round(value, 2) for name, value in fit.best_parameters.items()}) # {'k1': 0.05, 'phi': 0.11} # The Bayesian posterior and its log evidence. posterior = calibrator.uncertainty( BayesianPosterior( priors=Priors.uniform_on_bounds(), particles=500, target_cess=0.5, resample_threshold=0.5, stay_probability=0.05, ) ) print([round(bound, 3) for bound in posterior.sample.interval(0.9)["k1"]]) # [0.044, 0.072] print(round(posterior.log_evidence, 1)) # -113.2 # Prediction intervals of the held-out counts, with the noise of the model. predictive = Predictive(calibrator, posterior.sample, positions=list(range(41))) print(scores.coverage(predictive, held_out, DistributionNoise(), confidence_level=0.9).mean) # 1.0 print(round(scores.log_score(predictive, held_out, DistributionNoise()).mean, 2)) # -2.83 ``` ## Choosing a model The mean of every model is the prediction of the observation (its sums, window and factor), except the median of a `LogNormalObservations` with `center="median"`. A model only adds the scatter around it. | Your data | Model | Variance | Notes | | ---------------------------------------------------- | ---------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------- | | Continuous values with errors of one size | `NormalObservations(sd)` | `sd²` | With a known `sd` the fit is the least-squares fit | | Positive values with errors that grow with the value | `LogNormalObservations(sigma, center)` | `(e^{σ²} − 1)·μ²` with `center="mean"` | `center` is required: `"mean"` makes `E[y] = μ`; `"median"` is the proportional error `y = μ·e^ε` | | Continuous values with outliers | `StudentTObservations(df, scale)` | `scale²·df/(df − 2)` when `df > 2` | Heavier tails: an outlier pulls the fit less | | Counts | `PoissonObservations()` | `μ` | | | Counts that scatter more than a Poisson | `NegativeBinomialObservations(overdispersion)` | `μ + φμ²` | `φ = 0` is the Poisson; the dispersion `k` (`size` in R) is `1/φ` | | Successes out of known trials | `BinomialObservations(trials)` | `μ(1 − μ/n)` | The prediction is the expected count `n·p`, not a proportion | A `BinomialObservations` takes its trials from `trials=n`, for every observation of the series, or with no argument from `ObservedDataPoint.trials` of each observation, for trials that change from one observation to the next. A series has one source; both or neither raise a `ValueError` that names the observation. ## Noise parameters A noise argument is a number, or the id of a calibration parameter of type `"noise"`. A noise parameter is fitted, sampled and reported with the others, and never reaches the engine; `Model.apply_calibration_parameters` skips it, as it skips a factor. Its bounds must lie in the domain of the argument: above 0 for `sd`, `sigma`, `scale` and `df`, at least 0 for `overdispersion`. | Rule | Error | | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | | A problem has `loss_function` or `observation_models`, exactly one, with no default | `ValueError` from `CalibrationProblem` | | Every observed series has a model, and every model names an observed series | `ValueError` from `Calibrator` that names the series | | Every noise parameter is an argument of a model | `ValueError` that names the parameter | | Counts are whole numbers, at least 0; a binomial count is at most its trials; a log-normal value is above 0 | `ValueError` that names the observation | | A stochastic engine needs a loss | `ValueError` from `Calibrator` | | `normalize_observations` needs a loss: the models set the scale of each series | `ValueError` from `CalibrationProblem` | `Likelihood::new` in `crates/commol-calibration/src/likelihood/mod.rs` checks the models and values; `CalibrationProblem.validate_loss_or_models` checks the first and the last rules. A stochastic engine is refused because the likelihood of a stochastic model integrates its hidden path, which the observations of one run do not do. Fit a stochastic model with a loss and its replicates ([Stochastic engines](https://munqu.github.io/commol/latest/guide/calibration/#stochastic-engines)). ## The likelihood The log-likelihood is `Σᵢ wᵢ · log p(yᵢ | μᵢ)`, where `μᵢ` is the prediction and `wᵢ` the observation weight. The weight raises the density of the observation to its power: an observation of weight 2 counts twice, as in every loss. The log densities are written in commol (`Noise::log_density` in `crates/commol-calibration/src/likelihood/density.rs`), and `crates/commol-calibration/tests/test_likelihood.rs` checks them against reference values of SciPy. A prediction outside the means a model allows, such as a negative count or a binomial mean above its trials, leaves the point outside the support: the optimizer gets a penalty, and a sampler a density of 0. ## Maximum likelihood against the posterior | Method | Gives | Needs | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `Calibrator.run()` | The maximum-likelihood fit; `final_loss` is `−log L` | Nothing more | | `BayesianPosterior(priors, …)` | The posterior `prior(θ) · L(θ)` and its log evidence, on the core of [Sequential Monte Carlo](https://munqu.github.io/commol/latest/guide/uncertainty/smc/index.md) | A prior for every parameter, the noise parameters too | | `GibbsPosterior(eta=…)` | The fractional posterior `prior(θ) · L(θ)^η`; `η = 1` is the posterior | `η` as a number, `InformationMatchingEta(block_length)` (`η ≈ 1` for correct models) or `BootstrapCoverageEta(...)` | | `ParametricBootstrap(replicates)` | Fits to data sets drawn from the models at the fit | Models that hold: the observations of a replicate are independent given the fit | A posterior answers "what do the data and the priors say about the parameters"; the fit and the parametric bootstrap answer "what would another data set of this design give". With little data or a parameter near a bound, they differ; with much data and flat priors they agree. ```python bootstrap = calibrator.uncertainty(ParametricBootstrap(replicates=40)) low, high = bootstrap.sample.interval(0.9)["k1"] print(low < 0.05 < high) # True ``` `LossDistance()` of ABC needs a loss; `SeriesDistance(...)` works with models. ## Prediction with noise `DistributionNoise()` draws every observation from the model of its series around each draw's prediction, with the draw's noise parameters, so `Predictive.at_observations(observations, DistributionNoise())` gives a prediction interval and every score of `commol.scores` scores one. It needs a problem with `observation_models`, and serves the series they name, at any position. `scores.log_score(predictive, observations, DistributionNoise())` scores the observed values by the densities of these models ([Scores](https://munqu.github.io/commol/latest/guide/uncertainty/#scores)). ## Checklist - [ ] Every observed series has a model that fits how its data were measured. - [ ] Every noise parameter has bounds in its domain, and a prior for a posterior. - [ ] You use `DistributionNoise()` for prediction intervals of these models. - [ ] You score observations the fit did not see. ## Next steps | To learn | Read | | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | The sampler, its settings and its diagnostics | [Sequential Monte Carlo](https://munqu.github.io/commol/latest/guide/uncertainty/smc/index.md) | | Bootstraps with no noise model | [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/index.md) | | Prediction intervals with a coverage guarantee | [Conformal Prediction Intervals](https://munqu.github.io/commol/latest/guide/uncertainty/conformal/index.md) | | The problem, its observations and weights | [Model Calibration](https://munqu.github.io/commol/latest/guide/calibration/index.md) | # Identifiability [Back to Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) 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. ```python 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. ```python 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. ```python 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](https://munqu.github.io/commol/latest/guide/uncertainty/smc/index.md) contracts its priors by how much the data inform each parameter, and a sample drawn from the priors themselves has a contraction near 0. ```python 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](https://munqu.github.io/commol/latest/guide/uncertainty/profiles/#verdicts-and-the-identifiability-report)). `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](https://munqu.github.io/commol/latest/guide/reports/#the-report-of-an-identifiability-analysis)). ```python data = report.report() print(data["parameters"]["k2"]["practical"]["verdict"]) # determined ``` ## 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 `StructuralRank` and `Collinearity` refuse one; `SampleIdentifiability` and `StartsIdentifiability` accept it. ## Checklist - [ ] You checked the structural rank of the design before you fitted. - [ ] Each parameter `in_combination` is 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](https://munqu.github.io/commol/latest/guide/calibration-optimizers/#several-starts) | | Scale factors, which often pair with a rate | [Observations](https://munqu.github.io/commol/latest/guide/calibration-observations/index.md) | | What a sample is | [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/#the-parameter-sample) | | Intervals and verdicts from profiles | [Profile Likelihood](https://munqu.github.io/commol/latest/guide/uncertainty/profiles/index.md) | # Resampling: Bootstraps and the Jackknife [Back to Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) These methods give the uncertainty of a calibration with no noise model and no prior. They fit the model again to reweighted or resampled data, starting from the fit, and read the spread of the new fits. Use them when you can say how the data were collected but not how the observations scatter around the model. Each runs through `Calibrator.uncertainty`, in parallel in Rust (`crates/commol-inference/src/resampling/`). ## Quick path ```python import numpy as np from commol import ( Aggregate, BayesianBootstrap, BlockJackknife, CalibrationParameter, CalibrationProblem, Calibrator, ModelBuilder, NelderMeadConfig, NoNoise, ObservedDataPoint, Predictive, ResidualBootstrap, ResidualNoise, Simulation, WildBootstrap, scores, ) def ab_model(k: float | None): return ( ModelBuilder(name="AB") .add_bin(id="A", name="A") .add_bin(id="B", name="B") .add_parameter(id="k", value=k) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k") .build( Aggregate(bin_amounts={"A": 1000, "B": 0}), engine="DifferenceEquations", ) ) # Data with a multiplicative noise of 5 %: fit steps 1 to 60, hold out the rest. truth = Simulation(ab_model(0.05)).run(num_steps=80) rng = np.random.default_rng(1) observations = [ ObservedDataPoint( at=step, output="B", value=truth["B"][step] * float(np.exp(rng.normal(0.0, 0.05))), ) for step in range(1, 81) ] fitted = [point for point in observations if point.at <= 60] held_out = [point for point in observations if point.at > 60] problem = CalibrationProblem( observed_data=fitted, parameters=[ CalibrationParameter(id="k", parameter_type="parameter", min_bound=0.0, max_bound=0.5) ], loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=500), seed=42, ) calibrator = Calibrator(Simulation(ab_model(None)), problem) result = calibrator.uncertainty(BayesianBootstrap(replicates=100, block_length="auto")) lower, upper = result.sample.interval(0.9)["k"] print(round(lower, 4), round(upper, 4)) # 0.0486 0.0508 print(result.diagnostics.block_length) # 1 ``` `result.fit` is the fit, `result.sample` holds one draw per replicate that fitted, `result.failed` lists the replicates that failed, and `result.diagnostics` gives the block length. ## Which method to use | Method | Changes in each replicate | Assumes | Answers | | ------------------- | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | `BayesianBootstrap` | The weight of each block of observations | The blocks are exchangeable | How far the parameters move when the data are reweighted | | `ResidualBootstrap` | The observed values: fitted values plus blocks of residuals drawn with replacement | The model is right; the residuals of each block are exchangeable across positions | How far the parameters move under new noise like the fit's | | `WildBootstrap` | The observed values: each residual times a random sign or Mammen multiplier, one per block | The model is right; the noise has mean zero at each position | The same, when the size of the noise changes along the series | | `BlockJackknife` | One block left out (weight 0) per fit | The blocks are exchangeable | A standard error and a bias, and which block moves each parameter most | None of them needs a noise model. The Bayesian bootstrap never changes the observed values, so it also works when the model is wrong; the residual and wild bootstraps make new data from the model and its residuals. With [observation models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md), `ParametricBootstrap(replicates)` draws new data from the models at the fit; it draws no blocks, so its `diagnostics.block_length` is `None`. ## Blocks and their length A block holds every observation, of every series, whose position falls in one window of `block_length` consecutive distinct positions (`BlockScheme`, `resampling/blocks.rs`). The dependence of the data across series and along the positions stays inside blocks, so the methods hold for noise that is correlated in time or between series. `block_length` has no default: | Value | Use it when | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `1` | The noise of different positions is independent | | An integer | You know how far the dependence of the noise reaches | | `"auto"` | You do not know: the rule of Politis and White (2004), with the correction of Patton, Politis and White (2009), estimates a length for each series on the residuals of the fit, and the method takes the largest | With `"auto"`, `diagnostics.block_length_estimates` gives the estimate of each series and `diagnostics.block_length` the length used. The estimate depends on how a method draws its blocks: moving blocks get a length about 15 % longer than stationary or non-overlapping ones. ## Bayesian bootstrap `BayesianBootstrap(replicates, block_length, starts=1)` draws, for each replicate, one Dirichlet(1, …, 1) weight per block, scaled to mean 1, and multiplies the weight of every observation of the block by it. With a loss it is the loss-likelihood bootstrap of Lyddon, Holmes and Walker (2019); with `block_length=1`, the Bayesian bootstrap of Rubin (1981). Its variance matches the sandwich variance of least squares (`tests/uncertainty/test_resampling.py::TestBayesianBootstrap`). ## Residual block bootstrap `ResidualBootstrap(replicates, block_length, residual_scale, scheme, starts=1)` takes the residuals of the fit, joins blocks of them drawn with replacement, and fits the new data. | Setting | Values | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `residual_scale` | `"additive"`: `r = y − ŷ`, new data `ŷ + r*`. `"multiplicative"`: `r = log(y / ŷ)`, new data `ŷ · exp(r*)`; it needs positive observed and fitted values, and keeps the new data positive | | `scheme` | `"moving"`: blocks of `block_length` consecutive positions (Künsch 1989). `"stationary"`: blocks of geometric length with mean `block_length`, wrapped around the end (Politis and Romano 1994) | Use the multiplicative scale for counts or amounts that span orders of magnitude, where the noise grows with the value. On the additive scale a new value can fall below zero: the method keeps it, and counts it in `diagnostics.negative_values`. Many of them point to the multiplicative scale. ```python residual = calibrator.uncertainty( ResidualBootstrap( replicates=100, block_length="auto", residual_scale="multiplicative", scheme="stationary", ) ) print(residual.diagnostics.negative_values) # 0 ``` When a series has no observation at a drawn position, it takes the residual of the nearest position where it has one. ## Wild block bootstrap `WildBootstrap(replicates, block_length, multipliers, starts=1)` multiplies the additive residuals of each block by one draw of `multipliers` and fits `ŷ + r · v`. Every residual stays at its own position, so the method holds when the size of the noise changes along the series, where the residual bootstrap mixes small and large residuals. | `multipliers` | Distribution | | -------------- | -------------------------------------------------------------------------------------------------------- | | `"rademacher"` | `±1` with probability 1/2 | | `"mammen"` | Two values with mean 0, variance 1 and third moment 1 (Mammen 1993); keeps the skewness of the residuals | ```python wild = calibrator.uncertainty( WildBootstrap(replicates=100, block_length=5, multipliers="mammen") ) print(len(wild.sample)) # 100 ``` ## Block jackknife `BlockJackknife(block_length, starts=1)` fits once per block, with that block left out, and returns a `JackknifeResult`. With `B` blocks it needs `B` fits, usually far fewer than a bootstrap. | Field | Meaning | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `estimate` | The bias-corrected estimate of each parameter | | `bias` | `(B − 1)(θ̄ − θ̂)`, with `θ̄` the mean of the fits with one block left out | | `standard_error` | `√((B − 1)/B · Σ (θ̂₍₋ᵦ₎ − θ̄)²)` | | `blocks` | The first and last position of each block that fitted | | `pseudo_values` | `B θ̂ − (B − 1) θ̂₍₋ᵦ₎` per block | | `influence` | How far leaving each block out moves each parameter, in standard errors | | `failed` | Each block whose fit failed, `"Block failed: "`, with blocks numbered from 0 in order of position; also logged as a warning | | `not_converged` | Fits kept although their optimizer did not converge | | `diagnostics` | The block length, as for a bootstrap | The pseudo-values are not draws: their percentiles are not an interval, so the result has no `ParameterSample`. A block with a large influence holds data the fit depends on: check it. ```python jackknife = calibrator.uncertainty(BlockJackknife(block_length=10)) print(jackknife.blocks[:2]) # [(1.0, 10.0), (11.0, 20.0)] print(len(jackknife.influence["k"])) # 6 ``` ## The result A bootstrap returns a `BootstrapResult(fit, sample, failed, not_converged, diagnostics)`. The sample has equal weights, and the group of each draw is its replicate. | Field | Meaning | | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `failed` | Each replicate that failed, `"Replicate failed: "`; it is not in the sample, and it is logged as a warning | | `not_converged` | Replicates kept although their optimizer did not converge | | `diagnostics.block_length`, `diagnostics.block_length_estimates` | The length used, and the estimate of each series with `"auto"` | | `diagnostics.negative_values` | New observed values below zero, over every replicate | Every result of a method that fits several times lists the fits that failed in `failed` and counts the fits kept without convergence in `not_converged`, in the same way: a bootstrap, the jackknife, a profile with a bootstrap threshold and the conformal methods that refit. When too few fits succeed, the method raises a `RuntimeError` that says how many of how many fitted, how many it needs, and the first reasons. `result.interval_error(confidence_level)` gives the Monte Carlo error of each bound of the central interval: an order-statistic interval that holds the bound of infinitely many replicates with about 95 % probability. When it is wide against the interval, run more replicates. ```python error = result.interval_error(0.9)["k"] print(error.lower[0] <= lower <= error.lower[1]) # True ``` ## How replicates run Each replicate starts from the fit. With `starts > 1` it also starts from the [other starts](https://munqu.github.io/commol/latest/guide/calibration-optimizers/#several-starts) of the optimizer, and keeps the end of lowest loss, so a second basin of the loss shows up instead of adding optimizer noise to the data's uncertainty. Replicate `b` draws its data and its starts from streams of the problem's seed, one for the data and one for the fits, at index `b`: two runs with one seed give one result. A replicate changes only the observed values or the weights (`CalibrationProblem::with_data`); with `normalize_observations` its normalization comes from its own values, as the fit's does. ## Prediction intervals The draws of a bootstrap give a band of the model. `ResidualNoise` adds moving blocks of residuals of the fit to every draw's prediction of each observation, which gives a prediction interval with no noise model. It serves the series the fit has residuals of, at any position, and rejects an observation of any other series. ```python predictive = Predictive(calibrator, result.sample, positions=list(range(81))) noise = ResidualNoise( fit=result.fit, block_length=result.diagnostics.block_length, residual_scale="multiplicative", ) print(scores.coverage(predictive, held_out, NoNoise(), confidence_level=0.9).mean) # 0.0 print(scores.coverage(predictive, held_out, noise, confidence_level=0.9).mean) # 0.9 ``` The band of the model is narrow and covers none of the held-out observations; the prediction interval covers them at its level. ## Short series A block bootstrap reads the dependence of the noise from few blocks. On a short series its variance is too small by a term of order `1 / block_length`, and its interval covers less than its level. In the coverage study of `tests/uncertainty/test_resampling_coverage.py`, a 90 % interval of a series with AR(1) noise (`φ = 0.5`) covers about 0.8 with 200 positions and about 0.89 with 400. With few positions, prefer `block_length=1` only when the noise is independent, and report that the interval may be narrow. ## Checklist - [ ] You chose `block_length`, or read the estimates of `"auto"`. - [ ] You chose the residual scale for the data: multiplicative for values that span orders of magnitude. - [ ] `failed` is empty, or you know why replicates failed. - [ ] `not_converged` is 0, or you raised `max_iterations`. - [ ] `interval_error` is small against the interval. - [ ] You report a band of the model as a band of the model, and use `ResidualNoise` for a prediction interval. ## Next steps | To learn | Read | | --------------------------------------------------- | ----------------------------------------------------------------------------------------- | | The parameter sample, the predictive and the scores | [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) | | The settings of the optimizer each replicate runs | [Optimizers](https://munqu.github.io/commol/latest/guide/calibration-optimizers/index.md) | # Sequential Monte Carlo: Gibbs Posteriors and ABC [Back to Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) These methods give a posterior of the parameters with no noise model. The **Gibbs posterior** turns the loss of the calibration into a posterior; **ABC** (approximate Bayesian computation) keeps the parameters whose runs come close to the data. Use them when you can state priors but not how the observations scatter around the model. One sequential Monte Carlo (SMC) sampler runs both, in parallel in Rust (`crates/commol-inference/src/smc/`), through `Calibrator.uncertainty`. The same sampler runs `BayesianPosterior`, the posterior of [observation models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md), when you can state how the observations scatter. ## Quick path ```python import numpy as np from commol import ( ABCSMC, Aggregate, CalibrationParameter, CalibrationProblem, Calibrator, GibbsPosterior, InformationMatchingEta, LossDistance, ModelBuilder, NelderMeadConfig, ObservedDataPoint, Priors, Simulation, ) def flow_model(engine: str): """A flow c from A to B each step: absolute, since the rate names A.""" return ( ModelBuilder(name="Flow") .add_bin(id="A", name="A") .add_bin(id="B", name="B") .add_parameter(id="c", value=None) .add_transition(id="t_ab", source=["A"], target=["B"], rate="c * A / A") .build( Aggregate(bin_amounts={"A": 1000, "B": 0}), engine=engine, ) ) # The flow of each of 30 steps, 10 plus a noise of sd 1. rng = np.random.default_rng(1) flows = 10.0 + rng.normal(0.0, 1.0, 30) problem = CalibrationProblem( observed_data=[ ObservedDataPoint(at=step, output="B", value=float(value), window=1) for step, value in enumerate(flows, start=1) ], parameters=[ CalibrationParameter(id="c", parameter_type="parameter", min_bound=0.0, max_bound=30.0) ], loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=500), seed=3, ) calibrator = Calibrator(Simulation(flow_model("DifferenceEquations")), problem) gibbs = calibrator.uncertainty( GibbsPosterior( priors=Priors.uniform_on_bounds(), eta=InformationMatchingEta(block_length=1), particles=500, target_cess=0.5, resample_threshold=0.5, stay_probability=0.05, ) ) print(round(gibbs.eta, 2)) # 0.71: (n − 1)/(2 Σ r²), about 1/(2σ̂²) lower, upper = gibbs.sample.interval(0.9)["c"] print(lower < float(np.mean(flows)) < upper) # True # ABC on the same data, from the chain binomial: each run draws its own flows. stochastic = problem.model_copy(update={"replicates": 1, "replicate_summary": "mean"}) abc = Calibrator(Simulation(flow_model("StochasticSteps")), stochastic).uncertainty( ABCSMC( priors=Priors.uniform_on_bounds(), distance=LossDistance(), particles=300, target_cess=0.5, min_acceptance=0.05, stay_probability=0.05, ) ) print(abc.levels[0] > abc.levels[-1]) # True: the tolerance falls print(abc.diagnostics.notes[0][:44]) # Each distance reads one fresh replicate of t ``` ## Priors Every method of this page takes its `priors` with no default: what you know about each parameter before the data is a modelling choice. Give a mapping of parameter to distribution, or `Priors.uniform_on_bounds()`. A parameter without a prior, or a prior of a parameter that is not calibrated, raises a `ValueError` that names it. Each distribution is truncated to the bounds of its parameter: | Distribution | Density | Bounds it needs | | -------------------------- | ------------------------------------------------------- | --------------------- | | `Uniform()` | Uniform on the bounds | Finite | | `LogUniform()` | `∝ 1/x` on the bounds: `log x` is uniform | Finite, lower above 0 | | `Beta(a=…, b=…)` | Beta, scaled to the bounds | Finite | | `Normal(mean=…, sd=…)` | Normal, truncated | Any | | `LogNormal(mu=…, sigma=…)` | `log x` normal, truncated to `(0, ∞)` too | Upper above 0 | | `Gamma(shape=…, rate=…)` | Gamma of mean `shape / rate`, truncated to `(0, ∞)` too | Upper above 0 | A prior whose bounds do not fit raises a `ValueError` that names the parameter, its bounds and the distributions that fit them: a parameter in `[0, inf]` takes `Normal`, `LogNormal` or `Gamma`, not `Uniform`. Each distribution has `log_density(x, bounds)`, `cdf(x, bounds)`, `quantile(p, bounds)` and `sample(count, bounds, seed)`. A draw inverts the distribution function of a number of the commol generator, so a seed gives the same draws on every platform. ```python from commol import Gamma, Normal bounds = (0.0, 30.0) print(round(Normal(mean=10.0, sd=2.0).cdf(10.0, bounds), 3)) # 0.5 draws = Gamma(shape=4.0, rate=0.4).sample(3, bounds, seed=1) print(all(0.0 <= draw <= 30.0 for draw in draws)) # True priors = Priors(distributions={"c": Normal(mean=10.0, sd=2.0)}) ``` ## How the sampler works The sampler draws `particles` particles from the priors, runs the model at each, and keeps one value per particle: the loss for a Gibbs posterior, the distance for ABC. Each step then: 1. **Chooses the next level.** The level is a temperature from 0 (the priors) to 1 (the posterior), or an ABC tolerance that falls. The next one is the farthest whose conditional effective sample size (Zhou, Johansen and Aston 2016) is `target_cess` of the particles, by bisection. 1. **Reweights** each particle from the value it keeps. No model runs. 1. **Resamples** (systematic) when the effective size falls below `resample_threshold` of the particles. 1. **Moves** every particle by random-walk Metropolis, in parallel, each from its own stream of the generator. The walk proposes on an unbounded scale: the logit of the position between two finite bounds, `log(θ − a)` with a lower bound `a` only, `log(b − θ)` with an upper bound only, the parameter itself with none; so no proposal leaves the bounds. Its covariance is that of the particles on that scale, times `2.38² / d`. If the first move accepts a share `a` of its proposals, the step moves every particle `⌈log c / log(1 − a)⌉` times (Drovandi and Pettitt 2011), with `c` the `stay_probability`: each particle moves at least once with probability `1 − c`. | Setting | Meaning | Larger values | | -------------------- | -------------------------------------------------------------------------------------- | --------------------------------- | | `particles` | The size of the sample | Less Monte Carlo error, more runs | | `target_cess` | The share of the particles each step keeps | More, smaller steps | | `resample_threshold` | Resample below this share of effective size (`GibbsPosterior` and `BayesianPosterior`) | More resampling | | `stay_probability` | The chance that a particle does not move in a step | Fewer moves per step | None of them has a default: each sets the cost and the accuracy of the run. ## Gibbs posterior `GibbsPosterior` samples `π(θ) ∝ prior(θ) · exp(−η · loss(θ))`, where `loss` is the problem's loss with its weights and normalization. It returns a `GibbsResult`: the sample at temperature 1, the temperatures, the log of `∫ prior(θ) exp(−η loss(θ)) dθ`, the diagnostics, the `η` it ran with, the fit a calibration of `η` read (`fit`), the coverage of a bootstrap calibration (`coverage`), and each replicate of a bootstrap calibration whose sampler failed, `"Replicate failed: "`, also logged as a warning (`failed`). ### What `η` means `η` weighs the data against the priors. The loss has no scale of its own, so `η` has no default: with `η` too large the posterior is too narrow, with `η` too small too wide. For the sum of squares of a model with Gaussian noise of variance `σ²`, `η = 1/(2σ²)` makes the Gibbs posterior the Bayesian posterior. | `eta` | How it is chosen | Needs | Cost | | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | --------------------------------------------- | | A number above 0 | By you | Nothing | One sampler | | `InformationMatchingEta(block_length, starts=1)` | `η = tr(J I⁻¹ J) / tr(J)` at the fit: the Gauss–Newton curvature `J` of the objective against the variance `I` of the scores of its terms, summed over blocks of `block_length` positions (Holmes and Walker 2017) | `loss_function="sse"` or observation models, a deterministic engine, more blocks than parameters | A fit, finite differences and one sampler | | `BootstrapCoverageEta(replicates, block_length, confidence_level, starts=1)` | The posteriors of Bayesian-bootstrap data sets hold the fit at `confidence_level` (Syring and Martin 2019) | Any loss and engine | A fit and one sampler per replicate, then one | Both calibrations read the fit `Calibrator.run(starts)`, the best of [several starts](https://munqu.github.io/commol/latest/guide/calibration-optimizers/#several-starts). Information matching is cheap and local; it needs a sum of smooth terms, so `"rmse"` (a square root of a sum), `"mae"` (no curvature) and a stochastic engine raise a `ValueError` that points to the other choices. `J` and `I` are the curvature and the meat of the [sandwich](https://munqu.github.io/commol/latest/guide/uncertainty/local/#sandwich): `I` sums the scores over blocks, so it holds when the noise depends on its neighbours inside a block, and `block_length` has no default: `block_length=1` gives the independent version, every observation its own block. Bootstrap coverage runs one tempered sampler per replicate to a large `η`, reads the posterior of every smaller `η` by reweighting, so its search runs no model, and finds for each replicate and parameter the `η` where the interval stops holding the fit. `η` is the one quantile of commol of the log thresholds at `1 − c` (with equal weights, as [`weighted_quantiles`](https://munqu.github.io/commol/latest/guide/uncertainty/#the-one-quantile) reads them), which interpolates between two of them; it needs at least `1/(2(1 − c))` replicates times parameters, so that the level is not below the smallest threshold. With a stochastic engine the loss reads the problem's `replicates` with common random numbers, as the optimizers do, so it is a function of the parameters. With [observation models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md) the loss is the negative log-likelihood, so the Gibbs posterior is the fractional posterior `prior(θ) · L(θ)^η`, and `η = 1` is the posterior of `BayesianPosterior`. With a correct observation model `I ≈ J`, so information matching gives `η ≈ 1`; an `η` below 1 says the data are noisier, or more dependent, than the models say. ## ABC `ABCSMC` keeps a particle when the distance between its run and the data is at most a tolerance `ε`, and lowers `ε` step by step (Del Moral, Doucet and Jasra 2012). With weights of 0 or 1, the share of the particles that a tolerance keeps is its conditional effective sample size, so each next tolerance is the weighted quantile of the current distances at `target_cess`: the smallest distance whose share of the weight at or below it reaches it. The run stops when the first move of a step accepts less than `min_acceptance`, and keeps the sample of the tolerance before; it also stops when no lower tolerance keeps `target_cess` of the particles, as with whole numbers that tie. `diagnostics.stop_reason` says which. | `distance` | Value | | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `LossDistance()` | The problem's `loss_function`, with its weights and `normalize_observations`; a problem with observation models has none, so it raises a `ValueError` | | `SeriesDistance(metric="rmse")` or `"mae"` | `metric` of each series on its residuals divided by the root mean square of its observed values, then the largest over the series | `SeriesDistance` compares series in different units on one scale: scaling a series and its prediction by a constant leaves its distance unchanged, so the series of large values does not decide alone. With a stochastic engine each distance runs one fresh replicate, seeded from its particle's stream, and the particle keeps that distance until a move replaces it: that is what makes ABC sample its target. The problem's `replicates` and `replicate_summary` do not apply, and `diagnostics.notes` says so. The problem still needs them, since every calibration of a stochastic engine does. `ABCRejection(priors, distance, draws, quantile)` is one step of the same code with no move: it draws from the priors and keeps the closest share `quantile` of the draws. ```python from commol import ABCRejection rejection = calibrator.uncertainty( ABCRejection( priors=Priors.uniform_on_bounds(), distance=LossDistance(), draws=400, quantile=0.1 ) ) kept = [weight > float("-inf") for weight in rejection.sample.log_weights] print(sum(kept)) # 40 ``` ## Reading the diagnostics | Field | One entry per | What to look for | | ------------------------------ | ------------- | ------------------------------------------------------------------------------ | | `levels` | Step | A Gibbs posterior ends at 1; an ABC run lists its falling tolerances | | `diagnostics.effective_sizes` | Step | Kish's effective size after reweighting; near `target_cess` of the particles | | `diagnostics.resampled` | Step | Whether the step resampled | | `diagnostics.acceptance_rates` | Step | The share of the first move's proposals accepted; a small one means many moves | | `diagnostics.move_counts` | Step | How many times each particle moved; it grows as the acceptance falls | | `diagnostics.evaluations` | Run | The number of model runs | | `diagnostics.stop_reason` | Run | Why the run stopped before its last level, or None | | `log_evidence` | Run | The log of the product of the mean incremental weights | | `kept` | Particle | The loss or distance each particle keeps | ```python print(gibbs.levels[-1]) # 1.0 print(len(gibbs.diagnostics.move_counts) == len(gibbs.levels)) # True ``` A step whose acceptance rate is small moves each particle many times, and the run costs more; more particles or a larger `target_cess` make the steps smaller. A sample whose effective size is small gives intervals with a large Monte Carlo error. ## Checklist - [ ] Each calibrated parameter has a prior you can defend, on bounds that fit it. - [ ] `η` is a number you can justify, or one of its calibrations. - [ ] A Gibbs posterior ends at temperature 1; an ABC run says why it stopped. - [ ] With a stochastic engine, ABC reads one replicate per particle, not the problem's summary. ## Next steps | To learn | Read | | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | What every method returns and reads | [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) | | The posterior of observation models | [Observation Models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md) | | Uncertainty with no prior: bootstraps and the jackknife | [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/index.md) | | How far a sample contracts each prior | [Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/#a-sample) | | The stochastic engines and their replicates | [Stochastic engines](https://munqu.github.io/commol/latest/guide/stochastic/index.md) | # Local Approximations: Laplace, Sandwich and Importance Sampling [Back to Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) These methods give fast uncertainty around a fit from the curvature of the objective there, with a few runs per parameter instead of one fit per replicate. The **Laplace approximation** is a normal centred on the fit; the **delta method** gives the band of the outputs from it with no run per draw; the **sandwich** holds when the noise model is wrong or the noise is dependent, and needs no noise model; the **Laplace mixture** puts a Laplace approximation at each optimum of a multi-start fit and corrects it towards the posterior by importance sampling, with a check of when that fails. They run in Rust (`crates/commol-inference/src/local/`) through `Calibrator.uncertainty`. ## Quick path ```python import numpy as np from commol import ( Aggregate, CalibrationParameter, CalibrationProblem, Calibrator, Laplace, LaplaceMixture, LikelihoodTarget, ModelBuilder, NelderMeadConfig, NormalObservations, ObservedDataPoint, Priors, Sandwich, Simulation, ) def flow_model(rate: str): """A flow from A to B each step, of the rate `rate`.""" return ( ModelBuilder(name="Flow") .add_bin(id="A", name="A") .add_bin(id="B", name="B") .add_parameter(id="c", value=None) .add_transition(id="t_ab", source=["A"], target=["B"], rate=rate) .build( Aggregate(bin_amounts={"A": 1000, "B": 0}), engine="DifferenceEquations", ) ) # The flow of each of 30 steps, 10 plus a normal noise of sd 1. rng = np.random.default_rng(1) flows = 10.0 + rng.normal(0.0, 1.0, 30) problem = CalibrationProblem( observed_data=[ ObservedDataPoint(at=step, output="B", value=float(value), window=1) for step, value in enumerate(flows, start=1) ], parameters=[ CalibrationParameter(id="c", parameter_type="parameter", min_bound=0.0, max_bound=30.0) ], observation_models={"B": NormalObservations(sd=1.0)}, optimization_config=NelderMeadConfig(max_iterations=500), seed=3, ) calibrator = Calibrator(Simulation(flow_model("c * A / A")), problem) laplace = calibrator.uncertainty(Laplace(draws=2000)) lower, upper = laplace.sample.interval(0.9)["c"] print(round(lower, 2), round(upper, 2)) # 9.64 10.25 band = calibrator.delta_band(laplace, [10, 20, 30], 0.9) print([round(value, 1) for value in band.center["B"]]) # [99.3, 198.7, 298.0] sandwich = calibrator.uncertainty(Sandwich(block_length=1, draws=2000)) print(sandwich.blocks) # 30 # The noise of these data is smaller than sd = 1: the sandwich reads it. print(round(sandwich.covariance[0][0] / laplace.covariance[0][0], 2)) # 0.71 ``` ## The curvature and the unbounded scale Every method of this page reads the curvature `H` of the objective at the fit: `−log L` of the [observation models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md), or the loss. The setting `curvature` of each method chooses it: | `curvature` | What it is | Runs | Use it when | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------- | --------------------------------------------------------------------------- | | `"gauss_newton"` (default) | The expected curvature: `Jᵀ W J`, with `J` the derivatives of the predictions and `W` the Fisher information of each observation, plus the information of each noise parameter | `4p + 1` (finite differences) | The model fits and the noise model is right or close | | `"hessian"` | The observed curvature: central differences of the objective | `4p² + 1` | The residuals are large, the mean is wrong, or the fit sits on a constraint | The Fisher information of each observation model (`Noise::information` in `crates/commol-calibration/src/likelihood/density.rs`): | Model | Mean `μ` | Noise arguments | | ------------------------------ | ---------------------------- | --------------------------------------------------------------------- | | `NormalObservations` | `1/σ²` | `sd`: `2/σ²` | | `LogNormalObservations` | `1/(μ²σ²)` | `sigma`: `2/σ²`; with `center="mean"` also `1` and `−1/(μσ)` with `μ` | | `StudentTObservations` | `(ν+1)/((ν+3)s²)` | `df` and `scale`, and their cross term | | `PoissonObservations` | `1/μ` | | | `NegativeBinomialObservations` | `1/(μ + φμ²)` | `overdispersion`: the variance of its score, summed over the counts | | `BinomialObservations` | `1/(n p (1 − p))`, `p = μ/n` | | The Hessian steps are `δ^{1/4} · dθ/du` for a parameter with a bound and `δ^{1/4} · max(|θ|, 1)` for one without, where `δ` is the accuracy of the engine (machine precision, or the `rtol` of the ODE engine): the step that balances the truncation error of the differences with the error of the runs, and keeps every run inside the bounds. Each entry of either curvature has an estimated error. An entry whose error is above 1 % of `√(H_jj · H_kk)` is listed in `notes` and logged as a warning. The error is read as a share of a correlation: an off-diagonal entry near 0 is compared with the diagonal of its row and column, not with its own size. A note changes no result. Every distribution lives on the **unbounded scale** of the parameters, the scale on which the [sampler](https://munqu.github.io/commol/latest/guide/uncertainty/smc/#how-the-sampler-works) of sequential Monte Carlo proposes its moves. The curvature there is `H_u = D · H · D` with `D = dθ/du` at the fit, and `covariance` is on that scale. Every draw maps back inside the bounds, so a fit near a bound gives a skewed sample, not draws outside it. A fit **on** a bound of its parameter (within `1e-8` of the width of its bounds, or of `max(1, |bound|)` when the parameter has one finite bound) raises a `ValueError`: its variance on the unbounded scale is infinite there, and a [profile likelihood](https://munqu.github.io/commol/latest/guide/uncertainty/profiles/index.md) describes it. ## Laplace approximation and the delta method `Laplace(draws, curvature="gauss_newton", starts=1)` fits the problem (`Calibrator.run(starts)`) and returns a `LaplaceResult`: the fit, the covariance `Σ = H⁻¹` of the normal on the unbounded scale, the draws mapped back (`sample`, equal weights) and the notes. It needs `observation_models`. A loss has no scale of the noise, and its curvature is no inverse variance, and `Laplace` with a loss raises a `ValueError`. To get a Laplace approximation, give a model with a noise parameter, such as `NormalObservations(sd="sd")` with `sd` calibrated, or use `Sandwich`, which needs none. Every local approximation sits at the best of its `starts`, as [several starts](https://munqu.github.io/commol/latest/guide/calibration-optimizers/#several-starts) of `Calibrator.run`. `Sandwich` takes `starts` the same way. With a noise parameter the fit estimates the noise by maximum likelihood: for `NormalObservations(sd="sd")`, `σ̂² = SSE/n`, not `SSE/(n − p)`. The Laplace intervals are then narrower than the classical ones by `√(n/(n − p))`, which is negligible when `n ≫ p`. `calibrator.delta_band(result, positions, confidence_level)` gives the band of every output by the delta method: the prediction at the fit plus and minus the quantile of the normal times `√(J Σ Jᵀ)`, with `J` the derivatives of the output on the unbounded scale. It runs the model a few times per parameter, not once per draw, and for a model linear in its parameters it equals the band of the draws (`Predictive(calibrator, result.sample, positions).band(confidence_level)`). It reads only the fields of the result, so a result read back from JSON works. ```python widths = [round(high - low, 2) for low, high in zip(band.lower["B"], band.upper["B"])] print(widths) # [6.01, 12.01, 18.02] ``` ## Sandwich `Sandwich(block_length, draws, curvature="gauss_newton", starts=1)` gives the covariance `H⁻¹ B H⁻¹`, where `B` sums the outer products of the **scores** of blocks of observations: `B = G/(G − 1) · Σ_b s_b s_bᵀ`, with `s_b` the sum of the gradients of the terms of block `b` (the clustered estimator CR1, Cameron and Miller 2015). The blocks hold every series in a window of `block_length` positions, as the [bootstraps](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/#blocks-and-their-length) do; `"auto"` chooses the length of Politis and White (2004) from the residuals of the fit. It returns a `SandwichResult`: a `LaplaceResult` with `blocks` (`G`), `block_length` and the estimates of `"auto"`. What the sandwich protects against depends on `H`: | `curvature` | It holds when | | ---------------- | ------------------------------------------------------------------------------------------------------------------- | | `"gauss_newton"` | The noise model is wrong (its size changes, it is not normal, it is dependent inside a block) but the mean is right | | `"hessian"` | Also when the mean is wrong: the fit estimates the parameters that minimize the expected objective | It needs no noise model: with a loss, `H` and the scores are those of the loss (the sum of squares and the root mean squared error share them, since rescaling the objective does not change the sandwich). - **A Student t with `G − 1` degrees of freedom.** With few blocks, `B` is a noisy estimate; the t widens the intervals by the right amount, instead of a warning at an arbitrary number of blocks. Intervals of the draws and `Calibrator.delta_band` both read it. - **`G ≤ p` is refused.** `B` sums `G` outer products whose sum has rank at most `G − 1` after the fit, so with no more blocks than parameters some directions would get zero variance. The `ValueError` suggests shorter blocks. - **`loss_function="mae"` is refused.** The absolute error has no curvature: its sandwich would need the density of the residuals at 0, with a bandwidth that the library would choose for you. Use a [bootstrap](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/index.md). ## Laplace mixture and importance sampling A multi-start fit can end at several optima. `LaplaceMixture(draws, target, curvature="gauss_newton", starts=1)` runs `Calibrator.run(starts)`, keeps the distinct ends (an end closer than one Mahalanobis unit to a kept one merges into it), puts a Laplace approximation at each, weights each by its Laplace evidence, draws from the mixture, and weights every draw by the target over the mixture on the unbounded scale. The largest weights are smoothed by Pareto-smoothed importance sampling (PSIS, Vehtari et al. 2024). | `target` | Density | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `LikelihoodTarget(priors)` | `prior(θ) · L(θ)`: the posterior of the observation models | | `GibbsTarget(priors, eta)` | `prior(θ) · exp(−η · loss(θ))`: the [Gibbs posterior](https://munqu.github.io/commol/latest/guide/uncertainty/smc/#gibbs-posterior) of the loss | ```python # A flow c² has the two optima ±√ȳ. square = problem.model_copy( update={ "parameters": [ CalibrationParameter( id="c", parameter_type="parameter", min_bound=-5.0, max_bound=5.0, initial_guess=1.0, ) ], "observed_data": [ ObservedDataPoint(at=step, output="B", value=float(value), window=1) for step, value in enumerate(4.0 + rng.normal(0.0, 1.0, 10), start=1) ], } ) mixture = Calibrator(Simulation(flow_model("c * c * A / A")), square).uncertainty( LaplaceMixture( starts=8, draws=2000, target=LikelihoodTarget(priors=Priors.uniform_on_bounds()) ) ) print([round(component.center["c"], 2) for component in mixture.components]) # [2.05, -2.05] print(round(mixture.k_hat, 2), mixture.reliable) # 0.24 True ``` It returns an `ImportanceResult`: the fit, the weighted `sample`, the `components` (centre, weight, covariance, log evidence), `k_hat`, the `effective_size` and `reliable`. ### Reading `k_hat` `k_hat` is the estimated shape of the tail of the importance weights: how far the mixture misses the target. | `k_hat` | Meaning | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Up to 0.5 | The weights have a finite variance: the estimates converge at the usual rate | | 0.5 to 0.7 | They converge, more slowly: use more draws | | Above 0.7 | The mixture misses mass of the target, as for a curved (banana) posterior: `reliable` is false, `notes` and a warning say so, and the sample should not be used | A start that ends on a bound gives no component, and `notes` says so. When its objective is at least as good as that of the worst kept component, the mixture leaves out an optimum the data support, whose mass `k_hat` cannot see: `reliable` is false too. An end on a bound that fits worse than every component only adds the note. An unreliable sample calls for a method that samples the posterior itself: `BayesianPosterior` or `GibbsPosterior` ([Sequential Monte Carlo](https://munqu.github.io/commol/latest/guide/uncertainty/smc/index.md)). ## When a local approximation is good enough | Situation | Good enough? | | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A model near linear in its parameters around the fit, a correct noise model | Laplace, with `Calibrator.delta_band` for the outputs | | A wrong or unknown noise model, dependent residuals, a right mean | Sandwich with `"gauss_newton"` and blocks longer than the dependence | | A mean that may be wrong, large residuals | Sandwich with `"hessian"` | | Several optima of equal or similar loss | Laplace mixture; check `k_hat` | | A curved posterior, a parameter whose data barely bound it, a fit on a bound | No: sample the posterior ([Sequential Monte Carlo](https://munqu.github.io/commol/latest/guide/uncertainty/smc/index.md)), or a bootstrap ([Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/index.md)) | ## Checklist - [ ] The fit is a minimum inside the bounds; no `ValueError` names a parameter on its bound. - [ ] `notes` is empty, or you know why an entry of the curvature is uncertain. - [ ] A Laplace approximation has a noise model you trust; otherwise use the sandwich. - [ ] A sandwich has more blocks than parameters, of a length that holds the dependence. - [ ] A Laplace mixture has `k_hat` at most 0.7. ## Next steps | To learn | Read | | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | What every method returns and reads | [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) | | The observation models and their likelihood | [Observation Models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md) | | Posteriors that do not rely on a local shape | [Sequential Monte Carlo](https://munqu.github.io/commol/latest/guide/uncertainty/smc/index.md) | | Uncertainty by fitting again: bootstraps and the jackknife | [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/index.md) | | Which parameters the data determine | [Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/index.md) | # Profile Likelihood [Back to Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) A profile holds one parameter, or one prediction, at values that step away from the fit, and fits the other parameters again at each value. How fast the fit gets worse tells how well the data determine that quantity: the profile gives a confidence interval of each parameter and each prediction, and a verdict on its identifiability (Raue et al. 2009; Kreutz et al. 2012). Use it when the model is not linear in its parameters, where the intervals of a local approximation fail. Each profile refits with the problem's own optimizer, in parallel in Rust (`crates/commol-inference/src/profiles/`). ## Quick path ```python import numpy as np from commol import ( Aggregate, CalibrationParameter, CalibrationProblem, Calibrator, ModelBuilder, NelderMeadConfig, NormalObservations, ObservedDataPoint, PredictionProfile, PredictionSpec, ProfileLikelihood, Simulation, Threshold, ) def basic_model(k1: 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="k2", value=k2) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * 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", ) ) # B every 4 steps up to step 40, with normal noise of sd 10. truth = Simulation(basic_model(0.4, 0.1)).run(num_steps=60) rng = np.random.default_rng(3) observations = [ ObservedDataPoint( at=step, output="B", value=max(truth["B"][step] + rng.normal(0.0, 10.0), 0.0), ) for step in range(4, 41, 4) ] parameters = [ CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0), CalibrationParameter(id="k2", parameter_type="parameter", min_bound=0.0, max_bound=1.0), ] problem = CalibrationProblem( observed_data=observations, parameters=parameters, observation_models={"B": NormalObservations(sd=10.0)}, optimization_config=NelderMeadConfig(max_iterations=2000, sd_tolerance=1e-10), seed=42, ) calibrator = Calibrator(Simulation(basic_model(None, None)), problem) # The profile of each parameter, and its 95 % interval. result = calibrator.uncertainty( ProfileLikelihood(parameters=["k1", "k2"], threshold=Threshold.chi_square(0.95)) ) for name in ("k1", "k2"): interval = result.intervals[name] print(name, round(interval.lower, 4), round(interval.upper, 4), result.verdicts[name]) # k1 0.3929 0.4055 identifiable # k2 0.0984 0.104 identifiable # The profile of B at step 20, inside the data, and at step 50, after them. bands = calibrator.uncertainty( PredictionProfile( specs=[PredictionSpec(at=step, output="B", label=f"B at {step}") for step in (20, 50)], threshold=Threshold.chi_square(0.95), ) ) for label, interval in bands.intervals.items(): print(label, round(interval.lower, 1), round(interval.upper, 1)) # B at 20 407.7 430.1 # B at 50 37.1 44.0 ``` ## How to read a profile The **statistic** of a point is the objective of its refit less the objective of the fit: `−log L` with observation models, the loss otherwise. It is 0 at the fit and rises as the quantity moves away from it. The **interval** of the quantity is where the statistic stays below the **threshold**; each interval is pointwise, for one quantity at a time. | Field of `ProfileResult` | Holds | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `profiles[name]` | A `Profile`: the `values`, the `statistics`, every parameter of each refit, the `threshold`, the `ends` of each side and the `evaluations` of the objective | | `intervals[name]` | A `ProfileInterval`: `lower` and `upper`, `None` on a side that does not cross the threshold | | `verdicts[name]` | What the shape says ([Verdicts](#verdicts-and-the-identifiability-report)) | | `fit` | The fit every profile starts from | | `tolerance` | The numerical tolerance of the statistic, `max(τ, ν)` ([Verdicts](#verdicts-and-the-identifiability-report)) | | `structure` | What resetting the other parameters says of each flat profile of a parameter (`ProfileStructure`) | | `notes` | A threshold below the tolerance, or a refit below the fit | | `failed` | With `Threshold.bootstrap_calibrated`, each replicate that failed, `"Replicate failed: "`, also logged as a warning; empty with any other threshold | | `not_converged` | With `Threshold.bootstrap_calibrated`, the replicates kept although their optimizer did not converge | | Shape | Means | | ------------------------------------------------- | ------------------------------------------------------------------------------ | | A valley that crosses the threshold on both sides | The data determine the quantity; the interval is closed | | A valley open on one side, up to a bound | The data bound the quantity on one side only | | Flat over the whole range | The data cannot tell the values apart: another parameter makes up for this one | A refit below the fit (a negative statistic) means that the fit is not the optimum; `notes` says so, and a fit with more starts (`starts=n`) fixes it. ```python profile = result.profiles["k1"] print(profile.ends, round(profile.threshold, 4)) # ('crossed', 'crossed') 1.9207 ``` ## Thresholds The threshold is a required argument: it states what you assume about the noise. | Threshold | Statistic | Assumes | | ------------------------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `Threshold.chi_square(confidence_level)` | `−log L` of observation models | The models hold, and `2 × statistic` is near `χ²₁` (Wilks); it gives `½ χ²₁(confidence_level)`. It needs observation models | | `Threshold.beale(confidence_level)` | A sum of squares, the loss `"sse"` | Independent Gaussian errors of one variance: `S(θ̂) · F(1, n − p; confidence_level) / (n − p)`, exact for a model linear in its parameters | | `Threshold.bootstrap_calibrated(bootstrap, confidence_level)` | Any loss | That the bootstrap mimics how the data vary | The bootstrap threshold calibrates the statistic itself (prepivoting, Beran 1988). Each replicate of `bootstrap` is the replicate `Calibrator.uncertainty(bootstrap)` makes from the problem's seed. Each replicate has two fits: a free one (`θ̂_b`), and one that holds the quantity at the value of the fit of the original data. The rise of the replicate is `PL_b(θ̂ⱼ) − L_b(θ̂_b)`. The threshold of the quantity is the quantile `confidence_level` of the rises, so each parameter gets its own. It costs one fit and one refit per quantity for each replicate. Any bootstrap of [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/index.md) works, and the [parametric bootstrap](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/#maximum-likelihood-against-the-posterior) with observation models. ```python from commol import ResidualBootstrap loss_problem = CalibrationProblem( observed_data=observations, parameters=parameters, loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=2000, sd_tolerance=1e-10), seed=42, ) loss_calibrator = Calibrator(Simulation(basic_model(None, None)), loss_problem) bootstrap = ResidualBootstrap( replicates=100, block_length=1, residual_scale="additive", scheme="moving" ) for threshold in ( Threshold.beale(0.95), Threshold.bootstrap_calibrated(bootstrap, 0.95), ): profiled = loss_calibrator.uncertainty( ProfileLikelihood(parameters=["k1"], threshold=threshold) ) interval = profiled.intervals["k1"] print(threshold.kind, round(interval.lower, 4), round(interval.upper, 4)) # beale 0.3858 0.4129 # bootstrap_calibrated 0.3886 0.41 ``` `Threshold.chi_square` on a problem with a loss raises a `ValueError` that names the other two; `Threshold.beale` on another loss, or on observation models, raises one too. ## How a profile is traced | Step | Rule | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Start | The fit of `Calibrator.run(starts)` | | Step | The adaptive step of Raue et al. (2009): it starts at 1 % of the width of the bounds (of the value at the fit, for a prediction), doubles while a step rises by less than half of 10 % of the threshold, and halves when a step rises by more than twice it | | Start of each refit | Every parameter extrapolated along the line through the two points before, as in Data2Dynamics | | End of a side | Past the threshold (`"crossed"`), at a bound (`"bound"`), after `max_steps` points (`"max_steps"`), or at a prediction no values within the bounds give (`"unreachable"`) | Every side of every quantity runs in parallel. A held parameter leaves the values the optimizer searches, so Nelder-Mead searches one dimension less. A held prediction `g(θ) = c` is met as an equality constraint, by an augmented Lagrangian whose inner solver is the problem's optimizer. Its final multiplier is minus the slope of the profile (`Profile.multipliers`). With a particle swarm, each refit draws from a seed of its own, derived from the problem's seed, so a profile repeats; a swarm runs each outer iteration of a held prediction to its `max_iterations`, or until one of its [stop rules](https://munqu.github.io/commol/latest/guide/calibration-optimizers/#stop-rules) holds. ## Prediction profiles `PredictionProfile(specs, threshold)` profiles the value of each prediction: a `PredictionSpec` reads an output, or a sum of outputs, at a position, with a `window` and a `factor_id`, as an observation does. Its interval is the set of values the data do not reject (Kreutz et al. 2012); specs at several positions give a band. A prediction past the data has a wider interval, as `B at 50` shows in the [quick path](#quick-path). The intervals are pointwise: a band of them is not a joint region. ## Verdicts and the identifiability report | Verdict | The profile | In the report | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `identifiable` | Crosses the threshold on both sides | Practical `determined` | | `practically_non_identifiable` | Stays below on at least one side, up to a bound or `max_steps` | Practical `poorly_determined` | | `structurally_non_identifiable` | Flat within `tolerance` over its whole range inside the box of the parameters, or proven flat by the resets below | Structural `in_combination` or `non_identifiable` from the resets; with no structural verdict, practical `poorly_determined` | `tolerance` is `ε = max(τ, ν)`. `τ` is the `sd_tolerance` of Nelder-Mead, an absolute amount: argmin stops when the costs of its simplex spread by less. For a particle swarm with the improvement stop rule, `τ` is the improvement the rule allows at the fit, `tol_abs + tol_rel · |objective at the fit|`: the swarm stops when its best value improves by less. A swarm without that rule has no tolerance on the objective, and `τ` is `1e-6 · max(1, |objective at the fit|)`. `ν`, the noise floor, is the error of one evaluation of the objective at the fit, from the accuracy of a run: `Σ |∂ℓᵢ/∂mᵢ| · eᵢ`, with `eᵢ = rtol · |mᵢ| + k · atol` the error of a run on an observation that sums `k` outputs, its prediction `mᵢ`, and the accuracy of the engine (machine epsilon for a difference model, the `rtol` and `atol` of the ODE solver). A solver holds its tolerances at each step, so `ν` is an estimate, not a bound. A threshold at or below `ε` gives no verdict: the profile cannot tell a rise from numerical error, and `notes` says so. The box of the parameters is the space inside their bounds. A free parameter of a refit is on a bound when it is within `1e-8` of the width of its bounds, or of the larger of 1 and the size of its one finite bound, the rule of the [local approximations](https://munqu.github.io/commol/latest/guide/uncertainty/local/index.md). When it is on a bound that it is not on at the fit, the refit has left the box: the bound makes its rise, not the data. The flatness reads only the points inside the box, and a note names the parameter and the value where the ridge leaves it. When the crossing point of a side has such a parameter, and the parameter is not on that bound over the whole side, the side crosses because of the box. It counts as open, and its interval end stays, with a note. A parameter on its bound at every point from the fit to the crossing does not count: the verdict then holds within the box. A profile is flat for one of two reasons: other parameters compensate the parameter along it, or nothing the data read depends on it. Raue et al. (2009) read such relations from the paths of the profiles. To tell them apart with no optimizer, each *reset* puts the parameter at one value of its profile inside the box, or at one end of its box, and every other parameter at its fitted value. Each reset evaluates the objective once and compares it with the objective at the fit, `f̂`. The evaluation floor `2(ν₁ + ν₂) + 2γₙ Σ|ℓᵢ|` (`ProfileResult.evaluation_floor`) is how far two evaluations can differ: `ν₁` is `ν`, `ν₂ = Σ ½ |ℓᵢ''| · eᵢ²` uses the expected curvature of each term (an estimate for a model that is not normal), and `γₙ · Σ |ℓᵢ|` bounds the rounding of the sum of the `n` terms, with the constants of `−log L`. | Resets | Structural verdict | | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | Every reset feasible and within the floor of `f̂` | `non_identifiable`, unless another parameter sits on its bound at the fit; the profile then reads `structurally_non_identifiable` | | A flat profile with a feasible reset inside the box above `f̂ + T` | `in_combination` | | Otherwise, a profile below the fit by more than the floor, no floor (a loss with no term derivatives), or a stochastic engine | None: practical `poorly_determined` for a flat profile, and a note | A reset whose evaluation fails counts as infeasible. The ends of the box only rule out `non_identifiable`. A parameter is named in the reason when resetting it alone, where it moves most along the profile, raises the objective of that point above `T`; the parameters not named are then reset together, and a rise above the floor says that the list may be incomplete. Noise parameters of observation models are never named. `combinations` stays empty for a profile: it holds the algebraic combinations of the structural rank. Every reason says that the verdict holds with the other parameters at their fitted values. `Profile.refit_error` is the distance of a refit at the fit from `f̂`, a diagnostic of the optimizer, with a note when it is above `ε`. What the settings give: with Nelder-Mead at its default `sd_tolerance` (`1e-6`), the resets prove a parameter with no effect `non_identifiable` on a difference model and on an ODE model at the default solver tolerances. A ridge needs its profile flat within `ε`: on an ODE model at the default tolerances `ν` is near `1e-4` and the profiles of the ridge below read `in_combination`; on a difference model `ν` is near `1e-13`, a refit lands about `1e-6` from its minimum, and the profile reads practically non-identifiable. Its `in_combination` needs an `sd_tolerance` near `ν`, as the example below uses. `ProfileResult.identifiability()` turns the verdicts into an [identifiability report](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/#the-report) with a `profile` section, which `merge` joins with the other methods. A rate that reads `k1 * s` cannot tell `k1` from `s`: their profiles are flat on the ridge `k1 · s = constant`, resetting `s` breaks it, and the profile of `k1` reads `in_combination`, compensated by `s`, as the structural rank does. The two still disagree in some cases, and the merged verdict is then `conflicting`: - the rank reads the rank most points of a Latin hypercube across the whole box have, a profile one ridge through the fit; - the rank calls a parameter non-identifiable when its projection on the null space is above 0.95, and identifiable below 0.05: in a sum `k1 + k2` with `k1 ≫ k2`, the rank finds `k2` non-identifiable while its profile, which `k1` compensates, reads `in_combination`; - a parameter with no effect while the others sit at their fit, but an effect elsewhere in the box, is `non_identifiable` for the profile, and may be identifiable for the rank. ```python from commol import StructuralRank def product_model(k1: float | None, s: float | None, k2: float | None): return ( ModelBuilder(name="Product") .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="s", value=s) .add_parameter(id="k2", value=k2) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * s * 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", ) ) product_problem = CalibrationProblem( observed_data=observations, parameters=[ CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.2, max_bound=0.8), CalibrationParameter(id="s", parameter_type="parameter", min_bound=0.5, max_bound=2.0), CalibrationParameter(id="k2", parameter_type="parameter", min_bound=0.0, max_bound=1.0), ], observation_models={"B": NormalObservations(sd=10.0)}, optimization_config=NelderMeadConfig(max_iterations=4000, sd_tolerance=1e-15), seed=42, ) product = Calibrator(Simulation(product_model(None, None, None)), product_problem) profiles = product.uncertainty( ProfileLikelihood(parameters=["k1"], threshold=Threshold.chi_square(0.95)) ) print(profiles.verdicts) # {'k1': 'structurally_non_identifiable'} print(profiles.notes[0]) # 'k1': the ridge leaves the box at 0.8 above the fit, where 's' reaches its # bound; the profile is flat inside the box, and its rise past it comes from # the bounds, not the data. print(profiles.structure["k1"].compensated_by) # ['s'] report = product.identifiability(StructuralRank(points=5, design="observations")) merged = report.merge(profiles.identifiability()) print(merged.verdicts["k1"].structural.verdict, merged.verdicts["s"].structural.verdict) # in_combination in_combination ``` ## Plots `result.plot()` draws one panel per profile: the statistic of each point, the threshold as a dashed line and the ends of the interval as dotted lines. Like every figure of commol, it returns the matplotlib `Figure` and takes `output_file`, `config` and `axes` ([Plotting](https://munqu.github.io/commol/latest/guide/simulations/#plotting)). ```python figure = result.plot() ``` ## Checklist - [ ] The threshold states your noise: `chi_square` with observation models, `beale` for a sum of squares of independent Gaussian errors, `bootstrap_calibrated` otherwise. - [ ] No note says that a refit went below the fit; if one does, fit with more starts. - [ ] Every verdict is set; a threshold below the tolerance needs data with noise, or a tighter `sd_tolerance`. - [ ] For `in_combination` on a difference model, `sd_tolerance` is near the noise floor `ν` (`notes` says why a flat profile has no structural verdict, and when a refit is less accurate than the tolerance). - [ ] A side that ends at `max_steps` is open only because it stopped: raise `max_steps`. - [ ] Read an open or flat profile with the [structural rank](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/#structural-rank) before you call a parameter non-identifiable. ## Next steps | To learn | Read | | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | The other evidence of identifiability, and the report | [Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/index.md) | | Intervals from refits to resampled data | [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/index.md) | | Observation models and the likelihood | [Observation Models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md) | | The parameter sample, the predictive and its scores | [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) | # Conformal Prediction Intervals [Back to Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) Conformal prediction gives intervals for observations, with a coverage guarantee that needs no noise model, no prior and no correct model. It answers "will the next observations fall inside?", not "which parameters fit?". Each variant scores how far observations the fit did not use fall from their predictions, and a quantile of those scores sets the width of every interval. Use it when you must state how often new data fall inside an interval, and you cannot or will not say how the data scatter around the model. Every variant runs through `Calibrator.uncertainty`, in Rust (`crates/commol-inference/src/conformal/`). ## Quick path ```python import numpy as np from commol import ( Aggregate, CalibrationParameter, CalibrationProblem, Calibrator, ModelBuilder, NelderMeadConfig, ObservedDataPoint, Simulation, SplitConformal, ) def ab_model(k: float | None): return ( ModelBuilder(name="AB") .add_bin(id="A", name="A") .add_bin(id="B", name="B") .add_parameter(id="k", value=k) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k") .build( Aggregate(bin_amounts={"A": 1000, "B": 0}), engine="DifferenceEquations", ) ) # Noisy data of B at steps 1 to 80: fit steps 1 to 40, hold out 41 to 80. truth = Simulation(ab_model(0.05)).run(num_steps=90) rng = np.random.default_rng(1) observations = [ ObservedDataPoint(at=step, output="B", value=truth["B"][step] + rng.normal(0.0, 5.0)) for step in range(1, 81) ] fitted = [point for point in observations if point.at <= 40] held_out = [point for point in observations if point.at > 40] problem = CalibrationProblem( observed_data=fitted, parameters=[ CalibrationParameter(id="k", parameter_type="parameter", min_bound=0.0, max_bound=0.5) ], loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=500), seed=42, ) calibrator = Calibrator(Simulation(ab_model(None)), problem) result = calibrator.uncertainty( SplitConformal( calibration_data=held_out, score="absolute", by="series", confidence_level=0.9, positions=[85.0, 90.0], ) ) intervals = result.intervals.series["B"] print([round(value, 1) for value in intervals.lower]) # [981.0, 983.9] print([round(value, 1) for value in intervals.upper]) # [993.4, 996.3] print(result.guarantee) ``` `result.intervals` holds the intervals of each series at `positions`, `result.scores` the scores they came from, and `result.guarantee` what the coverage claim assumes. ## What the guarantee means A conformal interval of level `1 − α` covers a **new observation** with probability at least `1 − α`, averaged over the data and the new observation. | It says | It does not say | | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | Of many new observations, at least a share `1 − α` falls inside its interval | That one interval holds its observation with probability `1 − α` given the data | | The claim holds whatever the noise, and even when the model is wrong | That the interval contains the model curve or the true parameters | | A wrong model gives wide intervals, not wrong ones | That the intervals are narrow | The claim holds when the scores are **exchangeable**: any order of the scores of the held-out and the new observations is as likely as any other. Noise that is independent and of one distribution along the series is exchangeable; a trend in the errors, a change of regime, or errors that depend on each other are not. Each variant names its assumption in `result.guarantee`. The quantile of `n` scores at level `1 − α` is the `⌈(n + 1)(1 − α)⌉`-th smallest (`conformal_quantile` in `crates/commol-inference/src/conformal/quantile.rs`). With fewer than `1/α − 1` scores that rank is above `n`, so the interval is unbounded: `-inf` to `inf`. `result.intervals.unbounded` names the positions of each series where that happens. At level 0.9 a quantile needs 9 scores. ## Scores and pooling Every variant takes a `score`, a `by` and a `confidence_level`, none with a default: they change the width of the intervals, which is a modelling choice. | `score` | Score of `y` against `ŷ` | Interval | Use it when | | -------------- | ------------------------ | -------- | ---------------------------------------------------------------------------------------- | | `"absolute"` | \` | y − ŷ | \` | | `"normalized"` | \` | y − ŷ | / s`, with `s\` the root mean square of the observed values of the series in the problem | | `"spread"` | \` | y − ŷ | / σ̂`, with `σ̂\` the spread of the predictions at that point | `by="series"` gives each series (observation label) its own quantile; `by="all"` gives every series one quantile. Per series is the safer first choice: a small series does not borrow the errors of a large one, but it needs `1/α − 1` scores of its own. - `score="normalized"` with `by="series"` raises a `ValueError`: with one constant factor per series, its intervals are those of `"absolute"`. - `score="spread"` needs predictions with a spread. `SplitConformal` and `AdaptiveConformal` take it from a `sample`, whose predictive gives the prediction (its weighted median) and the spread (its weighted standard deviation); without one they raise a `ValueError`. `JackknifePlus` and `EnbPI` take it from their own refits. `RollingConformal` fits once per origin, so it rejects `"spread"`. ```python from commol import ParameterSample, SampleMethod sample = ParameterSample.equally_weighted( names=["k"], bounds=[(0.0, 0.5)], values=[[0.048], [0.05], [0.052]], method=SampleMethod(name="by hand"), ) spread = calibrator.uncertainty( SplitConformal( calibration_data=held_out, score="spread", by="series", confidence_level=0.9, positions=[85.0, 90.0], sample=sample, ) ) widths = [ round(upper - lower, 1) for lower, upper in zip(spread.intervals.series["B"].lower, spread.intervals.series["B"].upper) ] print(widths) # [6.3, 5.2] ``` ## Which variant to use | Variant | Held-out data | Fits | Guarantee | Use it for | | ------------------- | ------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `SplitConformal` | `calibration_data` | One | At least `1 − α`, exchangeable scores | Many observations, some of which you can leave out of the fit | | `RollingConformal` | None: each origin holds out what follows it | One per origin | At least `1 − α` per horizon, scores of a horizon exchangeable across origins | Forecasts: intervals ahead of the data that widen with the horizon | | `JackknifePlus` | None | One per block | At least `1 − 2α` with one observation per block; the CV+ bound with longer blocks; exchangeable blocks | Short series, where no data can be spared | | `EnbPI` | None | `replicates` | Near `1 − α`, approximately, under stationary and strongly mixing errors | Time series with dependent errors | | `AdaptiveConformal` | The `stream` of new observations | One | The long-run share of misses tends to `α`, with no assumption | Data that arrive one by one and can change regime | ## How to hold data out | Variant | What to hold out | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SplitConformal` | Pass the problem the observations to fit, and `calibration_data` the rest. For a forecast, hold out the latest positions, as in the quick path, so the scores measure errors ahead of the fit. The held-out points must not be in the problem. | | `RollingConformal` | Nothing: give `origins`. Each fits the observations at or before it and scores the later ones, so the problem holds every observation. | | `JackknifePlus`, `EnbPI` | Nothing: they leave blocks out of their own refits. A block holds every observation of every series at `block_length` consecutive positions, so dependence stays inside a block. | | `AdaptiveConformal` | The observations that arrive after the fit, in `stream`. | ## Split conformal `SplitConformal(calibration_data, score, by, confidence_level, positions, sample=None, starts=1)` fits the problem's observations (`Calibrator.run(starts)`), or reads a `sample`, scores `calibration_data` and gives intervals at `positions` for each series of `calibration_data`. A series of `calibration_data` is read as its observations define it: its outputs, factor and window. ## Rolling origins `RollingConformal(origins, score, by, confidence_level, positions, starts=1)` gives intervals ahead of the data, with one quantile per horizon. Horizon `k` is the `k`-th observation of a series after an origin: a count, not a distance on the axis, because positions can be irregular. Each origin fits the observations at or before it (one refit per origin, from the fit of every observation) and scores the later ones. The intervals are around the fit of every observation, at `positions`, which must all be after the data; the `k`-th position is horizon `k`, so give positions spaced as the observations. A horizon with fewer than `1/α − 1` origins that reach it has an unbounded interval. ```python from commol import RollingConformal every_point = CalibrationProblem( observed_data=observations, parameters=problem.parameters, loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=500), seed=42, ) calibrator_all = Calibrator(Simulation(ab_model(None)), every_point) rolling = calibrator_all.uncertainty( RollingConformal( origins=[float(step) for step in range(30, 71)], score="absolute", by="series", confidence_level=0.8, positions=[81.0, 82.0, 83.0], ) ) print(rolling.intervals.unbounded) # {} ``` ## Jackknife+ `JackknifePlus(block_length, score, by, confidence_level, positions, starts=1)` refits once per block left out (Barber et al. 2021). Observation `i` of block `b` has the residual `Rᵢ` of the fit `μ̂₋ᵦ` that left its block out, and the interval at a point `x` is ```text [ q⁻_α { μ̂₋ᵦ₍ᵢ₎(x) − Rᵢ } , q⁺_{1−α} { μ̂₋ᵦ₍ᵢ₎(x) + Rᵢ } ] ``` where `q⁺` is the `⌈(1 − α)(n + 1)⌉`-th smallest value and `q⁻` the `⌊α(n + 1)⌋`-th smallest. The guarantee holds with no held-out set, under exchangeable blocks. With `n` scored observations in `K` blocks it is the bound of K-fold CV+ (Theorem 4): ```text 1 − 2α − min{ 2(1 − 1/K) / (n/K + 1), (1 − K/n) / (K + 1) } ``` With one observation per block, `K = n` and the bound is `1 − 2α`. With longer blocks, jackknife+ is CV+ with the blocks as folds, so CV+ has no class of its own. The theorem assumes blocks of `n/K` observations each. `cv_plus_bound` in `crates/commol-inference/src/conformal/jackknife_plus.rs` computes it, and `result.guarantee` states it. ```python from commol import JackknifePlus jackknife = calibrator.uncertainty( JackknifePlus(block_length=4, score="absolute", by="series", confidence_level=0.9, positions=[45.0]) ) print(jackknife.guarantee) # ... at least 0.7318 ... with K = 10 blocks of 4 positions ... ``` ## EnbPI `EnbPI(replicates, block_length, score, by, confidence_level, positions, starts=1)` draws its own replicates (Xu and Xie 2021): each draws as many blocks as there are, with replacement, and fits with each observation's weight multiplied by the count of its block, so the blocks it did not draw get weight 0. The leave-one-out prediction of an observation is the mean of the replicates that left its block out; the point prediction at a position is the mean of the leave-one-out predictions there. No fit beyond the replicates is needed. A block left out by no replicate raises a `ValueError` that asks for more replicates: a block is left out by a replicate with probability about `1/e ≈ 0.37`. ## Adaptive conformal inference `AdaptiveConformal(confidence_level, step, score, by, stream, sample=None, starts=1)` walks the observations of `stream` in order of position (Gibbs and Candès 2021). The interval of each point is the quantile, at the working level `1 − α_t`, of the scores of the earlier points of the stream; then the point is scored and the working level moves: ```text α_{t+1} = α_t + γ (α − err_t), err_t = 1 for a miss, 0 for a cover ``` So a miss widens the next interval and a cover narrows it. The long-run share of misses tends to `α` whatever the data. `by="series"` keeps one working level per series, `by="all"` one for all. The first points have few earlier scores, so their intervals are unbounded. `result.intervals` holds the interval given at each point before it was seen. ```python from commol import AdaptiveConformal adaptive = calibrator.uncertainty( AdaptiveConformal(confidence_level=0.9, step=0.05, score="absolute", by="series", stream=held_out) ) series = adaptive.intervals.series["B"] misses = [ not lower <= point.value <= upper for lower, upper, point in zip(series.lower, series.upper, held_out) ] print(len(adaptive.intervals.unbounded["B"])) # 7 print(round(sum(misses) / len(misses), 3)) # 0.125 ``` ## Results, plots and reports Every variant returns a `ConformalResult(intervals, scores, guarantee, failed, not_converged)`. A variant that refits lists each refit that failed in `failed`, named by its unit: `"Origin failed: "` for `RollingConformal`, `"Block failed: "` for `JackknifePlus`, and `"Replicate failed: "` for `EnbPI`; each is also logged as a warning, and gives no score. `not_converged` counts the refits kept although their optimizer did not converge. Split and adaptive conformal do not refit, so their `failed` is empty and their `not_converged` is 0. `intervals` is a `PredictionIntervals`: its `confidence_level`, a `SeriesIntervals` per series (`positions`, `prediction`, `lower`, `upper`), and `unbounded`. `plot.series(prediction_intervals=...)` of a run, of replicates or of a predictive draws the band of each series on its panel. `SimulationResults.report`, `SimulationReplicates.report` and `Predictive.report` take `prediction_intervals=...` too, and add the intervals under the key `prediction_intervals`. ```python run = Simulation(ab_model(0.05)).run(num_steps=90) figure = run.plot.series( observed_data=observations, prediction_intervals=result.intervals, series=["B"] ) ``` ## Checklist - [ ] The held-out observations are not in the problem, and come from where the new observations will. - [ ] Each quantile has at least `1/α − 1` scores; `result.intervals.unbounded` is empty. - [ ] The assumption in `result.guarantee` fits your data: exchangeable scores, mixing errors, or none. - [ ] For a forecast, you use `RollingConformal`, `EnbPI` or `AdaptiveConformal`, not one split of errors one step ahead. ## Next steps | To learn | Read | | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Prediction intervals from the residuals of a fit, and the scores of a predictive | [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/#prediction-intervals), [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/#scores) | | Bootstraps and the jackknife of the parameters | [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/index.md) | | Plots | [Running Simulations](https://munqu.github.io/commol/latest/guide/simulations/#plotting) | # Sensitivity Analysis [Back to Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) A sensitivity analysis asks the forward questions of a model: how uncertainty in its inputs moves its outputs, and which inputs matter most. It needs no data. You say what varies and how (an `InputSpace`), what to read from each run (quantities of interest), and a method: Monte Carlo propagation, partial rank correlation (PRCC), Morris screening, Sobol indices or a polynomial chaos expansion. Every method runs through `SensitivityAnalysis(simulation, inputs).run(method)`, in one call into Rust: the design (`crates/commol-inference/src/design.rs`), the runs (`batch.rs`) and the estimator (`global_sensitivity/`), joined by `ForwardAnalysis` (`global_sensitivity/analysis.rs`). ## Quick path ```python from commol import ( Aggregate, CalibrationParameter, InputSpace, ModelBuilder, Normal, Peak, PRCC, SensitivityAnalysis, Simulation, Uniform, ) def abc_model(k1: float, k2: float): return ( ModelBuilder(name="ABC") .add_bin(id="A", name="A") .add_bin(id="B", name="B") .add_bin(id="C", name="C") .add_parameter(id="k1", value=k1) .add_parameter(id="k2", value=k2) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * 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", ) ) simulation = Simulation(abc_model(0.3, 0.1)) inputs = InputSpace.independent( [ CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.2, max_bound=0.6), CalibrationParameter(id="k2", parameter_type="parameter", min_bound=0.05, max_bound=0.2), ], {"k1": Uniform(), "k2": Normal(mean=0.1, sd=0.03)}, ) analysis = SensitivityAnalysis(simulation, inputs, seed=1) peak = Peak(series="B", positions=list(range(101))) result = analysis.run(PRCC(draws=200, quantities=[peak])) print(result.inputs, [round(c, 2) for c in result.coefficients[0]]) # ['k1', 'k2'] [0.97, -0.95] ``` The peak of `B` rises with `k1` and falls with `k2`: the PRCC of `k1` is near 1 and that of `k2` near −1. Over the axis (below), `C` early on rises with both, since a faster flow out of `B` fills `C` faster, and late falls with `k2`, since a faster flow out of `B` ends the growth of `B` sooner. ## Forward and inverse questions | Question | Needs data | Entry point | | ------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------- | | Which inputs fit the data, and how well do the data determine them? | Yes | `Calibrator.run`, `Calibrator.uncertainty`, `Calibrator.identifiability` | | How does uncertainty in the inputs move the outputs? | No | `SensitivityAnalysis.run(Propagation(...))` | | Which inputs matter most for an output? | No | `SensitivityAnalysis.run` with `PRCC`, `Morris`, `Sobol` or `PolynomialChaos` | The two meet: a sample that a calibration returns, such as a posterior, can be the input space of an analysis (`InputSpace.from_sample`). ## What varies Each input is a `CalibrationParameter`: a model parameter or an initial condition, with its bounds, as a calibration sets it. The values reach the engine as a calibration writes them (`ForwardModel::unobserved`, `crates/commol-calibration/src/forward.rs`). | Constructor | Inputs | Methods that take it | | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | | `InputSpace.independent(parameters, distributions)` | One distribution per input (`Uniform()`, `LogUniform()`, `Beta(a, b)`, `Normal(mean, sd)`, `LogNormal(mu, sigma)`, `Gamma(shape, rate)`), truncated to its bounds, independent of the others | Every method | | `InputSpace.uniform_on_bounds(parameters)` | `Uniform()` on the bounds of every input; they must be finite | Every method | | `InputSpace.from_sample(sample, parameters)` | The draws of a `ParameterSample`, with their weights; the inputs may depend on each other. The parameters give the type of each value, which a sample does not hold | `Propagation` and `PRCC` | An initial condition is an amount in the unit of its bin, or a category split in `[0, 1]`, with `initial_condition_remainder` as in a calibration problem. A factor or a noise parameter is not an input: both belong to observations, and an analysis has none. `SensitivityAnalysis(simulation, inputs, *, replicates=None, seed=None)` holds the inputs for every method you run on it. With a stochastic engine `replicates` (at least 1) and `seed` are required. A point of the design of PRCC, Morris, Sobol or a polynomial chaos runs `replicates` replicates, on the same seeds at every point, and holds their mean: the indices are those of the mean of `replicates` replicates, and they come close to those of the expected value as `replicates` grows. A propagation, and the `Predictive` of its draws, keep every replicate, each with a seed of its own. A deterministic engine does not use `replicates`, so the same analysis runs on either engine; with no seed, the engine draws one, which `analysis.seed` gives and the analysis logs. A negative `replicates` raises `ValueError` that names the setting. ## Quantities of interest A quantity reads a series of each run and reduces it to a scalar, in Rust, through the observation operator of the calibration (`crates/commol-inference/src/quantity.rs`), so a design of thousands of runs never returns to Python. The quantities live in `commol/context/inference/quantities.py`. | Series | Value at a position | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | A name, a list of names, or `Level(outputs)` | The sum of the outputs, their level | | `Increments(outputs, window)` | The gain of the sum over `(end - window, end]`: the position is the end of a window, at least `window` | | Quantity | Value | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `ValueAt(series, at)` | The value at `at` | | `Peak(series, positions)` | The largest value at the positions | | `PeakPosition(series, positions)` | The first of the positions where the value is largest | | `Trajectory(series, positions)` | The value at each position, one scalar per position, for an analysis over the axis | | `Gain(outputs, start, end)` | The value at `end` minus the value at `start`: what entered a cumulative output over `(start, end]`, as an observation with a window | | `Ratio(numerator, denominator, replicate_rule)` | One quantity of one value over another, in each run ([A ratio of two quantities](#a-ratio-of-two-quantities)) | The series is a modelling choice. The peak of a level is the largest amount; for an output that only grows, such as `C` here or an accumulator, it is the value at the last position. The peak of `Increments` is the largest gain over one window, and `PeakPosition` gives the end of that window: ```python from commol import Increments, PeakPosition, Propagation ends = list(range(10, 101, 10)) inflow = Increments("C", window=10) quantities = [ Peak(series=inflow, positions=ends), PeakPosition(series=inflow, positions=ends), Peak(series="C", positions=ends), ] gains = analysis.run(Propagation(draws=100, quantities=quantities)) for summary in gains.summary(0.9): print(summary.label, round(summary.lower), round(summary.median), round(summary.upper)) # peak of C gain over windows of 10 178 358 496 # position of the peak of C gain over windows of 10 20 30 50 # peak of C 809 980 999 ``` Every quantity follows the same rules: | Rule | So | | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | A quantity reads only the positions it names, with no interpolation | A position the engine does not report at is a `ValueError` | | Outputs are summed at each row before any reduction | The peak of `["A", "B"]` is the peak of `A + B` | | `PeakPosition` gives the first of the positions where the value is largest | A tie gives the earlier position | | A NaN in a value a quantity reads makes it NaN | Its peak and the position of its peak are NaN too | | With a stochastic engine, a quantity is computed for each replicate | A design point holds the mean of its replicates, or for a `Ratio` what its `replicate_rule` says; a propagation keeps each | Each quantity has a `label`, by default what it reads, such as `"B at 10"`. The default label writes each position in full, as Python reads it back, so two positions give two labels: `"B at 1000000"` and `"B at 1000001"`. ### A quantity that is NaN A propagation keeps the NaN of each run in `values`. Every reduction across runs raises a `ValueError` on a NaN: `summary()`, `member_statistics`, and PRCC, Morris, Sobol and the polynomial chaos at the first batch of their design, before they estimate. The message gives the index of the quantity, in the order of the labels, the points where it is NaN, and the first run: its point, inputs, replicate and seed. A `Propagation` with the same inputs and seed gives the value of each run. `PropagationResult.where(mask)` keeps the runs where `mask` is true and renormalises their weights to sum to 1. A filter conditions the result: the summary is the distribution given the condition, not over every run, and `effective_size` says how many runs the kept weights are worth. You write the condition, such as `np.isfinite(values[:, i])` or a value above a threshold: ```python import numpy as np values = np.asarray(gains.values) late = gains.where(values[:, 1] > 30) print(len(late.values), round(late.effective_size), round(gains.effective_size)) # 23 23 100 print(round(late.summary(0.9)[0].median)) # 216 ``` ### A ratio of two quantities `Ratio(numerator, denominator, replicate_rule)` divides two quantities of one value each (`ValueAt`, `Gain`, `Peak`), numerator N over denominator D, in each run. It is one quantity, so every method reads it, the sensitivity methods too. A `PeakPosition` or a `Trajectory` as an operand is a `ValueError`. The share of what entered `C` by position 100 that entered by position 50 has a denominator that depends on both inputs: ```python from commol import Gain, Ratio early = Ratio( numerator=Gain(outputs="C", start=0, end=50), denominator=Gain(outputs="C", start=0, end=100), replicate_rule="within", label="early share", ) spread = analysis.run(Propagation(draws=100, quantities=[early])) share = spread.summary(0.9)[0] print(share.label, round(share.lower, 2), round(share.median, 2), round(share.upper, 2)) # early share 0.61 0.94 0.99 ranked = analysis.run(PRCC(draws=200, quantities=[early])) print(ranked.inputs, [round(c, 2) for c in ranked.coefficients[0]]) # ['k1', 'k2'] [0.92, 0.69] ``` The default label is `"(N) / (D)"`, from the labels of the two quantities. `replicate_rule` is required, with no default: with a stochastic engine, it says how the replicates of a point of PRCC, Morris, Sobol or a polynomial chaos combine (`Quantities::combine` in `crates/commol-inference/src/quantity.rs`). They are three estimands, and the choice is yours: | `replicate_rule` | Value of a point | No value when | | ------------------- | ---------------------------------------------------------- | --------------------- | | `"within"` | The mean of the ratio of each replicate, E[N/D] | A replicate has D = 0 | | `"within_positive"` | The mean of the ratio of the replicates with D > 0, E\[N/D | D > 0\] | | `"pooled"` | The ratio of the means, E[N]/E[D] | E[D] = 0 | A deterministic engine has one run per point, so the three give N/D. A `Propagation`, `Scenarios` and the quantities of one run keep the ratio of each run, N/D, for each replicate. | Rule | So | | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The ratio of a run with D = 0 is NaN, also when N is not 0 | N/0 is not ±inf, which a check for NaN misses; the NaN rule of [A quantity that is NaN](#a-quantity-that-is-nan) holds in every method | | A point with no value is NaN in every method | The error names the point and its inputs, and the replicate that is NaN, when one is | | `"within"` with D = 0 in one replicate | The point has no value. A Sobol design of 10^5 replicates almost surely holds one such replicate when D can reach 0 | | `"within_positive"` | It conditions on D > 0, so it hides the effect of an input on the chance that D > 0: analyse that chance on its own. Its points average different numbers of replicates, so their noise differs | | `"pooled"` | The ratio of two means is another quantity than the mean of the ratios: it differs from `"within"` when D is small or 0 in some replicates | The indices of a ratio equal those of its numerator only when the denominator is fixed and is not an input, such as the sum of the bins at 0: then run the method on the numerator. When the denominator can be near 0, a ratio has large values in a few runs, and the Sobol and the chaos indices, which divide variances, are unstable; PRCC works on ranks, so it stays valid. A propagation keeps the NaN of each run in `values`, so `summary()` raises on a ratio with D = 0 in a run. `PropagationResult.where` keeps the runs where the ratio is finite, and the summary is then conditioned on that. ### The quantities of one run `results.quantities(quantities)` reduces one run to its quantities, as `dict[str, float]` with one value per label, and `replicates.quantities(quantities)` gives one such dict per replicate, in seed order, with no mean. Use them on the point estimate of a fit, or on any run you plot; `predictive.quantities(quantities)` gives one dict per draw of a predictive, and `Scenarios` compares them between scenarios ([Scenarios](https://munqu.github.io/commol/latest/guide/uncertainty/#scenarios)). Rust reads the rows of the run at the positions of the quantities and reduces them with the code of `Propagation` (`Quantities::evaluate_run` in `crates/commol-inference/src/quantity.rs`), so the values equal those of a `Propagation` of a sample of one draw with the parameters of the run. `quantity_schedule(quantities)` gives the positions the quantities read: the results of `run_at` at them hold every position. Each position is found within a relative tolerance of `1e-9`, as `index_at` finds it, so a window that starts at `0.3 - 0.1` reads the row of `0.2`. ```python from commol import quantity_schedule one_run = simulation.run_at(quantity_schedule(quantities)) for label, value in one_run.quantities(quantities).items(): print(label, round(value)) # peak of C gain over windows of 10 283 # position of the peak of C gain over windows of 10 30 # peak of C 946 ``` | Case | Result | | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The run lacks a position a quantity reads | `ValueError` that lists each such position with its quantity, and says to call `Simulation.run_at` | | A quantity names an output the results do not have | `ValueError` that names the output | | `run(n)` of the ODE engine | It interpolates the positions that are not breakpoints, so a value differs from that of a run that ends there by about the relative tolerance of the solver, and `PeakPosition` can change on a near-tie | | The replicates of a stochastic engine | Each replicate is reduced on its own. The runs of a draw follow the tree of seeds of a [predictive](https://munqu.github.io/commol/latest/guide/uncertainty/#the-predictive), from `result.draw_seeds`. The seed of the analysis itself gives other runs | ## Which method for which budget | Method | Answers | Runs | Inputs | | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | ----------------------- | | `Propagation(draws, quantities)` | The distribution of each quantity, and the band of every output | `draws`; with inputs from a sample, `draws` is left unset and every draw of the sample runs | Independent or a sample | | `PRCC(draws, quantities)` | How steadily each quantity rises or falls with each input | `draws`, more than the inputs + 1; with inputs from a sample, as `Propagation` | Independent or a sample | | `Morris(trajectories, levels, quantities)` | Which inputs matter, in few runs | `trajectories · (inputs + 1)` | Independent | | `Sobol(draws, replicates, confidence_level, quantities)` | The share of the variance each input explains, alone and with interactions, with intervals | `draws · (inputs + 2)` | Independent | | `PolynomialChaos(degree, draws, quantities, q_norm=1.0, max_interaction=None)` | A polynomial surrogate and its Sobol indices, in far fewer runs for a smooth quantity | `draws`, more than the terms | Independent | Start with Morris to drop the inputs that do not matter, then Sobol or a polynomial chaos on the rest. For a slow model, a polynomial chaos needs the fewest runs; read its leave-one-out error before its indices. ## Propagation `Propagation` returns a `PropagationResult`: the `sample` of the draws (a Latin hypercube of the distributions, or the input sample as it is), the quantities of each run, their `weights`, and the seed of each draw (`draw_seeds`). `summary(confidence_level)` gives the weighted mean, median and central interval of each quantity. The sample is a `ParameterSample`, so `Predictive(analysis, result.sample, positions)` gives the bands and plots of a calibration. ```python from commol import Predictive, Propagation, ValueAt result = analysis.run( Propagation(draws=100, quantities=[peak, ValueAt(series="C", at=100)]) ) for summary in result.summary(0.9): print(summary.label, round(summary.lower), round(summary.median), round(summary.upper)) # peak of B 172 421 655 # C at 100 809 980 999 band = Predictive(analysis, result.sample, [0.0, 25.0, 50.0]).band(0.9) print([round(value) for value in band.median["B"]]) # [10, 293, 53] ``` `Predictive.at_observations` of an analysis takes `NoNoise()` only: residual noise needs the residuals of a fit, and `DistributionNoise()` the observation models of a problem. ## PRCC `PRCC` (Marino et al. 2008) ranks the inputs and each quantity, and gives the correlation of the rank of an input with the rank of the quantity once the other inputs are accounted for. A coefficient near 1 or −1 says the quantity rises or falls steadily with the input; near 0, that it does not. `p_values` test each coefficient against 0. With inputs from a sample, the sample is the design, and the ranks, the correlations and the test read its weights: a rank is the weight below a value plus half the weight at it, and the test counts Kish's effective size in place of the draws. Equal weights give the usual PRCC. A `Trajectory` gives the coefficients over the axis; a quantity with one value in every run, such as a state at position 0, has NaN coefficients. A NaN has no rank, so a quantity that is NaN at a draw raises a `ValueError` before PRCC ranks any value ([A quantity that is NaN](#a-quantity-that-is-nan)). ```python from commol import Trajectory over_axis = analysis.run( PRCC(draws=100, quantities=[Trajectory(series="C", positions=[10, 50, 100])]) ) print(over_axis.labels) # ['C at 10', 'C at 50', 'C at 100'] print([[round(c, 2) for c in row] for row in over_axis.coefficients]) # [[0.99, 0.9], [0.91, 0.31], [0.96, -0.89]] ``` ## Morris screening `Morris` (Morris 1991) runs `trajectories` trajectories: each starts at a random level of every input and moves the inputs one at a time, by half the `levels`. The levels are the centres of strata of equal probability of each distribution, so a level is finite for an unbounded distribution too, and an effect is on the scale of `F(x)`, so inputs of different units compare. `mu_star` (Campolongo, Cariboni and Saltelli 2007) ranks the inputs; a `sigma` near or above `mu_star` says the effect depends on the other inputs or is not linear. ```python from commol import Morris screening = analysis.run(Morris(trajectories=20, levels=4, quantities=[peak])) print([round(value) for value in screening.mu_star[0]], screening.runs) # [385, 279] 60 ``` ## Sobol indices `Sobol` uses the design of Saltelli (2010) on two matrices of independent Monte Carlo draws. The first-order index of an input (`first`) is the share of the variance of the quantity it explains alone; the total index (`total`) adds every interaction it takes part in, so `first ≤ total` up to the Monte Carlo error that the intervals show: below, the estimates of `k1` cross, and their intervals overlap. The intervals are percentiles of a bootstrap of the rows, at `confidence_level`. Sobol, Morris and the polynomial chaos need independent inputs. With dependent inputs, such as a posterior, moving one input breaks its dependence on the others, and the indices lose their meaning and no longer add up; an input space from a sample raises a `ValueError` that names PRCC and propagation, which take it. ```python from commol import Sobol indices = analysis.run( Sobol(draws=1024, replicates=200, confidence_level=0.9, quantities=[peak]) ) for name, i in [("k1", 0), ("k2", 1)]: first = (indices.first_lower[0][i], indices.first[0][i], indices.first_upper[0][i]) total = (indices.total_lower[0][i], indices.total[0][i], indices.total_upper[0][i]) print(name, [round(s, 2) for s in first], [round(s, 2) for s in total]) # k1 [0.47, 0.63, 0.78] [0.55, 0.59, 0.63] # k2 [0.21, 0.36, 0.48] [0.38, 0.41, 0.45] ``` ## Polynomial chaos `PolynomialChaos` (Sudret 2008) takes every input to `u = F(x)`, uniform on `[0, 1]`, and fits orthonormal Legendre polynomials of `u` of total degree `degree`, or a truncation of that basis ([below](#bases-for-many-inputs)), by least squares on a Latin hypercube of `draws` points. One basis fits every distribution, truncated ones too. The Sobol indices come from the squares of the coefficients. `loo_error` is the leave-one-out error relative to the variance: the indices are those of the expansion, and they are those of the model only as far as the expansion predicts it. `predict` gives the quantities at any input values, as a surrogate. ```python from commol import PolynomialChaos chaos = analysis.run(PolynomialChaos(degree=4, draws=60, quantities=[peak])) print(round(chaos.loo_error[0], 4), [round(s, 2) for s in chaos.total[0]]) # 0.0036 [0.61, 0.39] print([round(value) for value in chaos.predict([[0.4, 0.1]])[0]]) # [428] ``` ### Bases for many inputs The total-degree basis has `(k + degree)! / (k! degree!)` terms in `k` inputs: 10,626 for 20 inputs at degree 4. A fit needs more draws than terms, so a model of many inputs needs a smaller basis. You choose how to truncate it, with two keyword arguments: | Argument | Keeps | Default | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------- | | `max_interaction` | The terms of at most this many inputs | `None`: no limit | | `q_norm` | The terms whose degrees `α` have `Σ α_i^q ≤ degree^q`, with `q` in `(0, 1]`: below 1, a term of several inputs keeps a lower degree than a term of one | `1.0`: the total degree | The defaults keep the total-degree basis. With `max_interaction=2`, 20 inputs at degree 4 have `1 + 20 · 4 + C(20, 2) · 6 = 1,221` terms. The number of terms is known before any run, so too few `draws` raise before the first run. A truncation drops the terms it does not keep: the total indices hold only the interactions of the basis, and `loo_error` says how much of the model the smaller basis misses. ```python additive = analysis.run( PolynomialChaos(degree=4, max_interaction=1, draws=30, quantities=[peak]) ) print(len(additive.indices), round(additive.loo_error[0], 4)) # 9 0.0071 print([round(s, 2) for s in additive.total[0]]) # [0.61, 0.39] ``` With one input per term, the 9 terms against the 15 of the total degree hold no interaction, so each total index equals the first-order one. Here the leave-one-out error stays small: the interaction of `k1` and `k2` adds little to the peak. ## Plots Each result draws its own figure with `plot()`, one panel per quantity, and returns the matplotlib `Figure` ([Plotting](https://munqu.github.io/commol/latest/guide/simulations/#plotting): the extra `commol[plot]`, `output_file`, `config` and `axes`). The `plot` of a `PRCCResult` draws one line per input over the positions of a `Trajectory` and one bar per input for any other quantity. The `plot` of a `MorrisResult` draws `sigma` against `mu_star`, one point per input. The `plot` of a `SobolResult` draws the first-order and total indices with their intervals, and the `plot` of a `ChaosResult` draws those of the expansion. ```python figure = over_axis.plot(x_label="Step") figure = screening.plot() figure = indices.plot() ``` ## Checklist - [ ] Each input has bounds and a distribution you can defend; a uniform needs finite bounds. - [ ] Morris, Sobol and the polynomial chaos run on independent inputs; a posterior goes to PRCC or propagation. - [ ] A stochastic engine has `replicates` and a `seed`. - [ ] Each quantity reads the series you mean: the level of an output that only grows peaks at the last position, its `Increments` at the largest gain of a window. - [ ] A quantity that is NaN is filtered with `where(mask)` only on a condition you can defend, and the summary is read as conditioned on it. - [ ] The intervals of Sobol are narrow enough to rank the inputs, and none is NaN (a bootstrap replicate of no variance); if not, raise `draws`. - [ ] The leave-one-out error of a polynomial chaos is small before you read its indices, and `predict` reads values inside the bounds of the inputs. - [ ] A truncated basis of a polynomial chaos (`max_interaction`, `q_norm`) keeps the interactions you expect to matter; its total indices hold no other. ## Next steps | To learn | Read | | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The parameter sample, the predictive and its scores | [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) | | A posterior to propagate | [Sequential Monte Carlo](https://munqu.github.io/commol/latest/guide/uncertainty/smc/index.md), [Observation Models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md) | | Which parameters the data determine | [Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/index.md) | | Reports of runs, predictives and analyses | [Reports](https://munqu.github.io/commol/latest/guide/reports/index.md) | # Reports [Back to Home](https://munqu.github.io/commol/latest/index.md) A report turns the data of an object into plain dictionaries, ready for JSON. Each report is a method of the object whose data it describes: | Method | Reports | | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `Model.applied_parameters(values, problem=None, …)` | What the model holds and what calibration set | | `SimulationResults.report(positions, window, windows, …)` | What a run produced, next to the observations | | `SimulationReplicates.report(confidence_level, …)` | The same, with each number reduced across the replicates of a stochastic engine | | `Predictive.report(confidence_level, …)` | The same, with each number reduced across the draws of a sample, with their weights | | `IdentifiabilityReport.report()` | The verdicts of an identifiability analysis ([Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/#the-report)) | A single run and an ensemble give the same layout. ## Quick path ```python import json from commol import ( Aggregate, CalibrationParameter, CalibrationProblem, Calibrator, ModelBuilder, NelderMeadConfig, ObservedDataPoint, Simulation, windowed_totals, ) def model(k1: float | None): return ( ModelBuilder(name="Reported") .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="k2", value=0.1) .add_accumulator(id="cum_ab", name="Total moved from A to B") .add_transition( id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total", accumulators=["cum_ab"] ) .add_transition(id="t_bc", source=["B"], target=["C"], rate="k2 * B") .build( Aggregate( bin_amounts={"A": 990, "B": 10, "C": 0}, ), engine="DifferenceEquations", ) ) ends = [7, 14, 21, 28] truth = Simulation(model(0.4)).run(num_steps=28) observed_data = [ ObservedDataPoint(at=end, output="cum_ab", value=count, window=7) for end, count in zip(ends, windowed_totals(truth["cum_ab"], truth.positions, 7, ends)) ] problem = CalibrationProblem( observed_data=observed_data, parameters=[ CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0) ], loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=500), ) calibrated = model(None) result = Calibrator(Simulation(calibrated), problem).run() calibrated.apply_calibration_parameters(result, problem) applied = calibrated.applied_parameters(result, problem) print(json.dumps(applied["parameters"]["k2"])) # {"value": 0.1, "calibrated": false} results = Simulation(calibrated).run(num_steps=28) run_report = results.report( positions=ends, window=7, windows=4, observed_data=observed_data, accumulators=["cum_ab"], ) print(sorted(run_report)) # ['accumulators', 'observed', 'series'] ``` ## The applied parameters of a model `Model.applied_parameters(values, problem=None, sample=None, confidence_level=None)` lists every number the calibrated model holds, and whether calibration set it. The `parameters` section is the same for every kind of model; the structure of the model adds the sections of its kind (`ModelStructure.report_sections`). An aggregate model adds `initial_amounts` and `category_splits`: ```json { "parameters": { "k1": {"value": 0.4, "calibrated": true}, "k2": {"value": 0.1, "calibrated": false} }, "initial_amounts": { "A": {"amount": 990.0, "unit": "item", "calibrated": false}, "B": {"amount": 10.0, "unit": "item", "calibrated": true}, "C": {"amount": 100000.0, "unit": "mg/l", "calibrated": false} }, "category_splits": { "g1": {"group": [], "fraction": 0.5, "calibrated": false}, "g2": {"group": [], "fraction": 0.5, "calibrated": false} } } ``` | Argument | Meaning | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | The model | Call the method on the model **after** `apply_calibration_parameters`. | | `values` | A `CalibrationResult`, or an `{id: value}` mapping such as one of `sample.draws()`. | | `problem` | Optional. Adds the calibrated `factor` and `noise` parameters, which the model does not hold. | | `sample`, `confidence_level` | Optional. A `ParameterSample`: each entry gains its weighted statistics (`ParameterSample.summary`). A sample needs a confidence level. | | Entry | Rule | | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Parameters | Only numeric parameters: formulas and series are inputs, not fitted values. | | `initial_amounts` | One row per bin and per overridden compartment: the `amount` the engine builds, overrides included, and the `unit` of its bin. No row is a share of a total, so bins of different units sit side by side. | | `category_splits` | One row per category: its `fraction` of the group it splits. A category has no amount, because it can split bins of different units. | | `group` | The conditioning categories of a conditional stratification, or `[]` when it splits every bin. | | An uncalibrated bin (`None` amount) | `amount` is `null`. | | A bin with no unit | `unit` is `null`. | | A category name that two stratifications declare | Its row is under the qualified id, such as `group:g1`. | | A `sample` | Each entry gains an `interval` block (`mean`, `median`, `ci_lower`, `ci_upper`, `min`, `max`, `std`, all weighted), and the report starts with `confidence_level`. | The key order of each entry is fixed (`value`, `calibrated`, then `interval`; `amount`, `unit`, `calibrated`, then `interval`; `group`, `fraction`, `calibrated`, then `interval`), so saved JSON files stay comparable. An interval is in the unit of its entry: an amount in the unit of its bin, a split as a fraction of its group. ## The report of a run `SimulationResults.report(positions, window, windows, observed_data=(), accumulators=(), prediction_intervals=None)` reports a run on one axis of windows. It reads each value at a position of the results (`results.positions`), so it works for any model type: a differential model reports at non-integer positions once the results hold them ([Reporting at any position](https://munqu.github.io/commol/latest/guide/simulations/#reporting-at-any-position)). | Block | Content | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `observed` | For each observation label: `values`, padded to `windows`, so it lines up with `windowed` index for index. An observation goes to the window that holds its position (the rule of `AxisUnits.window_index`). `outputs` and `factor_id` appear when a series uses them, and `weights` when a weight is not 1.0. | | `series` | Each output sampled at `positions`, sorted by name, except the outputs of the accumulators in `accumulators` | | `accumulators` | For each accumulator: `total` (its final value), `windowed` (the gain in each window that ends within the results, as the loss compares it) and `cumulative` (sampled at `positions`); each reading as a `total` and `by_output` | | Argument | Meaning | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `positions` | The positions where series and cumulative readings are sampled; each must be a position of the results (`ValueError` otherwise). | | `window`, `windows` | The window axis of `observed` and `windowed`: the length of one window and how many. | | `observed_data` | The observations to report next to the run. | | `accumulators` | The accumulator ids to report. The results read the outputs of each one from their model (`results.model`, which `Simulation.run`, `run_at` and `replicates` set); results built by hand have no model, and reporting an accumulator raises `ValueError` that names it. | | `prediction_intervals` | The `PredictionIntervals` of a [conformal method](https://munqu.github.io/commol/latest/guide/uncertainty/conformal/index.md) (`ConformalResult.intervals`). The report gains a last block, `prediction_intervals`: its `confidence_level`, `series` (by label: `positions`, `prediction`, `lower`, `upper`) and `unbounded`. | The report holds only these blocks, and `prediction_intervals` when it is given. Add your own envelope around them, such as a run label or summary figures in your domain's terms: ```python document = {"kind": "calibration", "at": ends, **run_report} print(json.dumps(document)[:30]) ``` The results hold a reference to their model, not a copy, and a report reads only its structure (the outputs of each bin and accumulator), never the values of its parameters, so a later `update_parameters` does not change the report of a run. Results with their model still pickle. ## The report of an ensemble `SimulationReplicates.report(confidence_level, positions, window, windows, observed_data=(), accumulators=(), prediction_intervals=None)` reports the replicates of a stochastic engine, and `Predictive.report` with the same arguments reports the draws of a [`Predictive`](https://munqu.github.io/commol/latest/guide/uncertainty/#the-predictive). The layout is the one of the report of a run; every number becomes a `{mean, median, ci_lower, ci_upper, min, max}` block, computed per member and then reduced across the members with their weights (equal for replicates). A predictive takes no point estimate: each number comes from the draws only. ```python from commol import ParameterSample, Predictive, SampleMethod sample = ParameterSample.equally_weighted( ["k1"], [(0.0, 1.0)], [[0.35], [0.4], [0.45]], SampleMethod(name="draws") ) predictive = Predictive(Calibrator(Simulation(model(None)), problem), sample, range(29)) band_report = predictive.report( 0.9, positions=ends, window=7, windows=4, accumulators=["cum_ab"] ) print(list(band_report["series"]["A"])) # ['mean', 'median', 'ci_lower', 'ci_upper', 'min', 'max'] ``` ## The report of an identifiability analysis `IdentifiabilityReport.report()` gives the result of `Calibrator.identifiability`, of `ProfileResult.identifiability()`, or of `IdentifiabilityReport.merge`, as `parameters` (each parameter, in problem order, with its `structural` and `practical` verdicts, their reasons and combinations, or `None`) and `sections` (the evidence of each method that ran, by section name). ## Checklist - [ ] The model reports `applied_parameters` after `apply_calibration_parameters`. - [ ] `problem` is passed when there are `factor` or `noise` parameters. - [ ] `positions`, `window` and `windows` match the observations, and the results hold every position the report reads. ## Next steps | To learn | Read | | ---------------------------------- | ------------------------------------------------------------------------------------------- | | Samples, predictives and intervals | [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) | | The threads of the parallel work | [Threads and Parallelism](https://munqu.github.io/commol/latest/guide/parallelism/index.md) | | Signatures | [API reference: Reports](https://munqu.github.io/commol/latest/api/reports/index.md) | # Threads and Parallelism [Back to Home](https://munqu.github.io/commol/latest/index.md) commol runs its independent work in parallel in Rust: the starts of a calibration, the replicates of a bootstrap, the particles of a swarm, the draws of a predictive. This page is for a user who wants to choose how many threads that work uses, and which levels of it run in parallel. By default, commol uses one thread per core and every level in parallel; a seed gives the same result whatever you choose. ## Quick path ```python import commol commol.set_threads(4) # every parallel method from here on, in this thread with commol.threads(2): # only inside the block print(commol.get_threads(), commol.get_levels()) # 2 all with commol.threads(levels="outer"): # the particles of a swarm in order print(commol.get_threads(), commol.get_levels()) # 4 outer ``` | Name | What it does | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `commol.set_threads(n, levels=None)` | Sets the number of threads, at least 1, and optionally the levels, until the next call. | | `commol.threads(n=None, levels=None)` | A context manager: the setting holds inside the block and the previous one comes back after it, also on an error. `None` keeps the current value. | | `commol.get_threads()` | The number of threads: the one set, or the number of cores. | | `commol.get_levels()` | `"all"` or `"outer"`: the levels set, or `"all"`. | A number below 1, or `levels` other than `"all"` and `"outer"`, raises `ValueError`. ## What runs in parallel | Method | What runs in parallel | | --------------------------------------------------------------- | ---------------------------------------------- | | `Calibrator.run(starts=n)` | The starts | | A particle swarm (`ParticleSwarmConfig`) | The particles of an iteration | | The bootstraps, the block jackknife | The replicates | | `Predictive`, `Scenarios`, scores | The draws of the sample | | `GibbsPosterior`, `BayesianPosterior`, `ABCSMC`, `ABCRejection` | The particles or the draws | | `ProfileLikelihood`, `PredictionProfile` | Each side of each quantity | | The conformal methods | The fits of their folds or blocks | | The sensitivity methods | The runs of the design | | The local approximations, the identifiability methods | The runs of the finite differences, the points | | `Simulation.replicates(count)` | The replicates of a stochastic engine | One run of a model is never split: each thread runs whole runs. ## The number of threads Every parallel method runs in a pool of `n` threads that commol builds from the setting, and uses at most those threads, nested levels included. The setting is a `contextvars` variable: it holds in the Python thread, or the `asyncio` task, that sets it, and another thread starts with the default. `commol.threads` limits one block, for example when another library shares the machine: ```python from commol import ( Aggregate, BayesianBootstrap, CalibrationParameter, CalibrationProblem, Calibrator, ModelBuilder, ObservedDataPoint, ParticleSwarmConfig, Simulation, ) def model(k1: float | None): return ( ModelBuilder(name="Threads") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_parameter(id="k1", value=k1) .add_transition(id="ab", source=["A"], target=["B"], rate="k1 * A") .build(Aggregate(bin_amounts={"A": 1000, "B": 0}), engine="DifferenceEquations") ) run = Simulation(model(0.2)).run(20) problem = CalibrationProblem( observed_data=[ ObservedDataPoint(at=t, output="B", value=run["B"][t]) for t in range(2, 21, 2) ], parameters=[ CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0) ], loss_function="sse", optimization_config=ParticleSwarmConfig(particles=10, max_iterations=30), seed=3, ) calibrator = Calibrator(Simulation(model(None)), problem) with commol.threads(2): fit = calibrator.run(starts=4) bootstrap = calibrator.uncertainty(BayesianBootstrap(replicates=8, block_length=1)) print(round(fit.best_parameters["k1"], 3)) # 0.2 ``` ## The levels Some methods nest one parallel level in another: the starts of a calibration each run a swarm, and so do the replicates of a bootstrap. `levels` chooses which of them run in parallel: | `levels` | The outer level (starts, replicates, draws) | The particles of a swarm | | ----------------- | ------------------------------------------- | ------------------------ | | `"all"` (default) | In parallel | In parallel | | `"outer"` | In parallel | One after the other | With `"all"`, both levels share the one pool, and a thread with no work takes work from another one, so a calibration with one start uses every thread and a run with fewer starts than threads fills the gaps. `"outer"` keeps the particles of a swarm in order. The levels change no result: a seed gives the same result with any number of threads and either level, bit for bit, the history of `Calibrator.run_with_history` included. ```python with commol.threads(4, levels="outer"): in_order = calibrator.run_with_history() with commol.threads(4, levels="all"): in_parallel = calibrator.run_with_history() print(in_order == in_parallel) # True ``` ## `RAYON_NUM_THREADS` commol does not read the environment variable `RAYON_NUM_THREADS` of the `rayon` library, and does not use the global thread pool of `rayon`. Use `commol.set_threads` instead. Another library of the process that uses `rayon` keeps its own setting. ## Troubleshooting | Symptom | Try | | ----------------------------------------------- | ------------------------------------------------------------------ | | commol takes every core from other work | `commol.set_threads(n)` with fewer threads | | A setting does not hold in a thread you started | Set it in that thread: each Python thread has its own | | A calibration with one start uses one core | Check `commol.get_levels()`: `"outer"` runs the particles in order | ## Next steps | To learn | Read | | ------------------------------------------ | ----------------------------------------------------------------------------------------- | | The optimizers and their starts | [Optimizers](https://munqu.github.io/commol/latest/guide/calibration-optimizers/index.md) | | The uncertainty methods | [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) | | Complete models, with a fit on two threads | [Examples](https://munqu.github.io/commol/latest/guide/examples/index.md) | # Examples [Back to Home](https://munqu.github.io/commol/latest/index.md) Complete models with abstract names. Each example runs on its own, shows one technique, and reads or draws its results. They follow the order of the guide: rates and stratifications first, then other engines, then a fit, its uncertainty and a comparison of scenarios. | Example | Technique | Guide | | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [1. A chain of states](#example-1-a-chain-of-states) | Absolute and proportional rates; read and plot the results | [Core Concepts](https://munqu.github.io/commol/latest/guide/core-concepts/index.md) | | [2. A periodic rate](#example-2-a-periodic-rate) | `sin` of `step` in a rate | [Mathematical Expressions](https://munqu.github.io/commol/latest/guide/mathematical-expressions/index.md) | | [3. A rate per group](#example-3-a-rate-per-group) | Stratified rates; a figure of the sum of a bin | [Transitions](https://munqu.github.io/commol/latest/guide/transitions/index.md) | | [4. Rates of an intersection](#example-4-rates-of-an-intersection) | Two stratifications, `total_`, `per_compartment` | [Transitions](https://munqu.github.io/commol/latest/guide/transitions/index.md) | | [5. A conditional stratification](#example-5-a-conditional-stratification) | A stratification that splits one group only | [Core Concepts](https://munqu.github.io/commol/latest/guide/core-concepts/#conditional-stratifications) | | [6. A capacity limit](#example-6-a-capacity-limit) | `max` in a rate | [Mathematical Expressions](https://munqu.github.io/commol/latest/guide/mathematical-expressions/index.md) | | [7. A cycle in continuous time](#example-7-a-cycle-in-continuous-time) | A transition back to the first state, run as differential equations | [Running Simulations](https://munqu.github.io/commol/latest/guide/simulations/#differential-equations) | | [8. Rates that follow a pattern](#example-8-rates-that-follow-a-pattern) | `AxisPattern` as a rate, and a schedule per group | [Axis Patterns](https://munqu.github.io/commol/latest/guide/axis-patterns/index.md) | | [9. Runs at random](#example-9-runs-at-random) | Replicates of a stochastic engine, their mean and band | [Stochastic Simulation](https://munqu.github.io/commol/latest/guide/stochastic/index.md) | | [10. A fit, its uncertainty and two scenarios](#example-10-a-fit-its-uncertainty-and-two-scenarios) | `Calibrator`, a bootstrap, `Predictive`, `Scenarios`, `plot_compared`, threads | [Model Calibration](https://munqu.github.io/commol/latest/guide/calibration/index.md), [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) | ## Example 1: A chain of states `A` moves to `B` at an absolute rate, which names compartments, and `B` moves to `C` at a proportional rate, which names none. A fourth state `M` sits between `A` and `B`: ```python from commol import Aggregate, ModelBuilder, Simulation model = ( ModelBuilder(name="Chain Model", version="1.0") .add_bin(id="A", name="State A") .add_bin(id="M", name="Intermediate") .add_bin(id="B", name="State B") .add_bin(id="C", name="State C") .add_parameter(id="k1", value=0.4, description="A to M rate") .add_parameter(id="k2", value=0.2, description="M to B rate") .add_parameter(id="k3", value=0.1, description="B to C rate") .add_transition(id="flow_AM", source=["A"], target=["M"], rate="k1 * A * B / total") .add_transition(id="flow_MB", source=["M"], target=["B"], rate="k2") .add_transition(id="flow_BC", source=["B"], target=["C"], rate="k3") .build( Aggregate(bin_amounts={"A": 999, "M": 0, "B": 1, "C": 0}), engine="DifferenceEquations", ) ) results = Simulation(model).run(num_steps=100) peak = max(results["B"]) print(f"B peaks at {peak:.0f} at step {results['B'].index(peak)}") results.plot.series(output_file="chain.png") ``` ## Example 2: A periodic rate The rate of `A` to `B` varies with a period of 365 steps; `step` is the position on the axis and `pi` a constant of the [expressions](https://munqu.github.io/commol/latest/guide/mathematical-expressions/index.md): ```python from commol import Aggregate, ModelBuilder, Simulation model = ( ModelBuilder(name="Periodic Model", version="1.0") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_bin(id="C", name="State C") .add_parameter(id="k_mean", value=0.3) .add_parameter(id="k_amp", value=0.2) .add_parameter(id="k2", value=0.1) .add_transition( id="periodic_flow", source=["A"], target=["B"], rate="k_mean * (1 + k_amp * sin(2 * pi * step / 365)) * A * B / total", ) .add_transition(id="flow_BC", source=["B"], target=["C"], rate="k2") .build( Aggregate(bin_amounts={"A": 990, "B": 10, "C": 0}), engine="DifferenceEquations", ) ) results = Simulation(model).run(num_steps=365 * 3) results.plot.series(series=["B"], tick_every=365, output_file="periodic.png") ``` ## Example 3: A rate per group A stratification splits every bin into three groups, and the transition from `B` to `C` has one rate per group. In a figure, a bin id such as `B` draws the sum of its compartments: ```python from commol import Aggregate, ModelBuilder, Simulation model = ( ModelBuilder(name="Stratified Model", version="1.0") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_bin(id="C", name="State C") .add_stratification(id="group", categories=["g1", "g2", "g3"]) .add_parameter(id="k1", value=0.0003) .add_parameter(id="k2_g1", value=0.15) .add_parameter(id="k2_g2", value=0.12) .add_parameter(id="k2_g3", value=0.08) .add_transition(id="flow_AB", source=["A"], target=["B"], rate="k1") .add_transition( id="flow_BC", source=["B"], target=["C"], stratified_rates=[ {"conditions": [{"stratification": "group", "category": "g1"}], "rate": "k2_g1"}, {"conditions": [{"stratification": "group", "category": "g2"}], "rate": "k2_g2"}, {"conditions": [{"stratification": "group", "category": "g3"}], "rate": "k2_g3"}, ], ) .build( Aggregate( bin_amounts={"A": 9900, "B": 100, "C": 0}, stratification_fractions={"group": {"g1": 0.25, "g2": 0.55, "g3": 0.2}}, ), engine="DifferenceEquations", ) ) results = Simulation(model).run(num_steps=100) for name in ["B_g1", "B_g2", "B_g3"]: print(f"{name}: {results[name][-1]:.2f}") results.plot.series(series=["B", "B_g1", "B_g3"], output_file="groups.png") ``` ## Example 4: Rates of an intersection Two stratifications, and a rate for the compartments of type `t2` only. In group `g1`, the rate divides by the total of the subgroup, `total_g1`, instead of the total of the model. `per_compartment=True` makes `A` and `B` in the rate the compartments of each flow, so each compartment of `A` loses an amount computed from its own amount: ```python from commol import Aggregate, ModelBuilder, Simulation model = ( ModelBuilder(name="Intersection Model", version="1.0") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_bin(id="C", name="State C") .add_stratification(id="group", categories=["g1", "g2"]) .add_stratification(id="type", categories=["t1", "t2"]) .add_parameter(id="k1_low", value=0.3) .add_parameter(id="k1_high", value=0.6) .add_parameter(id="k2", value=0.1) .add_transition( id="flow_AB", source=["A"], target=["B"], rate="k1_low * A * B / total", stratified_rates=[ { "conditions": [{"stratification": "group", "category": "g1"}], "rate": "k1_low * A * B / total_g1", }, { "conditions": [{"stratification": "type", "category": "t2"}], "rate": "k1_high * A * B / total", }, ], per_compartment=True, ) .add_transition(id="flow_BC", source=["B"], target=["C"], rate="k2") .build( Aggregate( bin_amounts={"A": 990, "B": 10, "C": 0}, stratification_fractions={ "group": {"g1": 0.6, "g2": 0.4}, "type": {"t1": 0.8, "t2": 0.2}, }, ), engine="DifferenceEquations", ) ) results = Simulation(model).run(num_steps=100) print(sorted(name for name in results if name.startswith("B_"))) # ['B_g1_t1', 'B_g1_t2', 'B_g2_t1', 'B_g2_t2'] ``` ## Example 5: A conditional stratification The stratification `subtype` splits only the compartments of group `g2`; the compartments of `g1` are not split further: ```python from commol import Aggregate, ModelBuilder, Simulation model = ( ModelBuilder(name="Conditional Stratification Model", version="1.0") .add_bin(id="A", name="State A") .add_bin(id="B", name="State B") .add_bin(id="C", name="State C") .add_stratification(id="group", categories=["g1", "g2"]) .add_stratification( id="subtype", categories=["s1", "s2"], conditions=[{"stratification": "group", "category": "g2"}], ) .add_parameter(id="k1", value=0.3) .add_parameter(id="k2_default", value=0.1) .add_parameter(id="k2_s1", value=0.15) .add_parameter(id="k2_s2", value=0.05) .add_transition( id="flow_AB", source=["A"], target=["B"], rate="k1 * A * B / total", per_compartment=True, ) .add_transition( id="flow_BC", source=["B"], target=["C"], rate="k2_default", stratified_rates=[ {"conditions": [{"stratification": "subtype", "category": "s1"}], "rate": "k2_s1"}, {"conditions": [{"stratification": "subtype", "category": "s2"}], "rate": "k2_s2"}, ], ) .build( Aggregate( bin_amounts={"A": 9900, "B": 100, "C": 0}, stratification_fractions={ "group": {"g1": 0.6, "g2": 0.4}, "subtype": {"s1": 0.7, "s2": 0.3}, }, ), engine="DifferenceEquations", ) ) results = Simulation(model).run(num_steps=100) print(sorted(name for name in results if name.startswith("B"))) # ['B_g1', 'B_g2_s1', 'B_g2_s2'] ``` ## Example 6: A capacity limit The flow from `B` slows as `C` fills, and stops when `C` reaches `capacity`. The rate names `B`, so it is the flow itself; the factor `max(0, 1 - C / capacity)` never turns it negative. A difference model moves the flow of a whole step at once, so the last step can pass `capacity` by a little: ```python from commol import Aggregate, ModelBuilder, Simulation model = ( ModelBuilder(name="Capacity Model", version="1.0") .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=0.5) .add_parameter(id="k2_max", value=0.15) .add_parameter(id="capacity", value=100.0) .add_transition(id="flow_AB", source=["A"], target=["B"], rate="k1 * A * B / total") .add_transition( id="flow_BC", source=["B"], target=["C"], rate="k2_max * B * max(0, 1 - C / capacity)", ) .build( Aggregate(bin_amounts={"A": 950, "B": 50, "C": 0}), engine="DifferenceEquations", ) ) results = Simulation(model).run(num_steps=100) print(round(results["C"][-1], 1)) # 100.0 ``` ## Example 7: A cycle in continuous time Amounts in `C` return to `A`, so the process never ends. The same process runs as differential equations: only the engine changes, and the results can be read at any position, here every half step: ```python from commol import Aggregate, ModelBuilder, Simulation model = ( ModelBuilder(name="Cyclic Model", version="1.0") .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=0.3) .add_parameter(id="k2", value=0.1) .add_parameter(id="k3", value=0.01, description="C to A rate") .add_transition(id="flow_AB", source=["A"], target=["B"], rate="k1 * A * B / total") .add_transition(id="flow_BC", source=["B"], target=["C"], rate="k2") .add_transition(id="return_CA", source=["C"], target=["A"], rate="k3") .build( Aggregate(bin_amounts={"A": 990, "B": 10, "C": 0}), engine="DifferentialEquations", ) ) simulation = Simulation(model) results = simulation.run_at([step / 2 for step in range(2001)]) print(results.positions[:3]) # [0.0, 0.5, 1.0] print(round(sum(results[name][-1] for name in ["A", "B", "C"]))) # 1000 ``` ## Example 8: Rates that follow a pattern An `AxisPattern` is a rate that changes along the axis. The first model combines an opening pulse, an event every 30 steps and a sinusoid. The second gives each group its own schedule: the first `add_group` call is on the class, the next ones on the pattern it returns: ```python from commol import Aggregate, AxisPattern, ModelBuilder, Simulation combined = AxisPattern.combine( AxisPattern.pulse(at=0, value=0.3), AxisPattern.periodic(period=30, value=0.05), AxisPattern.sinusoid(amplitude=0.005, period=365, baseline=0.01), ) model = ( ModelBuilder(name="Pattern Model") .add_bin(id="A", name="Source") .add_bin(id="B", name="Sink") .add_transition(id="flow", source=["A"], target=["B"], rate=combined) .build(Aggregate(bin_amounts={"A": 10000, "B": 0}), engine="DifferenceEquations") ) results = Simulation(model).run(num_steps=365) per_group = AxisPattern.add_group( conditions=[{"stratification": "group", "category": "cat1"}], schedule=AxisPattern.periodic(period=7, value=0.05), ).add_group( conditions=[{"stratification": "group", "category": "cat2"}], schedule=AxisPattern.periodic(period=30, value=0.05), ) grouped = ( ModelBuilder(name="Schedule Model") .add_bin(id="A", name="Source") .add_bin(id="B", name="Sink") .add_stratification(id="group", categories=["cat1", "cat2"]) .add_transition(id="flow", source=["A"], target=["B"], rate=per_group) .build( Aggregate( bin_amounts={"A": 1000, "B": 0}, stratification_fractions={"group": {"cat1": 0.5, "cat2": 0.5}}, ), engine="DifferenceEquations", ) ) grouped_results = Simulation(grouped).run(num_steps=60) print(grouped_results["B_cat1"][-1] > grouped_results["B_cat2"][-1]) # True ``` ## Example 9: Runs at random The `StochasticSteps` engine moves whole units at random, one step at a time. `replicates` runs the model once per seed; the mean, the band and the figure reduce the runs position by position: ```python from commol import Aggregate, ModelBuilder, Simulation model = ( ModelBuilder(name="Stochastic 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=0.3) .add_parameter(id="k2", value=0.1) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total") .add_transition(id="t_bc", source=["B"], target=["C"], rate="k2") .build(Aggregate(bin_amounts={"A": 990, "B": 10, "C": 0}), engine="StochasticSteps") ) replicates = Simulation(model).replicates(40, seed=7).run(num_steps=100) mean = replicates.mean() spread = replicates.member_statistics("C", confidence_level=0.9) print(round(mean["C"][-1]), round(spread["ci_lower"][-1]), round(spread["ci_upper"][-1])) replicates.plot.series(series=["B", "C"], confidence_level=0.9, output_file="replicates.png") ``` ## Example 10: A fit, its uncertainty and two scenarios A fit of `k1` to noisy observations of `B`, a sample of `k1` from a Bayesian bootstrap, its predictive band, and a comparison of two scenarios of `k2`. The work of the bootstrap and of the draws runs on two threads: ```python import numpy as np import commol from commol import ( Aggregate, BayesianBootstrap, CalibrationParameter, CalibrationProblem, Calibrator, ModelBuilder, NelderMeadConfig, ObservedDataPoint, Predictive, Scenarios, Simulation, ValueAt, plot_compared, ) def basic_model(k1: 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="k2", value=0.1) .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * 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)).run(num_steps=40) rng = np.random.default_rng(1) observed_data = [ ObservedDataPoint(at=step, output="B", value=truth["B"][step] * float(np.exp(rng.normal(0.0, 0.05)))) for step in range(2, 41, 2) ] problem = CalibrationProblem( observed_data=observed_data, parameters=[ CalibrationParameter(id="k1", parameter_type="parameter", min_bound=0.0, max_bound=1.0), ], loss_function="sse", optimization_config=NelderMeadConfig(max_iterations=500), seed=42, ) calibrator = Calibrator(Simulation(basic_model(None)), problem) with commol.threads(2): fit = calibrator.run() bootstrap = calibrator.uncertainty(BayesianBootstrap(replicates=20, block_length=1)) predictive = Predictive(calibrator, bootstrap.sample, positions=list(range(61))) scenarios = Scenarios( calibrator, bootstrap.sample, {"fitted": {}, "slower": {"k2": 0.05}}, positions=list(range(61)), ) print(round(fit.best_parameters["k1"], 2)) # close to 0.4 lower, upper = bootstrap.sample.interval(0.9)["k1"] print(round(lower, 3), round(upper, 3)) # the 90 % interval of k1 predictive.plot.series(series=["B"], observed_data=observed_data, output_file="band.png") [difference] = scenarios.compare("slower", "fitted", [ValueAt(series="C", at=60)], 0.9) print(difference.label, round(difference.median)) # how much less C reaches by step 60 plot_compared(scenarios, series=["B", "C"], output_file="scenarios.png") ``` ## Next steps | To learn | Read | | ----------------------------------------------------- | --------------------------------------------------------------------------------------- | | Every builder option | [Building Models](https://munqu.github.io/commol/latest/guide/building-models/index.md) | | Reading, aggregating and plotting results | [Running Simulations](https://munqu.github.io/commol/latest/guide/simulations/index.md) | | Calibration in depth | [Model Calibration](https://munqu.github.io/commol/latest/guide/calibration/index.md) | | The uncertainty methods, the predictive and scenarios | [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) | | Every class and argument | [API Reference](https://munqu.github.io/commol/latest/api/index.md) | # API reference # API Reference [Back to Home](https://munqu.github.io/commol/latest/index.md) Every public name of `commol`, generated from the docstrings. The guide pages explain how to use them; these pages list every argument, attribute and error. The table lists each name that the package root exports (`commol.__all__`), on the page that documents it. Import these names from the package root, as in `from commol import Simulation`. | Page | Names | | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [ModelBuilder](https://munqu.github.io/commol/latest/api/model-builder/index.md) | `ModelBuilder` | | [Model](https://munqu.github.io/commol/latest/api/model/index.md) | `Model`, `Process`, `ModelStructure`, `Aggregate`, `Complete`, `Groups`, `SeriesValue`, `ModelDescription` | | [AxisPattern](https://munqu.github.io/commol/latest/api/axis-patterns/index.md) | `AxisPattern` | | [AxisUnits](https://munqu.github.io/commol/latest/api/axis-units/index.md) | `AxisUnits` | | [Simulation](https://munqu.github.io/commol/latest/api/simulation/index.md) | `Simulation`, `SimulationResults`, `ReplicateRuns`, `SimulationReplicates`, `ODESolver`, `StochasticEventsSettings`, `StochasticStepsSettings`, `InfeasibleStateError`, `windowed_totals`, `sample` | | [Plotting](https://munqu.github.io/commol/latest/api/plotting/index.md) | `plot_compared`, `PlotConfig`; the methods `SimulationResults.plot`, `SimulationReplicates.plot`, `Predictive.plot`, `PRCCResult.plot`, `MorrisResult.plot`, `SobolResult.plot`, `ChaosResult.plot`, `ProfileResult.plot` | | [Calibration](https://munqu.github.io/commol/latest/api/calibrator/index.md) | `Calibrator`, `CalibrationProblem`, `Quantile`, `ObservedDataPoint`, `CalibrationParameter`, `CalibrationConstraint`, `NelderMeadConfig`, `ParticleSwarmConfig`, `CalibrationResult`, `StartSummary`, `CalibrationHistory`, `CalibrationEvaluation` | | [Observation models](https://munqu.github.io/commol/latest/api/calibrator/#observation-models) | `ObservationModel`, `NormalObservations`, `LogNormalObservations`, `StudentTObservations`, `PoissonObservations`, `NegativeBinomialObservations`, `BinomialObservations` | | [Reports](https://munqu.github.io/commol/latest/api/reports/index.md) | the methods `Model.applied_parameters`, `SimulationResults.report`, `SimulationReplicates.report`, `Predictive.report`, `IdentifiabilityReport.report` | | [Uncertainty](https://munqu.github.io/commol/latest/api/uncertainty/index.md) | `InferenceMethod`, `UncertaintyMethod`, `ParameterSample`, `SampleMethod`, `ParameterSummary`, `Predictive`, `PredictiveBand`, `SeriesBand`, `ObservationDraws`, `Scenarios`, `ScenarioSummary`, `NoNoise`, `ResidualNoise`, `DistributionNoise` | | [Resampling](https://munqu.github.io/commol/latest/api/uncertainty/#resampling) | `BayesianBootstrap`, `ResidualBootstrap`, `WildBootstrap`, `BlockJackknife`, `ParametricBootstrap`, `BootstrapResult`, `JackknifeResult`, `ResamplingDiagnostics`, `BoundError` | | [Profile likelihood](https://munqu.github.io/commol/latest/api/uncertainty/#profile-likelihood) | `ProfileLikelihood`, `PredictionProfile`, `PredictionSpec`, `Threshold`, `ProfileResult`, `Profile`, `ProfileInterval` | | [Sequential Monte Carlo](https://munqu.github.io/commol/latest/api/uncertainty/#sequential-monte-carlo) | `Priors`, `Uniform`, `LogUniform`, `Beta`, `Normal`, `LogNormal`, `Gamma`, `GibbsPosterior`, `BayesianPosterior`, `InformationMatchingEta`, `BootstrapCoverageEta`, `ABCSMC`, `ABCRejection`, `LossDistance`, `SeriesDistance`, `SMCResult`, `GibbsResult`, `SMCDiagnostics` | | [Local approximations](https://munqu.github.io/commol/latest/api/uncertainty/#local-approximations) | `Laplace`, `Sandwich`, `LaplaceMixture`, `LikelihoodTarget`, `GibbsTarget`, `LocalResult`, `LaplaceResult`, `SandwichResult`, `DeltaBand`, `ImportanceResult`, `MixtureComponent` | | [Conformal prediction](https://munqu.github.io/commol/latest/api/uncertainty/#conformal-prediction) | `SplitConformal`, `RollingConformal`, `JackknifePlus`, `EnbPI`, `AdaptiveConformal`, `ConformalResult`, `PredictionIntervals`, `SeriesIntervals` | | [Scores and intervals](https://munqu.github.io/commol/latest/api/uncertainty/#scores-and-intervals) | `weighted_quantiles`, `member_statistics`, `ci_percentiles` | | [Identifiability](https://munqu.github.io/commol/latest/api/identifiability/index.md) | `IdentifiabilityMethod`, `StructuralRank`, `Collinearity`, `SampleIdentifiability`, `StartsIdentifiability`, `IdentifiabilityReport` | | [Sensitivity](https://munqu.github.io/commol/latest/api/sensitivity/index.md) | `SensitivityAnalysis`, `InputSpace`, `SensitivityMethod`, `Propagation`, `PropagationResult`, `QuantitySummary`, `PRCC`, `PRCCResult`, `Morris`, `MorrisResult`, `Sobol`, `SobolResult`, `PolynomialChaos`, `ChaosResult`, `Level`, `Increments`, `ValueAt`, `Gain`, `Peak`, `PeakPosition`, `Trajectory`, `Ratio`, `quantity_schedule` | | [Parallelism](https://munqu.github.io/commol/latest/api/parallelism/index.md) | `set_threads`, `threads`, `get_threads`, `get_levels` | | [Package](https://munqu.github.io/commol/latest/api/package/index.md) | `__version__`, `build_info`, `BuildInfo` | The pages also document names that are not in the package root: - The scores are the module `commol.scores` (`from commol import scores`), on the [Uncertainty](https://munqu.github.io/commol/latest/api/uncertainty/#scores-and-intervals) page. - `LossFunction`, the values of `CalibrationProblem.loss_function`, is in `commol.context.constants`. - `UnitConsistencyError` is in `commol.utils.equations`. - Some types are only read from what a method returns, and you do not import them: `InitialCompartment` (an entry of `Aggregate.initial_state()`), the blocks of a `ModelDescription`, the parts of an `IdentifiabilityReport`, and the types of a model in [Model Structure](https://munqu.github.io/commol/latest/advanced/model-structure/index.md). # AxisPattern API [Back to API Reference](https://munqu.github.io/commol/latest/api/index.md) Rates that change along the axis, and schedules per subgroup. Guide: [Axis Patterns](https://munqu.github.io/commol/latest/guide/axis-patterns/index.md). ## AxisPattern Bases: `BaseModel` An expression that changes along the axis, usable as (or within) a transition rate. Instances are created exclusively via the factory classmethods (`pulse`, `periodic`, `window`, etc.). Each instance may carry stratification conditions that restrict it to a specific sub-group via `for_group`. Multi-group schedules are built by calling `AxisPattern.add_group(...)` directly on the class. The first call returns a new schedule; chain further `add_group` and `set_default` calls on the returned instance. A `AxisPattern` is accepted directly by :meth:`commol.api.model_builder.ModelBuilder.add_transition` via the `rate` argument; no auxiliary conversion is required. Attributes: | Name | Type | Description | | -------------------- | --------------------- | ----------- | | `conditions` | \`list[ConditionDict] | None\` | | `source_compartment` | \`str | None\` | Examples: ```pycon >>> from commol import AxisPattern >>> # Single pattern >>> rate = AxisPattern.pulse(at=10, value=0.05) >>> str(rate) '(if(step == 10, 0.05, 0))' >>> # Multi-group schedule >>> rate = AxisPattern.add_group( ... conditions=[{"stratification": "group", "category": "cat1"}], ... schedule=AxisPattern.pulse(at=10, value=0.05), ... ).add_group( ... conditions=[{"stratification": "group", "category": "cat2"}], ... schedule=AxisPattern.pulse(at=10, value=0.02), ... ) ``` Source code in `commol/api/patterns.py` ```python class AxisPattern(BaseModel): """ An expression that changes along the axis, usable as (or within) a transition rate. Instances are created exclusively via the factory classmethods (``pulse``, ``periodic``, ``window``, etc.). Each instance may carry stratification conditions that restrict it to a specific sub-group via ``for_group``. Multi-group schedules are built by calling ``AxisPattern.add_group(...)`` directly on the class. The first call returns a new schedule; chain further ``add_group`` and ``set_default`` calls on the returned instance. A ``AxisPattern`` is accepted directly by :meth:`commol.api.model_builder.ModelBuilder.add_transition` via the ``rate`` argument; no auxiliary conversion is required. Attributes ---------- conditions : list[ConditionDict] | None The stratification conditions of the sub-group the pattern applies to, set by ``for_group``. None: every compartment of the transition. source_compartment : str | None A compartment the rate is multiplied by, which makes the flow absolute, set by ``for_group``. None: the flow is proportional to its source. Examples -------- >>> from commol import AxisPattern >>> # Single pattern >>> rate = AxisPattern.pulse(at=10, value=0.05) >>> str(rate) '(if(step == 10, 0.05, 0))' >>> # Multi-group schedule >>> rate = AxisPattern.add_group( ... conditions=[{"stratification": "group", "category": "cat1"}], ... schedule=AxisPattern.pulse(at=10, value=0.05), ... ).add_group( ... conditions=[{"stratification": "group", "category": "cat2"}], ... schedule=AxisPattern.pulse(at=10, value=0.02), ... ) """ model_config = ConfigDict(extra="forbid") # Combine-mode constants — public; usable as AxisPattern.SUM, .MAX, .MIN SUM: ClassVar[CombineMode] = CombineMode.SUM MAX: ClassVar[CombineMode] = CombineMode.MAX MIN: ClassVar[CombineMode] = CombineMode.MIN conditions: list[ConditionDict] | None = None source_compartment: str | None = None # `add_group` is a hybrid descriptor (see _AddGroupDescriptor above). # The ClassVar annotation tells Pydantic this is not a model field. add_group: ClassVar[_AddGroupDescriptor] = _AddGroupDescriptor() @property @abstractmethod def formula(self) -> str: """The mathematical expression for this pattern (always parenthesised).""" def _as_series_data(self) -> list[tuple[int, float]] | None: """Return pulse data as (step, value) points if representable as a series. Returns None for patterns that cannot be expressed as a numeric series (e.g. formula values, mathematical patterns). Overridden only by ``_PulseAxisPattern`` and ``_PulsesAxisPattern``. """ return None def __str__(self) -> str: """Return the pattern's formula.""" return self.formula def for_group( self, conditions: list[ConditionDict], *, source_compartment: str | None = None, ) -> Self: """ Return a copy of this pattern restricted to a stratification sub-group. Parameters ---------- conditions : list[ConditionDict] The stratification conditions of the sub-group, as in a stratified rate. source_compartment : str | None, default=None A compartment to multiply the rate by, which makes the flow absolute. If None, the flow is proportional to its source. Returns ------- AxisPattern The copy, with `conditions` and `source_compartment` set. """ copied: list[ConditionDict] = [_copy_condition(c) for c in conditions] return self.model_copy( update={ "conditions": copied, "source_compartment": source_compartment, } ) def to_stratified_rate(self) -> StratifiedRateDict: """ Convert this pattern to a stratified-rate dict. If ``source_compartment`` is set, the rate is multiplied by that compartment variable (absolute flow). Otherwise the rate is the formula alone (proportional flow — the engine multiplies by the source compartment automatically). """ if self.source_compartment is not None: rate = f"{self.formula} * {self.source_compartment}" else: rate = self.formula return StratifiedRateDict( conditions=self.conditions if self.conditions is not None else [], rate=rate, ) # ------------------------------------------------------------------ # Builder-consumption hooks # ------------------------------------------------------------------ # The methods below are what ModelBuilder.add_transition reads when it # receives a AxisPattern as `rate=`. Users do not call them directly. def _builder_rate(self) -> str | float | None: """Default rate string to pass to add_transition (or None).""" if self.conditions is None: return self.formula return None def _builder_stratified_rates(self) -> list[StratifiedRateDict] | None: """Stratified-rates list to pass to add_transition (or None).""" if self.conditions is None: return None return [self.to_stratified_rate()] # ------------------------------------------------------------------ # Methods only meaningful on schedules — base raises by default. # ------------------------------------------------------------------ def set_default(self, pattern: "AxisPattern") -> "AxisPattern": # noqa: ARG002 """ Register the condition-free fallback pattern (schedules only). Parameters ---------- pattern : AxisPattern The pattern of every compartment that no group matches, with no conditions and no ``source_compartment``. Returns ------- AxisPattern The schedule. Raises ------ TypeError If this pattern is not a schedule, one that ``AxisPattern.add_group`` started. ValueError If the schedule has a default already, or `pattern` has conditions or a ``source_compartment``. """ raise TypeError( "set_default is only valid on a schedule AxisPattern; " "start one with AxisPattern.add_group(...) first." ) # ------------------------------------------------------------------ # Pattern factory classmethods # ------------------------------------------------------------------ @classmethod def pulse(cls, at: int, value: float | str) -> "AxisPattern": """Single-step pulse: rate = ``value`` at step ``at``, else 0.""" _validate_value_string(value) return _PulseAxisPattern(at=at, value=value) @classmethod def pulses( cls, at: Iterable[int], value: float | str | Iterable[float | str], ) -> "AxisPattern": """Multi-step discrete pulses. Steps must be unique and non-negative. Parameters ---------- at : Iterable[int] Step indices at which the pulse fires. Must be unique and non-negative. value : float | str | Iterable[float | str] Pulse magnitude. Either a single value applied to every step, or an iterable of values with the same length as ``at`` — one per step. """ value_resolved: float | str | list[float | str] = ( list(value) if not isinstance(value, (float, int, str)) else value ) if isinstance(value_resolved, (float, int, str)): _validate_value_string(value_resolved) return _PulsesAxisPattern(steps=list(at), value=value_resolved) @classmethod def periodic( cls, period: int, value: float | str, offset: int = 0, ) -> "AxisPattern": """Periodic pulse every ``period`` steps starting at ``offset``.""" _validate_value_string(value) return _PeriodicAxisPattern(period=period, value=value, offset=offset) @classmethod def interval( cls, start: float, end: float, value: float | str, ) -> "AxisPattern": """Constant rate ``value`` while ``t`` is in ``[start, end)``, else 0. In a DifferenceEquations model ``t`` is the step, so the rate acts on the steps ``start <= step < end``. In a DifferentialEquations model the interval opens and closes at any position, and the engine restarts its integration at both ends. """ _validate_value_string(value) return _IntervalAxisPattern(start=start, end=end, value=value) @classmethod def sinusoid( cls, amplitude: float | str, period: float | str, phase: float | str = 0, baseline: float | str = 0, ) -> "AxisPattern": """``baseline + amplitude * sin(2*pi*(t - phase) / period)``.""" return _SinusoidAxisPattern( amplitude=amplitude, period=period, phase=phase, baseline=baseline, ) @classmethod def gaussian_pulse( cls, center: float, width: float, peak: float | str, ) -> "AxisPattern": """Gaussian bell pulse: ``peak * exp(-(t - center)^2 / (2 * width^2))``.""" return _GaussianPulseAxisPattern(center=center, width=width, peak=peak) @classmethod def linear_ramp( cls, start: float, end: float, start_value: float, end_value: float, ) -> "AxisPattern": """Linear in ``t`` from ``start_value`` to ``end_value`` on ``[start, end)``. The rate is 0 outside the interval. In a DifferenceEquations model ``t`` is the step, so the ramp is read at each step; in a DifferentialEquations model it is continuous. """ return _LinearRampAxisPattern( start=start, end=end, start_value=start_value, end_value=end_value, ) @classmethod def combine( cls, *patterns: "AxisPattern", mode: CombineMode = CombineMode.SUM, config: SecurityConfig | None = None, ) -> "AxisPattern": """ Combine patterns into a single composed pattern. - ``combine(p)`` is the identity (returns ``p`` unchanged). - All input patterns must share the same ``conditions`` and ``source_compartment`` (or have none); the result preserves them. - ``mode`` is a `CombineMode` or its value (``"sum"``, ``"max"``, ``"min"``). - Raises ``ValueError`` if no patterns are supplied, ``mode`` is not a mode, the inputs have inconsistent group bindings, or the combined formula exceeds the security length cap. """ if len(patterns) == 0: raise ValueError("combine() requires at least one pattern") if len(patterns) == 1: return patterns[0] shared_conditions, shared_source = _require_consistent_group(patterns) result = _compose_formulas([p.formula for p in patterns], CombineMode(mode)) _enforce_length_cap(result, config) return _ComputedAxisPattern( computed_formula=result, conditions=shared_conditions, source_compartment=shared_source, ) @classmethod def from_formula( cls, expr: str, config: SecurityConfig | None = None, ) -> "AxisPattern": """ Create a pattern from an arbitrary expression string. The string is wrapped in ``(...)`` so it composes safely, then run through the full expression security validator. """ if not expr or not expr.strip(): raise ValueError("'expr' must be a non-empty expression") wrapped = f"({expr})" validate_expression_security(wrapped, config) return _ComputedAxisPattern(computed_formula=wrapped) ``` ### Attributes #### formula ```python formula: str ``` The mathematical expression for this pattern (always parenthesised). ### Methods: #### __str__ ```python __str__() -> str ``` Return the pattern's formula. Source code in `commol/api/patterns.py` ```python def __str__(self) -> str: """Return the pattern's formula.""" return self.formula ``` #### for_group ```python for_group(conditions: list[ConditionDict], *, source_compartment: str | None = None) -> Self ``` Return a copy of this pattern restricted to a stratification sub-group. Parameters: | Name | Type | Description | Default | | -------------------- | --------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | | `conditions` | `list[ConditionDict]` | The stratification conditions of the sub-group, as in a stratified rate. | *required* | | `source_compartment` | \`str | None\` | A compartment to multiply the rate by, which makes the flow absolute. If None, the flow is proportional to its source. | Returns: | Type | Description | | ------------- | ----------------------------------------------------- | | `AxisPattern` | The copy, with conditions and source_compartment set. | Source code in `commol/api/patterns.py` ```python def for_group( self, conditions: list[ConditionDict], *, source_compartment: str | None = None, ) -> Self: """ Return a copy of this pattern restricted to a stratification sub-group. Parameters ---------- conditions : list[ConditionDict] The stratification conditions of the sub-group, as in a stratified rate. source_compartment : str | None, default=None A compartment to multiply the rate by, which makes the flow absolute. If None, the flow is proportional to its source. Returns ------- AxisPattern The copy, with `conditions` and `source_compartment` set. """ copied: list[ConditionDict] = [_copy_condition(c) for c in conditions] return self.model_copy( update={ "conditions": copied, "source_compartment": source_compartment, } ) ``` #### to_stratified_rate ```python to_stratified_rate() -> StratifiedRateDict ``` Convert this pattern to a stratified-rate dict. If `source_compartment` is set, the rate is multiplied by that compartment variable (absolute flow). Otherwise the rate is the formula alone (proportional flow — the engine multiplies by the source compartment automatically). Source code in `commol/api/patterns.py` ```python def to_stratified_rate(self) -> StratifiedRateDict: """ Convert this pattern to a stratified-rate dict. If ``source_compartment`` is set, the rate is multiplied by that compartment variable (absolute flow). Otherwise the rate is the formula alone (proportional flow — the engine multiplies by the source compartment automatically). """ if self.source_compartment is not None: rate = f"{self.formula} * {self.source_compartment}" else: rate = self.formula return StratifiedRateDict( conditions=self.conditions if self.conditions is not None else [], rate=rate, ) ``` #### set_default ```python set_default(pattern: AxisPattern) -> AxisPattern ``` Register the condition-free fallback pattern (schedules only). Parameters: | Name | Type | Description | Default | | --------- | ------------- | ----------------------------------------------------------------------------------------------------- | ---------- | | `pattern` | `AxisPattern` | The pattern of every compartment that no group matches, with no conditions and no source_compartment. | *required* | Returns: | Type | Description | | ------------- | ------------- | | `AxisPattern` | The schedule. | Raises: | Type | Description | | ------------ | ----------------------------------------------------------------------------------------- | | `TypeError` | If this pattern is not a schedule, one that AxisPattern.add_group started. | | `ValueError` | If the schedule has a default already, or pattern has conditions or a source_compartment. | Source code in `commol/api/patterns.py` ```python def set_default(self, pattern: "AxisPattern") -> "AxisPattern": # noqa: ARG002 """ Register the condition-free fallback pattern (schedules only). Parameters ---------- pattern : AxisPattern The pattern of every compartment that no group matches, with no conditions and no ``source_compartment``. Returns ------- AxisPattern The schedule. Raises ------ TypeError If this pattern is not a schedule, one that ``AxisPattern.add_group`` started. ValueError If the schedule has a default already, or `pattern` has conditions or a ``source_compartment``. """ raise TypeError( "set_default is only valid on a schedule AxisPattern; " "start one with AxisPattern.add_group(...) first." ) ``` #### pulse ```python pulse(at: int, value: float | str) -> AxisPattern ``` Single-step pulse: rate = `value` at step `at`, else 0. Source code in `commol/api/patterns.py` ```python @classmethod def pulse(cls, at: int, value: float | str) -> "AxisPattern": """Single-step pulse: rate = ``value`` at step ``at``, else 0.""" _validate_value_string(value) return _PulseAxisPattern(at=at, value=value) ``` #### pulses ```python pulses(at: Iterable[int], value: float | str | Iterable[float | str]) -> AxisPattern ``` Multi-step discrete pulses. Steps must be unique and non-negative. Parameters: | Name | Type | Description | Default | | ------- | --------------- | ----------------------------------------------------------------------- | --------------- | | `at` | `Iterable[int]` | Step indices at which the pulse fires. Must be unique and non-negative. | *required* | | `value` | \`float | str | Iterable\[float | Source code in `commol/api/patterns.py` ```python @classmethod def pulses( cls, at: Iterable[int], value: float | str | Iterable[float | str], ) -> "AxisPattern": """Multi-step discrete pulses. Steps must be unique and non-negative. Parameters ---------- at : Iterable[int] Step indices at which the pulse fires. Must be unique and non-negative. value : float | str | Iterable[float | str] Pulse magnitude. Either a single value applied to every step, or an iterable of values with the same length as ``at`` — one per step. """ value_resolved: float | str | list[float | str] = ( list(value) if not isinstance(value, (float, int, str)) else value ) if isinstance(value_resolved, (float, int, str)): _validate_value_string(value_resolved) return _PulsesAxisPattern(steps=list(at), value=value_resolved) ``` #### periodic ```python periodic(period: int, value: float | str, offset: int = 0) -> AxisPattern ``` Periodic pulse every `period` steps starting at `offset`. Source code in `commol/api/patterns.py` ```python @classmethod def periodic( cls, period: int, value: float | str, offset: int = 0, ) -> "AxisPattern": """Periodic pulse every ``period`` steps starting at ``offset``.""" _validate_value_string(value) return _PeriodicAxisPattern(period=period, value=value, offset=offset) ``` #### interval ```python interval(start: float, end: float, value: float | str) -> AxisPattern ``` Constant rate `value` while `t` is in `[start, end)`, else 0. In a DifferenceEquations model `t` is the step, so the rate acts on the steps `start <= step < end`. In a DifferentialEquations model the interval opens and closes at any position, and the engine restarts its integration at both ends. Source code in `commol/api/patterns.py` ```python @classmethod def interval( cls, start: float, end: float, value: float | str, ) -> "AxisPattern": """Constant rate ``value`` while ``t`` is in ``[start, end)``, else 0. In a DifferenceEquations model ``t`` is the step, so the rate acts on the steps ``start <= step < end``. In a DifferentialEquations model the interval opens and closes at any position, and the engine restarts its integration at both ends. """ _validate_value_string(value) return _IntervalAxisPattern(start=start, end=end, value=value) ``` #### sinusoid ```python sinusoid(amplitude: float | str, period: float | str, phase: float | str = 0, baseline: float | str = 0) -> AxisPattern ``` `baseline + amplitude * sin(2*pi*(t - phase) / period)`. Source code in `commol/api/patterns.py` ```python @classmethod def sinusoid( cls, amplitude: float | str, period: float | str, phase: float | str = 0, baseline: float | str = 0, ) -> "AxisPattern": """``baseline + amplitude * sin(2*pi*(t - phase) / period)``.""" return _SinusoidAxisPattern( amplitude=amplitude, period=period, phase=phase, baseline=baseline, ) ``` #### gaussian_pulse ```python gaussian_pulse(center: float, width: float, peak: float | str) -> AxisPattern ``` Gaussian bell pulse: `peak * exp(-(t - center)^2 / (2 * width^2))`. Source code in `commol/api/patterns.py` ```python @classmethod def gaussian_pulse( cls, center: float, width: float, peak: float | str, ) -> "AxisPattern": """Gaussian bell pulse: ``peak * exp(-(t - center)^2 / (2 * width^2))``.""" return _GaussianPulseAxisPattern(center=center, width=width, peak=peak) ``` #### linear_ramp ```python linear_ramp(start: float, end: float, start_value: float, end_value: float) -> AxisPattern ``` Linear in `t` from `start_value` to `end_value` on `[start, end)`. The rate is 0 outside the interval. In a DifferenceEquations model `t` is the step, so the ramp is read at each step; in a DifferentialEquations model it is continuous. Source code in `commol/api/patterns.py` ```python @classmethod def linear_ramp( cls, start: float, end: float, start_value: float, end_value: float, ) -> "AxisPattern": """Linear in ``t`` from ``start_value`` to ``end_value`` on ``[start, end)``. The rate is 0 outside the interval. In a DifferenceEquations model ``t`` is the step, so the ramp is read at each step; in a DifferentialEquations model it is continuous. """ return _LinearRampAxisPattern( start=start, end=end, start_value=start_value, end_value=end_value, ) ``` #### combine ```python combine(*patterns: AxisPattern, mode: CombineMode = SUM, config: SecurityConfig | None = None) -> AxisPattern ``` Combine patterns into a single composed pattern. - `combine(p)` is the identity (returns `p` unchanged). - All input patterns must share the same `conditions` and `source_compartment` (or have none); the result preserves them. - `mode` is a `CombineMode` or its value (`"sum"`, `"max"`, `"min"`). - Raises `ValueError` if no patterns are supplied, `mode` is not a mode, the inputs have inconsistent group bindings, or the combined formula exceeds the security length cap. Source code in `commol/api/patterns.py` ```python @classmethod def combine( cls, *patterns: "AxisPattern", mode: CombineMode = CombineMode.SUM, config: SecurityConfig | None = None, ) -> "AxisPattern": """ Combine patterns into a single composed pattern. - ``combine(p)`` is the identity (returns ``p`` unchanged). - All input patterns must share the same ``conditions`` and ``source_compartment`` (or have none); the result preserves them. - ``mode`` is a `CombineMode` or its value (``"sum"``, ``"max"``, ``"min"``). - Raises ``ValueError`` if no patterns are supplied, ``mode`` is not a mode, the inputs have inconsistent group bindings, or the combined formula exceeds the security length cap. """ if len(patterns) == 0: raise ValueError("combine() requires at least one pattern") if len(patterns) == 1: return patterns[0] shared_conditions, shared_source = _require_consistent_group(patterns) result = _compose_formulas([p.formula for p in patterns], CombineMode(mode)) _enforce_length_cap(result, config) return _ComputedAxisPattern( computed_formula=result, conditions=shared_conditions, source_compartment=shared_source, ) ``` #### from_formula ```python from_formula(expr: str, config: SecurityConfig | None = None) -> AxisPattern ``` Create a pattern from an arbitrary expression string. The string is wrapped in `(...)` so it composes safely, then run through the full expression security validator. Source code in `commol/api/patterns.py` ```python @classmethod def from_formula( cls, expr: str, config: SecurityConfig | None = None, ) -> "AxisPattern": """ Create a pattern from an arbitrary expression string. The string is wrapped in ``(...)`` so it composes safely, then run through the full expression security validator. """ if not expr or not expr.strip(): raise ValueError("'expr' must be a non-empty expression") wrapped = f"({expr})" validate_expression_security(wrapped, config) return _ComputedAxisPattern(computed_formula=wrapped) ``` # AxisUnits API [Back to API Reference](https://munqu.github.io/commol/latest/api/index.md) Converts values in units to steps of a model axis, gives the rate that an engine reads from a mean span or a probability over a period, and gives the window grid of a width in a unit. Guide: [Units of the axis](https://munqu.github.io/commol/latest/guide/core-concepts/#units-of-the-axis). ## AxisUnits Bases: `BaseModel` The unit of one step on a model axis, and the engine that reads its rates. The axis of a model is its independent variable, of any quantity: time (`"day"`), a length (`"meter"`) or `"step"`, the default increment. `steps` converts a value in a unit of the same dimension to steps. `rate_from_mean_span` and `rate_from_probability` ask the engine what a rate means (`rate_meaning`) and give the rate it reads. `window_start`, `window_end`, `window_index` and `window_ends` give the grid of windows of one width, a span in a unit: window `w` covers the positions `(w * width, (w + 1) * width]`, as the engine computes it (`crates/commol-calibration/src/observation.rs`). An axis of time with an `origin` is on the calendar: `position_of` and `moment_of` convert between calendar moments and positions, and `day_ends` and `week_ends` give the positions where calendar days and weeks end. `Model.axis_units` builds the one of a model: its `step_unit`, its `origin` and its engine. Attributes: | Name | Type | Description | | ----------- | ---------- | -------------------------------------------------------------------------------------------------------------------- | | `step_unit` | `str` | Unit of one step, of one dimension with exponent 1. A name that pint does not define is a unit of its own dimension. | | `engine` | \`str | None\` | | `origin` | \`datetime | None\` | Examples: ```pycon >>> hours = AxisUnits(step_unit="hour") >>> hours.steps(3, "day") 72.0 >>> daily = AxisUnits(step_unit="day", engine="DifferentialEquations") >>> daily.rate_from_mean_span(2, "week") 0.07142857142857142 >>> hours.window_end(2, 1, "week") 504.0 >>> from datetime import date >>> dated = AxisUnits(step_unit="hour", origin=date(2020, 1, 1)) >>> dated.position_of(date(2020, 1, 2)) 24.0 >>> dated.moment_of(36) datetime.datetime(2020, 1, 2, 12, 0) ``` Source code in `commol/context/axis_units.py` ```python class AxisUnits(BaseModel): """ The unit of one step on a model axis, and the engine that reads its rates. The axis of a model is its independent variable, of any quantity: time (``"day"``), a length (``"meter"``) or ``"step"``, the default increment. `steps` converts a value in a unit of the same dimension to steps. `rate_from_mean_span` and `rate_from_probability` ask the engine what a rate means (`rate_meaning`) and give the rate it reads. `window_start`, `window_end`, `window_index` and `window_ends` give the grid of windows of one width, a span in a unit: window ``w`` covers the positions ``(w * width, (w + 1) * width]``, as the engine computes it (`crates/commol-calibration/src/observation.rs`). An axis of time with an `origin` is on the calendar: `position_of` and `moment_of` convert between calendar moments and positions, and `day_ends` and `week_ends` give the positions where calendar days and weeks end. `Model.axis_units` builds the one of a model: its ``step_unit``, its ``origin`` and its engine. Attributes ---------- step_unit : str Unit of one step, of one dimension with exponent 1. A name that `pint` does not define is a unit of its own dimension. engine : str | None Name of the engine that reads the rates, such as ``"DifferenceEquations"``. None allows only `steps`. origin : datetime | None Calendar moment of position 0, with no time zone; a `date` is its midnight. It needs a step unit of time whose length is a whole number of days or a whole fraction of a day, such as ``"hour"`` or ``"day"``. None means the axis has no dates. Examples -------- >>> hours = AxisUnits(step_unit="hour") >>> hours.steps(3, "day") 72.0 >>> daily = AxisUnits(step_unit="day", engine="DifferentialEquations") >>> daily.rate_from_mean_span(2, "week") 0.07142857142857142 >>> hours.window_end(2, 1, "week") 504.0 >>> from datetime import date >>> dated = AxisUnits(step_unit="hour", origin=date(2020, 1, 1)) >>> dated.position_of(date(2020, 1, 2)) 24.0 >>> dated.moment_of(36) datetime.datetime(2020, 1, 2, 12, 0) """ model_config = ConfigDict(extra="forbid", frozen=True) step_unit: str = Field( default=DEFAULT_STEP_UNIT, description="Unit of one step on the axis." ) engine: str | None = Field( default=None, description="Name of the engine that reads the rates." ) origin: Origin | None = Field( default=None, description="Calendar moment of position 0, with no time zone.", ) @model_validator(mode="after") def check_units_and_engine(self) -> Self: """Check the step unit and the origin, and ask for the engine.""" define_units(self.step_unit) check_step_unit(self.step_unit) if self.origin is not None: seconds_per_step(self.step_unit) if self.engine is not None: commol_rs.engine.EngineType(self.engine) return self def position_of(self, moment: date) -> float: """ Position of a calendar moment on the axis, in steps from the origin. The inverse of `moment_of`. A result within ``1e-9`` of an integer is that integer, as in `steps`. Parameters ---------- moment : date A `datetime.datetime` with no time zone, or a `datetime.date`, read as its midnight. Returns ------- float ``(moment - origin)`` in steps; negative before the origin. Raises ------ ValueError If the axis has no origin, or `moment` has a time zone. """ elapsed = naive_moment(moment) - self._origin() return self.steps(elapsed.total_seconds(), "second") def moment_of(self, position: float) -> datetime: """ Calendar moment of a position on the axis. The inverse of `position_of`, to the microsecond. Parameters ---------- position : float A finite position, in steps from the origin. Returns ------- datetime ``origin + position`` steps, with no time zone. Raises ------ ValueError If the axis has no origin, or `position` is not finite. """ origin = self._origin() if not math.isfinite(position): raise ValueError(f"A position on the axis is finite, got {position}.") return origin + timedelta(seconds=position * seconds_per_step(self.step_unit)) def day_ends(self, start: float, end: float) -> list[float]: """ Positions where calendar days end, after `start` and up to `end`. A day ends at midnight, where the next starts. The positions cover ``(start, end]``, the interval of a window: the first is the first midnight after `start`, so a day that starts before `start` ends inside the range. Parameters ---------- start : float First position of the range; a day that ends there is not in it. end : float Last position of the range. Returns ------- list[float] The positions of the midnights in ``(start, end]``, ascending. Raises ------ ValueError If the axis has no origin, or `start` or `end` is not finite. """ return self._period_ends(end, self._next_midnight(start), 1) def week_ends(self, start: float, end: float, first_weekday: int) -> list[float]: """ Positions where calendar weeks end, after `start` and up to `end`. A week starts at midnight of `first_weekday` and ends where the next starts, 7 days later. The positions cover ``(start, end]``, as `day_ends`. Parameters ---------- start : float First position of the range; a week that ends there is not in it. end : float Last position of the range. first_weekday : int Day on which a week starts, from 0 (Monday) to 6 (Sunday), as `datetime.date.weekday` and the constants of the `calendar` module (``calendar.SUNDAY``) number them. Returns ------- list[float] The positions of the week starts in ``(start, end]``, ascending. Raises ------ ValueError If the axis has no origin, `start` or `end` is not finite, or `first_weekday` is not an integer from 0 to 6. """ if first_weekday not in range(7): raise ValueError( "first_weekday is an integer from 0 (Monday) to 6 (Sunday), " f"got {first_weekday!r}." ) midnight = self._next_midnight(start) first = midnight + timedelta(days=(first_weekday - midnight.weekday()) % 7) return self._period_ends(end, first, 7) def _next_midnight(self, start: float) -> datetime: """The first midnight after the moment of `start`.""" moment = self.moment_of(start) return datetime.combine(moment.date(), time()) + timedelta(days=1) def _period_ends(self, end: float, first: datetime, days: int) -> list[float]: """Positions of `first` and every `days` days after it, up to `end`.""" if not math.isfinite(end): raise ValueError(f"A position on the axis is finite, got {end}.") ends: list[float] = [] moment = first position = self.position_of(moment) while position < end or math.isclose( position, end, rel_tol=WHOLE_STEP_TOLERANCE, abs_tol=WHOLE_STEP_TOLERANCE ): ends.append(position) moment += timedelta(days=days) position = self.position_of(moment) return ends def _origin(self) -> datetime: if self.origin is None: raise ValueError( "This axis has no origin, so it has no dates: give AxisUnits " "an origin, or declare origin in ModelBuilder." ) return self.origin def steps(self, value: float, unit: str) -> float: """ Number of steps that a value in `unit` spans on the axis. A result within ``1e-9`` of an integer is that integer, so ``steps(4.1, "hour")`` with a step of ``minute`` is ``246.0``, not ``245.99999999999997``. Whether a position must be a whole number of steps is the engine's check. Parameters ---------- value : float The value, in `unit`. unit : str A unit of the dimension of the step unit, such as ``"day"`` for a step of ``"hour"``. Returns ------- float The value in steps. Raises ------ ValueError If `unit` has another dimension than the step unit; the message names both units, and says to declare a step unit when the step unit is ``step``. """ define_units(unit) try: quantity = ureg.Quantity(float(value), ureg.parse_units(unit)) result = float(quantity.to(ureg.parse_units(self.step_unit)).magnitude) except Exception as error: message = ( f"Cannot convert {value} {unit} to steps of '{self.step_unit}': " f"'{unit}' has dimension " f"{ureg.parse_units(unit).dimensionality} and '{self.step_unit}' " f"has dimension {ureg.parse_units(self.step_unit).dimensionality}." ) if self.step_unit == DEFAULT_STEP_UNIT: message += ( " Declare the unit of a step (step_unit in ModelBuilder) " "to convert values in that unit." ) raise ValueError(message) from error nearest = round(result) if math.isclose(result, nearest, rel_tol=0.0, abs_tol=WHOLE_STEP_TOLERANCE): return float(nearest) return result def rate_from_mean_span(self, span: float, unit: str) -> float: """ Rate of one outflow whose residence has a mean `span` on the axis. The engine says what a rate means (`rate_meaning`). For a per-step rate (``DifferenceEquations``) it is ``1 - exp(-1/n)``, with `span` equal to ``n`` steps: the bin keeps ``exp(-k/n)`` of its start at each step ``k``, as the continuous process does, and its mean residence is ``1/rate`` steps, a little more than ``n``. For a continuous rate (``DifferentialEquations``) it is ``1/n``. Parameters ---------- span : float Mean span of the residence, in `unit`. unit : str Unit of `span`, of the dimension of the step unit. Returns ------- float The rate, per step. Raises ------ ValueError If there is no engine, `unit` has another dimension than the step unit, or `span` is not positive. """ return self._engine_type().rate_from_mean_span(self.steps(span, unit)) def rate_from_probability( self, probability: float, period: float, unit: str ) -> float: """ Rate of one outflow that moves `probability` of the bin over a period. The engine says what a rate means (`rate_meaning`). For a per-step rate (``DifferenceEquations``) it is ``1 - (1 - p)^(1/n)``, with the period equal to ``n`` steps; for a continuous rate (``DifferentialEquations``) it is ``-ln(1 - p)/n``. Either way the bin keeps ``1 - p`` of its start after the period. Parameters ---------- probability : float Fraction of the bin that leaves over the period, between 0 and 1. A continuous rate needs it below 1. period : float Length of the period, in `unit`. unit : str Unit of `period`, of the dimension of the step unit. Returns ------- float The rate, per step. Raises ------ ValueError If there is no engine, `unit` has another dimension than the step unit, `period` is not positive, or `probability` is outside the range the engine accepts. """ return self._engine_type().rate_from_probability( probability, self.steps(period, unit) ) def window_start(self, index: int, span: float, unit: str) -> float: """ Position at which window `index` opens. Parameters ---------- index : int Zero-based window index. span : float Width of one window, in `unit`. unit : str Unit of `span`, of the dimension of the step unit. Returns ------- float ``index`` times the width in steps. Raises ------ ValueError If the width is not a positive whole number of steps, `unit` has another dimension than the step unit, or `index` is negative. """ return commol_rs.calibration.window_bounds( index, self._window_steps(span, unit) )[0] def window_end(self, index: int, span: float, unit: str) -> float: """ Position at which window `index` closes, equal to the start of the next. Parameters ---------- index : int Zero-based window index. span : float Width of one window, in `unit`. unit : str Unit of `span`, of the dimension of the step unit. Returns ------- float ``index + 1`` times the width in steps. Raises ------ ValueError If the width is not a positive whole number of steps, `unit` has another dimension than the step unit, or `index` is negative. """ return commol_rs.calibration.window_bounds( index, self._window_steps(span, unit) )[1] def window_index(self, at: float, span: float, unit: str) -> int: """ Window that holds a position. The exact inverse of `window_end`, and the window that holds any position in between: ``max(ceil(at / width) - 1, 0)``, with the width in steps. Position 0 belongs to window 0. A report places each observation with this rule. Parameters ---------- at : float A position on the axis, in steps. span : float Width of one window, in `unit`. unit : str Unit of `span`, of the dimension of the step unit. Returns ------- int Zero-based window index. Raises ------ ValueError If the width is not a positive whole number of steps, `unit` has another dimension than the step unit, or `at` is negative. """ return commol_rs.calibration.window_index(at, self._window_steps(span, unit)) def window_ends(self, span: float, unit: str, end: float) -> list[float]: """ Positions that close each complete window up to a position. Parameters ---------- span : float Width of one window, in `unit`. unit : str Unit of `span`, of the dimension of the step unit. end : float Last position covered, in steps, such as the last position of a run. Returns ------- list[float] The width in steps, two widths, ... up to `end`. A trailing partial window is not included. Raises ------ ValueError If the width is not a positive whole number of steps, or `unit` has another dimension than the step unit. """ return commol_rs.calibration.window_ends(self._window_steps(span, unit), end) @property def rate_meaning(self) -> str: """ What a rate means to the engine: ``"per_step"`` or ``"continuous"``. Raises ------ ValueError If there is no engine. """ return self._engine_type().rate_meaning def _window_steps(self, span: float, unit: str) -> float: """Width of a window in steps; it must be a positive whole number.""" width = self.steps(span, unit) if not (width.is_integer() and width > 0): raise ValueError( f"A window must be a positive whole number of steps: {span} {unit} " f"is {width:g} steps of '{self.step_unit}'." ) return width def _engine_type(self) -> "EngineTypeProtocol": if self.engine is None: raise ValueError( "A rate needs the engine that reads it: give AxisUnits an " "engine, or use Model.axis_units()." ) return commol_rs.engine.EngineType(self.engine) ``` ### Attributes #### rate_meaning ```python rate_meaning: str ``` What a rate means to the engine: `"per_step"` or `"continuous"`. Raises: | Type | Description | | ------------ | ---------------------- | | `ValueError` | If there is no engine. | ### Methods: #### check_units_and_engine ```python check_units_and_engine() -> Self ``` Check the step unit and the origin, and ask for the engine. Source code in `commol/context/axis_units.py` ```python @model_validator(mode="after") def check_units_and_engine(self) -> Self: """Check the step unit and the origin, and ask for the engine.""" define_units(self.step_unit) check_step_unit(self.step_unit) if self.origin is not None: seconds_per_step(self.step_unit) if self.engine is not None: commol_rs.engine.EngineType(self.engine) return self ``` #### position_of ```python position_of(moment: date) -> float ``` Position of a calendar moment on the axis, in steps from the origin. The inverse of `moment_of`. A result within `1e-9` of an integer is that integer, as in `steps`. Parameters: | Name | Type | Description | Default | | -------- | ------ | -------------------------------------------------------------------------------- | ---------- | | `moment` | `date` | A datetime.datetime with no time zone, or a datetime.date, read as its midnight. | *required* | Returns: | Type | Description | | ------- | ------------------------------------------------------- | | `float` | (moment - origin) in steps; negative before the origin. | Raises: | Type | Description | | ------------ | ----------------------------------------------------- | | `ValueError` | If the axis has no origin, or moment has a time zone. | Source code in `commol/context/axis_units.py` ```python def position_of(self, moment: date) -> float: """ Position of a calendar moment on the axis, in steps from the origin. The inverse of `moment_of`. A result within ``1e-9`` of an integer is that integer, as in `steps`. Parameters ---------- moment : date A `datetime.datetime` with no time zone, or a `datetime.date`, read as its midnight. Returns ------- float ``(moment - origin)`` in steps; negative before the origin. Raises ------ ValueError If the axis has no origin, or `moment` has a time zone. """ elapsed = naive_moment(moment) - self._origin() return self.steps(elapsed.total_seconds(), "second") ``` #### moment_of ```python moment_of(position: float) -> datetime ``` Calendar moment of a position on the axis. The inverse of `position_of`, to the microsecond. Parameters: | Name | Type | Description | Default | | ---------- | ------- | -------------------------------------------- | ---------- | | `position` | `float` | A finite position, in steps from the origin. | *required* | Returns: | Type | Description | | ---------- | ------------------------------------------- | | `datetime` | origin + position steps, with no time zone. | Raises: | Type | Description | | ------------ | ----------------------------------------------------- | | `ValueError` | If the axis has no origin, or position is not finite. | Source code in `commol/context/axis_units.py` ```python def moment_of(self, position: float) -> datetime: """ Calendar moment of a position on the axis. The inverse of `position_of`, to the microsecond. Parameters ---------- position : float A finite position, in steps from the origin. Returns ------- datetime ``origin + position`` steps, with no time zone. Raises ------ ValueError If the axis has no origin, or `position` is not finite. """ origin = self._origin() if not math.isfinite(position): raise ValueError(f"A position on the axis is finite, got {position}.") return origin + timedelta(seconds=position * seconds_per_step(self.step_unit)) ``` #### day_ends ```python day_ends(start: float, end: float) -> list[float] ``` Positions where calendar days end, after `start` and up to `end`. A day ends at midnight, where the next starts. The positions cover `(start, end]`, the interval of a window: the first is the first midnight after `start`, so a day that starts before `start` ends inside the range. Parameters: | Name | Type | Description | Default | | ------- | ------- | ---------------------------------------------------------------- | ---------- | | `start` | `float` | First position of the range; a day that ends there is not in it. | *required* | | `end` | `float` | Last position of the range. | *required* | Returns: | Type | Description | | ------------- | ----------------------------------------------------------- | | `list[float]` | The positions of the midnights in (start, end\], ascending. | Raises: | Type | Description | | ------------ | --------------------------------------------------------- | | `ValueError` | If the axis has no origin, or start or end is not finite. | Source code in `commol/context/axis_units.py` ```python def day_ends(self, start: float, end: float) -> list[float]: """ Positions where calendar days end, after `start` and up to `end`. A day ends at midnight, where the next starts. The positions cover ``(start, end]``, the interval of a window: the first is the first midnight after `start`, so a day that starts before `start` ends inside the range. Parameters ---------- start : float First position of the range; a day that ends there is not in it. end : float Last position of the range. Returns ------- list[float] The positions of the midnights in ``(start, end]``, ascending. Raises ------ ValueError If the axis has no origin, or `start` or `end` is not finite. """ return self._period_ends(end, self._next_midnight(start), 1) ``` #### week_ends ```python week_ends(start: float, end: float, first_weekday: int) -> list[float] ``` Positions where calendar weeks end, after `start` and up to `end`. A week starts at midnight of `first_weekday` and ends where the next starts, 7 days later. The positions cover `(start, end]`, as `day_ends`. Parameters: | Name | Type | Description | Default | | --------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `start` | `float` | First position of the range; a week that ends there is not in it. | *required* | | `end` | `float` | Last position of the range. | *required* | | `first_weekday` | `int` | Day on which a week starts, from 0 (Monday) to 6 (Sunday), as datetime.date.weekday and the constants of the calendar module (calendar.SUNDAY) number them. | *required* | Returns: | Type | Description | | ------------- | ------------------------------------------------------------- | | `list[float]` | The positions of the week starts in (start, end\], ascending. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------------------ | | `ValueError` | If the axis has no origin, start or end is not finite, or first_weekday is not an integer from 0 to 6. | Source code in `commol/context/axis_units.py` ```python def week_ends(self, start: float, end: float, first_weekday: int) -> list[float]: """ Positions where calendar weeks end, after `start` and up to `end`. A week starts at midnight of `first_weekday` and ends where the next starts, 7 days later. The positions cover ``(start, end]``, as `day_ends`. Parameters ---------- start : float First position of the range; a week that ends there is not in it. end : float Last position of the range. first_weekday : int Day on which a week starts, from 0 (Monday) to 6 (Sunday), as `datetime.date.weekday` and the constants of the `calendar` module (``calendar.SUNDAY``) number them. Returns ------- list[float] The positions of the week starts in ``(start, end]``, ascending. Raises ------ ValueError If the axis has no origin, `start` or `end` is not finite, or `first_weekday` is not an integer from 0 to 6. """ if first_weekday not in range(7): raise ValueError( "first_weekday is an integer from 0 (Monday) to 6 (Sunday), " f"got {first_weekday!r}." ) midnight = self._next_midnight(start) first = midnight + timedelta(days=(first_weekday - midnight.weekday()) % 7) return self._period_ends(end, first, 7) ``` #### steps ```python steps(value: float, unit: str) -> float ``` Number of steps that a value in `unit` spans on the axis. A result within `1e-9` of an integer is that integer, so `steps(4.1, "hour")` with a step of `minute` is `246.0`, not `245.99999999999997`. Whether a position must be a whole number of steps is the engine's check. Parameters: | Name | Type | Description | Default | | ------- | ------- | ----------------------------------------------------------------------------- | ---------- | | `value` | `float` | The value, in unit. | *required* | | `unit` | `str` | A unit of the dimension of the step unit, such as "day" for a step of "hour". | *required* | Returns: | Type | Description | | ------- | ------------------- | | `float` | The value in steps. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If unit has another dimension than the step unit; the message names both units, and says to declare a step unit when the step unit is step. | Source code in `commol/context/axis_units.py` ```python def steps(self, value: float, unit: str) -> float: """ Number of steps that a value in `unit` spans on the axis. A result within ``1e-9`` of an integer is that integer, so ``steps(4.1, "hour")`` with a step of ``minute`` is ``246.0``, not ``245.99999999999997``. Whether a position must be a whole number of steps is the engine's check. Parameters ---------- value : float The value, in `unit`. unit : str A unit of the dimension of the step unit, such as ``"day"`` for a step of ``"hour"``. Returns ------- float The value in steps. Raises ------ ValueError If `unit` has another dimension than the step unit; the message names both units, and says to declare a step unit when the step unit is ``step``. """ define_units(unit) try: quantity = ureg.Quantity(float(value), ureg.parse_units(unit)) result = float(quantity.to(ureg.parse_units(self.step_unit)).magnitude) except Exception as error: message = ( f"Cannot convert {value} {unit} to steps of '{self.step_unit}': " f"'{unit}' has dimension " f"{ureg.parse_units(unit).dimensionality} and '{self.step_unit}' " f"has dimension {ureg.parse_units(self.step_unit).dimensionality}." ) if self.step_unit == DEFAULT_STEP_UNIT: message += ( " Declare the unit of a step (step_unit in ModelBuilder) " "to convert values in that unit." ) raise ValueError(message) from error nearest = round(result) if math.isclose(result, nearest, rel_tol=0.0, abs_tol=WHOLE_STEP_TOLERANCE): return float(nearest) return result ``` #### rate_from_mean_span ```python rate_from_mean_span(span: float, unit: str) -> float ``` Rate of one outflow whose residence has a mean `span` on the axis. The engine says what a rate means (`rate_meaning`). For a per-step rate (`DifferenceEquations`) it is `1 - exp(-1/n)`, with `span` equal to `n` steps: the bin keeps `exp(-k/n)` of its start at each step `k`, as the continuous process does, and its mean residence is `1/rate` steps, a little more than `n`. For a continuous rate (`DifferentialEquations`) it is `1/n`. Parameters: | Name | Type | Description | Default | | ------ | ------- | ------------------------------------------------ | ---------- | | `span` | `float` | Mean span of the residence, in unit. | *required* | | `unit` | `str` | Unit of span, of the dimension of the step unit. | *required* | Returns: | Type | Description | | ------- | ------------------- | | `float` | The rate, per step. | Raises: | Type | Description | | ------------ | ---------------------------------------------------------------------------------------------- | | `ValueError` | If there is no engine, unit has another dimension than the step unit, or span is not positive. | Source code in `commol/context/axis_units.py` ```python def rate_from_mean_span(self, span: float, unit: str) -> float: """ Rate of one outflow whose residence has a mean `span` on the axis. The engine says what a rate means (`rate_meaning`). For a per-step rate (``DifferenceEquations``) it is ``1 - exp(-1/n)``, with `span` equal to ``n`` steps: the bin keeps ``exp(-k/n)`` of its start at each step ``k``, as the continuous process does, and its mean residence is ``1/rate`` steps, a little more than ``n``. For a continuous rate (``DifferentialEquations``) it is ``1/n``. Parameters ---------- span : float Mean span of the residence, in `unit`. unit : str Unit of `span`, of the dimension of the step unit. Returns ------- float The rate, per step. Raises ------ ValueError If there is no engine, `unit` has another dimension than the step unit, or `span` is not positive. """ return self._engine_type().rate_from_mean_span(self.steps(span, unit)) ``` #### rate_from_probability ```python rate_from_probability(probability: float, period: float, unit: str) -> float ``` Rate of one outflow that moves `probability` of the bin over a period. The engine says what a rate means (`rate_meaning`). For a per-step rate (`DifferenceEquations`) it is `1 - (1 - p)^(1/n)`, with the period equal to `n` steps; for a continuous rate (`DifferentialEquations`) it is `-ln(1 - p)/n`. Either way the bin keeps `1 - p` of its start after the period. Parameters: | Name | Type | Description | Default | | ------------- | ------- | ----------------------------------------------------------------------------------------------------- | ---------- | | `probability` | `float` | Fraction of the bin that leaves over the period, between 0 and 1. A continuous rate needs it below 1. | *required* | | `period` | `float` | Length of the period, in unit. | *required* | | `unit` | `str` | Unit of period, of the dimension of the step unit. | *required* | Returns: | Type | Description | | ------- | ------------------- | | `float` | The rate, per step. | Raises: | Type | Description | | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If there is no engine, unit has another dimension than the step unit, period is not positive, or probability is outside the range the engine accepts. | Source code in `commol/context/axis_units.py` ```python def rate_from_probability( self, probability: float, period: float, unit: str ) -> float: """ Rate of one outflow that moves `probability` of the bin over a period. The engine says what a rate means (`rate_meaning`). For a per-step rate (``DifferenceEquations``) it is ``1 - (1 - p)^(1/n)``, with the period equal to ``n`` steps; for a continuous rate (``DifferentialEquations``) it is ``-ln(1 - p)/n``. Either way the bin keeps ``1 - p`` of its start after the period. Parameters ---------- probability : float Fraction of the bin that leaves over the period, between 0 and 1. A continuous rate needs it below 1. period : float Length of the period, in `unit`. unit : str Unit of `period`, of the dimension of the step unit. Returns ------- float The rate, per step. Raises ------ ValueError If there is no engine, `unit` has another dimension than the step unit, `period` is not positive, or `probability` is outside the range the engine accepts. """ return self._engine_type().rate_from_probability( probability, self.steps(period, unit) ) ``` #### window_start ```python window_start(index: int, span: float, unit: str) -> float ``` Position at which window `index` opens. Parameters: | Name | Type | Description | Default | | ------- | ------- | ------------------------------------------------ | ---------- | | `index` | `int` | Zero-based window index. | *required* | | `span` | `float` | Width of one window, in unit. | *required* | | `unit` | `str` | Unit of span, of the dimension of the step unit. | *required* | Returns: | Type | Description | | ------- | ------------------------------- | | `float` | index times the width in steps. | Raises: | Type | Description | | ------------ | -------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If the width is not a positive whole number of steps, unit has another dimension than the step unit, or index is negative. | Source code in `commol/context/axis_units.py` ```python def window_start(self, index: int, span: float, unit: str) -> float: """ Position at which window `index` opens. Parameters ---------- index : int Zero-based window index. span : float Width of one window, in `unit`. unit : str Unit of `span`, of the dimension of the step unit. Returns ------- float ``index`` times the width in steps. Raises ------ ValueError If the width is not a positive whole number of steps, `unit` has another dimension than the step unit, or `index` is negative. """ return commol_rs.calibration.window_bounds( index, self._window_steps(span, unit) )[0] ``` #### window_end ```python window_end(index: int, span: float, unit: str) -> float ``` Position at which window `index` closes, equal to the start of the next. Parameters: | Name | Type | Description | Default | | ------- | ------- | ------------------------------------------------ | ---------- | | `index` | `int` | Zero-based window index. | *required* | | `span` | `float` | Width of one window, in unit. | *required* | | `unit` | `str` | Unit of span, of the dimension of the step unit. | *required* | Returns: | Type | Description | | ------- | ----------------------------------- | | `float` | index + 1 times the width in steps. | Raises: | Type | Description | | ------------ | -------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If the width is not a positive whole number of steps, unit has another dimension than the step unit, or index is negative. | Source code in `commol/context/axis_units.py` ```python def window_end(self, index: int, span: float, unit: str) -> float: """ Position at which window `index` closes, equal to the start of the next. Parameters ---------- index : int Zero-based window index. span : float Width of one window, in `unit`. unit : str Unit of `span`, of the dimension of the step unit. Returns ------- float ``index + 1`` times the width in steps. Raises ------ ValueError If the width is not a positive whole number of steps, `unit` has another dimension than the step unit, or `index` is negative. """ return commol_rs.calibration.window_bounds( index, self._window_steps(span, unit) )[1] ``` #### window_index ```python window_index(at: float, span: float, unit: str) -> int ``` Window that holds a position. The exact inverse of `window_end`, and the window that holds any position in between: `max(ceil(at / width) - 1, 0)`, with the width in steps. Position 0 belongs to window 0. A report places each observation with this rule. Parameters: | Name | Type | Description | Default | | ------ | ------- | ------------------------------------------------ | ---------- | | `at` | `float` | A position on the axis, in steps. | *required* | | `span` | `float` | Width of one window, in unit. | *required* | | `unit` | `str` | Unit of span, of the dimension of the step unit. | *required* | Returns: | Type | Description | | ----- | ------------------------ | | `int` | Zero-based window index. | Raises: | Type | Description | | ------------ | ----------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If the width is not a positive whole number of steps, unit has another dimension than the step unit, or at is negative. | Source code in `commol/context/axis_units.py` ```python def window_index(self, at: float, span: float, unit: str) -> int: """ Window that holds a position. The exact inverse of `window_end`, and the window that holds any position in between: ``max(ceil(at / width) - 1, 0)``, with the width in steps. Position 0 belongs to window 0. A report places each observation with this rule. Parameters ---------- at : float A position on the axis, in steps. span : float Width of one window, in `unit`. unit : str Unit of `span`, of the dimension of the step unit. Returns ------- int Zero-based window index. Raises ------ ValueError If the width is not a positive whole number of steps, `unit` has another dimension than the step unit, or `at` is negative. """ return commol_rs.calibration.window_index(at, self._window_steps(span, unit)) ``` #### window_ends ```python window_ends(span: float, unit: str, end: float) -> list[float] ``` Positions that close each complete window up to a position. Parameters: | Name | Type | Description | Default | | ------ | ------- | -------------------------------------------------------------------- | ---------- | | `span` | `float` | Width of one window, in unit. | *required* | | `unit` | `str` | Unit of span, of the dimension of the step unit. | *required* | | `end` | `float` | Last position covered, in steps, such as the last position of a run. | *required* | Returns: | Type | Description | | ------------- | ----------------------------------------------------------------------------------------- | | `list[float]` | The width in steps, two widths, ... up to end. A trailing partial window is not included. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------------------- | | `ValueError` | If the width is not a positive whole number of steps, or unit has another dimension than the step unit. | Source code in `commol/context/axis_units.py` ```python def window_ends(self, span: float, unit: str, end: float) -> list[float]: """ Positions that close each complete window up to a position. Parameters ---------- span : float Width of one window, in `unit`. unit : str Unit of `span`, of the dimension of the step unit. end : float Last position covered, in steps, such as the last position of a run. Returns ------- list[float] The width in steps, two widths, ... up to `end`. A trailing partial window is not included. Raises ------ ValueError If the width is not a positive whole number of steps, or `unit` has another dimension than the step unit. """ return commol_rs.calibration.window_ends(self._window_steps(span, unit), end) ``` # Calibration API [Back to API Reference](https://munqu.github.io/commol/latest/api/index.md) Fits a model to observations. Guide: [Model Calibration](https://munqu.github.io/commol/latest/guide/calibration/index.md). ## Calibrator Calibrate the parameters of a simulation against observed data. The calibration problem is built in Rust once, when the calibrator is created, and every optimization, prediction and uncertainty method reads that one problem. Source code in `commol/api/calibrator.py` ```python class Calibrator: """ Calibrate the parameters of a simulation against observed data. The calibration problem is built in Rust once, when the calibrator is created, and every optimization, prediction and uncertainty method reads that one problem. """ def __init__( self, simulation: Simulation, problem: CalibrationProblem, ) -> None: """ Initializes the calibration from a Simulation and CalibrationProblem. Parameters ---------- simulation : Simulation A fully initialized Simulation object with the model to calibrate. problem : CalibrationProblem A fully constructed and validated calibration problem definition. Raises ------ ValueError If a parameter, observation, position or constraint does not fit the model. """ logger.info( f"Initializing Calibration for model: '{simulation.model_definition.name}'" ) self.simulation: Simulation = simulation self.problem: CalibrationProblem = problem self._engine: "EngineProtocol" = simulation.engine logger.info( ( f"Calibration initialized with {len(problem.parameters)} parameters " f"and {len(problem.observed_data)} observed data points." ) ) loss_config = self._build_loss_config() self._validate_calibration_parameters() self._warn_constraint_positions_beyond_observations() models = problem.observation_models self._rust_problem: "CalibrationProblemProtocol" = ( commol_rs.inference.CalibrationProblem( self._engine, engine_state.rust_observed_data(problem.observed_data), engine_state.rust_parameters(problem.parameters), engine_state.rust_constraints(problem.constraints), loss_config=loss_config, initial_condition_remainder=problem.initial_condition_remainder, observation_models=( None if models is None else engine_state.rust_observation_models(models) ), seed=problem.seed, ) ) if problem.seed is None: logger.info( "No seed set; drew the seed %d, which repeats this calibration.", self._rust_problem.seed, ) @property def rust_problem(self) -> "CalibrationProblemProtocol": """ The calibration problem built in Rust, for the runners of methods. Returns ------- CalibrationProblemProtocol The problem every optimization, prediction and method reads. """ return self._rust_problem def rust_predictive( self, sample: ParameterSample, positions: list[float] ) -> "PredictiveProtocol": """ The run of every draw of `sample` at `positions`, for a `Predictive`: each replicate of a stochastic engine has a seed of its own, derived from the calibrator's seed. Parameters ---------- sample : ParameterSample The draws, in the problem's order. positions : list[float] Positions to report at, sorted and unique. Returns ------- PredictiveProtocol The runs, computed in Rust. """ return self._rust_problem.predictive(sample.rust_sample, positions, self.seed) def with_model(self, model: "Model") -> "Calibrator": """ The same calibration problem on another model, with the same seed. The draws of a predictive of the copy take the seeds they take here, so `Scenarios` runs every scenario on common random numbers. Parameters ---------- model : Model The model, such as a copy of this one with other parameter values. Returns ------- Calibrator A calibrator of `model`, with `problem` and its seed set to `seed`. Raises ------ ValueError If a parameter, observation, position or constraint of the problem does not fit `model`. """ return Calibrator( Simulation(model), self.problem.model_copy(update={"seed": self.seed}) ) def point_sample(self, result: CalibrationResult) -> ParameterSample: """ The point estimate of a fit as a sample of one draw. A `Predictive` or `Scenarios` of it runs the model at the fit, so a fit is read as any sample is. Parameters ---------- result : CalibrationResult A fit of this calibration problem. Returns ------- ParameterSample One draw at ``result.best_parameters``, with the names and bounds of the parameters of the problem and ``SampleMethod(name="point", settings={"seed": result.seed})``. Raises ------ ValueError If the names of `result` are not those of the problem; the message names the names that differ. """ names = self.parameter_names missing = [name for name in names if name not in result.best_parameters] extra = [name for name in result.best_parameters if name not in names] if missing or extra: raise ValueError( f"The result is not a fit of this problem: it lacks {missing} and " f"has {extra}, which the problem does not have." ) return ParameterSample.equally_weighted( names, [(p.min_bound, p.max_bound) for p in self.problem.parameters], [[result.best_parameters[name] for name in names]], SampleMethod(name="point", settings={"seed": result.seed}), ) def delta_band( self, result: LocalResult, positions: Sequence[float], confidence_level: float ) -> DeltaBand: """ The band of every output of a local approximation by the delta method. The model is linearized at the fit of `result`: the variance of an output is ``J Σ Jᵀ``, with ``J`` its derivatives on the unbounded scale and ``Σ`` the covariance of `result`. The band is the prediction at the fit plus and minus the quantile of a normal, or of the Student t of a `SandwichResult`, times its standard deviation. It runs the model a few times per parameter, not once per draw, and reads only the fields of `result`, so a result read back from JSON works. Parameters ---------- result : LaplaceResult | SandwichResult A local approximation of this calibration problem. positions : Sequence[float] Positions to report at. confidence_level : float Strictly between 0 and 1. Returns ------- DeltaBand The prediction at the fit and the ends of the interval of each output at each position. Raises ------ ValueError If the result's parameters are not the problem's, the level is not strictly between 0 and 1, the engine is stochastic, or the engine rejects a position. RuntimeError If the derivatives fail at the fit. """ if result.sample.names != self.parameter_names: raise ValueError( f"The result has the parameters {result.sample.names}; the problem " f"has {self.parameter_names}." ) points = [float(position) for position in positions] outputs, center, lower, upper = commol_rs.inference.delta_band( self._rust_problem, [result.fit.best_parameters[name] for name in self.parameter_names], result.covariance, result.degrees_of_freedom(), points, confidence_level, ) return DeltaBand( confidence_level=confidence_level, positions=points, center=by_output(outputs, center), lower=by_output(outputs, lower), upper=by_output(outputs, upper), ) def rust_observation_draws( self, sample: ParameterSample, observed_data: list[ObservedDataPoint], noise: ObservationNoise, ) -> "ObservationDrawsProtocol": """ Every draw's value of `observed_data` with `noise`, for a `Predictive`. Parameters ---------- sample : ParameterSample The draws, in the problem's order. observed_data : list[ObservedDataPoint] The observations. noise : NoNoise | ResidualNoise | DistributionNoise The observation noise, drawn from the calibrator's seed. Returns ------- ObservationDrawsProtocol The draws, computed in Rust. """ residual_noise = None if isinstance(noise, ResidualNoise): residual_noise = ( [noise.fit.best_parameters[name] for name in self.parameter_names], noise.block_length, noise.residual_scale, ) return self._rust_problem.observation_draws( sample.rust_sample, engine_state.rust_observed_data(observed_data), self.seed, residual_noise=residual_noise, distribution_noise=isinstance(noise, DistributionNoise), ) def rust_log_score( self, sample: ParameterSample, observed_data: list[ObservedDataPoint] ) -> list[float]: """ The log score of each observation under the predictive of `sample`, for `commol.scores.log_score`. Parameters ---------- sample : ParameterSample The draws, in the problem's order. observed_data : list[ObservedDataPoint] The observations. Returns ------- list[float] One log score per observation, computed in Rust. """ return self._rust_problem.log_score( sample.rust_sample, engine_state.rust_observed_data(observed_data), self.seed, ) @property def rust_optimizer(self) -> "OptimizationConfigProtocol": """ The optimizer built in Rust, for the runners of methods. Returns ------- OptimizationConfigProtocol The optimizer of ``problem.optimization_config``. """ return self._build_optimization_config() @property def seed(self) -> int: """ The seed of the calibration's random numbers. Returns ------- int ``problem.seed``, or the seed the engine drew when the calibrator was built, which it logs. """ return self._rust_problem.seed def run(self, starts: int = 1) -> CalibrationResult: """ Runs the calibration optimization from one or more starts. Start 0 is the initial guess of each parameter. Each further start is a row of a Latin hypercube over the bounds, drawn from `seed`, so the starts cover the parameter space; a particle swarm takes a seed of its own at each start, also from `seed`. They run in parallel, on the threads of `commol.threads`; the result is the best of them, the first start of lowest loss, and lists every start. Distinct ends with equal losses are a sign that the data do not identify the parameters. Parameters ---------- starts : int, optional Number of starts, at least 1 (default 1). A verbose optimizer shows one start, so it needs ``starts=1``. Returns ------- CalibrationResult The best start: the optimized parameter values, in the order of the problem's parameters, its final loss and convergence, every start (``starts``), each start that failed (``failed``, also logged as warnings) and the seed (``seed``). Raises ------ ValueError If `starts` is below 1, or a verbose optimizer has more than one start. RuntimeError If every start fails. """ algorithm_name = ( OptimizationAlgorithm.NELDER_MEAD if isinstance(self.problem.optimization_config, NelderMeadConfig) else OptimizationAlgorithm.PARTICLE_SWARM ) measure = ( f"{self.problem.loss_function} loss function" if self.problem.loss_function is not None else "the negative log-likelihood of its observation models" ) logger.info( f"Starting calibration with {algorithm_name} algorithm, " f"{measure} and {starts} start(s)." ) run = self._rust_problem.optimize( self._build_optimization_config(), starts, self.seed ) names = self.parameter_names summaries: list[StartSummary] = [] failed: list[str] = [] for index, outcome in enumerate(run.outcomes): if outcome.result is None: reason = f"Start {index} failed: {outcome.error}" logger.warning(reason) failed.append(reason) continue summaries.append( StartSummary( start=dict(zip(names, outcome.start, strict=True)), end=dict(outcome.result.best_parameters), loss=outcome.result.final_loss, converged=outcome.result.converged, ) ) best = run.outcomes[run.best].result assert best is not None, "the best start did not fail" result = CalibrationResult( best_parameters=dict(best.best_parameters), final_loss=best.final_loss, iterations=best.iterations, converged=best.converged, termination_reason=best.termination_reason, starts=summaries, failed=failed, seed=self.seed, ) logger.info( ( f"Calibration finished after {result.iterations} iterations. " f"Final loss: {result.final_loss:.6f}" ) ) return result def run_with_history(self) -> CalibrationHistory: """ Runs the calibration from the initial guess and keeps every evaluation. Returns ------- CalibrationHistory The result, and every evaluation of a feasible point the optimizer made, in order. Infeasible points, which the objective penalizes, are not kept. Raises ------ RuntimeError If optimization fails. """ logger.info("Starting calibration with history tracking") history = self._rust_problem.optimize_with_history( self._build_optimization_config(), self.seed ) names = self.parameter_names rust_result = history.result result = CalibrationResult( best_parameters=dict(rust_result.best_parameters), final_loss=rust_result.final_loss, iterations=rust_result.iterations, converged=rust_result.converged, termination_reason=rust_result.termination_reason, seed=self.seed, ) evaluations = [ CalibrationEvaluation( parameters=dict(zip(names, evaluation.parameters, strict=True)), loss=evaluation.loss, ) for evaluation in history.evaluations ] logger.info( f"Calibration finished after {result.iterations} iterations. " f"Final loss: {result.final_loss:.6f}. " f"Collected {len(evaluations)} evaluations." ) return CalibrationHistory(result=result, evaluations=evaluations) def uncertainty(self, method: UncertaintyMethod[ResultT]) -> ResultT: """ Run an uncertainty method on this calibration problem. Each method is a configuration (a subclass of `UncertaintyMethod`) whose type sets the result type; its runner is registered in `commol.api.inference.registry`. The guide's table "Which method answers which question" lists them. Parameters ---------- method : UncertaintyMethod[ResultT] The configuration of the method. Returns ------- ResultT The result of the method. Raises ------ TypeError If `method` is an identifiability method; the message names ``Calibrator.identifiability``. ValueError If no runner is registered for the configuration's type (the message names the registered ones), or a setting, an input, the data or the model does not fit the method; the message says why. RuntimeError If a computation of the method that could start fails: an engine run, an optimizer, every replicate, or a sampler that does not reach its target. InfeasibleStateError If a draw of a prediction leaves the values its model allows. """ from commol.api.inference.registry import run_method return run_method(self, method, UncertaintyMethod) def identifiability(self, method: IdentifiabilityMethod[ResultT]) -> ResultT: """ Run an identifiability method on this calibration problem. Each method answers which parameters the model and the data determine, from another kind of evidence: `StructuralRank`, `Collinearity`, `SampleIdentifiability` or `StartsIdentifiability`. Each returns an `IdentifiabilityReport`; `IdentifiabilityReport.merge` joins them. Parameters ---------- method : IdentifiabilityMethod[ResultT] The configuration of the method. Returns ------- ResultT The result of the method. Raises ------ TypeError If `method` is an uncertainty method; the message names ``Calibrator.uncertainty``. ValueError If no runner is registered for the configuration's type, or a setting, an input, the data or the model does not fit the method; the message says why. RuntimeError If a computation of the method that could start fails: an engine run or an optimizer. """ from commol.api.inference.registry import run_method return run_method(self, method, IdentifiabilityMethod) def _build_loss_config(self) -> "LossConfigProtocol | None": """The Rust loss, with the settings of the replicates; None with observation models.""" if self.problem.loss_function is None: return None return engine_state.rust_loss_config(self.problem, self.problem.loss_function) def _build_optimization_config(self) -> "OptimizationConfigProtocol": """Convert Python OptimizationConfig to Rust OptimizationConfig.""" opt_config = self.problem.optimization_config if isinstance(opt_config, NelderMeadConfig): nm_config = rust_calibration.NelderMeadConfig( max_iterations=opt_config.max_iterations, sd_tolerance=opt_config.sd_tolerance, initial_simplex_size=opt_config.initial_simplex_size, alpha=opt_config.alpha, gamma=opt_config.gamma, rho=opt_config.rho, sigma=opt_config.sigma, verbose=opt_config.verbose, header_interval=opt_config.header_interval, ) return rust_calibration.OptimizationConfig.nelder_mead(nm_config) return self._build_pso_config(opt_config) def _build_pso_config( self, opt_config: ParticleSwarmConfig ) -> "OptimizationConfigProtocol": """Convert ParticleSwarmConfig to Rust OptimizationConfig.""" rust_inertia = self._build_rust_inertia(opt_config.inertia_config) rust_acceleration = self._build_rust_acceleration( opt_config.acceleration_config ) mutation = opt_config.mutation_config rust_mutation = None if mutation is not None: rust_mutation = rust_calibration.PSOMutation( strategy=mutation.strategy, scale=mutation.scale, probability=mutation.probability, application=mutation.application, ) velocity = opt_config.velocity_config rust_velocity = None if velocity is not None: rust_velocity = rust_calibration.PSOVelocity( clamp_factor=velocity.clamp_factor, mutation_threshold=velocity.mutation_threshold, ) improvement = opt_config.improvement_stop_config radius = opt_config.radius_stop_config ps_config = rust_calibration.ParticleSwarmConfig( particles=opt_config.particles, max_iterations=opt_config.max_iterations, verbose=opt_config.verbose, inertia=rust_inertia, acceleration=rust_acceleration, mutation=rust_mutation, velocity=rust_velocity, initialization=opt_config.initialization, improvement_stop=( None if improvement is None else (improvement.window, improvement.tol_abs, improvement.tol_rel) ), radius_stop=None if radius is None else radius.tol, ) return rust_calibration.OptimizationConfig.particle_swarm(ps_config) def _build_rust_inertia( self, inertia: PSOConstantInertia | PSOChaoticInertia | None ) -> "PSOInertiaConstantProtocol | PSOInertiaChaoticProtocol | None": """Convert Python inertia config to Rust type.""" if inertia is None: return None if isinstance(inertia, PSOConstantInertia): return rust_calibration.PSOInertiaConstant(factor=inertia.factor) return rust_calibration.PSOInertiaChaotic( w_min=inertia.w_min, w_max=inertia.w_max ) def _build_rust_acceleration( self, acceleration: PSOConstantAcceleration | PSOTimeVaryingAcceleration | None ) -> "PSOAccelerationConstantProtocol | PSOAccelerationTimeVaryingProtocol | None": """Convert Python acceleration config to Rust type.""" if acceleration is None: return None if isinstance(acceleration, PSOConstantAcceleration): return rust_calibration.PSOAccelerationConstant( cognitive=acceleration.cognitive, social=acceleration.social, ) return rust_calibration.PSOAccelerationTimeVarying( c1_initial=acceleration.c1_initial, c1_final=acceleration.c1_final, c2_initial=acceleration.c2_initial, c2_final=acceleration.c2_final, ) @property def num_parameters(self) -> int: """Number of parameters being calibrated.""" return len(self.problem.parameters) @property def num_observations(self) -> int: """Number of observed data points.""" return len(self.problem.observed_data) @property def parameter_names(self) -> list[str]: """Names of parameters being calibrated.""" return [param.id for param in self.problem.parameters] def _validate_calibration_parameters(self) -> None: """ Validate that all calibration parameters exist in the model. Raises ------ ValueError If a parameter ID doesn't exist in the model or if a bin ID is invalid. """ check_values( self.simulation.model_definition, self._engine, self.problem.parameters, self.problem.initial_condition_remainder, ) def _warn_constraint_positions_beyond_observations(self) -> None: """Warn when constraints at positions extend beyond observed data.""" max_observed_position = max(obs.at for obs in self.problem.observed_data) for constraint in self.problem.constraints: if constraint.at and max(constraint.at) > max_observed_position: logger.warning( "Constraint '%s' has positions beyond the maximum observed " "position (%s); simulation will be extended to evaluate them.", constraint.id, max_observed_position, ) ``` ### Attributes #### rust_problem ```python rust_problem: CalibrationProblemProtocol ``` The calibration problem built in Rust, for the runners of methods. Returns: | Type | Description | | ---------------------------- | ------------------------------------------------------------ | | `CalibrationProblemProtocol` | The problem every optimization, prediction and method reads. | #### rust_optimizer ```python rust_optimizer: OptimizationConfigProtocol ``` The optimizer built in Rust, for the runners of methods. Returns: | Type | Description | | ---------------------------- | --------------------------------------------- | | `OptimizationConfigProtocol` | The optimizer of problem.optimization_config. | #### seed ```python seed: int ``` The seed of the calibration's random numbers. Returns: | Type | Description | | ----- | --------------------------------------------------------------------------------------- | | `int` | problem.seed, or the seed the engine drew when the calibrator was built, which it logs. | #### num_parameters ```python num_parameters: int ``` Number of parameters being calibrated. #### num_observations ```python num_observations: int ``` Number of observed data points. #### parameter_names ```python parameter_names: list[str] ``` Names of parameters being calibrated. ### Methods: #### __init__ ```python __init__(simulation: Simulation, problem: CalibrationProblem) -> None ``` Initializes the calibration from a Simulation and CalibrationProblem. Parameters: | Name | Type | Description | Default | | ------------ | -------------------- | ------------------------------------------------------------------ | ---------- | | `simulation` | `Simulation` | A fully initialized Simulation object with the model to calibrate. | *required* | | `problem` | `CalibrationProblem` | A fully constructed and validated calibration problem definition. | *required* | Raises: | Type | Description | | ------------ | --------------------------------------------------------------------------- | | `ValueError` | If a parameter, observation, position or constraint does not fit the model. | Source code in `commol/api/calibrator.py` ```python def __init__( self, simulation: Simulation, problem: CalibrationProblem, ) -> None: """ Initializes the calibration from a Simulation and CalibrationProblem. Parameters ---------- simulation : Simulation A fully initialized Simulation object with the model to calibrate. problem : CalibrationProblem A fully constructed and validated calibration problem definition. Raises ------ ValueError If a parameter, observation, position or constraint does not fit the model. """ logger.info( f"Initializing Calibration for model: '{simulation.model_definition.name}'" ) self.simulation: Simulation = simulation self.problem: CalibrationProblem = problem self._engine: "EngineProtocol" = simulation.engine logger.info( ( f"Calibration initialized with {len(problem.parameters)} parameters " f"and {len(problem.observed_data)} observed data points." ) ) loss_config = self._build_loss_config() self._validate_calibration_parameters() self._warn_constraint_positions_beyond_observations() models = problem.observation_models self._rust_problem: "CalibrationProblemProtocol" = ( commol_rs.inference.CalibrationProblem( self._engine, engine_state.rust_observed_data(problem.observed_data), engine_state.rust_parameters(problem.parameters), engine_state.rust_constraints(problem.constraints), loss_config=loss_config, initial_condition_remainder=problem.initial_condition_remainder, observation_models=( None if models is None else engine_state.rust_observation_models(models) ), seed=problem.seed, ) ) if problem.seed is None: logger.info( "No seed set; drew the seed %d, which repeats this calibration.", self._rust_problem.seed, ) ``` #### rust_predictive ```python rust_predictive(sample: ParameterSample, positions: list[float]) -> PredictiveProtocol ``` The run of every draw of `sample` at `positions`, for a `Predictive`: each replicate of a stochastic engine has a seed of its own, derived from the calibrator's seed. Parameters: | Name | Type | Description | Default | | ----------- | ----------------- | ------------------------------------------ | ---------- | | `sample` | `ParameterSample` | The draws, in the problem's order. | *required* | | `positions` | `list[float]` | Positions to report at, sorted and unique. | *required* | Returns: | Type | Description | | -------------------- | --------------------------- | | `PredictiveProtocol` | The runs, computed in Rust. | Source code in `commol/api/calibrator.py` ```python def rust_predictive( self, sample: ParameterSample, positions: list[float] ) -> "PredictiveProtocol": """ The run of every draw of `sample` at `positions`, for a `Predictive`: each replicate of a stochastic engine has a seed of its own, derived from the calibrator's seed. Parameters ---------- sample : ParameterSample The draws, in the problem's order. positions : list[float] Positions to report at, sorted and unique. Returns ------- PredictiveProtocol The runs, computed in Rust. """ return self._rust_problem.predictive(sample.rust_sample, positions, self.seed) ``` #### with_model ```python with_model(model: Model) -> Calibrator ``` The same calibration problem on another model, with the same seed. The draws of a predictive of the copy take the seeds they take here, so `Scenarios` runs every scenario on common random numbers. Parameters: | Name | Type | Description | Default | | ------- | ------- | ------------------------------------------------------------------ | ---------- | | `model` | `Model` | The model, such as a copy of this one with other parameter values. | *required* | Returns: | Type | Description | | ------------ | ------------------------------------------------------------- | | `Calibrator` | A calibrator of model, with problem and its seed set to seed. | Raises: | Type | Description | | ------------ | -------------------------------------------------------------------------------------- | | `ValueError` | If a parameter, observation, position or constraint of the problem does not fit model. | Source code in `commol/api/calibrator.py` ```python def with_model(self, model: "Model") -> "Calibrator": """ The same calibration problem on another model, with the same seed. The draws of a predictive of the copy take the seeds they take here, so `Scenarios` runs every scenario on common random numbers. Parameters ---------- model : Model The model, such as a copy of this one with other parameter values. Returns ------- Calibrator A calibrator of `model`, with `problem` and its seed set to `seed`. Raises ------ ValueError If a parameter, observation, position or constraint of the problem does not fit `model`. """ return Calibrator( Simulation(model), self.problem.model_copy(update={"seed": self.seed}) ) ``` #### point_sample ```python point_sample(result: CalibrationResult) -> ParameterSample ``` The point estimate of a fit as a sample of one draw. A `Predictive` or `Scenarios` of it runs the model at the fit, so a fit is read as any sample is. Parameters: | Name | Type | Description | Default | | -------- | ------------------- | ---------------------------------- | ---------- | | `result` | `CalibrationResult` | A fit of this calibration problem. | *required* | Returns: | Type | Description | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ParameterSample` | One draw at result.best_parameters, with the names and bounds of the parameters of the problem and SampleMethod(name="point", settings={"seed": result.seed}). | Raises: | Type | Description | | ------------ | --------------------------------------------------------------------------------------------- | | `ValueError` | If the names of result are not those of the problem; the message names the names that differ. | Source code in `commol/api/calibrator.py` ```python def point_sample(self, result: CalibrationResult) -> ParameterSample: """ The point estimate of a fit as a sample of one draw. A `Predictive` or `Scenarios` of it runs the model at the fit, so a fit is read as any sample is. Parameters ---------- result : CalibrationResult A fit of this calibration problem. Returns ------- ParameterSample One draw at ``result.best_parameters``, with the names and bounds of the parameters of the problem and ``SampleMethod(name="point", settings={"seed": result.seed})``. Raises ------ ValueError If the names of `result` are not those of the problem; the message names the names that differ. """ names = self.parameter_names missing = [name for name in names if name not in result.best_parameters] extra = [name for name in result.best_parameters if name not in names] if missing or extra: raise ValueError( f"The result is not a fit of this problem: it lacks {missing} and " f"has {extra}, which the problem does not have." ) return ParameterSample.equally_weighted( names, [(p.min_bound, p.max_bound) for p in self.problem.parameters], [[result.best_parameters[name] for name in names]], SampleMethod(name="point", settings={"seed": result.seed}), ) ``` #### delta_band ```python delta_band(result: LocalResult, positions: Sequence[float], confidence_level: float) -> DeltaBand ``` The band of every output of a local approximation by the delta method. The model is linearized at the fit of `result`: the variance of an output is `J Σ Jᵀ`, with `J` its derivatives on the unbounded scale and `Σ` the covariance of `result`. The band is the prediction at the fit plus and minus the quantile of a normal, or of the Student t of a `SandwichResult`, times its standard deviation. It runs the model a few times per parameter, not once per draw, and reads only the fields of `result`, so a result read back from JSON works. Parameters: | Name | Type | Description | Default | | ------------------ | ----------------- | ------------------------- | -------------------------------------------------- | | `result` | \`LaplaceResult | SandwichResult\` | A local approximation of this calibration problem. | | `positions` | `Sequence[float]` | Positions to report at. | *required* | | `confidence_level` | `float` | Strictly between 0 and 1. | *required* | Returns: | Type | Description | | ----------- | --------------------------------------------------------------------------------------- | | `DeltaBand` | The prediction at the fit and the ends of the interval of each output at each position. | Raises: | Type | Description | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If the result's parameters are not the problem's, the level is not strictly between 0 and 1, the engine is stochastic, or the engine rejects a position. | | `RuntimeError` | If the derivatives fail at the fit. | Source code in `commol/api/calibrator.py` ```python def delta_band( self, result: LocalResult, positions: Sequence[float], confidence_level: float ) -> DeltaBand: """ The band of every output of a local approximation by the delta method. The model is linearized at the fit of `result`: the variance of an output is ``J Σ Jᵀ``, with ``J`` its derivatives on the unbounded scale and ``Σ`` the covariance of `result`. The band is the prediction at the fit plus and minus the quantile of a normal, or of the Student t of a `SandwichResult`, times its standard deviation. It runs the model a few times per parameter, not once per draw, and reads only the fields of `result`, so a result read back from JSON works. Parameters ---------- result : LaplaceResult | SandwichResult A local approximation of this calibration problem. positions : Sequence[float] Positions to report at. confidence_level : float Strictly between 0 and 1. Returns ------- DeltaBand The prediction at the fit and the ends of the interval of each output at each position. Raises ------ ValueError If the result's parameters are not the problem's, the level is not strictly between 0 and 1, the engine is stochastic, or the engine rejects a position. RuntimeError If the derivatives fail at the fit. """ if result.sample.names != self.parameter_names: raise ValueError( f"The result has the parameters {result.sample.names}; the problem " f"has {self.parameter_names}." ) points = [float(position) for position in positions] outputs, center, lower, upper = commol_rs.inference.delta_band( self._rust_problem, [result.fit.best_parameters[name] for name in self.parameter_names], result.covariance, result.degrees_of_freedom(), points, confidence_level, ) return DeltaBand( confidence_level=confidence_level, positions=points, center=by_output(outputs, center), lower=by_output(outputs, lower), upper=by_output(outputs, upper), ) ``` #### rust_observation_draws ```python rust_observation_draws(sample: ParameterSample, observed_data: list[ObservedDataPoint], noise: ObservationNoise) -> ObservationDrawsProtocol ``` Every draw's value of `observed_data` with `noise`, for a `Predictive`. Parameters: | Name | Type | Description | Default | | --------------- | ------------------------- | ---------------------------------- | ------------------- | | `sample` | `ParameterSample` | The draws, in the problem's order. | *required* | | `observed_data` | `list[ObservedDataPoint]` | The observations. | *required* | | `noise` | \`NoNoise | ResidualNoise | DistributionNoise\` | Returns: | Type | Description | | -------------------------- | ---------------------------- | | `ObservationDrawsProtocol` | The draws, computed in Rust. | Source code in `commol/api/calibrator.py` ```python def rust_observation_draws( self, sample: ParameterSample, observed_data: list[ObservedDataPoint], noise: ObservationNoise, ) -> "ObservationDrawsProtocol": """ Every draw's value of `observed_data` with `noise`, for a `Predictive`. Parameters ---------- sample : ParameterSample The draws, in the problem's order. observed_data : list[ObservedDataPoint] The observations. noise : NoNoise | ResidualNoise | DistributionNoise The observation noise, drawn from the calibrator's seed. Returns ------- ObservationDrawsProtocol The draws, computed in Rust. """ residual_noise = None if isinstance(noise, ResidualNoise): residual_noise = ( [noise.fit.best_parameters[name] for name in self.parameter_names], noise.block_length, noise.residual_scale, ) return self._rust_problem.observation_draws( sample.rust_sample, engine_state.rust_observed_data(observed_data), self.seed, residual_noise=residual_noise, distribution_noise=isinstance(noise, DistributionNoise), ) ``` #### rust_log_score ```python rust_log_score(sample: ParameterSample, observed_data: list[ObservedDataPoint]) -> list[float] ``` The log score of each observation under the predictive of `sample`, for `commol.scores.log_score`. Parameters: | Name | Type | Description | Default | | --------------- | ------------------------- | ---------------------------------- | ---------- | | `sample` | `ParameterSample` | The draws, in the problem's order. | *required* | | `observed_data` | `list[ObservedDataPoint]` | The observations. | *required* | Returns: | Type | Description | | ------------- | ------------------------------------------------ | | `list[float]` | One log score per observation, computed in Rust. | Source code in `commol/api/calibrator.py` ```python def rust_log_score( self, sample: ParameterSample, observed_data: list[ObservedDataPoint] ) -> list[float]: """ The log score of each observation under the predictive of `sample`, for `commol.scores.log_score`. Parameters ---------- sample : ParameterSample The draws, in the problem's order. observed_data : list[ObservedDataPoint] The observations. Returns ------- list[float] One log score per observation, computed in Rust. """ return self._rust_problem.log_score( sample.rust_sample, engine_state.rust_observed_data(observed_data), self.seed, ) ``` #### run ```python run(starts: int = 1) -> CalibrationResult ``` Runs the calibration optimization from one or more starts. Start 0 is the initial guess of each parameter. Each further start is a row of a Latin hypercube over the bounds, drawn from `seed`, so the starts cover the parameter space; a particle swarm takes a seed of its own at each start, also from `seed`. They run in parallel, on the threads of `commol.threads`; the result is the best of them, the first start of lowest loss, and lists every start. Distinct ends with equal losses are a sign that the data do not identify the parameters. Parameters: | Name | Type | Description | Default | | -------- | ----- | ---------------------------------------------------------------------------------------------------- | ------- | | `starts` | `int` | Number of starts, at least 1 (default 1). A verbose optimizer shows one start, so it needs starts=1. | `1` | Returns: | Type | Description | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CalibrationResult` | The best start: the optimized parameter values, in the order of the problem's parameters, its final loss and convergence, every start (starts), each start that failed (failed, also logged as warnings) and the seed (seed). | Raises: | Type | Description | | -------------- | --------------------------------------------------------------------- | | `ValueError` | If starts is below 1, or a verbose optimizer has more than one start. | | `RuntimeError` | If every start fails. | Source code in `commol/api/calibrator.py` ```python def run(self, starts: int = 1) -> CalibrationResult: """ Runs the calibration optimization from one or more starts. Start 0 is the initial guess of each parameter. Each further start is a row of a Latin hypercube over the bounds, drawn from `seed`, so the starts cover the parameter space; a particle swarm takes a seed of its own at each start, also from `seed`. They run in parallel, on the threads of `commol.threads`; the result is the best of them, the first start of lowest loss, and lists every start. Distinct ends with equal losses are a sign that the data do not identify the parameters. Parameters ---------- starts : int, optional Number of starts, at least 1 (default 1). A verbose optimizer shows one start, so it needs ``starts=1``. Returns ------- CalibrationResult The best start: the optimized parameter values, in the order of the problem's parameters, its final loss and convergence, every start (``starts``), each start that failed (``failed``, also logged as warnings) and the seed (``seed``). Raises ------ ValueError If `starts` is below 1, or a verbose optimizer has more than one start. RuntimeError If every start fails. """ algorithm_name = ( OptimizationAlgorithm.NELDER_MEAD if isinstance(self.problem.optimization_config, NelderMeadConfig) else OptimizationAlgorithm.PARTICLE_SWARM ) measure = ( f"{self.problem.loss_function} loss function" if self.problem.loss_function is not None else "the negative log-likelihood of its observation models" ) logger.info( f"Starting calibration with {algorithm_name} algorithm, " f"{measure} and {starts} start(s)." ) run = self._rust_problem.optimize( self._build_optimization_config(), starts, self.seed ) names = self.parameter_names summaries: list[StartSummary] = [] failed: list[str] = [] for index, outcome in enumerate(run.outcomes): if outcome.result is None: reason = f"Start {index} failed: {outcome.error}" logger.warning(reason) failed.append(reason) continue summaries.append( StartSummary( start=dict(zip(names, outcome.start, strict=True)), end=dict(outcome.result.best_parameters), loss=outcome.result.final_loss, converged=outcome.result.converged, ) ) best = run.outcomes[run.best].result assert best is not None, "the best start did not fail" result = CalibrationResult( best_parameters=dict(best.best_parameters), final_loss=best.final_loss, iterations=best.iterations, converged=best.converged, termination_reason=best.termination_reason, starts=summaries, failed=failed, seed=self.seed, ) logger.info( ( f"Calibration finished after {result.iterations} iterations. " f"Final loss: {result.final_loss:.6f}" ) ) return result ``` #### run_with_history ```python run_with_history() -> CalibrationHistory ``` Runs the calibration from the initial guess and keeps every evaluation. Returns: | Type | Description | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `CalibrationHistory` | The result, and every evaluation of a feasible point the optimizer made, in order. Infeasible points, which the objective penalizes, are not kept. | Raises: | Type | Description | | -------------- | ---------------------- | | `RuntimeError` | If optimization fails. | Source code in `commol/api/calibrator.py` ```python def run_with_history(self) -> CalibrationHistory: """ Runs the calibration from the initial guess and keeps every evaluation. Returns ------- CalibrationHistory The result, and every evaluation of a feasible point the optimizer made, in order. Infeasible points, which the objective penalizes, are not kept. Raises ------ RuntimeError If optimization fails. """ logger.info("Starting calibration with history tracking") history = self._rust_problem.optimize_with_history( self._build_optimization_config(), self.seed ) names = self.parameter_names rust_result = history.result result = CalibrationResult( best_parameters=dict(rust_result.best_parameters), final_loss=rust_result.final_loss, iterations=rust_result.iterations, converged=rust_result.converged, termination_reason=rust_result.termination_reason, seed=self.seed, ) evaluations = [ CalibrationEvaluation( parameters=dict(zip(names, evaluation.parameters, strict=True)), loss=evaluation.loss, ) for evaluation in history.evaluations ] logger.info( f"Calibration finished after {result.iterations} iterations. " f"Final loss: {result.final_loss:.6f}. " f"Collected {len(evaluations)} evaluations." ) return CalibrationHistory(result=result, evaluations=evaluations) ``` #### uncertainty ```python uncertainty(method: UncertaintyMethod[ResultT]) -> ResultT ``` Run an uncertainty method on this calibration problem. Each method is a configuration (a subclass of `UncertaintyMethod`) whose type sets the result type; its runner is registered in `commol.api.inference.registry`. The guide's table "Which method answers which question" lists them. Parameters: | Name | Type | Description | Default | | -------- | ---------------------------- | -------------------------------- | ---------- | | `method` | `UncertaintyMethod[ResultT]` | The configuration of the method. | *required* | Returns: | Type | Description | | --------- | ------------------------- | | `ResultT` | The result of the method. | Raises: | Type | Description | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `TypeError` | If method is an identifiability method; the message names Calibrator.identifiability. | | `ValueError` | If no runner is registered for the configuration's type (the message names the registered ones), or a setting, an input, the data or the model does not fit the method; the message says why. | | `RuntimeError` | If a computation of the method that could start fails: an engine run, an optimizer, every replicate, or a sampler that does not reach its target. | | `InfeasibleStateError` | If a draw of a prediction leaves the values its model allows. | Source code in `commol/api/calibrator.py` ```python def uncertainty(self, method: UncertaintyMethod[ResultT]) -> ResultT: """ Run an uncertainty method on this calibration problem. Each method is a configuration (a subclass of `UncertaintyMethod`) whose type sets the result type; its runner is registered in `commol.api.inference.registry`. The guide's table "Which method answers which question" lists them. Parameters ---------- method : UncertaintyMethod[ResultT] The configuration of the method. Returns ------- ResultT The result of the method. Raises ------ TypeError If `method` is an identifiability method; the message names ``Calibrator.identifiability``. ValueError If no runner is registered for the configuration's type (the message names the registered ones), or a setting, an input, the data or the model does not fit the method; the message says why. RuntimeError If a computation of the method that could start fails: an engine run, an optimizer, every replicate, or a sampler that does not reach its target. InfeasibleStateError If a draw of a prediction leaves the values its model allows. """ from commol.api.inference.registry import run_method return run_method(self, method, UncertaintyMethod) ``` #### identifiability ```python identifiability(method: IdentifiabilityMethod[ResultT]) -> ResultT ``` Run an identifiability method on this calibration problem. Each method answers which parameters the model and the data determine, from another kind of evidence: `StructuralRank`, `Collinearity`, `SampleIdentifiability` or `StartsIdentifiability`. Each returns an `IdentifiabilityReport`; `IdentifiabilityReport.merge` joins them. Parameters: | Name | Type | Description | Default | | -------- | -------------------------------- | -------------------------------- | ---------- | | `method` | `IdentifiabilityMethod[ResultT]` | The configuration of the method. | *required* | Returns: | Type | Description | | --------- | ------------------------- | | `ResultT` | The result of the method. | Raises: | Type | Description | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `TypeError` | If method is an uncertainty method; the message names Calibrator.uncertainty. | | `ValueError` | If no runner is registered for the configuration's type, or a setting, an input, the data or the model does not fit the method; the message says why. | | `RuntimeError` | If a computation of the method that could start fails: an engine run or an optimizer. | Source code in `commol/api/calibrator.py` ```python def identifiability(self, method: IdentifiabilityMethod[ResultT]) -> ResultT: """ Run an identifiability method on this calibration problem. Each method answers which parameters the model and the data determine, from another kind of evidence: `StructuralRank`, `Collinearity`, `SampleIdentifiability` or `StartsIdentifiability`. Each returns an `IdentifiabilityReport`; `IdentifiabilityReport.merge` joins them. Parameters ---------- method : IdentifiabilityMethod[ResultT] The configuration of the method. Returns ------- ResultT The result of the method. Raises ------ TypeError If `method` is an uncertainty method; the message names ``Calibrator.uncertainty``. ValueError If no runner is registered for the configuration's type, or a setting, an input, the data or the model does not fit the method; the message says why. RuntimeError If a computation of the method that could start fails: an engine run or an optimizer. """ from commol.api.inference.registry import run_method return run_method(self, method, IdentifiabilityMethod) ``` ## CalibrationProblem Bases: `RangeChecked` Defines a complete calibration problem. This class encapsulates all the information needed to calibrate model parameters against observed data. It provides validation of the calibration setup but delegates the actual optimization to the Rust backend. Attributes: | Name | Type | Description | | ----------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `observed_data` | `list[ObservedDataPoint]` | List of observed data points to fit against | | `parameters` | `list[CalibrationParameter]` | List of parameters to calibrate with their bounds | | `constraints` | `list[CalibrationConstraint]` | List of constraints on calibration parameters (optional, default: empty list) | | `loss_function` | \`{sse, rmse, mae} | None\` | | `observation_models` | \`dict[str, ObservationModel] | None\` | | `normalize_observations` | `bool` | When True, divide each residual by the RMS of its observation series before applying the loss metric (default: False). It needs a loss: observation models set the scale of each series with their noise. | | `optimization_config` | `OptimizationConfig` | Configuration for the optimization algorithm | | `initial_condition_remainder` | \`str | None\` | | `replicates` | \`int | None\` | | `replicate_summary` | \`{mean, median, crps} | Quantile | | `seed` | \`int | None\` | Source code in `commol/context/calibration.py` ```python class CalibrationProblem(RangeChecked): """ Defines a complete calibration problem. This class encapsulates all the information needed to calibrate model parameters against observed data. It provides validation of the calibration setup but delegates the actual optimization to the Rust backend. Attributes ---------- observed_data : list[ObservedDataPoint] List of observed data points to fit against parameters : list[CalibrationParameter] List of parameters to calibrate with their bounds constraints : list[CalibrationConstraint] List of constraints on calibration parameters (optional, default: empty list) loss_function : {"sse", "rmse", "mae"} | None The loss that measures the fit, with the observation weights. A problem has a loss or ``observation_models``, exactly one, with no default: what to fit is a modelling choice. observation_models : dict[str, ObservationModel] | None One observation model per series label (`NormalObservations`, `LogNormalObservations`, `StudentTObservations`, `PoissonObservations`, `NegativeBinomialObservations`, `BinomialObservations`): the problem fits by maximum likelihood, and its loss is the negative log-likelihood. Every observed series needs one, and every parameter of type ``"noise"`` must be an argument of one. It needs a deterministic engine. normalize_observations : bool When True, divide each residual by the RMS of its observation series before applying the loss metric (default: False). It needs a loss: observation models set the scale of each series with their noise. optimization_config : OptimizationConfig Configuration for the optimization algorithm initial_condition_remainder : str | None Expanded compartment or bin that takes what the calibrated initial conditions leave of the amounts the model states (default: None). It is not calibrated itself: it takes the sum of the amounts the model states, less every other compartment, so that sum does not change. A value that would make it negative is penalized. Without a remainder, calibrated initial conditions are applied as given, and the other compartments keep their amounts. replicates : int | None For a stochastic engine: how many replicates each evaluation runs, with common random numbers (the same seeds, derived from ``seed``, at every evaluation), so the loss is a deterministic function of the parameters. Required with a stochastic engine; a deterministic engine does not use it. replicate_summary : {"mean", "median", "crps"} | Quantile | None For a stochastic engine: what each observation is compared with. The mean, the median or a `Quantile` of the replicates, with ``loss_function``; or ``"crps"``, the continuous ranked probability score of their distribution, whose loss is the weighted mean score. Required with a stochastic engine, with no default: what to fit is a modelling choice. seed : int | None Seed of every random number of a calibration (default: None, a seed the engine draws when the calibrator is built, which `Calibrator.seed` gives and the calibrator logs): the replicates of a stochastic engine, the starts of ``Calibrator.run(starts=n)``, a particle swarm, and the uncertainty methods. Required with a stochastic engine. """ model_config = ConfigDict(extra="forbid") observed_data: list[ObservedDataPoint] = Field( default=..., min_length=1, description="Observed data points" ) parameters: list[CalibrationParameter] = Field( default=..., min_length=1, description="Parameters to calibrate" ) constraints: list[CalibrationConstraint] = Field( default_factory=list, description="Constraints on calibration parameters", ) loss_function: str | None = Field( default=None, description="Loss function to use for measuring fit quality", ) observation_models: dict[str, ObservationModel] | None = Field( default=None, description="One observation model per series label", ) normalize_observations: bool = Field( default=False, description=( "Normalize each residual by the RMS of its observation series before " "applying the loss metric. Keeps series measured on very different " "scales comparable in the loss." ), ) optimization_config: NelderMeadConfig | ParticleSwarmConfig = Field( default=..., description="Optimization algorithm configuration" ) initial_condition_remainder: str | None = Field( default=None, description=( "Expanded compartment or bin that takes what the calibrated " "initial conditions leave of the amounts the model states." ), ) replicates: int | None = Field( default=None, description="Replicates per evaluation of a stochastic engine.", ) replicate_summary: ReplicateSummary | None = Field( default=None, description="What the observations are compared with, for a stochastic engine.", ) seed: int | None = Field( default=None, description=( "Seed of every random number of a calibration: the replicates of a " "stochastic engine, the starts, a particle swarm and the methods" ), ) @field_validator("loss_function") @classmethod def validate_loss_function(cls, v: str | None) -> str | None: if v is not None and v not in LossFunction: raise ValueError( f"Invalid loss_function: {v}. Must be one of {list(LossFunction)}" ) return v @model_validator(mode="after") def validate_loss_or_models(self) -> Self: """Require a loss or observation models, exactly one.""" if self.loss_function is not None and self.observation_models is not None: raise ValueError( "A problem has a loss_function or observation_models, not both: " "the fit and the posterior would answer different questions." ) if self.loss_function is None and self.observation_models is None: raise ValueError( "A problem needs a loss_function (one of " f"{', '.join(LossFunction)}) or observation_models. There is no " "default: what to fit is a modelling choice." ) if self.observation_models is not None and self.normalize_observations: raise ValueError( "normalize_observations needs a loss_function: the observation " "models set the scale of each series with their noise." ) return self @model_validator(mode="after") def validate_unique_parameter_ids(self) -> Self: """Ensure parameter IDs are unique.""" param_ids = [p.id for p in self.parameters] if len(param_ids) != len(set(param_ids)): duplicates = [id for id in set(param_ids) if param_ids.count(id) > 1] raise ValueError(f"Duplicate parameter IDs found: {duplicates}") return self ``` ### Methods: #### validate_loss_or_models ```python validate_loss_or_models() -> Self ``` Require a loss or observation models, exactly one. Source code in `commol/context/calibration.py` ```python @model_validator(mode="after") def validate_loss_or_models(self) -> Self: """Require a loss or observation models, exactly one.""" if self.loss_function is not None and self.observation_models is not None: raise ValueError( "A problem has a loss_function or observation_models, not both: " "the fit and the posterior would answer different questions." ) if self.loss_function is None and self.observation_models is None: raise ValueError( "A problem needs a loss_function (one of " f"{', '.join(LossFunction)}) or observation_models. There is no " "default: what to fit is a modelling choice." ) if self.observation_models is not None and self.normalize_observations: raise ValueError( "normalize_observations needs a loss_function: the observation " "models set the scale of each series with their noise." ) return self ``` #### validate_unique_parameter_ids ```python validate_unique_parameter_ids() -> Self ``` Ensure parameter IDs are unique. Source code in `commol/context/calibration.py` ```python @model_validator(mode="after") def validate_unique_parameter_ids(self) -> Self: """Ensure parameter IDs are unique.""" param_ids = [p.id for p in self.parameters] if len(param_ids) != len(set(param_ids)): duplicates = [id for id in set(param_ids) if param_ids.count(id) > 1] raise ValueError(f"Duplicate parameter IDs found: {duplicates}") return self ``` ## Quantile Bases: `BaseModel` A quantile of the replicates of a stochastic engine, for `CalibrationProblem.replicate_summary`. It is the one quantile of commol, the one of `weighted_quantiles` and `SimulationReplicates.quantiles`: with equal weights, NumPy's `method="hazen"`. `Quantile(0.25)` of the replicates 1, 2, 3, 4, 5 is 1.75. Attributes: | Name | Type | Description | | ------- | ------- | ------------------------------------------------------- | | `level` | `float` | The level, between 0 and 1, such as 0.5 for the median. | Source code in `commol/context/calibration.py` ```python class Quantile(BaseModel): """ A quantile of the replicates of a stochastic engine, for ``CalibrationProblem.replicate_summary``. It is the one quantile of commol, the one of `weighted_quantiles` and `SimulationReplicates.quantiles`: with equal weights, NumPy's ``method="hazen"``. ``Quantile(0.25)`` of the replicates 1, 2, 3, 4, 5 is 1.75. Attributes ---------- level : float The level, between 0 and 1, such as 0.5 for the median. """ model_config = ConfigDict(extra="forbid") level: QuantileLevel = Field(..., description="Level of the quantile.") def __init__(self, level: float) -> None: """ A quantile of the replicates. Parameters ---------- level : float The level, between 0 and 1. """ super().__init__(level=level) ``` ### Methods: #### __init__ ```python __init__(level: float) -> None ``` A quantile of the replicates. Parameters: | Name | Type | Description | Default | | ------- | ------- | --------------------------- | ---------- | | `level` | `float` | The level, between 0 and 1. | *required* | Source code in `commol/context/calibration.py` ```python def __init__(self, level: float) -> None: """ A quantile of the replicates. Parameters ---------- level : float The level, between 0 and 1. """ super().__init__(level=level) ``` ## ObservedDataPoint Bases: `RangeChecked` Represents a single observed data point for calibration. An observation compares one output, or the sum of several outputs, with an observed value at a position on the axis. Attributes: | Name | Type | Description | | ----------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `at` | `float` | Position of the observation on the axis. The engine decides which positions its model type accepts: a DifferentialEquations model any non-negative position, a DifferenceEquations model only an integer step. | | `output` | \`str | None\` | | `outputs` | \`list[str] | None\` | | `label` | `str` | Name of the observed series, used by reports, plots and the per-series normalization. Defaults to output. | | `value` | `float` | Observed value | | `weight` | `float` | How much this observation counts in the loss (default: 1.0). Every loss multiplies the squared or absolute residual by it: sse is Σ w·r², rmse is √(Σ w·r² / Σ w), mae is Σ w· | | `factor_id` | \`str | None\` | | `window` | \`float | None\` | | `trials` | \`int | None\` | Source code in `commol/context/calibration.py` ```python class ObservedDataPoint(RangeChecked): """ Represents a single observed data point for calibration. An observation compares one output, or the sum of several outputs, with an observed value at a position on the axis. Attributes ---------- at : float Position of the observation on the axis. The engine decides which positions its model type accepts: a DifferentialEquations model any non-negative position, a DifferenceEquations model only an integer step. output : str | None The output being observed. Give either `output` or `outputs`. outputs : list[str] | None Outputs summed before comparing with this observation. Needs a `label`. label : str Name of the observed series, used by reports, plots and the per-series normalization. Defaults to `output`. value : float Observed value weight : float How much this observation counts in the loss (default: 1.0). Every loss multiplies the squared or absolute residual by it: ``sse`` is ``Σ w·r²``, ``rmse`` is ``√(Σ w·r² / Σ w)``, ``mae`` is ``Σ w·|r| / Σ w``. With observation models it is a power of the observation's likelihood, ``w·log p``: a weight of 2 counts the observation twice. factor_id : str | None Optional factor parameter ID that multiplies the model output before comparison window : float | None Optional window length for a cumulative output: the prediction is the output at `at` minus the output at ``at - window``. trials : int | None The trials of this observation, for a `BinomialObservations` model with no ``trials`` of its own (default: None). """ model_config = ConfigDict(extra="forbid") at: float = Field(default=..., description="Position on the axis") output: str | None = Field( default=None, min_length=1, description="The output being observed" ) outputs: list[str] | None = Field( default=None, description="Outputs summed before comparison", ) label: str = Field( default="", description="Name of the observed series; defaults to `output`", ) value: float = Field(default=..., description="Observed value") weight: float = Field( default=1.0, description="Weight for this observation in the loss function", ) factor_id: str | None = Field( default=None, description="Optional factor parameter ID that multiplies the model output", ) window: float | None = Field( default=None, description=( "Optional window length for cumulative-output observations. " "When set, prediction is value(at) - value(at - window)." ), ) trials: int | None = Field( default=None, description="Trials of a binomial observation model that reads them here", ) @model_validator(mode="after") def validate_observed_outputs(self) -> Self: """Require one of `output` and `outputs`; name the series.""" if (self.output is None) == (self.outputs is None): raise ValueError("Give exactly one of 'output' and 'outputs'.") if self.outputs is not None: if not self.outputs: raise ValueError("outputs must be non-empty when provided") if not self.label: raise ValueError( "An observation of several outputs needs a 'label' that names " "their sum." ) elif not self.label: self.label = self.output or "" return self @model_validator(mode="after") def validate_window(self) -> Self: """ A window must not start before position 0, with the rule of the engine (`check_window_end` of `crates/commol-calibration/src/observation.rs`). """ if self.window is not None: commol_rs.calibration.check_window_end(self.at, self.window) return self @property def observed_outputs(self) -> list[str]: """The outputs whose sum is compared: `outputs`, or `output` alone.""" return list(self.outputs) if self.outputs is not None else [self.output or ""] ``` ### Attributes #### observed_outputs ```python observed_outputs: list[str] ``` The outputs whose sum is compared: `outputs`, or `output` alone. ### Methods: #### validate_observed_outputs ```python validate_observed_outputs() -> Self ``` Require one of `output` and `outputs`; name the series. Source code in `commol/context/calibration.py` ```python @model_validator(mode="after") def validate_observed_outputs(self) -> Self: """Require one of `output` and `outputs`; name the series.""" if (self.output is None) == (self.outputs is None): raise ValueError("Give exactly one of 'output' and 'outputs'.") if self.outputs is not None: if not self.outputs: raise ValueError("outputs must be non-empty when provided") if not self.label: raise ValueError( "An observation of several outputs needs a 'label' that names " "their sum." ) elif not self.label: self.label = self.output or "" return self ``` #### validate_window ```python validate_window() -> Self ``` A window must not start before position 0, with the rule of the engine (`check_window_end` of `crates/commol-calibration/src/observation.rs`). Source code in `commol/context/calibration.py` ```python @model_validator(mode="after") def validate_window(self) -> Self: """ A window must not start before position 0, with the rule of the engine (`check_window_end` of `crates/commol-calibration/src/observation.rs`). """ if self.window is not None: commol_rs.calibration.check_window_end(self.at, self.window) return self ``` ## Observation models Guide: [Observation Models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md). ## ObservationModel ```python ObservationModel = Annotated[NormalObservations | LogNormalObservations | StudentTObservations | PoissonObservations | NegativeBinomialObservations | BinomialObservations, Field(discriminator=kind)] ``` One observation model of a series. ## NormalObservations Bases: `_ObservationModel`, `RangeChecked` Normal noise: `y ~ N(μ, sd²)`, with `μ` the prediction. For continuous values with errors of the same size everywhere. With a known `sd` the maximum-likelihood fit is the least-squares fit. Attributes: | Name | Type | Description | | ---- | ------- | ----------- | | `sd` | \`float | str\` | Source code in `commol/context/observation_models.py` ```python class NormalObservations(_ObservationModel, RangeChecked): """ Normal noise: ``y ~ N(μ, sd²)``, with ``μ`` the prediction. For continuous values with errors of the same size everywhere. With a known ``sd`` the maximum-likelihood fit is the least-squares fit. Attributes ---------- sd : float | str The standard deviation, above 0, or the id of a noise parameter. """ kind: Literal["normal"] = "normal" sd: NoiseArgument = Field(description="The standard deviation.") ``` ## LogNormalObservations Bases: `_ObservationModel`, `RangeChecked` Log-normal noise: `log y` is normal with standard deviation `sigma`. For positive values with errors that grow with the value. `center` says what the prediction `μ` is, and has no default: `"mean"` makes `E[y] = μ` (`log y ~ N(log μ − σ²/2, σ²)`), `"median"` makes the median `μ` (`log y ~ N(log μ, σ²)`, the proportional error `y = μ·e^ε`). Every observed value must be above 0. Attributes: | Name | Type | Description | | -------- | ---------------- | ----------------------- | | `sigma` | \`float | str\` | | `center` | `{mean, median}` | What the prediction is. | Source code in `commol/context/observation_models.py` ```python class LogNormalObservations(_ObservationModel, RangeChecked): """ Log-normal noise: ``log y`` is normal with standard deviation ``sigma``. For positive values with errors that grow with the value. ``center`` says what the prediction ``μ`` is, and has no default: ``"mean"`` makes ``E[y] = μ`` (``log y ~ N(log μ − σ²/2, σ²)``), ``"median"`` makes the median ``μ`` (``log y ~ N(log μ, σ²)``, the proportional error ``y = μ·e^ε``). Every observed value must be above 0. Attributes ---------- sigma : float | str The standard deviation of ``log y``, above 0, or the id of a noise parameter. center : {"mean", "median"} What the prediction is. """ kind: Literal["log_normal"] = "log_normal" sigma: NoiseArgument = Field(description="The standard deviation of log y.") center: Literal["mean", "median"] = Field(description="What the prediction is.") ``` ## StudentTObservations Bases: `_ObservationModel`, `RangeChecked` Student-t noise of center `μ`, the prediction: normal noise with heavier tails, so an outlier pulls the fit less. Attributes: | Name | Type | Description | | ------- | ------- | ----------- | | `df` | \`float | str\` | | `scale` | \`float | str\` | Source code in `commol/context/observation_models.py` ```python class StudentTObservations(_ObservationModel, RangeChecked): """ Student-t noise of center ``μ``, the prediction: normal noise with heavier tails, so an outlier pulls the fit less. Attributes ---------- df : float | str The degrees of freedom, above 0, or the id of a noise parameter. The noise has a mean when ``df > 1`` and a variance when ``df > 2``. scale : float | str The scale, above 0, or the id of a noise parameter. """ kind: Literal["student_t"] = "student_t" df: NoiseArgument = Field(description="The degrees of freedom.") scale: NoiseArgument = Field(description="The scale.") ``` ## PoissonObservations Bases: `_ObservationModel` Poisson counts of mean `μ`, the prediction: variance equal to the mean. Every observed value must be a whole number, at least 0. Source code in `commol/context/observation_models.py` ```python class PoissonObservations(_ObservationModel): """ Poisson counts of mean ``μ``, the prediction: variance equal to the mean. Every observed value must be a whole number, at least 0. """ kind: Literal["poisson"] = "poisson" ``` ## NegativeBinomialObservations Bases: `_ObservationModel`, `RangeChecked` Negative binomial counts of mean `μ`, the prediction, and variance `μ + φμ²`: counts that scatter more than a Poisson. `φ = 0` is the Poisson, so bounds `[0, …]` of a noise parameter hold it. The dispersion `k` (`size` in R) is `1/φ`. Every observed value must be a whole number, at least 0. Attributes: | Name | Type | Description | | ---------------- | ------- | ----------- | | `overdispersion` | \`float | str\` | Source code in `commol/context/observation_models.py` ```python class NegativeBinomialObservations(_ObservationModel, RangeChecked): """ Negative binomial counts of mean ``μ``, the prediction, and variance ``μ + φμ²``: counts that scatter more than a Poisson. ``φ = 0`` is the Poisson, so bounds ``[0, …]`` of a noise parameter hold it. The dispersion ``k`` (``size`` in R) is ``1/φ``. Every observed value must be a whole number, at least 0. Attributes ---------- overdispersion : float | str ``φ``, at least 0, or the id of a noise parameter. """ kind: Literal["negative_binomial"] = "negative_binomial" overdispersion: NoiseArgument = Field(description="The overdispersion φ.") ``` ## BinomialObservations Bases: `_ObservationModel`, `RangeChecked` Binomial counts of `n` trials and mean `μ`, the prediction: the probability is `μ / n`, so the prediction is an expected count, not a proportion. `trials` gives `n` for every observation of the series; without it, each observation of the series gives its own (`ObservedDataPoint.trials`), for trials that change from one observation to the next. One source per series. Every observed value must be a whole number between 0 and its trials. Attributes: | Name | Type | Description | | -------- | ----- | ----------- | | `trials` | \`int | None\` | Source code in `commol/context/observation_models.py` ```python class BinomialObservations(_ObservationModel, RangeChecked): """ Binomial counts of ``n`` trials and mean ``μ``, the prediction: the probability is ``μ / n``, so the prediction is an expected count, not a proportion. ``trials`` gives ``n`` for every observation of the series; without it, each observation of the series gives its own (``ObservedDataPoint.trials``), for trials that change from one observation to the next. One source per series. Every observed value must be a whole number between 0 and its trials. Attributes ---------- trials : int | None The trials of every observation, or None (default) to read the trials of each observation. """ kind: Literal["binomial"] = "binomial" trials: int | None = Field(default=None, description="The trials.") ``` ## Unknowns, optimizers and results ## CalibrationParameter Bases: `BaseModel` Defines a parameter or initial condition to be calibrated with its bounds. Attributes: | Name | Type | Description | | ---------------- | ------- | ----------------------------------------------------------------------- | | `id` | `str` | Identifier (parameter ID for parameters, bin ID for initial conditions) | | `parameter_type` | `str` | Type of value being calibrated | | `min_bound` | `float` | Minimum allowed value for this parameter | | `max_bound` | `float` | Maximum allowed value for this parameter | | `initial_guess` | \`float | None\` | Source code in `commol/context/calibration.py` ```python class CalibrationParameter(BaseModel): """ Defines a parameter or initial condition to be calibrated with its bounds. Attributes ---------- id : str Identifier (parameter ID for parameters, bin ID for initial conditions) parameter_type : str Type of value being calibrated min_bound : float Minimum allowed value for this parameter max_bound : float Maximum allowed value for this parameter initial_guess : float | None Optional starting value for optimization (if None, midpoint is used) """ model_config = ConfigDict(extra="forbid") id: str = Field( default=..., min_length=1, description="Parameter or bin identifier" ) parameter_type: str = Field( default=..., description="Type of value being calibrated", ) min_bound: float = Field(default=..., description="Minimum allowed value") max_bound: float = Field(default=..., description="Maximum allowed value") initial_guess: float | None = Field( default=None, description="Optional starting value for optimization" ) @field_validator("parameter_type") @classmethod def validate_parameter_type(cls, v: str) -> str: if v not in CalibrationParameterType: raise ValueError( f"Invalid parameter_type: {v}. " f"Must be one of {list(CalibrationParameterType)}" ) return v @model_validator(mode="after") def validate_bounds(self) -> Self: """Validate that max_bound > min_bound and initial_guess is within bounds.""" if self.max_bound <= self.min_bound: raise ValueError( ( f"max_bound ({self.max_bound}) must be greater than " f"min_bound ({self.min_bound}) for parameter '{self.id}'" ) ) if self.initial_guess is not None: if not (self.min_bound <= self.initial_guess <= self.max_bound): raise ValueError( ( f"initial_guess ({self.initial_guess}) must be between " f"min_bound ({self.min_bound}) and max_bound " f"({self.max_bound}) for parameter '{self.id}'" ) ) return self ``` ### Methods: #### validate_bounds ```python validate_bounds() -> Self ``` Validate that max_bound > min_bound and initial_guess is within bounds. Source code in `commol/context/calibration.py` ```python @model_validator(mode="after") def validate_bounds(self) -> Self: """Validate that max_bound > min_bound and initial_guess is within bounds.""" if self.max_bound <= self.min_bound: raise ValueError( ( f"max_bound ({self.max_bound}) must be greater than " f"min_bound ({self.min_bound}) for parameter '{self.id}'" ) ) if self.initial_guess is not None: if not (self.min_bound <= self.initial_guess <= self.max_bound): raise ValueError( ( f"initial_guess ({self.initial_guess}) must be between " f"min_bound ({self.min_bound}) and max_bound " f"({self.max_bound}) for parameter '{self.id}'" ) ) return self ``` ## CalibrationConstraint Bases: `RangeChecked` A constraint on calibration parameters defined as a mathematical expression. Constraints are mathematical expressions that must evaluate to >= 0 for the constraint to be satisfied. When the expression evaluates to < 0, the constraint is violated and a penalty is applied during optimization. Attributes: | Name | Type | Description | | ------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `str` | Unique identifier for this constraint | | `expression` | `str` | Mathematical expression that must evaluate >= 0 for constraint satisfaction. Can reference calibration parameters by their IDs. When at is specified, can also reference output values (A, B, C, etc.) at those positions. | | `description` | \`str | None\` | | `weight` | `float` | Penalty weight multiplier (default: 1.0). Higher values make this constraint more important relative to others. The amount of a violation of this constraint is weight \* | | `at` | \`list[float] | None\` | Examples: ```pycon >>> # k1/k2 must be <= 5 >>> constraint = CalibrationConstraint( ... id="ratio_bound", ... expression="5.0 - k1/k2", ... description="Ratio k1/k2 <= 5", ... ) ``` ```pycon >>> # k1 must be >= k2 >>> constraint = CalibrationConstraint( ... id="ordering", ... expression="k1 - k2", ... description="k1 >= k2", ... ) ``` ```pycon >>> # Sum of parameters <= 1.0 >>> constraint = CalibrationConstraint( ... id="sum_bound", ... expression="1.0 - (param1 + param2 + param3)", ... description="Sum of rates <= 1.0", ... ) ``` ```pycon >>> # At positions: bin B stays at or below 500 at specific positions >>> constraint = CalibrationConstraint( ... id="peak_b", ... expression="500.0 - B", ... description="Peak B never exceeds 500", ... at=[10, 20, 30, 40, 50], ... ) ``` Source code in `commol/context/calibration.py` ```python class CalibrationConstraint(RangeChecked): """ A constraint on calibration parameters defined as a mathematical expression. Constraints are mathematical expressions that must evaluate to >= 0 for the constraint to be satisfied. When the expression evaluates to < 0, the constraint is violated and a penalty is applied during optimization. Attributes ---------- id : str Unique identifier for this constraint expression : str Mathematical expression that must evaluate >= 0 for constraint satisfaction. Can reference calibration parameters by their IDs. When `at` is specified, can also reference output values (A, B, C, etc.) at those positions. description : str | None Human-readable description of the constraint (optional) weight : float Penalty weight multiplier (default: 1.0). Higher values make this constraint more important relative to others. The amount of a violation of this constraint is weight * |violation|, summed over its positions; the loss of a point that violates constraints is P * (1 + the sum of the amounts), with P much larger than any fit. at : list[float] | None Optional positions on the axis at which to evaluate this constraint. The engine decides which positions its model type accepts: a DifferenceEquations model only integer steps. If None, the constraint is evaluated once before simulation using parameter values only. If given, it is evaluated at each position and can reference output values. Examples -------- >>> # k1/k2 must be <= 5 >>> constraint = CalibrationConstraint( ... id="ratio_bound", ... expression="5.0 - k1/k2", ... description="Ratio k1/k2 <= 5", ... ) >>> # k1 must be >= k2 >>> constraint = CalibrationConstraint( ... id="ordering", ... expression="k1 - k2", ... description="k1 >= k2", ... ) >>> # Sum of parameters <= 1.0 >>> constraint = CalibrationConstraint( ... id="sum_bound", ... expression="1.0 - (param1 + param2 + param3)", ... description="Sum of rates <= 1.0", ... ) >>> # At positions: bin B stays at or below 500 at specific positions >>> constraint = CalibrationConstraint( ... id="peak_b", ... expression="500.0 - B", ... description="Peak B never exceeds 500", ... at=[10, 20, 30, 40, 50], ... ) """ model_config = ConfigDict(extra="forbid") id: str = Field( default=..., min_length=1, description="Unique identifier for this constraint" ) expression: str = Field( default=..., min_length=1, description="Mathematical expression that must evaluate >= 0", ) description: str | None = Field( default=None, description="Human-readable description of the constraint" ) weight: float = Field( default=1.0, description="Penalty weight multiplier for violations", ) at: list[float] | None = Field( default=None, description=( "Optional positions on the axis at which to evaluate the constraint; " "it can then reference output values." ), ) @field_validator("expression", mode="before") @classmethod def validate_expression(cls, value: str) -> str: """Perform security validation on the constraint expression.""" try: validate_expression_security(value) except ValueError as e: raise ValueError( f"Security validation failed for expression '{value}': {e}" ) return value @model_validator(mode="after") def validate_positions(self) -> Self: """Validate that the positions, when given, are not empty.""" if self.at is not None and len(self.at) == 0: raise ValueError( f"at for constraint '{self.id}' must not be empty if specified" ) return self ``` ### Methods: #### validate_expression ```python validate_expression(value: str) -> str ``` Perform security validation on the constraint expression. Source code in `commol/context/calibration.py` ```python @field_validator("expression", mode="before") @classmethod def validate_expression(cls, value: str) -> str: """Perform security validation on the constraint expression.""" try: validate_expression_security(value) except ValueError as e: raise ValueError( f"Security validation failed for expression '{value}': {e}" ) return value ``` #### validate_positions ```python validate_positions() -> Self ``` Validate that the positions, when given, are not empty. Source code in `commol/context/calibration.py` ```python @model_validator(mode="after") def validate_positions(self) -> Self: """Validate that the positions, when given, are not empty.""" if self.at is not None and len(self.at) == 0: raise ValueError( f"at for constraint '{self.id}' must not be empty if specified" ) return self ``` ## NelderMeadConfig Bases: `RangeChecked` Configuration for the Nelder-Mead optimization algorithm. The Nelder-Mead method is a simplex-based derivative-free optimization algorithm, suitable for problems where gradients are not available. Attributes: | Name | Type | Description | | ---------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `max_iterations` | `int` | Maximum number of iterations (default: 1000) | | `sd_tolerance` | `float` | Convergence tolerance for standard deviation (default: 1e-6) | | `initial_simplex_size` | `float` | Size of the initial simplex, as a fraction of each parameter's bound width, in (0, 1\] (default: 0.1). Nelder-Mead searches an unbounded coordinate u and scores x = lo + (hi - lo)(sin u + 1)/2, so every point it tries is inside the bounds. | | `alpha` | \`float | None\` | | `gamma` | \`float | None\` | | `rho` | \`float | None\` | | `sigma` | \`float | None\` | | `verbose` | `bool` | Enable verbose output during optimization (default: False) | | `header_interval` | `int` | Number of iterations between table header repeats in verbose output (default: 100) | Source code in `commol/context/calibration.py` ```python class NelderMeadConfig(RangeChecked): """ Configuration for the Nelder-Mead optimization algorithm. The Nelder-Mead method is a simplex-based derivative-free optimization algorithm, suitable for problems where gradients are not available. Attributes ---------- max_iterations : int Maximum number of iterations (default: 1000) sd_tolerance : float Convergence tolerance for standard deviation (default: 1e-6) initial_simplex_size : float Size of the initial simplex, as a fraction of each parameter's bound width, in (0, 1] (default: 0.1). Nelder-Mead searches an unbounded coordinate ``u`` and scores ``x = lo + (hi - lo)(sin u + 1)/2``, so every point it tries is inside the bounds. alpha : float | None Reflection coefficient (default: None, uses argmin's default) gamma : float | None Expansion coefficient (default: None, uses argmin's default) rho : float | None Contraction coefficient (default: None, uses argmin's default) sigma : float | None Shrink coefficient (default: None, uses argmin's default) verbose : bool Enable verbose output during optimization (default: False) header_interval: int Number of iterations between table header repeats in verbose output (default: 100) """ model_config = ConfigDict(extra="forbid") max_iterations: int = Field( default=1000, description="Maximum number of iterations" ) sd_tolerance: float = Field( default=1e-6, description="Convergence tolerance for standard deviation" ) initial_simplex_size: float = Field( default=0.1, description=( "Size of the initial simplex, as a fraction of each parameter's bound width" ), ) alpha: float | None = Field( default=None, description="Reflection coefficient (default: None, uses argmin's default)", ) gamma: float | None = Field( default=None, description="Expansion coefficient (default: None, uses argmin's default)", ) rho: float | None = Field( default=None, description="Contraction coefficient (default: None, uses argmin's default)", ) sigma: float | None = Field( default=None, description="Shrink coefficient (default: None, uses argmin's default)", ) verbose: bool = Field( default=False, description="Enable verbose output during optimization (default: False)", ) header_interval: int = Field( default=100, description=( "Number of iterations between table header repeats in verbose output " "(default: 100)" ), ) ``` ## ParticleSwarmConfig Bases: `RangeChecked` Configuration for the Particle Swarm Optimization algorithm. Attributes: | Name | Type | Description | | ---------------- | ------ | ----------------------------------------------------- | | `particles` | `int` | Number of particles in the swarm (default: 20) | | `max_iterations` | `int` | Maximum number of iterations (default: 1000) | | `verbose` | `bool` | Enable verbose output (default: False) | | `initialization` | `str` | Particle initialization strategy (default: "uniform") | Methods: | Name | Description | | ------------------ | ------------------------------------------------------------ | | `inertia` | Set inertia weight strategy ("constant" or "chaotic") | | `acceleration` | Set acceleration coefficients ("constant" or "time_varying") | | `mutation` | Enable mutation to escape local optima | | `velocity` | Configure velocity control | | `improvement_stop` | Stop when the best loss stops improving | | `radius_stop` | Stop when the swarm has gathered around its best position | Source code in `commol/context/calibration.py` ```python class ParticleSwarmConfig(RangeChecked): """ Configuration for the Particle Swarm Optimization algorithm. Attributes ---------- particles : int Number of particles in the swarm (default: 20) max_iterations : int Maximum number of iterations (default: 1000) verbose : bool Enable verbose output (default: False) initialization : str Particle initialization strategy (default: "uniform") Methods ------- inertia(type, **kwargs) Set inertia weight strategy ("constant" or "chaotic") acceleration(type, **kwargs) Set acceleration coefficients ("constant" or "time_varying") mutation(strategy, scale, probability, application) Enable mutation to escape local optima velocity(clamp_factor, mutation_threshold) Configure velocity control improvement_stop(window, tol_abs, tol_rel) Stop when the best loss stops improving radius_stop(tol) Stop when the swarm has gathered around its best position """ model_config = ConfigDict(extra="forbid") # Core parameters particles: int = Field(default=20, description="Number of particles in the swarm") max_iterations: int = Field( default=1000, description="Maximum number of iterations" ) verbose: bool = Field( default=False, description="Enable verbose output during optimization", ) initialization: str = Field( default=PSOInitializationStrategy.UNIFORM, description=( "Particle initialization strategy: 'uniform' (default), 'latin_hypercube'," "or 'opposition_based'" ), ) # Private attributes for optional configurations (set via fluent methods) _inertia: PSOConstantInertia | PSOChaoticInertia | None = PrivateAttr(default=None) _acceleration: PSOConstantAcceleration | PSOTimeVaryingAcceleration | None = ( PrivateAttr(default=None) ) _mutation: PSOMutationConfig | None = PrivateAttr(default=None) _velocity: PSOVelocityConfig | None = PrivateAttr(default=None) _improvement_stop: PSOImprovementStop | None = PrivateAttr(default=None) _radius_stop: PSORadiusStop | None = PrivateAttr(default=None) @field_validator("initialization") @classmethod def validate_initialization(cls, v: str) -> str: if v not in PSOInitializationStrategy: raise ValueError( f"Invalid initialization: {v}. " f"Must be one of {list(PSOInitializationStrategy)}" ) return v @overload def inertia( self, type: str, *, factor: float = PSO_DEFAULT_INERTIA_FACTOR ) -> Self: ... @overload def inertia(self, type: str, *, w_min: float, w_max: float) -> Self: ... def inertia(self, type: str, **kwargs: float) -> Self: """ Set inertia weight strategy. Parameters ---------- type : str Inertia strategy type: "constant" or "chaotic" **kwargs : float The settings of the strategy, listed under Other Parameters. Other Parameters ---------------- factor : float, default=PSO_DEFAULT_INERTIA_FACTOR Fixed inertia weight; the default is the weight of canonical PSO, 1/(2 ln 2) ≈ 0.72135, which a swarm with no inertia strategy uses. Only used when type="constant". w_min : float Minimum inertia weight. Only used when type="chaotic". w_max : float Maximum inertia weight (must be > w_min). Only used when type="chaotic". Returns ------- Self The config instance for method chaining """ if type not in PSOInertiaType: raise ValueError( f"Invalid inertia type: {type}. Must be one of {list(PSOInertiaType)}" ) if type == PSOInertiaType.CONSTANT: self._inertia = PSOConstantInertia( factor=kwargs.get("factor", PSO_DEFAULT_INERTIA_FACTOR) ) else: self._inertia = PSOChaoticInertia( w_min=kwargs["w_min"], w_max=kwargs["w_max"] ) return self @overload def acceleration( self, type: str, *, cognitive: float = PSO_DEFAULT_ACCELERATION, social: float = PSO_DEFAULT_ACCELERATION, ) -> Self: ... @overload def acceleration( self, type: str, *, c1_initial: float, c1_final: float, c2_initial: float, c2_final: float, ) -> Self: ... def acceleration(self, type: str, **kwargs: float) -> Self: """ Set acceleration coefficient strategy. Parameters ---------- type : str Acceleration strategy type: "constant" or "time_varying" **kwargs : float The settings of the strategy, listed under Other Parameters. Other Parameters ---------------- cognitive : float, default=PSO_DEFAULT_ACCELERATION Cognitive coefficient (c1) - attraction to personal best; the default is the coefficient of canonical PSO, 0.5 + ln 2 ≈ 1.19315, which a swarm with no acceleration strategy uses. Only used when type="constant". social : float, default=PSO_DEFAULT_ACCELERATION Social coefficient (c2) - attraction to swarm best, with the same default. Only used when type="constant". c1_initial : float Initial cognitive factor (typically high, e.g., 2.5). Only used when type="time_varying". c1_final : float Final cognitive factor (typically low, e.g., 0.5). Only used when type="time_varying". c2_initial : float Initial social factor (typically low, e.g., 0.5). Only used when type="time_varying". c2_final : float Final social factor (typically high, e.g., 2.5). Only used when type="time_varying". Returns ------- Self The config instance for method chaining """ if type not in PSOAccelerationType: raise ValueError( f"Invalid acceleration type: {type}. " f"Must be one of {list(PSOAccelerationType)}" ) if type == PSOAccelerationType.CONSTANT: self._acceleration = PSOConstantAcceleration( cognitive=kwargs.get("cognitive", PSO_DEFAULT_ACCELERATION), social=kwargs.get("social", PSO_DEFAULT_ACCELERATION), ) else: self._acceleration = PSOTimeVaryingAcceleration( c1_initial=kwargs["c1_initial"], c1_final=kwargs["c1_final"], c2_initial=kwargs["c2_initial"], c2_final=kwargs["c2_final"], ) return self def mutation( self, strategy: str, *, scale: float, probability: float, application: str, ) -> Self: """ Enable mutation to help escape local optima. Parameters ---------- strategy : str Mutation distribution: "gaussian" or "cauchy". Cauchy has heavier tails for larger jumps. scale : float Standard deviation (gaussian) or scale parameter (cauchy) probability : float Mutation probability per iteration (0.0 to 1.0) application : str Which particles to mutate: "global_best", "all_particles", or "below_average" Returns ------- Self The config instance for method chaining """ if strategy not in PSOMutationStrategy: raise ValueError( f"Invalid mutation strategy: {strategy}. " f"Must be one of {list(PSOMutationStrategy)}" ) if application not in PSOMutationApplication: raise ValueError( f"Invalid mutation application: {application}. " f"Must be one of {list(PSOMutationApplication)}" ) self._mutation = PSOMutationConfig( strategy=PSOMutationStrategy(strategy), scale=scale, probability=probability, application=PSOMutationApplication(application), ) return self def velocity( self, *, clamp_factor: float | None = None, mutation_threshold: float | None = None, ) -> Self: """ Configure velocity control. Parameters ---------- clamp_factor : float, optional Velocity clamping as fraction of search space (0.0-1.0). Typically 0.1-0.2. Prevents explosive velocities. mutation_threshold : float, optional Reinitialize velocities below this threshold. Typically 0.001-0.01. Prevents stagnation. Returns ------- Self The config instance for method chaining """ self._velocity = PSOVelocityConfig( clamp_factor=clamp_factor, mutation_threshold=mutation_threshold, ) return self def improvement_stop(self, *, window: int, tol_abs: float, tol_rel: float) -> Self: """ Stop when the best loss stops improving. The run stops when the best loss improved by at most ``tol_abs + tol_rel * |best loss|`` over the last `window` iterations, and reports ``converged=True``. Off by default. A swarm can stall for some iterations and then improve again, so a short window can stop it early. Parameters ---------- window : int Iterations over which the improvement is measured (> 0). tol_abs : float Absolute tolerance on the improvement (>= 0). tol_rel : float Tolerance on the improvement relative to the best loss (>= 0). Returns ------- Self The config instance for method chaining """ self._improvement_stop = PSOImprovementStop( window=window, tol_abs=tol_abs, tol_rel=tol_rel ) return self def radius_stop(self, tol: float) -> Self: """ Stop when the swarm has gathered around its best position. The run stops when the largest distance of a particle from the best position, divided by the size of the search box, is at most `tol`, and reports ``converged=True``. Off by default. Mutation and velocity mutation scatter the swarm again, so with them the radius can stay above `tol`; values that the data do not tell apart keep the swarm spread. Parameters ---------- tol : float Tolerance on the radius of the swarm, relative to the size of the search box (>= 0). Returns ------- Self The config instance for method chaining """ self._radius_stop = PSORadiusStop(tol=tol) return self @property def inertia_config(self) -> PSOConstantInertia | PSOChaoticInertia | None: """Get the inertia configuration (for internal use).""" return self._inertia @property def acceleration_config( self, ) -> PSOConstantAcceleration | PSOTimeVaryingAcceleration | None: """Get the acceleration configuration (for internal use).""" return self._acceleration @property def mutation_config(self) -> PSOMutationConfig | None: """Get the mutation configuration (for internal use).""" return self._mutation @property def velocity_config(self) -> PSOVelocityConfig | None: """Get the velocity configuration (for internal use).""" return self._velocity @property def improvement_stop_config(self) -> PSOImprovementStop | None: """Get the improvement stop rule (for internal use).""" return self._improvement_stop @property def radius_stop_config(self) -> PSORadiusStop | None: """Get the radius stop rule (for internal use).""" return self._radius_stop ``` ### Attributes #### inertia_config ```python inertia_config: PSOConstantInertia | PSOChaoticInertia | None ``` Get the inertia configuration (for internal use). #### acceleration_config ```python acceleration_config: PSOConstantAcceleration | PSOTimeVaryingAcceleration | None ``` Get the acceleration configuration (for internal use). #### mutation_config ```python mutation_config: PSOMutationConfig | None ``` Get the mutation configuration (for internal use). #### velocity_config ```python velocity_config: PSOVelocityConfig | None ``` Get the velocity configuration (for internal use). #### improvement_stop_config ```python improvement_stop_config: PSOImprovementStop | None ``` Get the improvement stop rule (for internal use). #### radius_stop_config ```python radius_stop_config: PSORadiusStop | None ``` Get the radius stop rule (for internal use). ### Methods: #### inertia ```python inertia(type: str, *, factor: float = PSO_DEFAULT_INERTIA_FACTOR) -> Self ``` ```python inertia(type: str, *, w_min: float, w_max: float) -> Self ``` ```python inertia(type: str, **kwargs: float) -> Self ``` Set inertia weight strategy. Parameters: | Name | Type | Description | Default | | ---------- | ------- | ------------------------------------------------------------ | ---------- | | `type` | `str` | Inertia strategy type: "constant" or "chaotic" | *required* | | `**kwargs` | `float` | The settings of the strategy, listed under Other Parameters. | `{}` | Other Parameters: | Name | Type | Description | | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `factor` | `float` | Fixed inertia weight; the default is the weight of canonical PSO, 1/(2 ln 2) ≈ 0.72135, which a swarm with no inertia strategy uses. Only used when type="constant". | | `w_min` | `float` | Minimum inertia weight. Only used when type="chaotic". | | `w_max` | `float` | Maximum inertia weight (must be > w_min). Only used when type="chaotic". | Returns: | Type | Description | | ------ | --------------------------------------- | | `Self` | The config instance for method chaining | Source code in `commol/context/calibration.py` ```python def inertia(self, type: str, **kwargs: float) -> Self: """ Set inertia weight strategy. Parameters ---------- type : str Inertia strategy type: "constant" or "chaotic" **kwargs : float The settings of the strategy, listed under Other Parameters. Other Parameters ---------------- factor : float, default=PSO_DEFAULT_INERTIA_FACTOR Fixed inertia weight; the default is the weight of canonical PSO, 1/(2 ln 2) ≈ 0.72135, which a swarm with no inertia strategy uses. Only used when type="constant". w_min : float Minimum inertia weight. Only used when type="chaotic". w_max : float Maximum inertia weight (must be > w_min). Only used when type="chaotic". Returns ------- Self The config instance for method chaining """ if type not in PSOInertiaType: raise ValueError( f"Invalid inertia type: {type}. Must be one of {list(PSOInertiaType)}" ) if type == PSOInertiaType.CONSTANT: self._inertia = PSOConstantInertia( factor=kwargs.get("factor", PSO_DEFAULT_INERTIA_FACTOR) ) else: self._inertia = PSOChaoticInertia( w_min=kwargs["w_min"], w_max=kwargs["w_max"] ) return self ``` #### acceleration ```python acceleration(type: str, *, cognitive: float = PSO_DEFAULT_ACCELERATION, social: float = PSO_DEFAULT_ACCELERATION) -> Self ``` ```python acceleration(type: str, *, c1_initial: float, c1_final: float, c2_initial: float, c2_final: float) -> Self ``` ```python acceleration(type: str, **kwargs: float) -> Self ``` Set acceleration coefficient strategy. Parameters: | Name | Type | Description | Default | | ---------- | ------- | ------------------------------------------------------------ | ---------- | | `type` | `str` | Acceleration strategy type: "constant" or "time_varying" | *required* | | `**kwargs` | `float` | The settings of the strategy, listed under Other Parameters. | `{}` | Other Parameters: | Name | Type | Description | | ------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `cognitive` | `float` | Cognitive coefficient (c1) - attraction to personal best; the default is the coefficient of canonical PSO, 0.5 + ln 2 ≈ 1.19315, which a swarm with no acceleration strategy uses. Only used when type="constant". | | `social` | `float` | Social coefficient (c2) - attraction to swarm best, with the same default. Only used when type="constant". | | `c1_initial` | `float` | Initial cognitive factor (typically high, e.g., 2.5). Only used when type="time_varying". | | `c1_final` | `float` | Final cognitive factor (typically low, e.g., 0.5). Only used when type="time_varying". | | `c2_initial` | `float` | Initial social factor (typically low, e.g., 0.5). Only used when type="time_varying". | | `c2_final` | `float` | Final social factor (typically high, e.g., 2.5). Only used when type="time_varying". | Returns: | Type | Description | | ------ | --------------------------------------- | | `Self` | The config instance for method chaining | Source code in `commol/context/calibration.py` ```python def acceleration(self, type: str, **kwargs: float) -> Self: """ Set acceleration coefficient strategy. Parameters ---------- type : str Acceleration strategy type: "constant" or "time_varying" **kwargs : float The settings of the strategy, listed under Other Parameters. Other Parameters ---------------- cognitive : float, default=PSO_DEFAULT_ACCELERATION Cognitive coefficient (c1) - attraction to personal best; the default is the coefficient of canonical PSO, 0.5 + ln 2 ≈ 1.19315, which a swarm with no acceleration strategy uses. Only used when type="constant". social : float, default=PSO_DEFAULT_ACCELERATION Social coefficient (c2) - attraction to swarm best, with the same default. Only used when type="constant". c1_initial : float Initial cognitive factor (typically high, e.g., 2.5). Only used when type="time_varying". c1_final : float Final cognitive factor (typically low, e.g., 0.5). Only used when type="time_varying". c2_initial : float Initial social factor (typically low, e.g., 0.5). Only used when type="time_varying". c2_final : float Final social factor (typically high, e.g., 2.5). Only used when type="time_varying". Returns ------- Self The config instance for method chaining """ if type not in PSOAccelerationType: raise ValueError( f"Invalid acceleration type: {type}. " f"Must be one of {list(PSOAccelerationType)}" ) if type == PSOAccelerationType.CONSTANT: self._acceleration = PSOConstantAcceleration( cognitive=kwargs.get("cognitive", PSO_DEFAULT_ACCELERATION), social=kwargs.get("social", PSO_DEFAULT_ACCELERATION), ) else: self._acceleration = PSOTimeVaryingAcceleration( c1_initial=kwargs["c1_initial"], c1_final=kwargs["c1_final"], c2_initial=kwargs["c2_initial"], c2_final=kwargs["c2_final"], ) return self ``` #### mutation ```python mutation(strategy: str, *, scale: float, probability: float, application: str) -> Self ``` Enable mutation to help escape local optima. Parameters: | Name | Type | Description | Default | | ------------- | ------- | ----------------------------------------------------------------------------------------- | ---------- | | `strategy` | `str` | Mutation distribution: "gaussian" or "cauchy". Cauchy has heavier tails for larger jumps. | *required* | | `scale` | `float` | Standard deviation (gaussian) or scale parameter (cauchy) | *required* | | `probability` | `float` | Mutation probability per iteration (0.0 to 1.0) | *required* | | `application` | `str` | Which particles to mutate: "global_best", "all_particles", or "below_average" | *required* | Returns: | Type | Description | | ------ | --------------------------------------- | | `Self` | The config instance for method chaining | Source code in `commol/context/calibration.py` ```python def mutation( self, strategy: str, *, scale: float, probability: float, application: str, ) -> Self: """ Enable mutation to help escape local optima. Parameters ---------- strategy : str Mutation distribution: "gaussian" or "cauchy". Cauchy has heavier tails for larger jumps. scale : float Standard deviation (gaussian) or scale parameter (cauchy) probability : float Mutation probability per iteration (0.0 to 1.0) application : str Which particles to mutate: "global_best", "all_particles", or "below_average" Returns ------- Self The config instance for method chaining """ if strategy not in PSOMutationStrategy: raise ValueError( f"Invalid mutation strategy: {strategy}. " f"Must be one of {list(PSOMutationStrategy)}" ) if application not in PSOMutationApplication: raise ValueError( f"Invalid mutation application: {application}. " f"Must be one of {list(PSOMutationApplication)}" ) self._mutation = PSOMutationConfig( strategy=PSOMutationStrategy(strategy), scale=scale, probability=probability, application=PSOMutationApplication(application), ) return self ``` #### velocity ```python velocity(*, clamp_factor: float | None = None, mutation_threshold: float | None = None) -> Self ``` Configure velocity control. Parameters: | Name | Type | Description | Default | | -------------------- | ------- | ---------------------------------------------------------------------------------------------------------- | ------- | | `clamp_factor` | `float` | Velocity clamping as fraction of search space (0.0-1.0). Typically 0.1-0.2. Prevents explosive velocities. | `None` | | `mutation_threshold` | `float` | Reinitialize velocities below this threshold. Typically 0.001-0.01. Prevents stagnation. | `None` | Returns: | Type | Description | | ------ | --------------------------------------- | | `Self` | The config instance for method chaining | Source code in `commol/context/calibration.py` ```python def velocity( self, *, clamp_factor: float | None = None, mutation_threshold: float | None = None, ) -> Self: """ Configure velocity control. Parameters ---------- clamp_factor : float, optional Velocity clamping as fraction of search space (0.0-1.0). Typically 0.1-0.2. Prevents explosive velocities. mutation_threshold : float, optional Reinitialize velocities below this threshold. Typically 0.001-0.01. Prevents stagnation. Returns ------- Self The config instance for method chaining """ self._velocity = PSOVelocityConfig( clamp_factor=clamp_factor, mutation_threshold=mutation_threshold, ) return self ``` #### improvement_stop ```python improvement_stop(*, window: int, tol_abs: float, tol_rel: float) -> Self ``` Stop when the best loss stops improving. The run stops when the best loss improved by at most `tol_abs + tol_rel * |best loss|` over the last `window` iterations, and reports `converged=True`. Off by default. A swarm can stall for some iterations and then improve again, so a short window can stop it early. Parameters: | Name | Type | Description | Default | | --------- | ------- | -------------------------------------------------------------- | ---------- | | `window` | `int` | Iterations over which the improvement is measured (> 0). | *required* | | `tol_abs` | `float` | Absolute tolerance on the improvement (>= 0). | *required* | | `tol_rel` | `float` | Tolerance on the improvement relative to the best loss (>= 0). | *required* | Returns: | Type | Description | | ------ | --------------------------------------- | | `Self` | The config instance for method chaining | Source code in `commol/context/calibration.py` ```python def improvement_stop(self, *, window: int, tol_abs: float, tol_rel: float) -> Self: """ Stop when the best loss stops improving. The run stops when the best loss improved by at most ``tol_abs + tol_rel * |best loss|`` over the last `window` iterations, and reports ``converged=True``. Off by default. A swarm can stall for some iterations and then improve again, so a short window can stop it early. Parameters ---------- window : int Iterations over which the improvement is measured (> 0). tol_abs : float Absolute tolerance on the improvement (>= 0). tol_rel : float Tolerance on the improvement relative to the best loss (>= 0). Returns ------- Self The config instance for method chaining """ self._improvement_stop = PSOImprovementStop( window=window, tol_abs=tol_abs, tol_rel=tol_rel ) return self ``` #### radius_stop ```python radius_stop(tol: float) -> Self ``` Stop when the swarm has gathered around its best position. The run stops when the largest distance of a particle from the best position, divided by the size of the search box, is at most `tol`, and reports `converged=True`. Off by default. Mutation and velocity mutation scatter the swarm again, so with them the radius can stay above `tol`; values that the data do not tell apart keep the swarm spread. Parameters: | Name | Type | Description | Default | | ----- | ------- | ------------------------------------------------------------------------------------ | ---------- | | `tol` | `float` | Tolerance on the radius of the swarm, relative to the size of the search box (>= 0). | *required* | Returns: | Type | Description | | ------ | --------------------------------------- | | `Self` | The config instance for method chaining | Source code in `commol/context/calibration.py` ```python def radius_stop(self, tol: float) -> Self: """ Stop when the swarm has gathered around its best position. The run stops when the largest distance of a particle from the best position, divided by the size of the search box, is at most `tol`, and reports ``converged=True``. Off by default. Mutation and velocity mutation scatter the swarm again, so with them the radius can stay above `tol`; values that the data do not tell apart keep the swarm spread. Parameters ---------- tol : float Tolerance on the radius of the swarm, relative to the size of the search box (>= 0). Returns ------- Self The config instance for method chaining """ self._radius_stop = PSORadiusStop(tol=tol) return self ``` ## CalibrationResult Bases: `ResultModel`, `RangeChecked` Result of a calibration: the best of its starts. Attributes: | Name | Type | Description | | -------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `best_parameters` | `dict[str, float]` | The calibrated value of each parameter, in the order of the problem's parameters. | | `final_loss` | `float` | Loss at the best parameters. | | `iterations` | `int` | Iterations of the best start. | | `converged` | `bool` | Whether the best start converged. | | `termination_reason` | `str` | Why the best start stopped. | | `starts` | `list[StartSummary]` | Every start that finished, in order; the first is the initial guess. | | `failed` | `list[str]` | Each start that failed: "Start failed: ", also logged as a warning. | | `seed` | `int` | The seed of the calibration's random numbers (Calibrator.seed): problem.seed, or the seed the calibrator drew. Set as problem.seed, it repeats the calibration and its uncertainty methods. | Source code in `commol/context/calibration.py` ```python class CalibrationResult(ResultModel, RangeChecked): """ Result of a calibration: the best of its starts. Attributes ---------- best_parameters : dict[str, float] The calibrated value of each parameter, in the order of the problem's parameters. final_loss : float Loss at the best parameters. iterations : int Iterations of the best start. converged : bool Whether the best start converged. termination_reason : str Why the best start stopped. starts : list[StartSummary] Every start that finished, in order; the first is the initial guess. failed : list[str] Each start that failed: ``"Start failed: "``, also logged as a warning. seed : int The seed of the calibration's random numbers (`Calibrator.seed`): ``problem.seed``, or the seed the calibrator drew. Set as ``problem.seed``, it repeats the calibration and its uncertainty methods. """ model_config = ConfigDict(extra="forbid") best_parameters: dict[str, float] = Field( default=..., description="Calibrated parameter values" ) final_loss: float = Field(default=..., description="Final loss value") iterations: int = Field(default=..., description="Number of iterations performed") converged: bool = Field(default=..., description="Whether optimization converged") termination_reason: str = Field( default=..., description="Reason for optimization termination" ) starts: list[StartSummary] = Field( default_factory=list, description="Every start that finished" ) failed: list[str] = Field( default_factory=list, description="Each start that failed" ) seed: int = Field( default=..., description="Seed of the calibration's random numbers" ) @override def __str__(self) -> str: """String representation of calibration result.""" return ( f"CalibrationResult(\n" f" converged={self.converged},\n" f" final_loss={self.final_loss:.6f},\n" f" iterations={self.iterations},\n" f" best_parameters={self.best_parameters},\n" f" termination_reason='{self.termination_reason}',\n" f" starts={len(self.starts)},\n" f" failed={len(self.failed)},\n" f" seed={self.seed}\n" f")" ) ``` ### Methods: #### __str__ ```python __str__() -> str ``` String representation of calibration result. Source code in `commol/context/calibration.py` ```python @override def __str__(self) -> str: """String representation of calibration result.""" return ( f"CalibrationResult(\n" f" converged={self.converged},\n" f" final_loss={self.final_loss:.6f},\n" f" iterations={self.iterations},\n" f" best_parameters={self.best_parameters},\n" f" termination_reason='{self.termination_reason}',\n" f" starts={len(self.starts)},\n" f" failed={len(self.failed)},\n" f" seed={self.seed}\n" f")" ) ``` ## StartSummary Bases: `ResultModel` One start of a multi-start optimization. Distinct ends with equal losses are a sign that the data do not identify the parameters. Attributes: | Name | Type | Description | | ----------- | ------------------ | ---------------------------------------------------- | | `start` | `dict[str, float]` | The point the optimizer started from. | | `end` | `dict[str, float]` | The best point it found. | | `loss` | `float` | The loss at the end. | | `converged` | `bool` | Whether the optimizer met its convergence criterion. | Source code in `commol/context/calibration.py` ```python class StartSummary(ResultModel): """ One start of a multi-start optimization. Distinct ends with equal losses are a sign that the data do not identify the parameters. Attributes ---------- start : dict[str, float] The point the optimizer started from. end : dict[str, float] The best point it found. loss : float The loss at the end. converged : bool Whether the optimizer met its convergence criterion. """ model_config = ConfigDict(extra="forbid", frozen=True) start: dict[str, float] = Field(description="Start point") end: dict[str, float] = Field(description="Best point found") loss: float = Field(description="Loss at the end") converged: bool = Field(description="Whether the optimizer converged") ``` ## CalibrationHistory Bases: `ResultModel` A calibration and every evaluation of a feasible point it made. Attributes: | Name | Type | Description | | ------------- | ----------------------------- | ------------------------------------------------------------------ | | `result` | `CalibrationResult` | The result of the calibration. | | `evaluations` | `list[CalibrationEvaluation]` | Every evaluation of a feasible point, in the order they were made. | Source code in `commol/context/calibration.py` ```python class CalibrationHistory(ResultModel): """ A calibration and every evaluation of a feasible point it made. Attributes ---------- result : CalibrationResult The result of the calibration. evaluations : list[CalibrationEvaluation] Every evaluation of a feasible point, in the order they were made. """ model_config = ConfigDict(extra="forbid") result: CalibrationResult = Field(description="The result") evaluations: list[CalibrationEvaluation] = Field( description="Every evaluation of a feasible point" ) ``` ## CalibrationEvaluation Bases: `ResultModel` One evaluation of a feasible point during an optimization. Attributes: | Name | Type | Description | | ------------ | ------------------ | -------------------------------- | | `parameters` | `dict[str, float]` | The values the objective scored. | | `loss` | `float` | The loss at these values. | Source code in `commol/context/calibration.py` ```python class CalibrationEvaluation(ResultModel): """ One evaluation of a feasible point during an optimization. Attributes ---------- parameters : dict[str, float] The values the objective scored. loss : float The loss at these values. """ model_config = ConfigDict(extra="forbid", frozen=True) parameters: dict[str, float] = Field(description="Values the objective scored") loss: float = Field(description="Loss at these values") ``` ## LossFunction Bases: `StrEnum` Available loss functions for calibration. Values SSE Sum of Squared Errors RMSE Root Mean Squared Error MAE Mean Absolute Error Source code in `commol/context/constants.py` ```python @unique class LossFunction(StrEnum): """ Available loss functions for calibration. Values ------ SSE Sum of Squared Errors RMSE Root Mean Squared Error MAE Mean Absolute Error """ SSE = "sse" RMSE = "rmse" MAE = "mae" ``` # Identifiability API [Back to API Reference](https://munqu.github.io/commol/latest/api/index.md) The identifiability methods, the report they return, and its parts. Guide: [Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/index.md). ## IdentifiabilityMethod Bases: `InferenceMethod[ResultT]`, `Generic[ResultT]` The configuration of an identifiability method, whose result is `ResultT`. `Calibrator.identifiability(method)` runs it, and returns its result type. The methods of commol return an `IdentifiabilityReport`. Source code in `commol/context/inference/method.py` ```python class IdentifiabilityMethod(InferenceMethod[ResultT], Generic[ResultT]): """ The configuration of an identifiability method, whose result is `ResultT`. ``Calibrator.identifiability(method)`` runs it, and returns its result type. The methods of commol return an `IdentifiabilityReport`. """ ``` ## StructuralRank Bases: `IdentifiabilityMethod[IdentifiabilityReport]`, `RangeChecked` Structural identifiability, local and numerical: the rank of the scaled sensitivities at random points inside the bounds. The sensitivities come from finite differences, with a step and an error set by the accuracy of the engine; a singular value counts when it is above the norm of that error. The engine must be deterministic. The points come from the seed of the calibrator, `Calibrator.seed`. Attributes: | Name | Type | Description | | -------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `points` | `int` | How many rows of a Latin hypercube over the bounds, at least 1. | | `design` | `str` | "observations": the positions and outputs of the observations, their values ignored (can this design determine the parameters?). "dense": every observed series at every unit position up to the last observation (can denser data of these series?). | Source code in `commol/context/inference/identifiability.py` ```python class StructuralRank(IdentifiabilityMethod[IdentifiabilityReport], RangeChecked): """ Structural identifiability, local and numerical: the rank of the scaled sensitivities at random points inside the bounds. The sensitivities come from finite differences, with a step and an error set by the accuracy of the engine; a singular value counts when it is above the norm of that error. The engine must be deterministic. The points come from the seed of the calibrator, ``Calibrator.seed``. Attributes ---------- points : int How many rows of a Latin hypercube over the bounds, at least 1. design : str ``"observations"``: the positions and outputs of the observations, their values ignored (can this design determine the parameters?). ``"dense"``: every observed series at every unit position up to the last observation (can denser data of these series?). """ points: int = Field() design: StructuralDesign ``` ## Collinearity Bases: `IdentifiabilityMethod[IdentifiabilityReport]`, `RangeChecked` Collinearity analysis after Brun et al. (2001): the sensitivity measure of each parameter and the collinearity index of each subset. The index of a subset is 1 when its sensitivities are orthogonal and grows without bound as they approach linear dependence. The literature puts the threshold between 5 and 20. The engine must be deterministic. Attributes: | Name | Type | Description | | ----------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `at` | \`CalibrationResult | ParameterSample\` | | `max_subset_size` | `int` | The largest subset, at least 2 and at most the number of parameters. Every subset up to 6 parameters is evaluated; a greedy search adds parameters above. | | `threshold` | `float` | The index below which a subset can be fitted together. No default: it is a modelling judgment. | Source code in `commol/context/inference/identifiability.py` ```python class Collinearity(IdentifiabilityMethod[IdentifiabilityReport], RangeChecked): """ Collinearity analysis after Brun et al. (2001): the sensitivity measure of each parameter and the collinearity index of each subset. The index of a subset is 1 when its sensitivities are orthogonal and grows without bound as they approach linear dependence. The literature puts the threshold between 5 and 20. The engine must be deterministic. Attributes ---------- at : CalibrationResult | ParameterSample Where to evaluate: the best point of a fit, or each draw of a sample, summarized by the weighted median. max_subset_size : int The largest subset, at least 2 and at most the number of parameters. Every subset up to 6 parameters is evaluated; a greedy search adds parameters above. threshold : float The index below which a subset can be fitted together. No default: it is a modelling judgment. """ at: CalibrationResult | ParameterSample max_subset_size: int threshold: float = Field() ``` ## SampleIdentifiability Bases: `IdentifiabilityMethod[IdentifiabilityReport]`, `RangeChecked` Practical identifiability from any parameter sample: how much it contracts each parameter, which parameters move together, and which marginals have several modes. Attributes: | Name | Type | Description | | ----------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sample` | `ParameterSample` | The sample, such as the draws of an uncertainty method. | | `reference` | `{bounds, prior}` | The distribution the contraction compares with: "bounds", the uniform distribution on the bounds, or "prior", the prior of each parameter in priors, such as the priors the sample was drawn with. | | `correlation_threshold` | `float` | The absolute correlation above which a pair is listed and its parameters are poorly determined, strictly between 0 and 1. No default: it is a modelling judgment. | | `priors` | \`Priors | dict[str, Distribution] | Source code in `commol/context/inference/identifiability.py` ```python class SampleIdentifiability(IdentifiabilityMethod[IdentifiabilityReport], RangeChecked): """ Practical identifiability from any parameter sample: how much it contracts each parameter, which parameters move together, and which marginals have several modes. Attributes ---------- sample : ParameterSample The sample, such as the draws of an uncertainty method. reference : {"bounds", "prior"} The distribution the contraction compares with: ``"bounds"``, the uniform distribution on the bounds, or ``"prior"``, the prior of each parameter in `priors`, such as the priors the sample was drawn with. correlation_threshold : float The absolute correlation above which a pair is listed and its parameters are poorly determined, strictly between 0 and 1. No default: it is a modelling judgment. priors : Priors | dict[str, Distribution] | None The prior of each parameter, with ``reference="prior"`` only. """ sample: ParameterSample reference: SampleReference correlation_threshold: float priors: Priors | None = None @model_validator(mode="before") @classmethod def read_priors(cls, data: JsonValue) -> JsonValue: """Read a mapping of parameter to distribution as `Priors`.""" if isinstance(data, Mapping) and data.get("priors") is not None: return {**data, "priors": coerce_priors(data["priors"])} return data @model_validator(mode="after") def check_priors(self) -> Self: """The priors go with ``reference="prior"``, and only with it.""" if self.reference == "prior" and self.priors is None: raise ValueError( "reference='prior' needs priors: the prior of each parameter." ) if self.reference == "bounds" and self.priors is not None: raise ValueError( "priors are read only with reference='prior'; with " "reference='bounds' leave them out." ) return self ``` ### Methods: #### read_priors ```python read_priors(data: JsonValue) -> JsonValue ``` Read a mapping of parameter to distribution as `Priors`. Source code in `commol/context/inference/identifiability.py` ```python @model_validator(mode="before") @classmethod def read_priors(cls, data: JsonValue) -> JsonValue: """Read a mapping of parameter to distribution as `Priors`.""" if isinstance(data, Mapping) and data.get("priors") is not None: return {**data, "priors": coerce_priors(data["priors"])} return data ``` #### check_priors ```python check_priors() -> Self ``` The priors go with `reference="prior"`, and only with it. Source code in `commol/context/inference/identifiability.py` ```python @model_validator(mode="after") def check_priors(self) -> Self: """The priors go with ``reference="prior"``, and only with it.""" if self.reference == "prior" and self.priors is None: raise ValueError( "reference='prior' needs priors: the prior of each parameter." ) if self.reference == "bounds" and self.priors is not None: raise ValueError( "priors are read only with reference='prior'; with " "reference='bounds' leave them out." ) return self ``` ## StartsIdentifiability Bases: `IdentifiabilityMethod[IdentifiabilityReport]`, `RangeChecked` Practical identifiability from the starts of a calibration (`Calibrator.run(starts=n)`): converged starts of equal loss that end apart flag the parameters that differ. Attributes: | Name | Type | Description | | --------------------- | ------------------- | ------------------------------------------------------------- | | `result` | `CalibrationResult` | The result, with at least two converged starts. | | `parameter_tolerance` | `float` | The share of its bound width above which a parameter differs. | | `loss_tolerance` | `float` | The share of the best loss within which two losses are equal. | Source code in `commol/context/inference/identifiability.py` ```python class StartsIdentifiability(IdentifiabilityMethod[IdentifiabilityReport], RangeChecked): """ Practical identifiability from the starts of a calibration (``Calibrator.run(starts=n)``): converged starts of equal loss that end apart flag the parameters that differ. Attributes ---------- result : CalibrationResult The result, with at least two converged starts. parameter_tolerance : float The share of its bound width above which a parameter differs. loss_tolerance : float The share of the best loss within which two losses are equal. """ result: CalibrationResult parameter_tolerance: float = Field() loss_tolerance: float = Field() ``` ## IdentifiabilityReport Bases: `_FrozenResult` Which parameters the model and the data determine: one structural and one practical verdict per parameter, and the evidence of each method. The structural verdict comes from `StructuralRank` on the design of the observations, and from a flat profile; a dense design answers what denser data would determine, so its verdicts stay in its section. The practical verdict comes from `Collinearity`, `SampleIdentifiability`, `StartsIdentifiability` and the other profiles. Attributes: | Name | Type | Description | | --------------------------------------------------- | ------------------------------ | -------------------------------------------------- | | `parameters` | `list[str]` | The parameters, in problem order. | | `verdicts` | `dict[str, ParameterVerdicts]` | The verdicts of each parameter. | | `structural, collinearity, sample, starts, profile` | `list` | The sections of the methods that ran, one per run. | Source code in `commol/context/inference/identifiability.py` ```python class IdentifiabilityReport(_FrozenResult): """ Which parameters the model and the data determine: one structural and one practical verdict per parameter, and the evidence of each method. The structural verdict comes from `StructuralRank` on the design of the observations, and from a flat profile; a dense design answers what denser data would determine, so its verdicts stay in its section. The practical verdict comes from `Collinearity`, `SampleIdentifiability`, `StartsIdentifiability` and the other profiles. Attributes ---------- parameters : list[str] The parameters, in problem order. verdicts : dict[str, ParameterVerdicts] The verdicts of each parameter. structural, collinearity, sample, starts, profile : list The sections of the methods that ran, one per run. """ parameters: list[str] verdicts: dict[str, ParameterVerdicts] structural: list[StructuralSection] = Field(default_factory=list) collinearity: list[CollinearitySection] = Field(default_factory=list) sample: list[SampleSection] = Field(default_factory=list) starts: list[StartsSection] = Field(default_factory=list) profile: list[ProfileSection] = Field(default_factory=list) @model_validator(mode="after") def check_parameters(self) -> Self: """Check that the verdicts hold the parameters, in order.""" if list(self.verdicts) != self.parameters: raise ValueError( f"The verdicts must hold the parameters {self.parameters} in " f"order, got {list(self.verdicts)}." ) return self def report(self) -> "IdentifiabilityReportData": """ Report the identifiability of each parameter as plain data. Returns ------- IdentifiabilityReportData ``parameters``: each parameter, in problem order, with its structural and practical verdicts; ``sections``: the evidence of each method that ran. """ from commol.api.reporting import _identifiability_report return _identifiability_report(self) def merge(self, *others: "IdentifiabilityReport") -> "IdentifiabilityReport": """ Join this report with others of the same parameters. The sections are kept side by side. A verdict that the reports agree on stays, with the reasons of each; one they disagree on is ``conflicting``, with every reason. Parameters ---------- *others : IdentifiabilityReport The reports to join. Returns ------- IdentifiabilityReport The joined report. Raises ------ ValueError If a report has other parameters. """ reports = [self, *others] for other in others: if other.parameters != self.parameters: raise ValueError( f"Reports of different parameters cannot merge: " f"{self.parameters} and {other.parameters}." ) verdicts = { name: ParameterVerdicts( structural=_merge_structural( [ v for r in reports if (v := r.verdicts[name].structural) is not None ] ), practical=_merge_practical( [ v for r in reports if (v := r.verdicts[name].practical) is not None ] ), ) for name in self.parameters } return IdentifiabilityReport( parameters=self.parameters, verdicts=verdicts, structural=[s for r in reports for s in r.structural], collinearity=[s for r in reports for s in r.collinearity], sample=[s for r in reports for s in r.sample], starts=[s for r in reports for s in r.starts], profile=[s for r in reports for s in r.profile], ) ``` ### Methods: #### check_parameters ```python check_parameters() -> Self ``` Check that the verdicts hold the parameters, in order. Source code in `commol/context/inference/identifiability.py` ```python @model_validator(mode="after") def check_parameters(self) -> Self: """Check that the verdicts hold the parameters, in order.""" if list(self.verdicts) != self.parameters: raise ValueError( f"The verdicts must hold the parameters {self.parameters} in " f"order, got {list(self.verdicts)}." ) return self ``` #### report ```python report() -> IdentifiabilityReportData ``` Report the identifiability of each parameter as plain data. Returns: | Type | Description | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `IdentifiabilityReportData` | parameters: each parameter, in problem order, with its structural and practical verdicts; sections: the evidence of each method that ran. | Source code in `commol/context/inference/identifiability.py` ```python def report(self) -> "IdentifiabilityReportData": """ Report the identifiability of each parameter as plain data. Returns ------- IdentifiabilityReportData ``parameters``: each parameter, in problem order, with its structural and practical verdicts; ``sections``: the evidence of each method that ran. """ from commol.api.reporting import _identifiability_report return _identifiability_report(self) ``` #### merge ```python merge(*others: IdentifiabilityReport) -> IdentifiabilityReport ``` Join this report with others of the same parameters. The sections are kept side by side. A verdict that the reports agree on stays, with the reasons of each; one they disagree on is `conflicting`, with every reason. Parameters: | Name | Type | Description | Default | | --------- | ----------------------- | -------------------- | ------- | | `*others` | `IdentifiabilityReport` | The reports to join. | `()` | Returns: | Type | Description | | ----------------------- | ------------------ | | `IdentifiabilityReport` | The joined report. | Raises: | Type | Description | | ------------ | --------------------------------- | | `ValueError` | If a report has other parameters. | Source code in `commol/context/inference/identifiability.py` ```python def merge(self, *others: "IdentifiabilityReport") -> "IdentifiabilityReport": """ Join this report with others of the same parameters. The sections are kept side by side. A verdict that the reports agree on stays, with the reasons of each; one they disagree on is ``conflicting``, with every reason. Parameters ---------- *others : IdentifiabilityReport The reports to join. Returns ------- IdentifiabilityReport The joined report. Raises ------ ValueError If a report has other parameters. """ reports = [self, *others] for other in others: if other.parameters != self.parameters: raise ValueError( f"Reports of different parameters cannot merge: " f"{self.parameters} and {other.parameters}." ) verdicts = { name: ParameterVerdicts( structural=_merge_structural( [ v for r in reports if (v := r.verdicts[name].structural) is not None ] ), practical=_merge_practical( [ v for r in reports if (v := r.verdicts[name].practical) is not None ] ), ) for name in self.parameters } return IdentifiabilityReport( parameters=self.parameters, verdicts=verdicts, structural=[s for r in reports for s in r.structural], collinearity=[s for r in reports for s in r.collinearity], sample=[s for r in reports for s in r.sample], starts=[s for r in reports for s in r.starts], profile=[s for r in reports for s in r.profile], ) ``` ## ParameterVerdicts Bases: `_FrozenResult` The two verdicts of one parameter; `None` when no evidence gives one. Attributes: | Name | Type | Description | | ------------ | ------------------- | ----------- | | `structural` | \`StructuralVerdict | None\` | | `practical` | \`PracticalVerdict | None\` | Source code in `commol/context/inference/identifiability.py` ```python class ParameterVerdicts(_FrozenResult): """ The two verdicts of one parameter; ``None`` when no evidence gives one. Attributes ---------- structural : StructuralVerdict | None From the structural rank on the design of the observations. practical : PracticalVerdict | None From collinearity, a sample or the starts. """ structural: StructuralVerdict | None = None practical: PracticalVerdict | None = None ``` ## StructuralVerdict Bases: `_FrozenResult` The structural verdict of one parameter. Attributes: | Name | Type | Description | | -------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `verdict` | `StructuralVerdictName` | identifiable: the null space of the sensitivities has no component on it, or a profile crosses its threshold. in_combination: the data determine it only together with other parameters. non_identifiable: it lies in the null space, or nothing the data read depends on it. conflicting: two reports disagree. | | `reasons` | `list[str]` | Why, one per piece of evidence. | | `combinations` | `list[str]` | The algebraic combinations that hold the parameter, such as "k1 · s", from the methods that compute them (the structural rank); empty for the verdict of a profile, whose reason names the compensating parameters. | Source code in `commol/context/inference/identifiability.py` ```python class StructuralVerdict(_FrozenResult): """ The structural verdict of one parameter. Attributes ---------- verdict : StructuralVerdictName ``identifiable``: the null space of the sensitivities has no component on it, or a profile crosses its threshold. ``in_combination``: the data determine it only together with other parameters. ``non_identifiable``: it lies in the null space, or nothing the data read depends on it. ``conflicting``: two reports disagree. reasons : list[str] Why, one per piece of evidence. combinations : list[str] The algebraic combinations that hold the parameter, such as ``"k1 · s"``, from the methods that compute them (the structural rank); empty for the verdict of a profile, whose reason names the compensating parameters. """ verdict: StructuralVerdictName reasons: list[str] = Field(default_factory=list) combinations: list[str] = Field(default_factory=list) ``` ## PracticalVerdict Bases: `_FrozenResult` The practical verdict of one parameter. Attributes: | Name | Type | Description | | --------- | ---------------------- | ---------------------------------------------------------------------------------------- | | `verdict` | `PracticalVerdictName` | determined or poorly_determined by these data, or conflicting when two reports disagree. | | `reasons` | `list[str]` | Why, one per piece of evidence. | Source code in `commol/context/inference/identifiability.py` ```python class PracticalVerdict(_FrozenResult): """ The practical verdict of one parameter. Attributes ---------- verdict : PracticalVerdictName ``determined`` or ``poorly_determined`` by these data, or ``conflicting`` when two reports disagree. reasons : list[str] Why, one per piece of evidence. """ verdict: PracticalVerdictName reasons: list[str] = Field(default_factory=list) ``` ## StructuralSection Bases: `_FrozenResult` The evidence of `StructuralRank`. Attributes: | Name | Type | Description | | -------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------- | | `design` | `str` | The design the sensitivities were evaluated on: "observations" or "dense". | | `points` | `list[PointRank]` | Each feasible point. | | `skipped` | `list[str]` | Why each infeasible point was skipped. | | `generic_rank` | `int` | The rank at most points. | | `projections` | `dict[str, float]` | The norm of the projection of each parameter on the null space, averaged over the points of generic rank. | | `verdicts` | `dict[str, StructuralVerdictName]` | The verdict of each parameter on this design. | | `combinations` | `list[Combination]` | The identifiable combinations. | Source code in `commol/context/inference/identifiability.py` ```python class StructuralSection(_FrozenResult): """ The evidence of `StructuralRank`. Attributes ---------- design : str The design the sensitivities were evaluated on: ``"observations"`` or ``"dense"``. points : list[PointRank] Each feasible point. skipped : list[str] Why each infeasible point was skipped. generic_rank : int The rank at most points. projections : dict[str, float] The norm of the projection of each parameter on the null space, averaged over the points of generic rank. verdicts : dict[str, StructuralVerdictName] The verdict of each parameter on this design. combinations : list[Combination] The identifiable combinations. """ design: StructuralDesign points: list[PointRank] skipped: list[str] generic_rank: int projections: dict[str, float] verdicts: dict[str, StructuralVerdictName] combinations: list[Combination] ``` ## PointRank Bases: `_FrozenResult` The rank of the scaled sensitivities at one point. Attributes: | Name | Type | Description | | ----------------- | ------------------ | ------------------------------------------------------------------------------------------------- | | `values` | `dict[str, float]` | The point. | | `singular_values` | `list[float]` | The singular values, largest first. | | `threshold` | `float` | The norm of the estimated errors of the sensitivities, which a singular value must pass to count. | | `rank` | `int` | How many pass it. | Source code in `commol/context/inference/identifiability.py` ```python class PointRank(_FrozenResult): """ The rank of the scaled sensitivities at one point. Attributes ---------- values : dict[str, float] The point. singular_values : list[float] The singular values, largest first. threshold : float The norm of the estimated errors of the sensitivities, which a singular value must pass to count. rank : int How many pass it. """ values: dict[str, float] singular_values: list[float] threshold: float rank: int ``` ## Combination Bases: `_FrozenResult` A combination of parameters the data determine: the product of each parameter raised to its exponent. Attributes: | Name | Type | Description | | ----------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------ | | `exponents` | `dict[str, float]` | The exponent of each parameter, in problem order. | | `text` | `str` | The product, such as "k1 · s" or "k1 / s^2"; a parameter whose bounds allow negative values reads as its absolute value. | Source code in `commol/context/inference/identifiability.py` ```python class Combination(_FrozenResult): """ A combination of parameters the data determine: the product of each parameter raised to its exponent. Attributes ---------- exponents : dict[str, float] The exponent of each parameter, in problem order. text : str The product, such as ``"k1 · s"`` or ``"k1 / s^2"``; a parameter whose bounds allow negative values reads as its absolute value. """ exponents: dict[str, float] text: str ``` ## CollinearitySection Bases: `_FrozenResult` The evidence of `Collinearity`. Attributes: | Name | Type | Description | | -------------- | ----------------------------- | ---------------------------------------------------------------------------- | | `threshold` | `float` | The index below which a subset can be fitted together. | | `points` | `int` | How many points were used: one for a fit, one per feasible draw of a sample. | | `skipped` | `list[str]` | Why each infeasible point was skipped. | | `sensitivity` | `dict[str, float]` | The weighted median of Brun's sensitivity measure of each parameter. | | `pair_gamma` | `dict[str, dict[str, float]]` | The weighted median of the index of each pair. | | `best_by_size` | `list[SubsetIndex]` | The subset of lowest index of each size. | | `admissible` | `list[SubsetIndex]` | The largest subsets whose index is below the threshold. | Source code in `commol/context/inference/identifiability.py` ```python class CollinearitySection(_FrozenResult): """ The evidence of `Collinearity`. Attributes ---------- threshold : float The index below which a subset can be fitted together. points : int How many points were used: one for a fit, one per feasible draw of a sample. skipped : list[str] Why each infeasible point was skipped. sensitivity : dict[str, float] The weighted median of Brun's sensitivity measure of each parameter. pair_gamma : dict[str, dict[str, float]] The weighted median of the index of each pair. best_by_size : list[SubsetIndex] The subset of lowest index of each size. admissible : list[SubsetIndex] The largest subsets whose index is below the threshold. """ threshold: float points: int skipped: list[str] sensitivity: dict[str, float] pair_gamma: dict[str, dict[str, float]] best_by_size: list[SubsetIndex] admissible: list[SubsetIndex] ``` ## SubsetIndex Bases: `_FrozenResult` A subset of parameters and its collinearity index. Attributes: | Name | Type | Description | | ------------- | ----------- | ----------------------------------------------------------------------------- | | `parameters` | `list[str]` | The parameters, in problem order. | | `gamma` | `float` | The weighted median of the index over the points. | | `share_below` | `float` | The share of the weight of the points where the index is below the threshold. | Source code in `commol/context/inference/identifiability.py` ```python class SubsetIndex(_FrozenResult): """ A subset of parameters and its collinearity index. Attributes ---------- parameters : list[str] The parameters, in problem order. gamma : float The weighted median of the index over the points. share_below : float The share of the weight of the points where the index is below the threshold. """ parameters: list[str] gamma: float share_below: float ``` ## SampleSection Bases: `_FrozenResult` The evidence of `SampleIdentifiability`. Attributes: | Name | Type | Description | | ----------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `reference` | `{bounds, prior}` | The distribution the contraction compares with: "bounds", the uniform distribution on the bounds, or "prior", the prior of each parameter. | | `correlation_threshold` | `float` | The absolute correlation above which a pair is listed. | | `contraction` | `dict[str, float]` | 1 − Var(sample) / Var(reference) of each parameter: near 0 when the sample spreads as the reference, near 1 when it sits on a value. | | `correlation` | `dict[str, dict[str, float]]` | The weighted correlation matrix. | | `correlated_pairs` | `list[CorrelatedPair]` | The pairs above the threshold. | | `modes` | `dict[str, list[float]]` | The modes of the marginal of each parameter. | Source code in `commol/context/inference/identifiability.py` ```python class SampleSection(_FrozenResult): """ The evidence of `SampleIdentifiability`. Attributes ---------- reference : {"bounds", "prior"} The distribution the contraction compares with: ``"bounds"``, the uniform distribution on the bounds, or ``"prior"``, the prior of each parameter. correlation_threshold : float The absolute correlation above which a pair is listed. contraction : dict[str, float] ``1 − Var(sample) / Var(reference)`` of each parameter: near 0 when the sample spreads as the reference, near 1 when it sits on a value. correlation : dict[str, dict[str, float]] The weighted correlation matrix. correlated_pairs : list[CorrelatedPair] The pairs above the threshold. modes : dict[str, list[float]] The modes of the marginal of each parameter. """ reference: SampleReference correlation_threshold: float contraction: dict[str, float] correlation: dict[str, dict[str, float]] correlated_pairs: list[CorrelatedPair] modes: dict[str, list[float]] ``` ## CorrelatedPair Bases: `_FrozenResult` Two parameters whose absolute correlation is above the threshold. Attributes: | Name | Type | Description | | --------------- | ------- | --------------------------------- | | `first, second` | `str` | The parameters, in problem order. | | `correlation` | `float` | Their weighted correlation. | Source code in `commol/context/inference/identifiability.py` ```python class CorrelatedPair(_FrozenResult): """ Two parameters whose absolute correlation is above the threshold. Attributes ---------- first, second : str The parameters, in problem order. correlation : float Their weighted correlation. """ first: str second: str correlation: float ``` ## StartsSection Bases: `_FrozenResult` The evidence of `StartsIdentifiability`. Attributes: | Name | Type | Description | | --------------------- | ------------------ | ------------------------------------------------------------------------------------- | | `parameter_tolerance` | `float` | The share of its bound width above which a parameter differs. | | `loss_tolerance` | `float` | The share of the best loss within which two losses are equal. | | `near_best` | `list[int]` | The converged starts within the loss tolerance of the best. | | `spread` | `dict[str, float]` | The largest difference of each parameter between them, as a share of its bound width. | | `flagged` | `list[str]` | The parameters whose spread is above the tolerance. | Source code in `commol/context/inference/identifiability.py` ```python class StartsSection(_FrozenResult): """ The evidence of `StartsIdentifiability`. Attributes ---------- parameter_tolerance : float The share of its bound width above which a parameter differs. loss_tolerance : float The share of the best loss within which two losses are equal. near_best : list[int] The converged starts within the loss tolerance of the best. spread : dict[str, float] The largest difference of each parameter between them, as a share of its bound width. flagged : list[str] The parameters whose spread is above the tolerance. """ parameter_tolerance: float loss_tolerance: float near_best: list[int] spread: dict[str, float] flagged: list[str] ``` ## ProfileSection Bases: `_FrozenResult` The evidence of a `ProfileLikelihood`. Attributes: | Name | Type | Description | | ------------------ | ------------------------------------------- | ----------------------------------------- | | `threshold` | `{chi_square, beale, bootstrap_calibrated}` | The rule of the threshold. | | `confidence_level` | `float` | The level of each pointwise interval. | | `tolerance` | `float` | The numerical tolerance of the statistic. | | `verdicts` | \`dict\[str, ProfileVerdictName | None\]\` | | `intervals` | \`dict\[str, tuple\[float | None, float | Source code in `commol/context/inference/identifiability.py` ```python class ProfileSection(_FrozenResult): """ The evidence of a `ProfileLikelihood`. Attributes ---------- threshold : {"chi_square", "beale", "bootstrap_calibrated"} The rule of the threshold. confidence_level : float The level of each pointwise interval. tolerance : float The numerical tolerance of the statistic. verdicts : dict[str, ProfileVerdictName | None] The verdict of each profiled parameter; None when its threshold is below the tolerance. intervals : dict[str, tuple[float | None, float | None]] The interval of each profiled parameter; None on a side that does not cross the threshold. """ threshold: ProfileThresholdName confidence_level: float tolerance: float verdicts: dict[str, ProfileVerdictName | None] intervals: dict[str, tuple[float | None, float | None]] ``` # ModelBuilder API [Back to API Reference](https://munqu.github.io/commol/latest/api/index.md) Builds a `Model`. Guide: [Building Models](https://munqu.github.io/commol/latest/guide/building-models/index.md), [Transitions](https://munqu.github.io/commol/latest/guide/transitions/index.md). ## ModelBuilder A fluent interface that writes the process of a model, once. The builder adds bins, accumulators, stratifications, transitions and parameters: the process, which is the same for every kind of model. `build` joins it with a structure, which lays the process out (such as `Aggregate`, amounts per compartment), and an engine, which runs it. Each method that adds returns the builder, so the calls chain. Source code in `commol/api/model_builder.py` ```python class ModelBuilder: """ A fluent interface that writes the process of a model, once. The builder adds bins, accumulators, stratifications, transitions and parameters: the process, which is the same for every kind of model. `build` joins it with a structure, which lays the process out (such as `Aggregate`, amounts per compartment), and an engine, which runs it. Each method that adds returns the builder, so the calls chain. """ def __init__( self, name: str, description: str | None = None, version: str | None = None, bin_unit: str | None = None, step_unit: str | None = None, origin: date | None = None, ) -> None: """ Initialize the ModelBuilder. Parameters ---------- name : str The unique name that identifies the model. description : str | None, default=None A human-readable description of the model's purpose and function. version : str | None, default=None The version number of the model. bin_unit : str | None, default=None The default unit for all bins. Individual bins can override this with their own unit parameter. Units are optional but required for model.check_unit_consistency(). step_unit : str | None, default=None Unit of one step on the axis, of any single dimension (for example ``"day"`` or ``"meter"``). The unit check requires every flow to be ``/``, and ``Model.axis_units()`` converts values to steps of it. None means ``step``, the default increment of any axis. origin : date | None, default=None Calendar moment of position 0: a `datetime.datetime` with no time zone, or a `datetime.date`, read as its midnight. With it, ``Model.axis_units()`` converts between dates and positions, and ``results.plot.series()`` labels its ticks with dates. It needs a ``step_unit`` of time whose length is a whole number of days or a whole fraction of a day, such as ``"hour"`` or ``"day"``; `build` raises ``ValueError`` otherwise, or for a moment with a time zone. """ self._name: str = name self._description: str | None = description self._version: str | None = version self._bin_unit: str | None = bin_unit self._step_unit: str | None = step_unit self._origin: date | None = origin self._bins: list[Bin] = [] self._accumulators: list[Accumulator] = [] self._stratifications: list[Stratification] = [] self._transitions: list[Transition] = [] self._parameters: list[Parameter] = [] self._series_counter: int = 0 logger.info( ( f"Initialized ModelBuilder: name='{self._name}', " f"version='{self._version or 'N/A'}'" ) ) def add_bin(self, id: str, name: str, unit: str | None = None) -> Self: """ Add a bin to the model. Parameters ---------- id : str Unique identifier for the bin. name : str Human-readable name for the bin. unit : str | None, default=None Unit of measurement for this bin. If None, uses the model-level bin_unit. Returns ------- ModelBuilder Self for method chaining. """ # Use bin-specific unit, or fall back to model-level bin_unit final_unit = unit if unit is not None else self._bin_unit self._bins.append(Bin(id=id, name=name, unit=final_unit)) logger.info(f"Added bin: id='{id}', name='{name}', unit='{final_unit}'") return self def add_accumulator(self, id: str, name: str) -> Self: """ Add an accumulator: a cumulative event counter in the model outputs. A transition adds its flow to the accumulators it names (``add_transition(accumulators=...)``). An accumulator is an output, not a part of the state, so ``total`` leaves it out. Parameters ---------- id : str Unique identifier for the accumulator. It names its output, which stratifications expand as they expand a bin (``acc_g1``, ``acc_g2``). name : str Human-readable name for the accumulator. Returns ------- ModelBuilder Self for method chaining. """ self._accumulators.append(Accumulator(id=id, name=name)) logger.info(f"Added accumulator: id='{id}', name='{name}'") return self def add_stratification( self, id: str, categories: list[str], description: str | None = None, conditions: list[StratificationConditionDict] | None = None, ) -> Self: """ Add a stratification to the model. Parameters ---------- id : str Unique identifier for the stratification. categories : list[str] list of category identifiers within this stratification. description : str | None, default=None Human-readable description of the stratification. conditions : list[dict] | None, default=None When set, this stratification only expands compartments whose already-applied categories satisfy ALL conditions. Each dict must contain "stratification" and "category" keys referencing a stratification declared before this one. Example: only apply the `sub` stratification to `group1`:: conditions = [{"stratification": "group", "category": "group1"}] Returns ------- ModelBuilder Self for method chaining. Raises ------ ValueError If any condition references a stratification not yet declared. """ if conditions: declared_ids = {s.id for s in self._stratifications} for cond in conditions: if cond["stratification"] not in declared_ids: raise ValueError( f"Stratification '{id}': condition references " f"'{cond['stratification']}' which has not been declared yet. " "Conditions may only reference stratifications declared before " "this one." ) condition_objects = ( [StratificationCondition(**c) for c in conditions] if conditions else None ) self._stratifications.append( Stratification( id=id, categories=categories, description=description, conditions=condition_objects, ) ) logger.info(f"Added stratification: id='{id}', categories={categories}") return self def add_parameter( self, id: str, value: float | str | list[tuple[float, float]] | None, description: str | None = None, unit: str | None = None, mode: str | None = None, ) -> Self: """ Add a global parameter to the model. Parameters ---------- id : str Unique identifier for the parameter. value : float | str | list[tuple[float, float]] | None Value of the parameter. Can be: - float: A numerical constant value - str: A mathematical formula that can reference other parameters, special variables (total, total_, step, t, pi, e), or contain mathematical expressions (e.g., "k1 * 2", "total_g1 / total") - list[tuple[float, float]]: Empirical series as (position, value) points; requires ``mode`` to be set. - None: Indicates that the parameter needs to be calibrated before use description : str | None, default=None Human-readable description of the parameter. unit : str | None, default=None Unit of the parameter (e.g., "1/day", "dimensionless", "item"). Used for unit consistency checking in equations. mode : str | None, default=None Required when ``value`` is a list of (position, value) points. Selects how the series is read: ``"pulse"`` (the value on the step of a point, 0 on every other step; integer positions only), ``"step_function"`` (the value of the last point at or before ``t``), or ``"linear"`` (linear interpolation in ``t``). Returns ------- ModelBuilder Self for method chaining. Raises ------ ValueError If ``value`` is a list but ``mode`` is not provided, if ``mode`` is provided without a list ``value``, or if a formula ``value`` fails the security check or is not in the language of formulas. """ if isinstance(value, list): if mode is None: raise ValueError( f"Parameter '{id}': 'mode' is required when 'value' is a " "series list. Use mode='pulse', 'step_function', or 'linear'." ) param_value: float | str | SeriesValue | None = SeriesValue( data=value, mode=mode ) else: if mode is not None: raise ValueError( f"Parameter '{id}': 'mode' is only valid when 'value' is a " "series list." ) param_value = value self._ensure_parameter_id_available(id) self._parameters.append( Parameter(id=id, value=param_value, description=description, unit=unit) ) logger.info(f"Added parameter: id='{id}', value={value}, unit='{unit}'") return self def _ensure_parameter_id_available(self, id: str) -> None: if any(parameter.id == id for parameter in self._parameters): raise ValueError(f"Parameter id '{id}' already exists") def _register_series_pattern( self, pattern: AxisPattern, *, transition_id: str, source_bin: str, target_bin: str, ) -> AxisPattern: """Auto-register a series parameter for pulse-type patterns. If ``pattern`` has numeric pulse data, registers a ``"pulse"`` series parameter and returns a ``_ComputedAxisPattern`` whose formula is the generated parameter name. Otherwise returns ``pattern`` unchanged. """ data = pattern._as_series_data() if data is None: return pattern conditions = pattern.conditions if transition_id and conditions: src_cats = "_".join(c["category"] for c in conditions if "category" in c) tgt_cats = "_".join( c["to"] if "to" in c else c["category"] for c in conditions if "to" in c or "category" in c ) src = f"{source_bin}_{src_cats}" if source_bin else src_cats tgt = f"{target_bin}_{tgt_cats}" if target_bin else tgt_cats param_name = f"{transition_id}_{src}_to_{tgt}" else: param_name = f"_series_{self._series_counter}" self._series_counter += 1 self._ensure_parameter_id_available(param_name) self._parameters.append( Parameter( id=param_name, value=SeriesValue(data=data, mode="pulse"), ) ) return _ComputedAxisPattern( computed_formula=param_name, conditions=conditions, source_compartment=pattern.source_compartment, ) def add_transition( self, id: str, source: list[str], target: list[str], rate: "str | float | AxisPattern | None" = None, stratified_rates: list[StratifiedRateDict] | None = None, per_compartment: bool | None = None, accumulators: list[str] | None = None, impulse_at: list[float] | None = None, impulse_every: tuple[float, float] | None = None, ) -> Self: """ Add a transition between states to the model. This method supports two distinct behaviors: When you specify multiple sources without the $compartment placeholder, a single transition is created that affects all sources simultaneously. The rate is evaluated once, at each position the engine reads it, and that value applies to all source compartments at once. What the value means (an amount moved in one step or an instantaneous rate) is the engine's; see "What a rate means" in the Core Concepts guide. When you use the $compartment placeholder in the rate formula with multiple sources, the system automatically expands this into multiple independent transitions - one for each source compartment. Each transition has its own rate calculation where $compartment is replaced with the actual compartment name. Each transition's rate is evaluated independently, giving per-compartment dynamics. This suits an outflow such as ``k1 * $compartment``, where the flow of each compartment is proportional to its own amount. **$compartment Usage Rules**: - Only valid with several sources (and one or no target) or several targets (and one or no source) - Can be used with stratified_rates - the placeholder will be expanded in both the base rate and all stratified rate expressions - The placeholder $compartment will be replaced with each source compartment name **$compartment Restrictions**: - Using $compartment with a single source will raise an error (use the compartment name directly instead) - Using $compartment with multiple targets will raise an error (ambiguous which target corresponds to which source) Parameters ---------- id : str Unique identifier for the transition. When using $compartment expansion, the system will append "__" to create unique IDs for each expanded transition (e.g., "out__A", "out__B"). source : list[str] List of source state/category identifiers. When using $compartment in the rate, each source will generate a separate transition. target : list[str] List of target state/category identifiers. When using $compartment with several targets, each target generates a separate transition. rate : str | float | AxisPattern | None, default=None Default mathematical formula, parameter reference, or constant value for the transition rate. Used when no stratified rate matches. Can be: - A parameter reference - A constant value - A mathematical formula - A formula with $compartment placeholder Special variables available in formulas: - total: The sum of every compartment, accumulators excluded - total_…: The sum of the compartments with these categories, in the declaration order of the stratifications - t: The current position on the axis - step: The step that holds it, ``floor(t)``; in a DifferenceEquations model ``t`` and ``step`` are equal - pi, e: Mathematical constants - $compartment: Expands to each source compartment name (only with multiple sources) stratified_rates : list[dict] | None, default=None List of stratification-specific rates. Each dict must contain: - "conditions": List of dicts with "stratification" and "category" keys - "rate": Rate expression string per_compartment : bool | None, default=None When True, each id of a source or target bin in a rate reads the compartment of the flow (``A_g1``) instead of the total of the bin. Any other bin keeps its total: in a transition from ``A`` to ``B``, ``k * A * (C + D) / total`` reads ``A_g1`` and the totals of ``C`` and ``D``. It applies to the fallback rate, to every stratified rate, and to every transition that ``$compartment`` expands to, where ``$compartment`` reads the compartment that flows. A transition between categories ignores it. accumulators : list[str] | None, default=None Ids of accumulators that add up the flow of this transition. impulse_at : list[float] | None, default=None Make the transition an impulse at these positions on the axis. At each one, ``rate`` is the fraction of the source (proportional) or the amount (absolute) moved at once, before the flows of that step. A DifferenceEquations or StochasticSteps model needs integer positions. impulse_every : tuple[float, float] | None, default=None ``(period, offset)``: make the transition an impulse at ``offset``, ``offset + period``, ``offset + 2 * period``, ... Returns ------- ModelBuilder Self for method chaining. Raises ------ ValueError - If $compartment is used with only one source compartment - If $compartment is used with multiple targets - If rate is None when using $compartment """ # If `rate` is a AxisPattern, extract its rate string and stratified # rates and route them into the existing pipeline. A AxisPattern fully # specifies how the rate changes along the axis, so passing `stratified_rates=` # alongside it is a conflict. if isinstance(rate, AxisPattern): if impulse_at is not None or impulse_every is not None: raise ValueError( f"Transition '{id}': an impulse moves its rate at once; pass " "a fraction or an amount as `rate=`, not a AxisPattern." ) if stratified_rates is not None: raise ValueError( f"Transition '{id}': pass either a AxisPattern as `rate=` " "or `stratified_rates=`, not both." ) src_bin = source[0] if source else "" tgt_bin = target[0] if target else "" if isinstance(rate, _ScheduleAxisPattern): resolved = rate.model_copy( update={ "patterns": [ _GroupEntry( pattern=self._register_series_pattern( entry.pattern, transition_id=id, source_bin=src_bin, target_bin=tgt_bin, ), absolute=entry.absolute, ) for entry in rate.patterns ] } ) rate = resolved else: rate = self._register_series_pattern( rate, transition_id=id, source_bin=src_bin, target_bin=tgt_bin ) stratified_rates = rate._builder_stratified_rates() rate = rate._builder_rate() # Convert rate to string if numeric if isinstance(rate, int) or isinstance(rate, float): rate = str(rate) # Check for $compartment placeholder and handle expansion if rate and "$compartment" in rate: self._validate_compartment_placeholder(id, source, target) self._expand_compartment_transition( id, source, target, rate, stratified_rates, per_compartment, accumulators, impulse_at, impulse_every, ) return self stratified_rates_objects = _stratified_rate_objects(stratified_rates) self._transitions.append( Transition( id=id, source=source, target=target, accumulators=accumulators or [], rate=rate, stratified_rates=stratified_rates_objects, per_compartment=per_compartment, impulse_at=impulse_at, impulse_every=impulse_every, ) ) logger.info( ( f"Added transition: id='{id}', source={source}, target={target}, " f"rate='{rate}', stratified_rates={ len(stratified_rates_objects) if stratified_rates_objects else 0 }" ) ) return self def _validate_compartment_placeholder( self, id: str, source: list[str], target: list[str], ) -> None: """ Validate that $compartment placeholder is used correctly. Parameters ---------- id : str Transition identifier (for error messages) source : list[str] Source compartments target : list[str] Target compartments rate : str Rate formula stratified_rates : list[StratifiedRateDict] | None Stratified rates Raises ------ ValueError If $compartment usage violates any rules """ # Check that we have either multiple sources OR multiple targets, but not both has_multiple_sources = len(source) > 1 has_multiple_targets = len(target) > 1 if not has_multiple_sources and not has_multiple_targets: raise ValueError( ( f"Transition '{id}': $compartment placeholder requires either " f"multiple source compartments or multiple target compartments " f"(found {len(source)} source(s) and {len(target)} target(s)). " f"If you have a single source/target, use the compartment name " f"directly in the rate formula instead of $compartment." ) ) if has_multiple_sources and has_multiple_targets: raise ValueError( ( f"Transition '{id}': $compartment placeholder cannot be used " f"with both multiple sources and multiple targets " f"(found {len(source)} sources and {len(target)} targets). " f"This would create ambiguous mappings. Use either multiple " f"sources with single/empty target, or single/empty source with " f"multiple targets." ) ) def _expand_compartment_transition( self, id: str, source: list[str], target: list[str], rate: str, stratified_rates: list[StratifiedRateDict] | None, per_compartment: bool | None, accumulators: list[str] | None, impulse_at: list[float] | None, impulse_every: tuple[float, float] | None, ) -> None: """ Expand a transition with $compartment placeholder into multiple transitions. Parameters ---------- id : str Base transition identifier source : list[str] Source compartments (will create one transition per source if multiple sources) target : list[str] Target compartments (will create one transition per target if multiple targets) rate : str Rate formula containing $compartment stratified_rates : list[StratifiedRateDict] | None Stratified rates (can contain $compartment which will be expanded) per_compartment, accumulators, impulse_at, impulse_every : optional The arguments of `add_transition` that every expanded transition keeps """ # Determine if we're expanding over sources or targets has_multiple_sources = len(source) > 1 if has_multiple_sources: compartments = source expand_type = "source" else: compartments = target expand_type = "target" logger.info( ( f"Expanding transition '{id}' with $compartment placeholder: " f"{len(compartments)} {expand_type} compartments will create " f"{len(compartments)} separate transitions" ) ) for compartment in compartments: # Replace $compartment with actual compartment name in base rate expanded_rate = rate.replace("$compartment", compartment) expanded_stratified_rates = _stratified_rate_objects( stratified_rates, compartment ) # Generate unique ID for this expanded transition expanded_id = f"{id}__{compartment}" # add_transition accepts several sources or several targets. if has_multiple_sources: expanded_source = [compartment] expanded_target = target else: expanded_source = source expanded_target = [compartment] self._transitions.append( Transition( id=expanded_id, source=expanded_source, target=expanded_target, accumulators=accumulators or [], rate=expanded_rate, stratified_rates=expanded_stratified_rates, per_compartment=per_compartment, impulse_at=impulse_at, impulse_every=impulse_every, ) ) strat_info = ( f", {len(expanded_stratified_rates)} stratified rates" if expanded_stratified_rates else "" ) logger.info( ( f" Created expanded transition: id='{expanded_id}', " f"source=['{compartment}'], target={target}, " f"rate='{expanded_rate}'{strat_info}" ) ) logger.info( f"Successfully expanded transition '{id}' into {len(compartments)} " "transitions" ) def get_summary(self) -> dict[str, str | int | list[str] | None]: """ Get a summary of the current model builder state. Returns ------- dict[str, dict[str, str | int | list[str] | None]] Dictionary containing summary information about the model being built. """ return { "name": self._name, "description": self._description, "version": self._version, "bins_count": len(self._bins), "bin_ids": [state.id for state in self._bins], "stratifications_count": len(self._stratifications), "stratification_ids": [strat.id for strat in self._stratifications], "transitions_count": len(self._transitions), "transition_ids": [trans.id for trans in self._transitions], "parameters_count": len(self._parameters), "parameter_ids": [param.id for param in self._parameters], } def clone(self) -> Self: """ Create a deep copy of this ModelBuilder. Returns ------- ModelBuilder A new ModelBuilder instance with the same configuration: every bin, accumulator, stratification, transition and parameter. Changes to one builder do not affect the other. """ return copy.deepcopy(self) def reset(self) -> Self: """ Reset the builder to empty state. The name, description, version, ``bin_unit``, ``step_unit`` and ``origin`` are kept. Every bin, accumulator, stratification, transition and parameter is removed. Returns ------- ModelBuilder Self for method chaining. """ self._bins.clear() self._accumulators.clear() self._stratifications.clear() self._transitions.clear() self._parameters.clear() self._series_counter = 0 return self def process(self) -> Process: """ The process the builder has written so far. Returns ------- Process The bins, accumulators, stratifications and transitions. Raises ------ ValueError If ids repeat, or a transition or a stratified rate names an id the process does not have. """ return Process( bins=self._bins, accumulators=self._accumulators, stratifications=self._stratifications, transitions=self._transitions, ) def build( self, structure: StructureT, engine: EngineName, settings: EngineSettings | None = None, ) -> Model[StructureT]: """ Join the process with a structure and an engine into a model. The process is written once; the structure lays it out and sets the kind of the model, so one builder builds every kind its process fits: ``builder.build(Aggregate(bin_amounts={"A": 990, "B": 10, "C": 0}), engine="DifferentialEquations")`` Parameters ---------- structure : ModelStructure How the process is laid out, such as `Aggregate` (amounts per compartment). The model is a ``Model`` of its class. engine : EngineName The engine that runs the model, which must run the kind of the structure: ``"DifferenceEquations"`` (discrete time), ``"DifferentialEquations"`` (continuous time), ``"StochasticEvents"`` or ``"StochasticSteps"`` (at random). settings : EngineSettings | None, default=None Settings of that engine; None uses its defaults: `ODESolver` for ``"DifferentialEquations"``, `StochasticEventsSettings` and `StochasticStepsSettings` for the stochastic engines. Returns ------- Model[StructureT] The model, of the class of ``structure``. Raises ------ ValueError If validation fails, the structure does not fit the process (for example a fraction names no bin, or a rate reads ``neighbors_`` and the structure has no ``neighbors``), the engine is unknown or does not run the kind of the structure, the settings are not settings of that engine, a series point or an impulse position is one the engine cannot run (a position between two steps for an engine that steps only at integers, or a ``"pulse"`` point between two steps), a per-compartment override names an unknown compartment, a stochastic engine gets an amount that is not whole, or the origin has a time zone or a step unit that is not of fixed calendar length. """ model: Model[StructureT] = Model( name=self._name, description=self._description, version=self._version, parameters=self._parameters, engine=ModelEngine(type=engine, settings=settings), process=self.process(), structure=structure, step_unit=self._step_unit, origin=self._origin, ) logger.info(f"Model '{self._name}' successfully built for the {engine} engine.") return model ``` ### Methods: #### __init__ ```python __init__(name: str, description: str | None = None, version: str | None = None, bin_unit: str | None = None, step_unit: str | None = None, origin: date | None = None) -> None ``` Initialize the ModelBuilder. Parameters: | Name | Type | Description | Default | | ------------- | ------ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | `str` | The unique name that identifies the model. | *required* | | `description` | \`str | None\` | A human-readable description of the model's purpose and function. | | `version` | \`str | None\` | The version number of the model. | | `bin_unit` | \`str | None\` | The default unit for all bins. Individual bins can override this with their own unit parameter. Units are optional but required for model.check_unit_consistency(). | | `step_unit` | \`str | None\` | Unit of one step on the axis, of any single dimension (for example "day" or "meter"). The unit check requires every flow to be /\, and Model.axis_units() converts values to steps of it. None means step, the default increment of any axis. | | `origin` | \`date | None\` | Calendar moment of position 0: a datetime.datetime with no time zone, or a datetime.date, read as its midnight. With it, Model.axis_units() converts between dates and positions, and results.plot.series() labels its ticks with dates. It needs a step_unit of time whose length is a whole number of days or a whole fraction of a day, such as "hour" or "day"; build raises ValueError otherwise, or for a moment with a time zone. | Source code in `commol/api/model_builder.py` ```python def __init__( self, name: str, description: str | None = None, version: str | None = None, bin_unit: str | None = None, step_unit: str | None = None, origin: date | None = None, ) -> None: """ Initialize the ModelBuilder. Parameters ---------- name : str The unique name that identifies the model. description : str | None, default=None A human-readable description of the model's purpose and function. version : str | None, default=None The version number of the model. bin_unit : str | None, default=None The default unit for all bins. Individual bins can override this with their own unit parameter. Units are optional but required for model.check_unit_consistency(). step_unit : str | None, default=None Unit of one step on the axis, of any single dimension (for example ``"day"`` or ``"meter"``). The unit check requires every flow to be ``/``, and ``Model.axis_units()`` converts values to steps of it. None means ``step``, the default increment of any axis. origin : date | None, default=None Calendar moment of position 0: a `datetime.datetime` with no time zone, or a `datetime.date`, read as its midnight. With it, ``Model.axis_units()`` converts between dates and positions, and ``results.plot.series()`` labels its ticks with dates. It needs a ``step_unit`` of time whose length is a whole number of days or a whole fraction of a day, such as ``"hour"`` or ``"day"``; `build` raises ``ValueError`` otherwise, or for a moment with a time zone. """ self._name: str = name self._description: str | None = description self._version: str | None = version self._bin_unit: str | None = bin_unit self._step_unit: str | None = step_unit self._origin: date | None = origin self._bins: list[Bin] = [] self._accumulators: list[Accumulator] = [] self._stratifications: list[Stratification] = [] self._transitions: list[Transition] = [] self._parameters: list[Parameter] = [] self._series_counter: int = 0 logger.info( ( f"Initialized ModelBuilder: name='{self._name}', " f"version='{self._version or 'N/A'}'" ) ) ``` #### add_bin ```python add_bin(id: str, name: str, unit: str | None = None) -> Self ``` Add a bin to the model. Parameters: | Name | Type | Description | Default | | ------ | ----- | -------------------------------- | ------------------------------------------------------------------------- | | `id` | `str` | Unique identifier for the bin. | *required* | | `name` | `str` | Human-readable name for the bin. | *required* | | `unit` | \`str | None\` | Unit of measurement for this bin. If None, uses the model-level bin_unit. | Returns: | Type | Description | | -------------- | ------------------------- | | `ModelBuilder` | Self for method chaining. | Source code in `commol/api/model_builder.py` ```python def add_bin(self, id: str, name: str, unit: str | None = None) -> Self: """ Add a bin to the model. Parameters ---------- id : str Unique identifier for the bin. name : str Human-readable name for the bin. unit : str | None, default=None Unit of measurement for this bin. If None, uses the model-level bin_unit. Returns ------- ModelBuilder Self for method chaining. """ # Use bin-specific unit, or fall back to model-level bin_unit final_unit = unit if unit is not None else self._bin_unit self._bins.append(Bin(id=id, name=name, unit=final_unit)) logger.info(f"Added bin: id='{id}', name='{name}', unit='{final_unit}'") return self ``` #### add_accumulator ```python add_accumulator(id: str, name: str) -> Self ``` Add an accumulator: a cumulative event counter in the model outputs. A transition adds its flow to the accumulators it names (`add_transition(accumulators=...)`). An accumulator is an output, not a part of the state, so `total` leaves it out. Parameters: | Name | Type | Description | Default | | ------ | ----- | ------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `id` | `str` | Unique identifier for the accumulator. It names its output, which stratifications expand as they expand a bin (acc_g1, acc_g2). | *required* | | `name` | `str` | Human-readable name for the accumulator. | *required* | Returns: | Type | Description | | -------------- | ------------------------- | | `ModelBuilder` | Self for method chaining. | Source code in `commol/api/model_builder.py` ```python def add_accumulator(self, id: str, name: str) -> Self: """ Add an accumulator: a cumulative event counter in the model outputs. A transition adds its flow to the accumulators it names (``add_transition(accumulators=...)``). An accumulator is an output, not a part of the state, so ``total`` leaves it out. Parameters ---------- id : str Unique identifier for the accumulator. It names its output, which stratifications expand as they expand a bin (``acc_g1``, ``acc_g2``). name : str Human-readable name for the accumulator. Returns ------- ModelBuilder Self for method chaining. """ self._accumulators.append(Accumulator(id=id, name=name)) logger.info(f"Added accumulator: id='{id}', name='{name}'") return self ``` #### add_stratification ```python add_stratification(id: str, categories: list[str], description: str | None = None, conditions: list[ConditionDict] | None = None) -> Self ``` Add a stratification to the model. Parameters: | Name | Type | Description | Default | | ------------- | ------------ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `str` | Unique identifier for the stratification. | *required* | | `categories` | `list[str]` | list of category identifiers within this stratification. | *required* | | `description` | \`str | None\` | Human-readable description of the stratification. | | `conditions` | \`list[dict] | None\` | When set, this stratification only expands compartments whose already-applied categories satisfy ALL conditions. Each dict must contain "stratification" and "category" keys referencing a stratification declared before this one. Example: only apply the sub stratification to group1:: conditions = [{"stratification": "group", "category": "group1"}] | Returns: | Type | Description | | -------------- | ------------------------- | | `ModelBuilder` | Self for method chaining. | Raises: | Type | Description | | ------------ | -------------------------------------------------------------- | | `ValueError` | If any condition references a stratification not yet declared. | Source code in `commol/api/model_builder.py` ```python def add_stratification( self, id: str, categories: list[str], description: str | None = None, conditions: list[StratificationConditionDict] | None = None, ) -> Self: """ Add a stratification to the model. Parameters ---------- id : str Unique identifier for the stratification. categories : list[str] list of category identifiers within this stratification. description : str | None, default=None Human-readable description of the stratification. conditions : list[dict] | None, default=None When set, this stratification only expands compartments whose already-applied categories satisfy ALL conditions. Each dict must contain "stratification" and "category" keys referencing a stratification declared before this one. Example: only apply the `sub` stratification to `group1`:: conditions = [{"stratification": "group", "category": "group1"}] Returns ------- ModelBuilder Self for method chaining. Raises ------ ValueError If any condition references a stratification not yet declared. """ if conditions: declared_ids = {s.id for s in self._stratifications} for cond in conditions: if cond["stratification"] not in declared_ids: raise ValueError( f"Stratification '{id}': condition references " f"'{cond['stratification']}' which has not been declared yet. " "Conditions may only reference stratifications declared before " "this one." ) condition_objects = ( [StratificationCondition(**c) for c in conditions] if conditions else None ) self._stratifications.append( Stratification( id=id, categories=categories, description=description, conditions=condition_objects, ) ) logger.info(f"Added stratification: id='{id}', categories={categories}") return self ``` #### add_parameter ```python add_parameter(id: str, value: float | str | list[tuple[float, float]] | None, description: str | None = None, unit: str | None = None, mode: str | None = None) -> Self ``` Add a global parameter to the model. Parameters: | Name | Type | Description | Default | | ------------- | ------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `str` | Unique identifier for the parameter. | *required* | | `value` | \`float | str | list\[tuple[float, float]\] | | `description` | \`str | None\` | Human-readable description of the parameter. | | `unit` | \`str | None\` | Unit of the parameter (e.g., "1/day", "dimensionless", "item"). Used for unit consistency checking in equations. | | `mode` | \`str | None\` | Required when value is a list of (position, value) points. Selects how the series is read: "pulse" (the value on the step of a point, 0 on every other step; integer positions only), "step_function" (the value of the last point at or before t), or "linear" (linear interpolation in t). | Returns: | Type | Description | | -------------- | ------------------------- | | `ModelBuilder` | Self for method chaining. | Raises: | Type | Description | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If value is a list but mode is not provided, if mode is provided without a list value, or if a formula value fails the security check or is not in the language of formulas. | Source code in `commol/api/model_builder.py` ```python def add_parameter( self, id: str, value: float | str | list[tuple[float, float]] | None, description: str | None = None, unit: str | None = None, mode: str | None = None, ) -> Self: """ Add a global parameter to the model. Parameters ---------- id : str Unique identifier for the parameter. value : float | str | list[tuple[float, float]] | None Value of the parameter. Can be: - float: A numerical constant value - str: A mathematical formula that can reference other parameters, special variables (total, total_, step, t, pi, e), or contain mathematical expressions (e.g., "k1 * 2", "total_g1 / total") - list[tuple[float, float]]: Empirical series as (position, value) points; requires ``mode`` to be set. - None: Indicates that the parameter needs to be calibrated before use description : str | None, default=None Human-readable description of the parameter. unit : str | None, default=None Unit of the parameter (e.g., "1/day", "dimensionless", "item"). Used for unit consistency checking in equations. mode : str | None, default=None Required when ``value`` is a list of (position, value) points. Selects how the series is read: ``"pulse"`` (the value on the step of a point, 0 on every other step; integer positions only), ``"step_function"`` (the value of the last point at or before ``t``), or ``"linear"`` (linear interpolation in ``t``). Returns ------- ModelBuilder Self for method chaining. Raises ------ ValueError If ``value`` is a list but ``mode`` is not provided, if ``mode`` is provided without a list ``value``, or if a formula ``value`` fails the security check or is not in the language of formulas. """ if isinstance(value, list): if mode is None: raise ValueError( f"Parameter '{id}': 'mode' is required when 'value' is a " "series list. Use mode='pulse', 'step_function', or 'linear'." ) param_value: float | str | SeriesValue | None = SeriesValue( data=value, mode=mode ) else: if mode is not None: raise ValueError( f"Parameter '{id}': 'mode' is only valid when 'value' is a " "series list." ) param_value = value self._ensure_parameter_id_available(id) self._parameters.append( Parameter(id=id, value=param_value, description=description, unit=unit) ) logger.info(f"Added parameter: id='{id}', value={value}, unit='{unit}'") return self ``` #### add_transition ```python add_transition(id: str, source: list[str], target: list[str], rate: str | float | AxisPattern | None = None, stratified_rates: list[StratifiedRateDict] | None = None, per_compartment: bool | None = None, accumulators: list[str] | None = None, impulse_at: list[float] | None = None, impulse_every: tuple[float, float] | None = None) -> Self ``` Add a transition between states to the model. This method supports two distinct behaviors: When you specify multiple sources without the $compartment placeholder, a single transition is created that affects all sources simultaneously. The rate is evaluated once, at each position the engine reads it, and that value applies to all source compartments at once. What the value means (an amount moved in one step or an instantaneous rate) is the engine's; see "What a rate means" in the Core Concepts guide. When you use the $compartment placeholder in the rate formula with multiple sources, the system automatically expands this into multiple independent transitions - one for each source compartment. Each transition has its own rate calculation where $compartment is replaced with the actual compartment name. Each transition's rate is evaluated independently, giving per-compartment dynamics. This suits an outflow such as `k1 * $compartment`, where the flow of each compartment is proportional to its own amount. **$compartment Usage Rules**: - Only valid with several sources (and one or no target) or several targets (and one or no source) - Can be used with stratified_rates - the placeholder will be expanded in both the base rate and all stratified rate expressions - The placeholder $compartment will be replaced with each source compartment name **$compartment Restrictions**: - Using $compartment with a single source will raise an error (use the compartment name directly instead) - Using $compartment with multiple targets will raise an error (ambiguous which target corresponds to which source) Parameters: | Name | Type | Description | Default | | ------------------ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `str` | Unique identifier for the transition. When using $compartment expansion, the system will append "\_\_" to create unique IDs for each expanded transition (e.g., "out\_\_A", "out\_\_B"). | *required* | | `source` | `list[str]` | List of source state/category identifiers. When using $compartment in the rate, each source will generate a separate transition. | *required* | | `target` | `list[str]` | List of target state/category identifiers. When using $compartment with several targets, each target generates a separate transition. | *required* | | `rate` | \`str | float | AxisPattern | | `stratified_rates` | \`list[dict] | None\` | List of stratification-specific rates. Each dict must contain: - "conditions": List of dicts with "stratification" and "category" keys - "rate": Rate expression string | | `per_compartment` | \`bool | None\` | When True, each id of a source or target bin in a rate reads the compartment of the flow (A_g1) instead of the total of the bin. Any other bin keeps its total: in a transition from A to B, k * A * (C + D) / total reads A_g1 and the totals of C and D. It applies to the fallback rate, to every stratified rate, and to every transition that $compartment expands to, where $compartment reads the compartment that flows. A transition between categories ignores it. | | `accumulators` | \`list[str] | None\` | Ids of accumulators that add up the flow of this transition. | | `impulse_at` | \`list[float] | None\` | Make the transition an impulse at these positions on the axis. At each one, rate is the fraction of the source (proportional) or the amount (absolute) moved at once, before the flows of that step. A DifferenceEquations or StochasticSteps model needs integer positions. | | `impulse_every` | \`tuple[float, float] | None\` | (period, offset): make the transition an impulse at offset, offset + period, offset + 2 * period, ... | Returns: | Type | Description | | -------------- | ------------------------- | | `ModelBuilder` | Self for method chaining. | Raises: | Type | Description | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If $compartment is used with only one source compartment If $compartment is used with multiple targets If rate is None when using $compartment | Source code in `commol/api/model_builder.py` ```python def add_transition( self, id: str, source: list[str], target: list[str], rate: "str | float | AxisPattern | None" = None, stratified_rates: list[StratifiedRateDict] | None = None, per_compartment: bool | None = None, accumulators: list[str] | None = None, impulse_at: list[float] | None = None, impulse_every: tuple[float, float] | None = None, ) -> Self: """ Add a transition between states to the model. This method supports two distinct behaviors: When you specify multiple sources without the $compartment placeholder, a single transition is created that affects all sources simultaneously. The rate is evaluated once, at each position the engine reads it, and that value applies to all source compartments at once. What the value means (an amount moved in one step or an instantaneous rate) is the engine's; see "What a rate means" in the Core Concepts guide. When you use the $compartment placeholder in the rate formula with multiple sources, the system automatically expands this into multiple independent transitions - one for each source compartment. Each transition has its own rate calculation where $compartment is replaced with the actual compartment name. Each transition's rate is evaluated independently, giving per-compartment dynamics. This suits an outflow such as ``k1 * $compartment``, where the flow of each compartment is proportional to its own amount. **$compartment Usage Rules**: - Only valid with several sources (and one or no target) or several targets (and one or no source) - Can be used with stratified_rates - the placeholder will be expanded in both the base rate and all stratified rate expressions - The placeholder $compartment will be replaced with each source compartment name **$compartment Restrictions**: - Using $compartment with a single source will raise an error (use the compartment name directly instead) - Using $compartment with multiple targets will raise an error (ambiguous which target corresponds to which source) Parameters ---------- id : str Unique identifier for the transition. When using $compartment expansion, the system will append "__" to create unique IDs for each expanded transition (e.g., "out__A", "out__B"). source : list[str] List of source state/category identifiers. When using $compartment in the rate, each source will generate a separate transition. target : list[str] List of target state/category identifiers. When using $compartment with several targets, each target generates a separate transition. rate : str | float | AxisPattern | None, default=None Default mathematical formula, parameter reference, or constant value for the transition rate. Used when no stratified rate matches. Can be: - A parameter reference - A constant value - A mathematical formula - A formula with $compartment placeholder Special variables available in formulas: - total: The sum of every compartment, accumulators excluded - total_…: The sum of the compartments with these categories, in the declaration order of the stratifications - t: The current position on the axis - step: The step that holds it, ``floor(t)``; in a DifferenceEquations model ``t`` and ``step`` are equal - pi, e: Mathematical constants - $compartment: Expands to each source compartment name (only with multiple sources) stratified_rates : list[dict] | None, default=None List of stratification-specific rates. Each dict must contain: - "conditions": List of dicts with "stratification" and "category" keys - "rate": Rate expression string per_compartment : bool | None, default=None When True, each id of a source or target bin in a rate reads the compartment of the flow (``A_g1``) instead of the total of the bin. Any other bin keeps its total: in a transition from ``A`` to ``B``, ``k * A * (C + D) / total`` reads ``A_g1`` and the totals of ``C`` and ``D``. It applies to the fallback rate, to every stratified rate, and to every transition that ``$compartment`` expands to, where ``$compartment`` reads the compartment that flows. A transition between categories ignores it. accumulators : list[str] | None, default=None Ids of accumulators that add up the flow of this transition. impulse_at : list[float] | None, default=None Make the transition an impulse at these positions on the axis. At each one, ``rate`` is the fraction of the source (proportional) or the amount (absolute) moved at once, before the flows of that step. A DifferenceEquations or StochasticSteps model needs integer positions. impulse_every : tuple[float, float] | None, default=None ``(period, offset)``: make the transition an impulse at ``offset``, ``offset + period``, ``offset + 2 * period``, ... Returns ------- ModelBuilder Self for method chaining. Raises ------ ValueError - If $compartment is used with only one source compartment - If $compartment is used with multiple targets - If rate is None when using $compartment """ # If `rate` is a AxisPattern, extract its rate string and stratified # rates and route them into the existing pipeline. A AxisPattern fully # specifies how the rate changes along the axis, so passing `stratified_rates=` # alongside it is a conflict. if isinstance(rate, AxisPattern): if impulse_at is not None or impulse_every is not None: raise ValueError( f"Transition '{id}': an impulse moves its rate at once; pass " "a fraction or an amount as `rate=`, not a AxisPattern." ) if stratified_rates is not None: raise ValueError( f"Transition '{id}': pass either a AxisPattern as `rate=` " "or `stratified_rates=`, not both." ) src_bin = source[0] if source else "" tgt_bin = target[0] if target else "" if isinstance(rate, _ScheduleAxisPattern): resolved = rate.model_copy( update={ "patterns": [ _GroupEntry( pattern=self._register_series_pattern( entry.pattern, transition_id=id, source_bin=src_bin, target_bin=tgt_bin, ), absolute=entry.absolute, ) for entry in rate.patterns ] } ) rate = resolved else: rate = self._register_series_pattern( rate, transition_id=id, source_bin=src_bin, target_bin=tgt_bin ) stratified_rates = rate._builder_stratified_rates() rate = rate._builder_rate() # Convert rate to string if numeric if isinstance(rate, int) or isinstance(rate, float): rate = str(rate) # Check for $compartment placeholder and handle expansion if rate and "$compartment" in rate: self._validate_compartment_placeholder(id, source, target) self._expand_compartment_transition( id, source, target, rate, stratified_rates, per_compartment, accumulators, impulse_at, impulse_every, ) return self stratified_rates_objects = _stratified_rate_objects(stratified_rates) self._transitions.append( Transition( id=id, source=source, target=target, accumulators=accumulators or [], rate=rate, stratified_rates=stratified_rates_objects, per_compartment=per_compartment, impulse_at=impulse_at, impulse_every=impulse_every, ) ) logger.info( ( f"Added transition: id='{id}', source={source}, target={target}, " f"rate='{rate}', stratified_rates={ len(stratified_rates_objects) if stratified_rates_objects else 0 }" ) ) return self ``` #### get_summary ```python get_summary() -> dict[str, str | int | list[str] | None] ``` Get a summary of the current model builder state. Returns: | Type | Description | | --------------------------- | ----------- | | \`dict\[str, dict\[str, str | int | Source code in `commol/api/model_builder.py` ```python def get_summary(self) -> dict[str, str | int | list[str] | None]: """ Get a summary of the current model builder state. Returns ------- dict[str, dict[str, str | int | list[str] | None]] Dictionary containing summary information about the model being built. """ return { "name": self._name, "description": self._description, "version": self._version, "bins_count": len(self._bins), "bin_ids": [state.id for state in self._bins], "stratifications_count": len(self._stratifications), "stratification_ids": [strat.id for strat in self._stratifications], "transitions_count": len(self._transitions), "transition_ids": [trans.id for trans in self._transitions], "parameters_count": len(self._parameters), "parameter_ids": [param.id for param in self._parameters], } ``` #### clone ```python clone() -> Self ``` Create a deep copy of this ModelBuilder. Returns: | Type | Description | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ModelBuilder` | A new ModelBuilder instance with the same configuration: every bin, accumulator, stratification, transition and parameter. Changes to one builder do not affect the other. | Source code in `commol/api/model_builder.py` ```python def clone(self) -> Self: """ Create a deep copy of this ModelBuilder. Returns ------- ModelBuilder A new ModelBuilder instance with the same configuration: every bin, accumulator, stratification, transition and parameter. Changes to one builder do not affect the other. """ return copy.deepcopy(self) ``` #### reset ```python reset() -> Self ``` Reset the builder to empty state. The name, description, version, `bin_unit`, `step_unit` and `origin` are kept. Every bin, accumulator, stratification, transition and parameter is removed. Returns: | Type | Description | | -------------- | ------------------------- | | `ModelBuilder` | Self for method chaining. | Source code in `commol/api/model_builder.py` ```python def reset(self) -> Self: """ Reset the builder to empty state. The name, description, version, ``bin_unit``, ``step_unit`` and ``origin`` are kept. Every bin, accumulator, stratification, transition and parameter is removed. Returns ------- ModelBuilder Self for method chaining. """ self._bins.clear() self._accumulators.clear() self._stratifications.clear() self._transitions.clear() self._parameters.clear() self._series_counter = 0 return self ``` #### process ```python process() -> Process ``` The process the builder has written so far. Returns: | Type | Description | | --------- | -------------------------------------------------------- | | `Process` | The bins, accumulators, stratifications and transitions. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------ | | `ValueError` | If ids repeat, or a transition or a stratified rate names an id the process does not have. | Source code in `commol/api/model_builder.py` ```python def process(self) -> Process: """ The process the builder has written so far. Returns ------- Process The bins, accumulators, stratifications and transitions. Raises ------ ValueError If ids repeat, or a transition or a stratified rate names an id the process does not have. """ return Process( bins=self._bins, accumulators=self._accumulators, stratifications=self._stratifications, transitions=self._transitions, ) ``` #### build ```python build(structure: StructureT, engine: EngineName, settings: EngineSettings | None = None) -> Model[StructureT] ``` Join the process with a structure and an engine into a model. The process is written once; the structure lays it out and sets the kind of the model, so one builder builds every kind its process fits: `builder.build(Aggregate(bin_amounts={"A": 990, "B": 10, "C": 0}), engine="DifferentialEquations")` Parameters: | Name | Type | Description | Default | | ----------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `structure` | `ModelStructure` | How the process is laid out, such as Aggregate (amounts per compartment). The model is a Model of its class. | *required* | | `engine` | `EngineName` | The engine that runs the model, which must run the kind of the structure: "DifferenceEquations" (discrete time), "DifferentialEquations" (continuous time), "StochasticEvents" or "StochasticSteps" (at random). | *required* | | `settings` | \`EngineSettings | None\` | Settings of that engine; None uses its defaults: ODESolver for "DifferentialEquations", StochasticEventsSettings and StochasticStepsSettings for the stochastic engines. | Returns: | Type | Description | | ------------------- | ------------------------------------- | | `Model[StructureT]` | The model, of the class of structure. | Raises: | Type | Description | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If validation fails, the structure does not fit the process (for example a fraction names no bin, or a rate reads neighbors\_ and the structure has no neighbors), the engine is unknown or does not run the kind of the structure, the settings are not settings of that engine, a series point or an impulse position is one the engine cannot run (a position between two steps for an engine that steps only at integers, or a "pulse" point between two steps), a per-compartment override names an unknown compartment, a stochastic engine gets an amount that is not whole, or the origin has a time zone or a step unit that is not of fixed calendar length. | Source code in `commol/api/model_builder.py` ```python def build( self, structure: StructureT, engine: EngineName, settings: EngineSettings | None = None, ) -> Model[StructureT]: """ Join the process with a structure and an engine into a model. The process is written once; the structure lays it out and sets the kind of the model, so one builder builds every kind its process fits: ``builder.build(Aggregate(bin_amounts={"A": 990, "B": 10, "C": 0}), engine="DifferentialEquations")`` Parameters ---------- structure : ModelStructure How the process is laid out, such as `Aggregate` (amounts per compartment). The model is a ``Model`` of its class. engine : EngineName The engine that runs the model, which must run the kind of the structure: ``"DifferenceEquations"`` (discrete time), ``"DifferentialEquations"`` (continuous time), ``"StochasticEvents"`` or ``"StochasticSteps"`` (at random). settings : EngineSettings | None, default=None Settings of that engine; None uses its defaults: `ODESolver` for ``"DifferentialEquations"``, `StochasticEventsSettings` and `StochasticStepsSettings` for the stochastic engines. Returns ------- Model[StructureT] The model, of the class of ``structure``. Raises ------ ValueError If validation fails, the structure does not fit the process (for example a fraction names no bin, or a rate reads ``neighbors_`` and the structure has no ``neighbors``), the engine is unknown or does not run the kind of the structure, the settings are not settings of that engine, a series point or an impulse position is one the engine cannot run (a position between two steps for an engine that steps only at integers, or a ``"pulse"`` point between two steps), a per-compartment override names an unknown compartment, a stochastic engine gets an amount that is not whole, or the origin has a time zone or a step unit that is not of fixed calendar length. """ model: Model[StructureT] = Model( name=self._name, description=self._description, version=self._version, parameters=self._parameters, engine=ModelEngine(type=engine, settings=settings), process=self.process(), structure=structure, step_unit=self._step_unit, origin=self._origin, ) logger.info(f"Model '{self._name}' successfully built for the {engine} engine.") return model ``` # Model API [Back to API Reference](https://munqu.github.io/commol/latest/api/index.md) The validated model that `ModelBuilder.build` returns: a header, the process written once, the structure that lays it out (`Aggregate`, with its neighbors `Complete` and `Groups`), the entries of `Aggregate.initial_state()`, the `SeriesValue` of a series parameter, and `UnitConsistencyError`, the error of `Model.check_unit_consistency`. Guide: [Core Concepts](https://munqu.github.io/commol/latest/guide/core-concepts/index.md), [Building Models](https://munqu.github.io/commol/latest/guide/building-models/#the-structure-aggregate), [Describing a Model](https://munqu.github.io/commol/latest/guide/describing-models/index.md), [Units](https://munqu.github.io/commol/latest/guide/units-and-equations/index.md). The tree of types is in [Model Structure](https://munqu.github.io/commol/latest/advanced/model-structure/index.md). ## Model Bases: `BaseModel`, `Generic[StructureT]` A model of any kind: a header, a process, a structure and an engine. The process (bins, stratifications, transitions) is written once; the structure lays it out and changes between kinds of model, so `Model[Aggregate]` is a model of amounts per compartment. `ModelBuilder.build` joins its process with a structure. A model loaded without a structure class reads the class from the `kind` and the `variant` of its structure. Attributes: | Name | Type | Description | | ------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------- | | `name` | `str` | A unique name that identifies the model. | | `description` | \`str | None\` | | `version` | \`str | None\` | | `parameters` | `list[Parameter]` | A list of global model parameters. | | `engine` | `ModelEngine` | The engine that runs the model, and its settings. | | `process` | `Process` | What changes and how: the bins, accumulators, stratifications and transitions, written once for every structure. | | `structure` | `StructureT` | How the process is laid out, which changes between kinds of model, such as the amounts per compartment of an Aggregate. | | `step_unit` | \`str | None\` | | `origin` | \`datetime | None\` | Source code in `commol/context/model.py` ```python class Model(BaseModel, Generic[StructureT]): """ A model of any kind: a header, a process, a structure and an engine. The process (bins, stratifications, transitions) is written once; the structure lays it out and changes between kinds of model, so ``Model[Aggregate]`` is a model of amounts per compartment. `ModelBuilder.build` joins its process with a structure. A model loaded without a structure class reads the class from the ``kind`` and the ``variant`` of its structure. Attributes ---------- name : str A unique name that identifies the model. description : str | None A human-readable description of the model's purpose and function. version : str | None The version number of the model. parameters : list[Parameter] A list of global model parameters. engine : ModelEngine The engine that runs the model, and its settings. process : Process What changes and how: the bins, accumulators, stratifications and transitions, written once for every structure. structure : StructureT How the process is laid out, which changes between kinds of model, such as the amounts per compartment of an `Aggregate`. step_unit : str | None Unit of one step on the axis, of any single dimension (``"day"``, ``"meter"``). The unit check and `axis_units` use it; None means ``step``, the default increment of any axis. origin : datetime | None Calendar moment of position 0, with no time zone, for the dates of `axis_units`. It needs a ``step_unit`` of time whose length is a whole number of days or a whole fraction of a day. None means the axis has no dates. """ model_config = ConfigDict(extra="forbid") name: str = Field(..., description="Name which identifies the model.") description: str | None = Field( None, description="Human-readable description of the model's purpose and function.", ) version: str | None = Field(None, description="Version number of the model.") parameters: list[Parameter] engine: ModelEngine process: Process = Field(default_factory=Process) structure: SerializeAsAny[StructureT] step_unit: str | None = Field( None, description=( "Unit of one step on the axis (the independent variable, of any " "dimension), for the unit check and axis_units. " "None means 'step', the default increment of any axis." ), ) origin: Origin | None = Field( None, description=( "Calendar moment of position 0, with no time zone, for the dates " "of axis_units. None means the axis has no dates." ), ) @model_validator(mode="after") def check_origin_unit(self) -> Self: """Check that an origin has a step unit of fixed calendar length.""" if self.origin is not None: seconds_per_step(self.step_unit or DEFAULT_STEP_UNIT) return self @field_validator("structure", mode="before") @classmethod def read_structure_kind(cls, value: JsonValue) -> JsonValue | ModelStructure: """Read a structure mapping as the class of its ``kind`` and ``variant``.""" if not isinstance(value, Mapping): return value kind = value.get("kind") variant = value.get("variant") if not isinstance(kind, str): raise ValueError( "A model structure needs a 'kind'. Known kinds: " f"{sorted({known for known, _ in ModelStructure._kinds})}" ) if variant is not None and not isinstance(variant, str): raise ValueError(f"The variant of a structure is a name, got {variant!r}") return ModelStructure.of_kind(kind, variant).model_validate(value) @classmethod def from_json(cls, file_path: str | Path) -> Self: """ Loads a model from a JSON file. The method reads the specified JSON file as UTF-8, the encoding of JSON, parses its content, and validates it against the Model schema. Parameters ---------- file_path : str | Path The path to the JSON file. Returns ------- Model A validated Model instance. Raises ------ FileNotFoundError If the file at `file_path` does not exist. pydantic.ValidationError If the JSON content does not conform to the Model schema. """ return cls.model_validate_json(Path(file_path).read_text(encoding="utf-8")) def to_json(self, file_path: str | Path, indent: int = 2) -> None: """ Save the model to a JSON file. Every field is written, including those left unset, so the file always reloads through :meth:`from_json` to an equal model. Long numeric arrays, such as a series parameter, are written on a single line. Parameters ---------- file_path : str | Path Path of the file to write. indent : int, optional Indentation width for nested structures. """ payload = self.model_dump(mode="json") Path(file_path).write_text( render_json(payload, indent=indent), encoding="utf-8" ) @model_validator(mode="after") def validate_engine_runs_structure(self) -> Self: """Ask the engine whether it runs the kind of the structure.""" commol_rs.engine.EngineType(self.engine.type).check_kind(self.structure.kind) return self @model_validator(mode="after") def validate_unique_parameter_ids(self) -> Self: """ Validates that parameter IDs are unique. """ parameter_ids = [p.id for p in self.parameters] if len(parameter_ids) != len(set(parameter_ids)): duplicates = [ item for item in set(parameter_ids) if parameter_ids.count(item) > 1 ] raise ValueError(f"Duplicate parameter IDs found: {duplicates}") return self @model_validator(mode="after") def validate_structure(self) -> Self: """Check the structure against the parameters and the engine.""" self.structure.check_model(self) return self def update_parameters( self, parameter_values: Mapping[str, ParameterUpdate] ) -> None: """ Update parameter values in the model. The model takes every value or none: the values pass the checks of `Parameter` and of the model, as when the model is made, before any is written. Parameters ---------- parameter_values : Mapping[str, ParameterUpdate] Each parameter id mapped to its new value: a number, a formula, a `SeriesValue`, or None to leave it for calibration. A list of (position, value) points replaces the points of a series parameter and keeps its mode; a `SeriesValue` sets the points and the mode. Raises ------ ValueError If an id is not a parameter of the model, a list is given for a parameter that is not a series, a value is not valid (the message names the parameter), or the model rejects the new values. """ current = {param.id: param for param in self.parameters} updated = dict(current) for param_id, value in parameter_values.items(): if param_id not in current: raise ValueError( ( f"Parameter '{param_id}' not found in model. " f"Available parameters: {', '.join(current.keys())}" ) ) updated[param_id] = current[param_id].with_value(value) parameters = list(updated.values()) self.structure.check_model(self.model_copy(update={"parameters": parameters})) self.parameters = parameters def get_uncalibrated_parameters(self) -> list[str]: """ Get a list of parameter IDs that have None values (need calibration). Returns ------- list[str] List of parameter IDs that require calibration. """ return [param.id for param in self.parameters if param.value is None] def apply_calibration_parameters( self, result: CalibrationResult | Mapping[str, float], problem: CalibrationProblem, ) -> None: """ Write calibrated values back onto the model. Each value is routed by the ``parameter_type`` declared for it in ``problem``: ``parameter`` values update model parameters, ``factor`` and ``noise`` values are ignored because they apply to observations rather than to the model, and the structure writes back the values of its kind (`ModelStructure.with_calibrated_values`). An aggregate structure writes ``initial_condition`` values so that the model rebuilds the initial state the engine scored for them. Parameters ---------- result : CalibrationResult | Mapping The calibration result, or a mapping of parameter id to value such as one draw of a `ParameterSample` (``sample.draws()``). problem : CalibrationProblem The problem the result came from, used to resolve parameter types. Raises ------ ValueError If an id is not declared in ``problem``, or the structure cannot write back a value of its kind. TypeError If `result` is a `ParameterSample`, which has no single set of parameters. """ values = calibrated_values(result) types = {param.id: param.parameter_type for param in problem.parameters} unknown = sorted(set(values) - set(types)) if unknown: raise ValueError( f"Cannot apply values with no declared parameter type: {unknown}. " f"Declared calibration parameters: {sorted(types)}" ) parameters: dict[str, float | None] = { param_id: value for param_id, value in values.items() if types[param_id] == CalibrationParameterType.PARAMETER } # Computed before any mutation, so a rejected value leaves the model # unchanged. structure = self.structure.with_calibrated_values(self, problem, values) if parameters: self.update_parameters(parameters) self.structure = structure def applied_parameters( self, values: CalibrationResult | Mapping[str, float], problem: CalibrationProblem | None = None, sample: "ParameterSample | None" = None, confidence_level: float | None = None, ) -> "AppliedParametersReport": """ Report every parameter of the model, and the values of its kind. Call it on the model **after** `apply_calibration_parameters`. Each entry carries the value the model holds and whether calibration set it, so entries the calibration left alone appear alongside the fitted ones. The structure of the model adds the sections of its kind (`ModelStructure.report_sections`); an aggregate model adds ``initial_amounts`` and ``category_splits``. Parameters that are not a single number, such as a formula or a series, are omitted: they are inputs rather than fitted values. ``factor`` and ``noise`` parameters have no place in the model, so they are reported from `problem` when it is given. ``initial_amounts`` gives each bin and each overridden compartment of an aggregate model its ``amount`` the engine builds and the ``unit`` of its bin, so bins of different units are reported side by side; no row is a share of a total. A bin left uncalibrated keeps a ``None`` amount as its "needs calibration" marker even when overrides touch some of its compartments. ``category_splits`` gives each category its ``fraction`` of the group it splits, and that ``group``: the categories a conditional stratification is conditioned on, empty when it splits every bin. With a `sample` each entry gains an ``interval`` holding the weighted statistics of its draws (`ParameterSample.summary`). Every entry is in the unit it was calibrated in: a parameter in its own, an amount in the unit of its bin, and a category split as a fraction of its group. Parameters ---------- values : CalibrationResult | Mapping The calibration result, or a mapping of parameter id to value such as one draw of a sample. problem : CalibrationProblem | None, optional The problem the values came from, used to report factor and noise parameters. sample : ParameterSample | None, optional Draws of the parameters, such as the sample of an uncertainty method. confidence_level : float | None, optional Confidence level of the intervals; required with a sample. Returns ------- AppliedParametersReport Key ``parameters`` and the sections of the kind (``initial_amounts`` and ``category_splits`` for an aggregate model), preceded by ``confidence_level`` when there is one. Raises ------ ValueError If a sample is given without a confidence level. """ from commol.api.reporting import _applied_parameters_report return _applied_parameters_report( self, values, problem, sample, confidence_level ) def describe(self, layout: Layout = "default") -> ModelDescription: """ A description of the model, as data. The description is a list of sections of typed blocks: tables, equations, state graphs and text. `ModelDescription.render` turns it into text, Markdown, LaTeX or JSON, and `ModelDescription.save` writes it to a file; in a notebook it shows as Markdown, with the state graph of the model drawn. Parameters ---------- layout : {"default", "odd"}, default="default" ``"default"``: overview, entities, dynamics, schedule, initialization, parameters and outputs. ``"odd"``: the ODD protocol for describing individual- and agent-based models (Grimm et al. 2006, 2010, 2020): purpose, entities, process overview and scheduling, design concepts, initialization, input data and submodels; an element commol cannot fill says "To be written by the modeller.". Returns ------- ModelDescription The description. Raises ------ ValueError If the layout is not one of these. Examples -------- >>> description = model.describe() >>> print(description.render("text")) >>> description.save("model.md") >>> model.describe(layout="odd").save("model.tex") """ from commol.context._model.describe import describe_model return describe_model(self, layout) def axis_units(self) -> AxisUnits: """ The unit of one step of the model, and its engine, to convert values. The step unit is ``step_unit`` or, when the model declares none, ``step``, the default increment of any axis. The origin is ``origin``, for the dates of the axis. The engine is the one of the model, so the rates of `AxisUnits` mean what this engine reads. Returns ------- AxisUnits The converter of the model. Examples -------- >>> units = model.axis_units() >>> units.steps(2, "week") # with step_unit="day" 14.0 >>> gamma = units.rate_from_mean_span(5, "day") """ return AxisUnits( step_unit=self.step_unit or DEFAULT_STEP_UNIT, engine=self.engine.type, origin=self.origin, ) def check_unit_consistency(self, verbose: bool = False) -> None: """ Check unit consistency of all equations in the model. The structure checks them (`ModelStructure.check_unit_consistency`). For an aggregate model, the engine converts no value between units. So the check uses one step unit, ``step_unit`` or else ``step``, and every flow must be exactly ``/``, such as ``item/day``. The unit is the same for every engine; what the value of a flow means is the engine's. Parameters ---------- verbose : bool, default=False If True, prints a success message when all units are consistent. Raises ------ UnitConsistencyError If a constant parameter or a bin has no unit, if ``step_unit`` is not one unit of one dimension, or if a flow does not have the unit ``/``. Without ``step_unit``, the message says to declare it. ValueError If the model type doesn't support unit checking. NotImplementedError If the kind of the model has no unit check. Notes ----- - Predefined variables (total, total_g1, etc.) have the bin unit - ``t``, a position on the axis, has the step unit; a number added to, subtracted from or compared with it is read in that unit - ``step``, a count of increments, is dimensionless - Mathematical constants (pi, e) are dimensionless """ self.structure.check_unit_consistency(self, verbose) ``` ### Methods: #### check_origin_unit ```python check_origin_unit() -> Self ``` Check that an origin has a step unit of fixed calendar length. Source code in `commol/context/model.py` ```python @model_validator(mode="after") def check_origin_unit(self) -> Self: """Check that an origin has a step unit of fixed calendar length.""" if self.origin is not None: seconds_per_step(self.step_unit or DEFAULT_STEP_UNIT) return self ``` #### read_structure_kind ```python read_structure_kind(value: JsonValue) -> JsonValue | ModelStructure ``` Read a structure mapping as the class of its `kind` and `variant`. Source code in `commol/context/model.py` ```python @field_validator("structure", mode="before") @classmethod def read_structure_kind(cls, value: JsonValue) -> JsonValue | ModelStructure: """Read a structure mapping as the class of its ``kind`` and ``variant``.""" if not isinstance(value, Mapping): return value kind = value.get("kind") variant = value.get("variant") if not isinstance(kind, str): raise ValueError( "A model structure needs a 'kind'. Known kinds: " f"{sorted({known for known, _ in ModelStructure._kinds})}" ) if variant is not None and not isinstance(variant, str): raise ValueError(f"The variant of a structure is a name, got {variant!r}") return ModelStructure.of_kind(kind, variant).model_validate(value) ``` #### from_json ```python from_json(file_path: str | Path) -> Self ``` Loads a model from a JSON file. The method reads the specified JSON file as UTF-8, the encoding of JSON, parses its content, and validates it against the Model schema. Parameters: | Name | Type | Description | Default | | ----------- | ----- | ----------- | -------------------------- | | `file_path` | \`str | Path\` | The path to the JSON file. | Returns: | Type | Description | | ------- | --------------------------- | | `Model` | A validated Model instance. | Raises: | Type | Description | | ------------------- | --------------------------------------------------------- | | `FileNotFoundError` | If the file at file_path does not exist. | | `ValidationError` | If the JSON content does not conform to the Model schema. | Source code in `commol/context/model.py` ```python @classmethod def from_json(cls, file_path: str | Path) -> Self: """ Loads a model from a JSON file. The method reads the specified JSON file as UTF-8, the encoding of JSON, parses its content, and validates it against the Model schema. Parameters ---------- file_path : str | Path The path to the JSON file. Returns ------- Model A validated Model instance. Raises ------ FileNotFoundError If the file at `file_path` does not exist. pydantic.ValidationError If the JSON content does not conform to the Model schema. """ return cls.model_validate_json(Path(file_path).read_text(encoding="utf-8")) ``` #### to_json ```python to_json(file_path: str | Path, indent: int = 2) -> None ``` Save the model to a JSON file. Every field is written, including those left unset, so the file always reloads through :meth:`from_json` to an equal model. Long numeric arrays, such as a series parameter, are written on a single line. Parameters: | Name | Type | Description | Default | | ----------- | ----- | ---------------------------------------- | -------------------------- | | `file_path` | \`str | Path\` | Path of the file to write. | | `indent` | `int` | Indentation width for nested structures. | `2` | Source code in `commol/context/model.py` ```python def to_json(self, file_path: str | Path, indent: int = 2) -> None: """ Save the model to a JSON file. Every field is written, including those left unset, so the file always reloads through :meth:`from_json` to an equal model. Long numeric arrays, such as a series parameter, are written on a single line. Parameters ---------- file_path : str | Path Path of the file to write. indent : int, optional Indentation width for nested structures. """ payload = self.model_dump(mode="json") Path(file_path).write_text( render_json(payload, indent=indent), encoding="utf-8" ) ``` #### validate_engine_runs_structure ```python validate_engine_runs_structure() -> Self ``` Ask the engine whether it runs the kind of the structure. Source code in `commol/context/model.py` ```python @model_validator(mode="after") def validate_engine_runs_structure(self) -> Self: """Ask the engine whether it runs the kind of the structure.""" commol_rs.engine.EngineType(self.engine.type).check_kind(self.structure.kind) return self ``` #### validate_unique_parameter_ids ```python validate_unique_parameter_ids() -> Self ``` Validates that parameter IDs are unique. Source code in `commol/context/model.py` ```python @model_validator(mode="after") def validate_unique_parameter_ids(self) -> Self: """ Validates that parameter IDs are unique. """ parameter_ids = [p.id for p in self.parameters] if len(parameter_ids) != len(set(parameter_ids)): duplicates = [ item for item in set(parameter_ids) if parameter_ids.count(item) > 1 ] raise ValueError(f"Duplicate parameter IDs found: {duplicates}") return self ``` #### validate_structure ```python validate_structure() -> Self ``` Check the structure against the parameters and the engine. Source code in `commol/context/model.py` ```python @model_validator(mode="after") def validate_structure(self) -> Self: """Check the structure against the parameters and the engine.""" self.structure.check_model(self) return self ``` #### update_parameters ```python update_parameters(parameter_values: Mapping[str, ParameterUpdate]) -> None ``` Update parameter values in the model. The model takes every value or none: the values pass the checks of `Parameter` and of the model, as when the model is made, before any is written. Parameters: | Name | Type | Description | Default | | ------------------ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `parameter_values` | `Mapping[str, ParameterUpdate]` | Each parameter id mapped to its new value: a number, a formula, a SeriesValue, or None to leave it for calibration. A list of (position, value) points replaces the points of a series parameter and keeps its mode; a SeriesValue sets the points and the mode. | *required* | Raises: | Type | Description | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If an id is not a parameter of the model, a list is given for a parameter that is not a series, a value is not valid (the message names the parameter), or the model rejects the new values. | Source code in `commol/context/model.py` ```python def update_parameters( self, parameter_values: Mapping[str, ParameterUpdate] ) -> None: """ Update parameter values in the model. The model takes every value or none: the values pass the checks of `Parameter` and of the model, as when the model is made, before any is written. Parameters ---------- parameter_values : Mapping[str, ParameterUpdate] Each parameter id mapped to its new value: a number, a formula, a `SeriesValue`, or None to leave it for calibration. A list of (position, value) points replaces the points of a series parameter and keeps its mode; a `SeriesValue` sets the points and the mode. Raises ------ ValueError If an id is not a parameter of the model, a list is given for a parameter that is not a series, a value is not valid (the message names the parameter), or the model rejects the new values. """ current = {param.id: param for param in self.parameters} updated = dict(current) for param_id, value in parameter_values.items(): if param_id not in current: raise ValueError( ( f"Parameter '{param_id}' not found in model. " f"Available parameters: {', '.join(current.keys())}" ) ) updated[param_id] = current[param_id].with_value(value) parameters = list(updated.values()) self.structure.check_model(self.model_copy(update={"parameters": parameters})) self.parameters = parameters ``` #### get_uncalibrated_parameters ```python get_uncalibrated_parameters() -> list[str] ``` Get a list of parameter IDs that have None values (need calibration). Returns: | Type | Description | | ----------- | ----------------------------------------------- | | `list[str]` | List of parameter IDs that require calibration. | Source code in `commol/context/model.py` ```python def get_uncalibrated_parameters(self) -> list[str]: """ Get a list of parameter IDs that have None values (need calibration). Returns ------- list[str] List of parameter IDs that require calibration. """ return [param.id for param in self.parameters if param.value is None] ``` #### apply_calibration_parameters ```python apply_calibration_parameters(result: CalibrationResult | Mapping[str, float], problem: CalibrationProblem) -> None ``` Write calibrated values back onto the model. Each value is routed by the `parameter_type` declared for it in `problem`: `parameter` values update model parameters, `factor` and `noise` values are ignored because they apply to observations rather than to the model, and the structure writes back the values of its kind (`ModelStructure.with_calibrated_values`). An aggregate structure writes `initial_condition` values so that the model rebuilds the initial state the engine scored for them. Parameters: | Name | Type | Description | Default | | --------- | -------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `result` | \`CalibrationResult | Mapping\` | The calibration result, or a mapping of parameter id to value such as one draw of a ParameterSample (sample.draws()). | | `problem` | `CalibrationProblem` | The problem the result came from, used to resolve parameter types. | *required* | Raises: | Type | Description | | ------------ | -------------------------------------------------------------------------------------------- | | `ValueError` | If an id is not declared in problem, or the structure cannot write back a value of its kind. | | `TypeError` | If result is a ParameterSample, which has no single set of parameters. | Source code in `commol/context/model.py` ```python def apply_calibration_parameters( self, result: CalibrationResult | Mapping[str, float], problem: CalibrationProblem, ) -> None: """ Write calibrated values back onto the model. Each value is routed by the ``parameter_type`` declared for it in ``problem``: ``parameter`` values update model parameters, ``factor`` and ``noise`` values are ignored because they apply to observations rather than to the model, and the structure writes back the values of its kind (`ModelStructure.with_calibrated_values`). An aggregate structure writes ``initial_condition`` values so that the model rebuilds the initial state the engine scored for them. Parameters ---------- result : CalibrationResult | Mapping The calibration result, or a mapping of parameter id to value such as one draw of a `ParameterSample` (``sample.draws()``). problem : CalibrationProblem The problem the result came from, used to resolve parameter types. Raises ------ ValueError If an id is not declared in ``problem``, or the structure cannot write back a value of its kind. TypeError If `result` is a `ParameterSample`, which has no single set of parameters. """ values = calibrated_values(result) types = {param.id: param.parameter_type for param in problem.parameters} unknown = sorted(set(values) - set(types)) if unknown: raise ValueError( f"Cannot apply values with no declared parameter type: {unknown}. " f"Declared calibration parameters: {sorted(types)}" ) parameters: dict[str, float | None] = { param_id: value for param_id, value in values.items() if types[param_id] == CalibrationParameterType.PARAMETER } # Computed before any mutation, so a rejected value leaves the model # unchanged. structure = self.structure.with_calibrated_values(self, problem, values) if parameters: self.update_parameters(parameters) self.structure = structure ``` #### applied_parameters ```python applied_parameters(values: CalibrationResult | Mapping[str, float], problem: CalibrationProblem | None = None, sample: ParameterSample | None = None, confidence_level: float | None = None) -> AppliedParametersReport ``` Report every parameter of the model, and the values of its kind. Call it on the model **after** `apply_calibration_parameters`. Each entry carries the value the model holds and whether calibration set it, so entries the calibration left alone appear alongside the fitted ones. The structure of the model adds the sections of its kind (`ModelStructure.report_sections`); an aggregate model adds `initial_amounts` and `category_splits`. Parameters that are not a single number, such as a formula or a series, are omitted: they are inputs rather than fitted values. `factor` and `noise` parameters have no place in the model, so they are reported from `problem` when it is given. `initial_amounts` gives each bin and each overridden compartment of an aggregate model its `amount` the engine builds and the `unit` of its bin, so bins of different units are reported side by side; no row is a share of a total. A bin left uncalibrated keeps a `None` amount as its "needs calibration" marker even when overrides touch some of its compartments. `category_splits` gives each category its `fraction` of the group it splits, and that `group`: the categories a conditional stratification is conditioned on, empty when it splits every bin. With a `sample` each entry gains an `interval` holding the weighted statistics of its draws (`ParameterSample.summary`). Every entry is in the unit it was calibrated in: a parameter in its own, an amount in the unit of its bin, and a category split as a fraction of its group. Parameters: | Name | Type | Description | Default | | ------------------ | -------------------- | ----------- | ------------------------------------------------------------------------------------------- | | `values` | \`CalibrationResult | Mapping\` | The calibration result, or a mapping of parameter id to value such as one draw of a sample. | | `problem` | \`CalibrationProblem | None\` | The problem the values came from, used to report factor and noise parameters. | | `sample` | \`ParameterSample | None\` | Draws of the parameters, such as the sample of an uncertainty method. | | `confidence_level` | \`float | None\` | Confidence level of the intervals; required with a sample. | Returns: | Type | Description | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AppliedParametersReport` | Key parameters and the sections of the kind (initial_amounts and category_splits for an aggregate model), preceded by confidence_level when there is one. | Raises: | Type | Description | | ------------ | ------------------------------------------------ | | `ValueError` | If a sample is given without a confidence level. | Source code in `commol/context/model.py` ```python def applied_parameters( self, values: CalibrationResult | Mapping[str, float], problem: CalibrationProblem | None = None, sample: "ParameterSample | None" = None, confidence_level: float | None = None, ) -> "AppliedParametersReport": """ Report every parameter of the model, and the values of its kind. Call it on the model **after** `apply_calibration_parameters`. Each entry carries the value the model holds and whether calibration set it, so entries the calibration left alone appear alongside the fitted ones. The structure of the model adds the sections of its kind (`ModelStructure.report_sections`); an aggregate model adds ``initial_amounts`` and ``category_splits``. Parameters that are not a single number, such as a formula or a series, are omitted: they are inputs rather than fitted values. ``factor`` and ``noise`` parameters have no place in the model, so they are reported from `problem` when it is given. ``initial_amounts`` gives each bin and each overridden compartment of an aggregate model its ``amount`` the engine builds and the ``unit`` of its bin, so bins of different units are reported side by side; no row is a share of a total. A bin left uncalibrated keeps a ``None`` amount as its "needs calibration" marker even when overrides touch some of its compartments. ``category_splits`` gives each category its ``fraction`` of the group it splits, and that ``group``: the categories a conditional stratification is conditioned on, empty when it splits every bin. With a `sample` each entry gains an ``interval`` holding the weighted statistics of its draws (`ParameterSample.summary`). Every entry is in the unit it was calibrated in: a parameter in its own, an amount in the unit of its bin, and a category split as a fraction of its group. Parameters ---------- values : CalibrationResult | Mapping The calibration result, or a mapping of parameter id to value such as one draw of a sample. problem : CalibrationProblem | None, optional The problem the values came from, used to report factor and noise parameters. sample : ParameterSample | None, optional Draws of the parameters, such as the sample of an uncertainty method. confidence_level : float | None, optional Confidence level of the intervals; required with a sample. Returns ------- AppliedParametersReport Key ``parameters`` and the sections of the kind (``initial_amounts`` and ``category_splits`` for an aggregate model), preceded by ``confidence_level`` when there is one. Raises ------ ValueError If a sample is given without a confidence level. """ from commol.api.reporting import _applied_parameters_report return _applied_parameters_report( self, values, problem, sample, confidence_level ) ``` #### describe ```python describe(layout: Layout = 'default') -> ModelDescription ``` A description of the model, as data. The description is a list of sections of typed blocks: tables, equations, state graphs and text. `ModelDescription.render` turns it into text, Markdown, LaTeX or JSON, and `ModelDescription.save` writes it to a file; in a notebook it shows as Markdown, with the state graph of the model drawn. Parameters: | Name | Type | Description | Default | | -------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | `layout` | `(default, odd)` | "default": overview, entities, dynamics, schedule, initialization, parameters and outputs. "odd": the ODD protocol for describing individual- and agent-based models (Grimm et al. 2006, 2010, 2020): purpose, entities, process overview and scheduling, design concepts, initialization, input data and submodels; an element commol cannot fill says "To be written by the modeller.". | `"default"` | Returns: | Type | Description | | ------------------ | ---------------- | | `ModelDescription` | The description. | Raises: | Type | Description | | ------------ | ---------------------------------- | | `ValueError` | If the layout is not one of these. | Examples: ```pycon >>> description = model.describe() >>> print(description.render("text")) >>> description.save("model.md") >>> model.describe(layout="odd").save("model.tex") ``` Source code in `commol/context/model.py` ```python def describe(self, layout: Layout = "default") -> ModelDescription: """ A description of the model, as data. The description is a list of sections of typed blocks: tables, equations, state graphs and text. `ModelDescription.render` turns it into text, Markdown, LaTeX or JSON, and `ModelDescription.save` writes it to a file; in a notebook it shows as Markdown, with the state graph of the model drawn. Parameters ---------- layout : {"default", "odd"}, default="default" ``"default"``: overview, entities, dynamics, schedule, initialization, parameters and outputs. ``"odd"``: the ODD protocol for describing individual- and agent-based models (Grimm et al. 2006, 2010, 2020): purpose, entities, process overview and scheduling, design concepts, initialization, input data and submodels; an element commol cannot fill says "To be written by the modeller.". Returns ------- ModelDescription The description. Raises ------ ValueError If the layout is not one of these. Examples -------- >>> description = model.describe() >>> print(description.render("text")) >>> description.save("model.md") >>> model.describe(layout="odd").save("model.tex") """ from commol.context._model.describe import describe_model return describe_model(self, layout) ``` #### axis_units ```python axis_units() -> AxisUnits ``` The unit of one step of the model, and its engine, to convert values. The step unit is `step_unit` or, when the model declares none, `step`, the default increment of any axis. The origin is `origin`, for the dates of the axis. The engine is the one of the model, so the rates of `AxisUnits` mean what this engine reads. Returns: | Type | Description | | ----------- | --------------------------- | | `AxisUnits` | The converter of the model. | Examples: ```pycon >>> units = model.axis_units() >>> units.steps(2, "week") # with step_unit="day" 14.0 >>> gamma = units.rate_from_mean_span(5, "day") ``` Source code in `commol/context/model.py` ```python def axis_units(self) -> AxisUnits: """ The unit of one step of the model, and its engine, to convert values. The step unit is ``step_unit`` or, when the model declares none, ``step``, the default increment of any axis. The origin is ``origin``, for the dates of the axis. The engine is the one of the model, so the rates of `AxisUnits` mean what this engine reads. Returns ------- AxisUnits The converter of the model. Examples -------- >>> units = model.axis_units() >>> units.steps(2, "week") # with step_unit="day" 14.0 >>> gamma = units.rate_from_mean_span(5, "day") """ return AxisUnits( step_unit=self.step_unit or DEFAULT_STEP_UNIT, engine=self.engine.type, origin=self.origin, ) ``` #### check_unit_consistency ```python check_unit_consistency(verbose: bool = False) -> None ``` Check unit consistency of all equations in the model. The structure checks them (`ModelStructure.check_unit_consistency`). For an aggregate model, the engine converts no value between units. So the check uses one step unit, `step_unit` or else `step`, and every flow must be exactly `/`, such as `item/day`. The unit is the same for every engine; what the value of a flow means is the engine's. Parameters: | Name | Type | Description | Default | | --------- | ------ | ---------------------------------------------------------------- | ------- | | `verbose` | `bool` | If True, prints a success message when all units are consistent. | `False` | Raises: | Type | Description | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `UnitConsistencyError` | If a constant parameter or a bin has no unit, if step_unit is not one unit of one dimension, or if a flow does not have the unit /. Without step_unit, the message says to declare it. | | `ValueError` | If the model type doesn't support unit checking. | | `NotImplementedError` | If the kind of the model has no unit check. | Notes - Predefined variables (total, total_g1, etc.) have the bin unit - `t`, a position on the axis, has the step unit; a number added to, subtracted from or compared with it is read in that unit - `step`, a count of increments, is dimensionless - Mathematical constants (pi, e) are dimensionless Source code in `commol/context/model.py` ```python def check_unit_consistency(self, verbose: bool = False) -> None: """ Check unit consistency of all equations in the model. The structure checks them (`ModelStructure.check_unit_consistency`). For an aggregate model, the engine converts no value between units. So the check uses one step unit, ``step_unit`` or else ``step``, and every flow must be exactly ``/``, such as ``item/day``. The unit is the same for every engine; what the value of a flow means is the engine's. Parameters ---------- verbose : bool, default=False If True, prints a success message when all units are consistent. Raises ------ UnitConsistencyError If a constant parameter or a bin has no unit, if ``step_unit`` is not one unit of one dimension, or if a flow does not have the unit ``/``. Without ``step_unit``, the message says to declare it. ValueError If the model type doesn't support unit checking. NotImplementedError If the kind of the model has no unit check. Notes ----- - Predefined variables (total, total_g1, etc.) have the bin unit - ``t``, a position on the axis, has the step unit; a number added to, subtracted from or compared with it is read in that unit - ``step``, a count of increments, is dimensionless - Mathematical constants (pi, e) are dimensionless """ self.structure.check_unit_consistency(self, verbose) ``` ## Process Bases: `BaseModel` What changes in a model and how, written once for every structure. Attributes: | Name | Type | Description | | ----------------- | ---------------------- | ----------------------------------------------------------------------------- | | `bins` | `list[Bin]` | The states of the process. Stratifications expand each one into compartments. | | `accumulators` | `list[Accumulator]` | Counters of the flow of the transitions that name them. | | `stratifications` | `list[Stratification]` | Categorical subdivisions of the bins, in expansion order. | | `transitions` | `list[Transition]` | Flows between bins, or between the categories of one stratification. | Source code in `commol/context/process.py` ```python class Process(BaseModel): """ What changes in a model and how, written once for every structure. Attributes ---------- bins : list[Bin] The states of the process. Stratifications expand each one into compartments. accumulators : list[Accumulator] Counters of the flow of the transitions that name them. stratifications : list[Stratification] Categorical subdivisions of the bins, in expansion order. transitions : list[Transition] Flows between bins, or between the categories of one stratification. """ model_config = ConfigDict(extra="forbid") bins: list[Bin] = Field(default_factory=list) accumulators: list[Accumulator] = Field(default_factory=list) stratifications: list[Stratification] = Field(default_factory=list) transitions: list[Transition] = Field(default_factory=list) @model_validator(mode="after") def validate_unique_ids(self) -> Self: """Validate that bin, stratification and transition ids are unique.""" for label, ids in ( ("bin", [item.id for item in self.bins]), ("stratification", [item.id for item in self.stratifications]), ("transition", [item.id for item in self.transitions]), ): if len(ids) != len(set(ids)): duplicates = [item for item in set(ids) if ids.count(item) > 1] raise ValueError(f"Duplicate {label} IDs found: {duplicates}") return self @model_validator(mode="after") def validate_stratified_rates(self) -> Self: """Validate that stratified rates name stratifications and categories.""" strat_map = {strat.id: strat for strat in self.stratifications} for transition in self.transitions: for idx, stratified_rate in enumerate(transition.stratified_rates or []): for condition in stratified_rate.conditions: if condition.stratification not in strat_map: raise ValueError( f"In transition '{transition.id}', stratified rate " f"{idx}: Stratification " f"'{condition.stratification}' not found. " f"Available: {list(strat_map.keys())}" ) # A condition without a category only redirects the target. strat = strat_map[condition.stratification] if ( condition.category is not None and condition.category not in strat.categories ): raise ValueError( f"In transition '{transition.id}', stratified rate " f"{idx}: Category '{condition.category}' not found " f"in stratification '{condition.stratification}'. " f"Available: {strat.categories}" ) return self @model_validator(mode="after") def validate_transition_ids(self) -> Self: """ Check that each transition moves between bins, or between the categories of one stratification. """ bin_ids = {bin_item.id for bin_item in self.bins} category_to_stratification = { cat: strat.id for strat in self.stratifications for cat in strat.categories } categories_ids = set(category_to_stratification) bin_and_categories_ids = bin_ids | categories_ids for transition in self.transitions: transition_ids = set(transition.source) | set(transition.target) if not transition_ids.issubset(bin_and_categories_ids): invalid_ids = transition_ids - bin_and_categories_ids raise ValueError( f"Transition '{transition.id}' contains invalid ids: " f"{invalid_ids}. Ids must be defined in Bin ids " f"or Stratification Categories." ) is_bin_flow = transition_ids.issubset(bin_ids) is_stratification_flow = transition_ids.issubset(categories_ids) if not is_bin_flow and not is_stratification_flow: raise ValueError( f"Transition '{transition.id}' mixes id types. " f"Found Bin ids ({transition_ids & bin_ids}) and " f"Stratification Categories ids " f"({transition_ids & categories_ids}). " "Transitions must be purely Bin flow or purely " f"Stratification flow." ) if is_stratification_flow: parent_stratification_ids = { category_to_stratification[cat_id] for cat_id in transition_ids } if len(parent_stratification_ids) > 1: raise ValueError( f"Transition '{transition.id}' is a Stratification flow " f"but involves categories from multiple stratifications: " f"{', '.join(sorted(parent_stratification_ids))}. A single " f"transition must only move between categories belonging " f"to the same parent stratification." ) return self def is_empty(self) -> bool: """ Whether the process holds nothing. Returns ------- bool True when it has no bin, accumulator, stratification or transition. """ return not ( self.bins or self.accumulators or self.stratifications or self.transitions ) def bin(self, bin_id: str) -> Bin | None: """ The bin with an id. Parameters ---------- bin_id : str Id of a bin. Returns ------- Bin | None The bin, or None when the process has no bin with that id. """ return next((item for item in self.bins if item.id == bin_id), None) ``` ### Methods: #### validate_unique_ids ```python validate_unique_ids() -> Self ``` Validate that bin, stratification and transition ids are unique. Source code in `commol/context/process.py` ```python @model_validator(mode="after") def validate_unique_ids(self) -> Self: """Validate that bin, stratification and transition ids are unique.""" for label, ids in ( ("bin", [item.id for item in self.bins]), ("stratification", [item.id for item in self.stratifications]), ("transition", [item.id for item in self.transitions]), ): if len(ids) != len(set(ids)): duplicates = [item for item in set(ids) if ids.count(item) > 1] raise ValueError(f"Duplicate {label} IDs found: {duplicates}") return self ``` #### validate_stratified_rates ```python validate_stratified_rates() -> Self ``` Validate that stratified rates name stratifications and categories. Source code in `commol/context/process.py` ```python @model_validator(mode="after") def validate_stratified_rates(self) -> Self: """Validate that stratified rates name stratifications and categories.""" strat_map = {strat.id: strat for strat in self.stratifications} for transition in self.transitions: for idx, stratified_rate in enumerate(transition.stratified_rates or []): for condition in stratified_rate.conditions: if condition.stratification not in strat_map: raise ValueError( f"In transition '{transition.id}', stratified rate " f"{idx}: Stratification " f"'{condition.stratification}' not found. " f"Available: {list(strat_map.keys())}" ) # A condition without a category only redirects the target. strat = strat_map[condition.stratification] if ( condition.category is not None and condition.category not in strat.categories ): raise ValueError( f"In transition '{transition.id}', stratified rate " f"{idx}: Category '{condition.category}' not found " f"in stratification '{condition.stratification}'. " f"Available: {strat.categories}" ) return self ``` #### validate_transition_ids ```python validate_transition_ids() -> Self ``` Check that each transition moves between bins, or between the categories of one stratification. Source code in `commol/context/process.py` ```python @model_validator(mode="after") def validate_transition_ids(self) -> Self: """ Check that each transition moves between bins, or between the categories of one stratification. """ bin_ids = {bin_item.id for bin_item in self.bins} category_to_stratification = { cat: strat.id for strat in self.stratifications for cat in strat.categories } categories_ids = set(category_to_stratification) bin_and_categories_ids = bin_ids | categories_ids for transition in self.transitions: transition_ids = set(transition.source) | set(transition.target) if not transition_ids.issubset(bin_and_categories_ids): invalid_ids = transition_ids - bin_and_categories_ids raise ValueError( f"Transition '{transition.id}' contains invalid ids: " f"{invalid_ids}. Ids must be defined in Bin ids " f"or Stratification Categories." ) is_bin_flow = transition_ids.issubset(bin_ids) is_stratification_flow = transition_ids.issubset(categories_ids) if not is_bin_flow and not is_stratification_flow: raise ValueError( f"Transition '{transition.id}' mixes id types. " f"Found Bin ids ({transition_ids & bin_ids}) and " f"Stratification Categories ids " f"({transition_ids & categories_ids}). " "Transitions must be purely Bin flow or purely " f"Stratification flow." ) if is_stratification_flow: parent_stratification_ids = { category_to_stratification[cat_id] for cat_id in transition_ids } if len(parent_stratification_ids) > 1: raise ValueError( f"Transition '{transition.id}' is a Stratification flow " f"but involves categories from multiple stratifications: " f"{', '.join(sorted(parent_stratification_ids))}. A single " f"transition must only move between categories belonging " f"to the same parent stratification." ) return self ``` #### is_empty ```python is_empty() -> bool ``` Whether the process holds nothing. Returns: | Type | Description | | ------ | ------------------------------------------------------------------- | | `bool` | True when it has no bin, accumulator, stratification or transition. | Source code in `commol/context/process.py` ```python def is_empty(self) -> bool: """ Whether the process holds nothing. Returns ------- bool True when it has no bin, accumulator, stratification or transition. """ return not ( self.bins or self.accumulators or self.stratifications or self.transitions ) ``` #### bin ```python bin(bin_id: str) -> Bin | None ``` The bin with an id. Parameters: | Name | Type | Description | Default | | -------- | ----- | ------------ | ---------- | | `bin_id` | `str` | Id of a bin. | *required* | Returns: | Type | Description | | ----- | ----------- | | \`Bin | None\` | Source code in `commol/context/process.py` ```python def bin(self, bin_id: str) -> Bin | None: """ The bin with an id. Parameters ---------- bin_id : str Id of a bin. Returns ------- Bin | None The bin, or None when the process has no bin with that id. """ return next((item for item in self.bins if item.id == bin_id), None) ``` ## ModelStructure Bases: `BaseModel`, `ABC` How the process of a model is laid out: the part that depends on its kind. Each kind subclasses it and sets `kind` to a `Literal` default, its name in the engine's list of kinds. A variant subclasses the structure of its kind and sets `variant` to a `Literal` default. A subclass that does not implement an abstract method cannot be made: the error names the method. Attributes: | Name | Type | Description | | --------- | ----- | --------------------------------------------------------- | | `kind` | `str` | Name of the model kind, the tag of the structure in JSON. | | `variant` | \`str | None\` | Source code in `commol/context/structure.py` ```python class ModelStructure(BaseModel, ABC): """ How the process of a model is laid out: the part that depends on its kind. Each kind subclasses it and sets ``kind`` to a ``Literal`` default, its name in the engine's list of kinds. A variant subclasses the structure of its kind and sets ``variant`` to a ``Literal`` default. A subclass that does not implement an abstract method cannot be made: the error names the method. Attributes ---------- kind : str Name of the model kind, the tag of the structure in JSON. variant : str | None Name of the variant, or None for the structure of the kind itself. """ model_config = ConfigDict(extra="forbid") kind: str variant: str | None = None _kinds: ClassVar[dict[tuple[str, str | None], type["ModelStructure"]]] = {} @classmethod def __pydantic_init_subclass__(cls, **kwargs: object) -> None: """Register a concrete subclass under its ``kind`` and ``variant``.""" super().__pydantic_init_subclass__(**kwargs) kind = cls.model_fields["kind"].default variant = cls.model_fields["variant"].default if inspect.isabstract(cls) or not isinstance(kind, str): return key = (kind, variant) known = ModelStructure._kinds.get(key) if known is not None and known.__qualname__ != cls.__qualname__: raise TypeError( f"Model kind '{kind}' with variant {variant!r} is already the " f"structure of {known.__qualname__}" ) if variant is not None and (kind, None) not in ModelStructure._kinds: raise TypeError( f"The variant {variant!r} of {cls.__qualname__} needs the " f"structure of its kind '{kind}' first" ) ModelStructure._kinds[key] = cls @classmethod def of_kind(cls, kind: str, variant: str | None = None) -> type["ModelStructure"]: """ The structure class of a model kind, or of one of its variants. Parameters ---------- kind : str Name of the kind, such as ``"aggregate"``. variant : str | None, default=None Name of a variant of the kind, or None for the kind itself. Returns ------- type[ModelStructure] The subclass registered for them. Raises ------ ValueError If no structure class has that kind and variant. """ structure = cls._kinds.get((kind, variant)) if structure is not None: return structure kinds = sorted({known for known, _ in cls._kinds}) if kind not in kinds: raise ValueError(f"Unknown model kind '{kind}'. Known kinds: {kinds}") variants = sorted( known for known_kind, known in cls._kinds if known_kind == kind and known ) raise ValueError( f"Unknown variant {variant!r} of the {kind} kind. Known variants: " f"{variants}" ) @abstractmethod def check_model(self, model: "Model[Self]") -> None: """ Check the structure against the rest of its model. The model calls it when it is made, so a model that cannot be run as written is rejected then. Parameters ---------- model : Model The model that holds this structure. Raises ------ ValueError If the structure does not agree with the parameters or the engine. """ @abstractmethod def outputs_by_source(self, model: "Model[Self]") -> dict[str, list[str]]: """ The output names of the model, grouped by the id they come from. Reports and plots name a group of outputs by this id and sum them. Parameters ---------- model : Model The model that holds this structure. Returns ------- dict[str, list[str]] Each id mapped to its output names, in engine order. """ def series_label( self, model: "Model[Self]", # noqa: ARG002 series_id: str, ) -> SeriesLabel: """ How a plot names a series and its unit. Parameters ---------- model : Model The model that holds this structure. series_id : str An output name or an id of `outputs_by_source`. Returns ------- SeriesLabel The id itself and no unit, unless the kind knows better. """ return SeriesLabel(series_id, None) def calibration_value_types(self) -> frozenset[CalibrationParameterType]: """ Types of value of this kind that calibration may set. ``parameter`` and ``factor`` are not here: every kind accepts them. The engine's list of kinds declares the others. Returns ------- frozenset[CalibrationParameterType] For example ``{"initial_condition"}`` for an aggregate model. """ return frozenset( CalibrationParameterType(name) for name in commol_rs.engine.ModelKind(self.kind).value_types() ) def uncalibrated_values(self) -> list[str]: """ Ids of the values of this kind that need calibration before a run. Returns ------- list[str] Empty unless the kind has values left unset. """ return [] def check_calibration_values( self, model: "Model[Self]", # noqa: ARG002 engine: "EngineProtocol", # noqa: ARG002 parameters: Sequence["CalibrationParameter"], # noqa: ARG002 remainder: str | None, # noqa: ARG002 ) -> None: """ Check the values of this kind that a calibration or a sensitivity analysis sets. The engine has resolved each value and the remainder when this runs; a kind checks here what only the Python definition knows, such as the units of the bins. The default checks nothing. Parameters ---------- model : Model The model the values are set on. engine : EngineProtocol The engine built from it. parameters : Sequence[CalibrationParameter] The values set, with their types and bounds. remainder : str | None The id that takes what the initial conditions leave of the sum the kind keeps, if any. """ def with_calibrated_values( self, model: "Model[Self]", # noqa: ARG002 problem: "CalibrationProblem", # noqa: ARG002 values: Mapping[str, float], # noqa: ARG002 ) -> Self: """ The structure with the calibrated values of its kind written back. Parameters ---------- model : Model The model the problem was built on. It is not changed. problem : CalibrationProblem The problem, for the types of its parameters. values : Mapping[str, float] Calibrated value per parameter id. Returns ------- Self A structure; this one when the kind sets no values of its own. """ return self def report_sections( self, model: "Model[Self]", # noqa: ARG002 calibrated_ids: set[str], # noqa: ARG002 parameter_statistics: "Mapping[str, ParameterSummary] | None", # noqa: ARG002 ) -> ReportSections: """ Sections of this kind in a report of applied values. Parameters ---------- model : Model The model that holds the applied values. calibrated_ids : set[str] Ids that calibration set. parameter_statistics : Mapping[str, ParameterSummary] | None Statistics of each id from a sample, or None without one. Returns ------- ReportSections Each section by its key in the report; none by default. """ return {} def describe( self, model: "Model[Self]", # noqa: ARG002 ) -> "list[Section]": """ The sections of a description that this kind fills. `Model.describe` adds them to the sections every kind shares (overview, schedule, parameters, outputs). A kind fills the sections it knows, by key: ``entities`` (what holds the state), ``dynamics`` (how it changes), ``initialization`` (the state it starts from) and ``design_concepts`` (a `Table` of elements of the ODD protocol and their text). A section with no blocks is left out of a rendering. Parameters ---------- model : Model The model that holds this structure. Returns ------- list[Section] The sections of the kind; none by default. """ return [] def check_unit_consistency(self, model: "Model[Self]", verbose: bool) -> None: """ Check the units of the model. Raises ------ NotImplementedError If the kind has no unit check. """ raise NotImplementedError(f"A {self.kind} model has no unit check") ``` ### Methods: #### __pydantic_init_subclass__ ```python __pydantic_init_subclass__(**kwargs: object) -> None ``` Register a concrete subclass under its `kind` and `variant`. Source code in `commol/context/structure.py` ```python @classmethod def __pydantic_init_subclass__(cls, **kwargs: object) -> None: """Register a concrete subclass under its ``kind`` and ``variant``.""" super().__pydantic_init_subclass__(**kwargs) kind = cls.model_fields["kind"].default variant = cls.model_fields["variant"].default if inspect.isabstract(cls) or not isinstance(kind, str): return key = (kind, variant) known = ModelStructure._kinds.get(key) if known is not None and known.__qualname__ != cls.__qualname__: raise TypeError( f"Model kind '{kind}' with variant {variant!r} is already the " f"structure of {known.__qualname__}" ) if variant is not None and (kind, None) not in ModelStructure._kinds: raise TypeError( f"The variant {variant!r} of {cls.__qualname__} needs the " f"structure of its kind '{kind}' first" ) ModelStructure._kinds[key] = cls ``` #### of_kind ```python of_kind(kind: str, variant: str | None = None) -> type[ModelStructure] ``` The structure class of a model kind, or of one of its variants. Parameters: | Name | Type | Description | Default | | --------- | ----- | -------------------------------------- | ----------------------------------------------------------- | | `kind` | `str` | Name of the kind, such as "aggregate". | *required* | | `variant` | \`str | None\` | Name of a variant of the kind, or None for the kind itself. | Returns: | Type | Description | | ---------------------- | --------------------------------- | | `type[ModelStructure]` | The subclass registered for them. | Raises: | Type | Description | | ------------ | ------------------------------------------------ | | `ValueError` | If no structure class has that kind and variant. | Source code in `commol/context/structure.py` ```python @classmethod def of_kind(cls, kind: str, variant: str | None = None) -> type["ModelStructure"]: """ The structure class of a model kind, or of one of its variants. Parameters ---------- kind : str Name of the kind, such as ``"aggregate"``. variant : str | None, default=None Name of a variant of the kind, or None for the kind itself. Returns ------- type[ModelStructure] The subclass registered for them. Raises ------ ValueError If no structure class has that kind and variant. """ structure = cls._kinds.get((kind, variant)) if structure is not None: return structure kinds = sorted({known for known, _ in cls._kinds}) if kind not in kinds: raise ValueError(f"Unknown model kind '{kind}'. Known kinds: {kinds}") variants = sorted( known for known_kind, known in cls._kinds if known_kind == kind and known ) raise ValueError( f"Unknown variant {variant!r} of the {kind} kind. Known variants: " f"{variants}" ) ``` #### check_model ```python check_model(model: Model[Self]) -> None ``` Check the structure against the rest of its model. The model calls it when it is made, so a model that cannot be run as written is rejected then. Parameters: | Name | Type | Description | Default | | ------- | ------- | ------------------------------------ | ---------- | | `model` | `Model` | The model that holds this structure. | *required* | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------ | | `ValueError` | If the structure does not agree with the parameters or the engine. | Source code in `commol/context/structure.py` ```python @abstractmethod def check_model(self, model: "Model[Self]") -> None: """ Check the structure against the rest of its model. The model calls it when it is made, so a model that cannot be run as written is rejected then. Parameters ---------- model : Model The model that holds this structure. Raises ------ ValueError If the structure does not agree with the parameters or the engine. """ ``` #### outputs_by_source ```python outputs_by_source(model: Model[Self]) -> dict[str, list[str]] ``` The output names of the model, grouped by the id they come from. Reports and plots name a group of outputs by this id and sum them. Parameters: | Name | Type | Description | Default | | ------- | ------- | ------------------------------------ | ---------- | | `model` | `Model` | The model that holds this structure. | *required* | Returns: | Type | Description | | ---------------------- | ---------------------------------------------------- | | `dict[str, list[str]]` | Each id mapped to its output names, in engine order. | Source code in `commol/context/structure.py` ```python @abstractmethod def outputs_by_source(self, model: "Model[Self]") -> dict[str, list[str]]: """ The output names of the model, grouped by the id they come from. Reports and plots name a group of outputs by this id and sum them. Parameters ---------- model : Model The model that holds this structure. Returns ------- dict[str, list[str]] Each id mapped to its output names, in engine order. """ ``` #### series_label ```python series_label(model: Model[Self], series_id: str) -> SeriesLabel ``` How a plot names a series and its unit. Parameters: | Name | Type | Description | Default | | ----------- | ------- | --------------------------------------------- | ---------- | | `model` | `Model` | The model that holds this structure. | *required* | | `series_id` | `str` | An output name or an id of outputs_by_source. | *required* | Returns: | Type | Description | | ------------- | -------------------------------------------------------- | | `SeriesLabel` | The id itself and no unit, unless the kind knows better. | Source code in `commol/context/structure.py` ```python def series_label( self, model: "Model[Self]", # noqa: ARG002 series_id: str, ) -> SeriesLabel: """ How a plot names a series and its unit. Parameters ---------- model : Model The model that holds this structure. series_id : str An output name or an id of `outputs_by_source`. Returns ------- SeriesLabel The id itself and no unit, unless the kind knows better. """ return SeriesLabel(series_id, None) ``` #### calibration_value_types ```python calibration_value_types() -> frozenset[CalibrationParameterType] ``` Types of value of this kind that calibration may set. `parameter` and `factor` are not here: every kind accepts them. The engine's list of kinds declares the others. Returns: | Type | Description | | ------------------------------------- | --------------------------------------------------------- | | `frozenset[CalibrationParameterType]` | For example {"initial_condition"} for an aggregate model. | Source code in `commol/context/structure.py` ```python def calibration_value_types(self) -> frozenset[CalibrationParameterType]: """ Types of value of this kind that calibration may set. ``parameter`` and ``factor`` are not here: every kind accepts them. The engine's list of kinds declares the others. Returns ------- frozenset[CalibrationParameterType] For example ``{"initial_condition"}`` for an aggregate model. """ return frozenset( CalibrationParameterType(name) for name in commol_rs.engine.ModelKind(self.kind).value_types() ) ``` #### uncalibrated_values ```python uncalibrated_values() -> list[str] ``` Ids of the values of this kind that need calibration before a run. Returns: | Type | Description | | ----------- | -------------------------------------------- | | `list[str]` | Empty unless the kind has values left unset. | Source code in `commol/context/structure.py` ```python def uncalibrated_values(self) -> list[str]: """ Ids of the values of this kind that need calibration before a run. Returns ------- list[str] Empty unless the kind has values left unset. """ return [] ``` #### check_calibration_values ```python check_calibration_values(model: Model[Self], engine: EngineProtocol, parameters: Sequence[CalibrationParameter], remainder: str | None) -> None ``` Check the values of this kind that a calibration or a sensitivity analysis sets. The engine has resolved each value and the remainder when this runs; a kind checks here what only the Python definition knows, such as the units of the bins. The default checks nothing. Parameters: | Name | Type | Description | Default | | ------------ | -------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------- | | `model` | `Model` | The model the values are set on. | *required* | | `engine` | `EngineProtocol` | The engine built from it. | *required* | | `parameters` | `Sequence[CalibrationParameter]` | The values set, with their types and bounds. | *required* | | `remainder` | \`str | None\` | The id that takes what the initial conditions leave of the sum the kind keeps, if any. | Source code in `commol/context/structure.py` ```python def check_calibration_values( self, model: "Model[Self]", # noqa: ARG002 engine: "EngineProtocol", # noqa: ARG002 parameters: Sequence["CalibrationParameter"], # noqa: ARG002 remainder: str | None, # noqa: ARG002 ) -> None: """ Check the values of this kind that a calibration or a sensitivity analysis sets. The engine has resolved each value and the remainder when this runs; a kind checks here what only the Python definition knows, such as the units of the bins. The default checks nothing. Parameters ---------- model : Model The model the values are set on. engine : EngineProtocol The engine built from it. parameters : Sequence[CalibrationParameter] The values set, with their types and bounds. remainder : str | None The id that takes what the initial conditions leave of the sum the kind keeps, if any. """ ``` #### with_calibrated_values ```python with_calibrated_values(model: Model[Self], problem: CalibrationProblem, values: Mapping[str, float]) -> Self ``` The structure with the calibrated values of its kind written back. Parameters: | Name | Type | Description | Default | | --------- | --------------------- | ------------------------------------------------------ | ---------- | | `model` | `Model` | The model the problem was built on. It is not changed. | *required* | | `problem` | `CalibrationProblem` | The problem, for the types of its parameters. | *required* | | `values` | `Mapping[str, float]` | Calibrated value per parameter id. | *required* | Returns: | Type | Description | | ------ | -------------------------------------------------------------- | | `Self` | A structure; this one when the kind sets no values of its own. | Source code in `commol/context/structure.py` ```python def with_calibrated_values( self, model: "Model[Self]", # noqa: ARG002 problem: "CalibrationProblem", # noqa: ARG002 values: Mapping[str, float], # noqa: ARG002 ) -> Self: """ The structure with the calibrated values of its kind written back. Parameters ---------- model : Model The model the problem was built on. It is not changed. problem : CalibrationProblem The problem, for the types of its parameters. values : Mapping[str, float] Calibrated value per parameter id. Returns ------- Self A structure; this one when the kind sets no values of its own. """ return self ``` #### report_sections ```python report_sections(model: Model[Self], calibrated_ids: set[str], parameter_statistics: Mapping[str, ParameterSummary] | None) -> ReportSections ``` Sections of this kind in a report of applied values. Parameters: | Name | Type | Description | Default | | ---------------------- | -------------------------------- | ---------------------------------------- | --------------------------------------------------------- | | `model` | `Model` | The model that holds the applied values. | *required* | | `calibrated_ids` | `set[str]` | Ids that calibration set. | *required* | | `parameter_statistics` | \`Mapping[str, ParameterSummary] | None\` | Statistics of each id from a sample, or None without one. | Returns: | Type | Description | | ---------------- | ------------------------------------------------------- | | `ReportSections` | Each section by its key in the report; none by default. | Source code in `commol/context/structure.py` ```python def report_sections( self, model: "Model[Self]", # noqa: ARG002 calibrated_ids: set[str], # noqa: ARG002 parameter_statistics: "Mapping[str, ParameterSummary] | None", # noqa: ARG002 ) -> ReportSections: """ Sections of this kind in a report of applied values. Parameters ---------- model : Model The model that holds the applied values. calibrated_ids : set[str] Ids that calibration set. parameter_statistics : Mapping[str, ParameterSummary] | None Statistics of each id from a sample, or None without one. Returns ------- ReportSections Each section by its key in the report; none by default. """ return {} ``` #### describe ```python describe(model: Model[Self]) -> list[Section] ``` The sections of a description that this kind fills. `Model.describe` adds them to the sections every kind shares (overview, schedule, parameters, outputs). A kind fills the sections it knows, by key: `entities` (what holds the state), `dynamics` (how it changes), `initialization` (the state it starts from) and `design_concepts` (a `Table` of elements of the ODD protocol and their text). A section with no blocks is left out of a rendering. Parameters: | Name | Type | Description | Default | | ------- | ------- | ------------------------------------ | ---------- | | `model` | `Model` | The model that holds this structure. | *required* | Returns: | Type | Description | | --------------- | ------------------------------------------ | | `list[Section]` | The sections of the kind; none by default. | Source code in `commol/context/structure.py` ```python def describe( self, model: "Model[Self]", # noqa: ARG002 ) -> "list[Section]": """ The sections of a description that this kind fills. `Model.describe` adds them to the sections every kind shares (overview, schedule, parameters, outputs). A kind fills the sections it knows, by key: ``entities`` (what holds the state), ``dynamics`` (how it changes), ``initialization`` (the state it starts from) and ``design_concepts`` (a `Table` of elements of the ODD protocol and their text). A section with no blocks is left out of a rendering. Parameters ---------- model : Model The model that holds this structure. Returns ------- list[Section] The sections of the kind; none by default. """ return [] ``` #### check_unit_consistency ```python check_unit_consistency(model: Model[Self], verbose: bool) -> None ``` Check the units of the model. Raises: | Type | Description | | --------------------- | ------------------------------ | | `NotImplementedError` | If the kind has no unit check. | Source code in `commol/context/structure.py` ```python def check_unit_consistency(self, model: "Model[Self]", verbose: bool) -> None: """ Check the units of the model. Raises ------ NotImplementedError If the kind has no unit check. """ raise NotImplementedError(f"A {self.kind} model has no unit check") ``` ## Aggregate Bases: `ModelStructure`, `InitialConditions` Amounts per compartment: how much starts where, and who the neighbors of a unit are. The bins of the process expand over its stratifications into compartments. `bin_amounts` gives the amount of each bin at the start, in the unit of the bin, `stratification_fractions` splits it among the categories, and `compartment_amounts` overrides single compartments. Bins of different units, such as a count and a concentration, need no total over them. Attributes: | Name | Type | Description | | -------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kind` | `Literal['aggregate']` | The model kind. | | `variant` | \`str | None\` | | `bin_amounts` | \`dict\[str, float | None\]\` | | `stratification_fractions` | `dict[str, dict[str, float]]` | The fraction of each category, by stratification id, such as {"group": {"g1": 0.3, "g2": 0.7}}: the split of the amount of every bin it applies to. A conditional stratification splits only the group its conditions select. | | `compartment_amounts` | `dict[str, float]` | Amounts of single expanded compartments, such as {"A_g1": 0.0}, applied after the expansion. The names are checked against the engine when the model is built. | | `neighbors` | \`Complete | Groups | Source code in `commol/context/aggregate.py` ```python class Aggregate(ModelStructure, InitialConditions): """ Amounts per compartment: how much starts where, and who the neighbors of a unit are. The bins of the process expand over its stratifications into compartments. ``bin_amounts`` gives the amount of each bin at the start, in the unit of the bin, ``stratification_fractions`` splits it among the categories, and ``compartment_amounts`` overrides single compartments. Bins of different units, such as a count and a concentration, need no total over them. Attributes ---------- kind : Literal["aggregate"] The model kind. variant : str | None The variant of the structure (see `ModelStructure`), None for `Aggregate` itself. bin_amounts : dict[str, float | None] The amount of each bin at the start, by bin id, in the unit of the bin, such as ``{"A": 990, "B": 10, "C": 1e5}``; finite and not negative. None leaves an amount for calibration. stratification_fractions : dict[str, dict[str, float]] The fraction of each category, by stratification id, such as ``{"group": {"g1": 0.3, "g2": 0.7}}``: the split of the amount of every bin it applies to. A conditional stratification splits only the group its conditions select. compartment_amounts : dict[str, float] Amounts of single expanded compartments, such as ``{"A_g1": 0.0}``, applied after the expansion. The names are checked against the engine when the model is built. neighbors : Complete | Groups | None Who the neighbors of a unit are, for the rate variables ``neighbors_`` and ``neighbors``. Required, with no default, when a rate reads them. """ kind: Literal["aggregate"] = "aggregate" neighbors: Neighbors | None = Field( default=None, description="Who the neighbors of a unit are; required when a rate reads them.", ) # ------------------------------------------------------------------ # The interface of a model kind # ------------------------------------------------------------------ def check_model(self, model: "Model[Self]") -> None: """ Check the structure against the process and the parameters. The process needs a bin and a transition, the amounts must name every bin and the fractions every category of the process, and the engine must plan every transition, accept every id (no parameter id is an engine variable, a bin, a compartment or a subgroup total), resolve the neighbors, find every variable a rate or a formula parameter reads, and build the initial state. Parameters ---------- model : Model The model that holds this structure. Raises ------ ValueError If one of these checks fails. """ process = model.process if not process.bins: raise ValueError("At least one bin must be defined.") if not process.transitions: raise ValueError("At least one transition must be defined.") self._check_bin_amounts(process) self._check_stratification_fractions(process) engine_state.transition_flows(process, self) engine_state.check_model(model) if self.compartment_amounts: engine_state.initial_state(process, self) def outputs_by_source(self, model: "Model[Self]") -> dict[str, list[str]]: """ Map each bin and accumulator id to the output names it expands into. A bin or accumulator produces one output per combination of the stratification categories that apply to it. Without stratifications it produces a single output named after the id itself. Parameters ---------- model : Model The model that holds this structure. Returns ------- dict[str, list[str]] Bin and accumulator ids, each mapped to its output names in declaration order. """ process = model.process outputs: dict[str, list[str]] = { source_id: [] for source_id in ( *(bin_item.id for bin_item in process.bins), *(accumulator.id for accumulator in process.accumulators), ) } compartments, accumulators = engine_state.expanded_outputs(process) for output in (*compartments, *accumulators): outputs[output.source].append(output.name) return outputs def series_label(self, model: "Model[Self]", series_id: str) -> SeriesLabel: """ The name and unit of a bin, for a plot of its series. Parameters ---------- model : Model The model that holds this structure. series_id : str A bin id, or any other series id. Returns ------- SeriesLabel The bin's name and unit, or the id and no unit when no bin has it. """ bin_item = model.process.bin(series_id) if bin_item is None: return SeriesLabel(series_id, None) return SeriesLabel(bin_item.name or series_id, bin_item.unit) def uncalibrated_values(self) -> list[str]: """ Ids of the bins whose initial amount needs calibration. Returns ------- list[str] Bin ids with a None amount. """ return self.get_uncalibrated_bins() def check_calibration_values( self, model: "Model[Self]", engine: "EngineProtocol", parameters: Sequence["CalibrationParameter"], remainder: str | None, ) -> None: """ Check that the remainder has the unit of every amount it gives. The remainder takes what the amounts set leave of the sum it keeps, so a bin, prefix or compartment value must be in the unit of the remainder; a category split has no unit. Parameters ---------- model : Model The model the values are set on. engine : EngineProtocol The engine built from it. parameters : Sequence[CalibrationParameter] The values set, with their types and bounds. remainder : str | None The bin or compartment that takes what the initial conditions leave, if any. Raises ------ ValueError If an amount set is in another unit than the remainder. """ if remainder is None: return initial_conditions = [ param for param in parameters if param.parameter_type == CalibrationParameterType.INITIAL_CONDITION ] covered, kept = engine_state.initial_condition_coverage( engine, initial_conditions, remainder ) bin_of = {c.name: c.bin for c in self.initial_state(model.process)} def unit(compartment: str) -> str | None: bin_item = model.process.bin(bin_of[compartment]) return None if bin_item is None else bin_item.unit remainder_unit = unit(kept[0]) for param, compartments in zip(initial_conditions, covered, strict=True): for compartment in compartments: if unit(compartment) != remainder_unit: raise ValueError( f"The initial-condition remainder '{remainder}' is in " f"{_unit_name(remainder_unit)}, but the calibrated " f"initial condition '{param.id}' sets '{compartment}', " f"in {_unit_name(unit(compartment))}. The remainder " f"keeps a sum of amounts of one unit: name a remainder " f"of the unit of '{param.id}'." ) def with_calibrated_values( self, model: "Model[Self]", problem: "CalibrationProblem", values: Mapping[str, float], ) -> Self: """ The structure with calibrated initial conditions written back. The written structure rebuilds the initial state the engine scored. Bin values update the amounts they declare, in the unit of the bin, and category values the splits. Every compartment those do not reproduce, such as an exact compartment value or a value that overlaps a per-compartment override, is written as a per-compartment override. Parameters ---------- model : Model The model the problem was built on. It is not changed. problem : CalibrationProblem The problem, for its parameters and remainder. values : Mapping[str, float] Calibrated value per parameter id. Returns ------- Aggregate A copy that holds the calibrated initial conditions. Raises ------ ValueError If the engine cannot resolve an initial-condition id or the remainder, or the values leave the remainder below zero. """ from commol.context._model.calibration_write_back import ( calibrated_structure, ) return calibrated_structure(model, problem, values) def report_sections( self, model: "Model[Self]", calibrated_ids: set[str], parameter_statistics: "Mapping[str, ParameterSummary] | None", ) -> ReportSections: """ The ``initial_amounts`` and ``category_splits`` sections of a report. See `Model.applied_parameters`. Parameters ---------- model : Model The model that holds the applied values. calibrated_ids : set[str] Ids that calibration set. parameter_statistics : Mapping[str, ParameterSummary] | None Statistics of each id from a sample, or None without one. Returns ------- ReportSections ``{"initial_amounts": ..., "category_splits": ...}``. """ from commol.context._model.report import ( category_splits_report, initial_amounts_report, ) return { "initial_amounts": initial_amounts_report( model.process, self, calibrated_ids, parameter_statistics ), "category_splits": category_splits_report( model.process, self, calibrated_ids, parameter_statistics ), } def describe(self, model: "Model[Self]") -> "list[Section]": """ The entities, dynamics, initialization and design concepts. ``entities``: the bins, accumulators, stratifications and expanded compartments. ``dynamics``: the state graphs, the compact and expanded forms of the equations the engine simulates, the impulses and the neighbors. ``initialization``: the bin amounts, the category splits, the overrides and the initial state the engine builds. ``design_concepts``: the interaction of the units, when the structure says who their neighbors are. Parameters ---------- model : Model The model that holds this structure. Returns ------- list[Section] The sections of an aggregate model. """ from commol.context._model.aggregate_description import describe_aggregate return describe_aggregate(model) def check_unit_consistency(self, model: "Model[Self]", verbose: bool) -> None: """Check the units of the model; see `Model.check_unit_consistency`.""" from commol.context._model.units import UnitChecker UnitChecker(model).check_unit_consistency(verbose) # ------------------------------------------------------------------ # Initial conditions # ------------------------------------------------------------------ def update_compartment_amounts( self, process: Process, amounts: Mapping[str, float] ) -> None: """ Upsert per-compartment initial condition overrides. A compartment already declared has its amount replaced; a new one is appended. The engine validates the result against the expansion of the process before it is stored, so a rejected call leaves the structure unchanged. Parameters ---------- process : Process The process of the model, such as ``model.process``. amounts : Mapping[str, float] Dictionary mapping expanded compartment names to their new amount, in the unit of their bin. Raises ------ ValueError If a name is not an expanded compartment of the model, or an amount is not finite or is negative. """ candidate = self.model_copy(deep=True) candidate._upsert_compartment_amounts(amounts) engine_state.initial_state(process, candidate) self.compartment_amounts = candidate.compartment_amounts def initial_state(self, process: Process) -> list[InitialCompartment]: """ Expanded compartments and the amount each one starts with. The engine builds this state from the bin amounts, the stratification fractions and the per-compartment overrides; it is the state a simulation starts from. Parameters ---------- process : Process The process of the model, such as ``model.process``. Returns ------- list[InitialCompartment] One entry per expanded compartment, in engine order. Raises ------ ValueError If an amount is not valid or an override names no compartment of the structure. """ return engine_state.initial_state(process, self) def get_conditioning_categories( self, process: Process, stratification_id: str ) -> tuple[str, ...]: """ Get the categories a stratification is conditioned on. An unconditional stratification subdivides the whole model and has no conditioning categories. A conditional one subdivides only the subgroup its conditions select, and its fractions are relative to that subgroup rather than to the whole model. Parameters ---------- process : Process The process of the model, such as ``model.process``. stratification_id : str Id of a stratification of the process. Returns ------- tuple[str, ...] Ids of the categories selecting the subgroup this stratification subdivides, empty for an unconditional stratification. A category name another stratification also declares is qualified, as in ``group:g1``. Raises ------ ValueError If the stratification is not found. """ for stratification in process.stratifications: if stratification.id == stratification_id: return tuple( self.category_id(condition.stratification, condition.category) for condition in (stratification.conditions or []) if condition.category is not None ) raise ValueError( f"Stratification '{stratification_id}' not found. Available: " f"{sorted(s.id for s in process.stratifications)}" ) def subgroup_total(self, process: Process, categories: Iterable[str] = ()) -> float: """ Get the initial sum of the compartments of a subgroup. The sum is taken of the state the engine builds, overrides included, over every compartment that has each of the categories. Passing no categories gives the sum of every compartment. It adds every bin it covers, so over bins of different units it has no unit. Parameters ---------- process : Process The process of the model, such as ``model.process``. categories : Iterable[str], optional Category ids, bare or qualified (``group:g1``). Returns ------- float The sum of the compartments of the subgroup at step 0. Raises ------ ValueError If a category is not found or a bare name is ambiguous. """ resolved = [self.resolve_category(c) for c in categories] return sum( compartment.amount for compartment in self.initial_state(process) if all( compartment.categories.get(stratification) == name for stratification, name in resolved ) ) # ------------------------------------------------------------------ # Checks against the model # ------------------------------------------------------------------ def _check_bin_amounts(self, process: Process) -> None: """Check that the bin amounts name every bin of the process.""" actual_bins = set(self.bin_amounts) expected_bins = {bin_item.id for bin_item in process.bins} if actual_bins != expected_bins: raise ValueError( f"Initial bin amounts keys must exactly match bin ids. " f"Missing ids: {sorted(expected_bins - actual_bins)}, " f"Extra ids: {sorted(actual_bins - expected_bins)}." ) def _check_stratification_fractions(self, process: Process) -> None: """Check that the fractions name each stratification and its categories; the rule of their values is the engine's, checked when the structure is built.""" strat_map = {strat.id: strat for strat in process.stratifications} actual_strat = set(self.stratification_fractions) expected_strat = set(strat_map) if actual_strat != expected_strat: raise ValueError( f"Initial stratification fractions keys must exactly match " f"stratification ids. " f"Missing ids: {sorted(expected_strat - actual_strat)}, " f"Extra ids: {sorted(actual_strat - expected_strat)}." ) for strat_id, fractions in self.stratification_fractions.items(): strat_instance = strat_map[strat_id] expected = set(strat_instance.categories) actual = set(fractions) if actual != expected: raise ValueError( f"Categories for stratification '{strat_id}' must exactly " f"match defined categories in instance '{strat_instance.id}'. " f"Missing categories: {sorted(expected - actual)}, " f"Extra categories: {sorted(actual - expected)}." ) ``` ### Methods: #### check_model ```python check_model(model: Model[Self]) -> None ``` Check the structure against the process and the parameters. The process needs a bin and a transition, the amounts must name every bin and the fractions every category of the process, and the engine must plan every transition, accept every id (no parameter id is an engine variable, a bin, a compartment or a subgroup total), resolve the neighbors, find every variable a rate or a formula parameter reads, and build the initial state. Parameters: | Name | Type | Description | Default | | ------- | ------- | ------------------------------------ | ---------- | | `model` | `Model` | The model that holds this structure. | *required* | Raises: | Type | Description | | ------------ | ----------------------------- | | `ValueError` | If one of these checks fails. | Source code in `commol/context/aggregate.py` ```python def check_model(self, model: "Model[Self]") -> None: """ Check the structure against the process and the parameters. The process needs a bin and a transition, the amounts must name every bin and the fractions every category of the process, and the engine must plan every transition, accept every id (no parameter id is an engine variable, a bin, a compartment or a subgroup total), resolve the neighbors, find every variable a rate or a formula parameter reads, and build the initial state. Parameters ---------- model : Model The model that holds this structure. Raises ------ ValueError If one of these checks fails. """ process = model.process if not process.bins: raise ValueError("At least one bin must be defined.") if not process.transitions: raise ValueError("At least one transition must be defined.") self._check_bin_amounts(process) self._check_stratification_fractions(process) engine_state.transition_flows(process, self) engine_state.check_model(model) if self.compartment_amounts: engine_state.initial_state(process, self) ``` #### outputs_by_source ```python outputs_by_source(model: Model[Self]) -> dict[str, list[str]] ``` Map each bin and accumulator id to the output names it expands into. A bin or accumulator produces one output per combination of the stratification categories that apply to it. Without stratifications it produces a single output named after the id itself. Parameters: | Name | Type | Description | Default | | ------- | ------- | ------------------------------------ | ---------- | | `model` | `Model` | The model that holds this structure. | *required* | Returns: | Type | Description | | ---------------------- | ------------------------------------------------------------------------------ | | `dict[str, list[str]]` | Bin and accumulator ids, each mapped to its output names in declaration order. | Source code in `commol/context/aggregate.py` ```python def outputs_by_source(self, model: "Model[Self]") -> dict[str, list[str]]: """ Map each bin and accumulator id to the output names it expands into. A bin or accumulator produces one output per combination of the stratification categories that apply to it. Without stratifications it produces a single output named after the id itself. Parameters ---------- model : Model The model that holds this structure. Returns ------- dict[str, list[str]] Bin and accumulator ids, each mapped to its output names in declaration order. """ process = model.process outputs: dict[str, list[str]] = { source_id: [] for source_id in ( *(bin_item.id for bin_item in process.bins), *(accumulator.id for accumulator in process.accumulators), ) } compartments, accumulators = engine_state.expanded_outputs(process) for output in (*compartments, *accumulators): outputs[output.source].append(output.name) return outputs ``` #### series_label ```python series_label(model: Model[Self], series_id: str) -> SeriesLabel ``` The name and unit of a bin, for a plot of its series. Parameters: | Name | Type | Description | Default | | ----------- | ------- | ------------------------------------ | ---------- | | `model` | `Model` | The model that holds this structure. | *required* | | `series_id` | `str` | A bin id, or any other series id. | *required* | Returns: | Type | Description | | ------------- | ------------------------------------------------------------------ | | `SeriesLabel` | The bin's name and unit, or the id and no unit when no bin has it. | Source code in `commol/context/aggregate.py` ```python def series_label(self, model: "Model[Self]", series_id: str) -> SeriesLabel: """ The name and unit of a bin, for a plot of its series. Parameters ---------- model : Model The model that holds this structure. series_id : str A bin id, or any other series id. Returns ------- SeriesLabel The bin's name and unit, or the id and no unit when no bin has it. """ bin_item = model.process.bin(series_id) if bin_item is None: return SeriesLabel(series_id, None) return SeriesLabel(bin_item.name or series_id, bin_item.unit) ``` #### uncalibrated_values ```python uncalibrated_values() -> list[str] ``` Ids of the bins whose initial amount needs calibration. Returns: | Type | Description | | ----------- | --------------------------- | | `list[str]` | Bin ids with a None amount. | Source code in `commol/context/aggregate.py` ```python def uncalibrated_values(self) -> list[str]: """ Ids of the bins whose initial amount needs calibration. Returns ------- list[str] Bin ids with a None amount. """ return self.get_uncalibrated_bins() ``` #### check_calibration_values ```python check_calibration_values(model: Model[Self], engine: EngineProtocol, parameters: Sequence[CalibrationParameter], remainder: str | None) -> None ``` Check that the remainder has the unit of every amount it gives. The remainder takes what the amounts set leave of the sum it keeps, so a bin, prefix or compartment value must be in the unit of the remainder; a category split has no unit. Parameters: | Name | Type | Description | Default | | ------------ | -------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------- | | `model` | `Model` | The model the values are set on. | *required* | | `engine` | `EngineProtocol` | The engine built from it. | *required* | | `parameters` | `Sequence[CalibrationParameter]` | The values set, with their types and bounds. | *required* | | `remainder` | \`str | None\` | The bin or compartment that takes what the initial conditions leave, if any. | Raises: | Type | Description | | ------------ | ------------------------------------------------------- | | `ValueError` | If an amount set is in another unit than the remainder. | Source code in `commol/context/aggregate.py` ```python def check_calibration_values( self, model: "Model[Self]", engine: "EngineProtocol", parameters: Sequence["CalibrationParameter"], remainder: str | None, ) -> None: """ Check that the remainder has the unit of every amount it gives. The remainder takes what the amounts set leave of the sum it keeps, so a bin, prefix or compartment value must be in the unit of the remainder; a category split has no unit. Parameters ---------- model : Model The model the values are set on. engine : EngineProtocol The engine built from it. parameters : Sequence[CalibrationParameter] The values set, with their types and bounds. remainder : str | None The bin or compartment that takes what the initial conditions leave, if any. Raises ------ ValueError If an amount set is in another unit than the remainder. """ if remainder is None: return initial_conditions = [ param for param in parameters if param.parameter_type == CalibrationParameterType.INITIAL_CONDITION ] covered, kept = engine_state.initial_condition_coverage( engine, initial_conditions, remainder ) bin_of = {c.name: c.bin for c in self.initial_state(model.process)} def unit(compartment: str) -> str | None: bin_item = model.process.bin(bin_of[compartment]) return None if bin_item is None else bin_item.unit remainder_unit = unit(kept[0]) for param, compartments in zip(initial_conditions, covered, strict=True): for compartment in compartments: if unit(compartment) != remainder_unit: raise ValueError( f"The initial-condition remainder '{remainder}' is in " f"{_unit_name(remainder_unit)}, but the calibrated " f"initial condition '{param.id}' sets '{compartment}', " f"in {_unit_name(unit(compartment))}. The remainder " f"keeps a sum of amounts of one unit: name a remainder " f"of the unit of '{param.id}'." ) ``` #### with_calibrated_values ```python with_calibrated_values(model: Model[Self], problem: CalibrationProblem, values: Mapping[str, float]) -> Self ``` The structure with calibrated initial conditions written back. The written structure rebuilds the initial state the engine scored. Bin values update the amounts they declare, in the unit of the bin, and category values the splits. Every compartment those do not reproduce, such as an exact compartment value or a value that overlaps a per-compartment override, is written as a per-compartment override. Parameters: | Name | Type | Description | Default | | --------- | --------------------- | ------------------------------------------------------ | ---------- | | `model` | `Model` | The model the problem was built on. It is not changed. | *required* | | `problem` | `CalibrationProblem` | The problem, for its parameters and remainder. | *required* | | `values` | `Mapping[str, float]` | Calibrated value per parameter id. | *required* | Returns: | Type | Description | | ----------- | ---------------------------------------------------- | | `Aggregate` | A copy that holds the calibrated initial conditions. | Raises: | Type | Description | | ------------ | -------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If the engine cannot resolve an initial-condition id or the remainder, or the values leave the remainder below zero. | Source code in `commol/context/aggregate.py` ```python def with_calibrated_values( self, model: "Model[Self]", problem: "CalibrationProblem", values: Mapping[str, float], ) -> Self: """ The structure with calibrated initial conditions written back. The written structure rebuilds the initial state the engine scored. Bin values update the amounts they declare, in the unit of the bin, and category values the splits. Every compartment those do not reproduce, such as an exact compartment value or a value that overlaps a per-compartment override, is written as a per-compartment override. Parameters ---------- model : Model The model the problem was built on. It is not changed. problem : CalibrationProblem The problem, for its parameters and remainder. values : Mapping[str, float] Calibrated value per parameter id. Returns ------- Aggregate A copy that holds the calibrated initial conditions. Raises ------ ValueError If the engine cannot resolve an initial-condition id or the remainder, or the values leave the remainder below zero. """ from commol.context._model.calibration_write_back import ( calibrated_structure, ) return calibrated_structure(model, problem, values) ``` #### report_sections ```python report_sections(model: Model[Self], calibrated_ids: set[str], parameter_statistics: Mapping[str, ParameterSummary] | None) -> ReportSections ``` The `initial_amounts` and `category_splits` sections of a report. See `Model.applied_parameters`. Parameters: | Name | Type | Description | Default | | ---------------------- | -------------------------------- | ---------------------------------------- | --------------------------------------------------------- | | `model` | `Model` | The model that holds the applied values. | *required* | | `calibrated_ids` | `set[str]` | Ids that calibration set. | *required* | | `parameter_statistics` | \`Mapping[str, ParameterSummary] | None\` | Statistics of each id from a sample, or None without one. | Returns: | Type | Description | | ---------------- | ------------------------------------------------- | | `ReportSections` | {"initial_amounts": ..., "category_splits": ...}. | Source code in `commol/context/aggregate.py` ```python def report_sections( self, model: "Model[Self]", calibrated_ids: set[str], parameter_statistics: "Mapping[str, ParameterSummary] | None", ) -> ReportSections: """ The ``initial_amounts`` and ``category_splits`` sections of a report. See `Model.applied_parameters`. Parameters ---------- model : Model The model that holds the applied values. calibrated_ids : set[str] Ids that calibration set. parameter_statistics : Mapping[str, ParameterSummary] | None Statistics of each id from a sample, or None without one. Returns ------- ReportSections ``{"initial_amounts": ..., "category_splits": ...}``. """ from commol.context._model.report import ( category_splits_report, initial_amounts_report, ) return { "initial_amounts": initial_amounts_report( model.process, self, calibrated_ids, parameter_statistics ), "category_splits": category_splits_report( model.process, self, calibrated_ids, parameter_statistics ), } ``` #### describe ```python describe(model: Model[Self]) -> list[Section] ``` The entities, dynamics, initialization and design concepts. `entities`: the bins, accumulators, stratifications and expanded compartments. `dynamics`: the state graphs, the compact and expanded forms of the equations the engine simulates, the impulses and the neighbors. `initialization`: the bin amounts, the category splits, the overrides and the initial state the engine builds. `design_concepts`: the interaction of the units, when the structure says who their neighbors are. Parameters: | Name | Type | Description | Default | | ------- | ------- | ------------------------------------ | ---------- | | `model` | `Model` | The model that holds this structure. | *required* | Returns: | Type | Description | | --------------- | ----------------------------------- | | `list[Section]` | The sections of an aggregate model. | Source code in `commol/context/aggregate.py` ```python def describe(self, model: "Model[Self]") -> "list[Section]": """ The entities, dynamics, initialization and design concepts. ``entities``: the bins, accumulators, stratifications and expanded compartments. ``dynamics``: the state graphs, the compact and expanded forms of the equations the engine simulates, the impulses and the neighbors. ``initialization``: the bin amounts, the category splits, the overrides and the initial state the engine builds. ``design_concepts``: the interaction of the units, when the structure says who their neighbors are. Parameters ---------- model : Model The model that holds this structure. Returns ------- list[Section] The sections of an aggregate model. """ from commol.context._model.aggregate_description import describe_aggregate return describe_aggregate(model) ``` #### check_unit_consistency ```python check_unit_consistency(model: Model[Self], verbose: bool) -> None ``` Check the units of the model; see `Model.check_unit_consistency`. Source code in `commol/context/aggregate.py` ```python def check_unit_consistency(self, model: "Model[Self]", verbose: bool) -> None: """Check the units of the model; see `Model.check_unit_consistency`.""" from commol.context._model.units import UnitChecker UnitChecker(model).check_unit_consistency(verbose) ``` #### update_compartment_amounts ```python update_compartment_amounts(process: Process, amounts: Mapping[str, float]) -> None ``` Upsert per-compartment initial condition overrides. A compartment already declared has its amount replaced; a new one is appended. The engine validates the result against the expansion of the process before it is stored, so a rejected call leaves the structure unchanged. Parameters: | Name | Type | Description | Default | | --------- | --------------------- | -------------------------------------------------------------------------------------------- | ---------- | | `process` | `Process` | The process of the model, such as model.process. | *required* | | `amounts` | `Mapping[str, float]` | Dictionary mapping expanded compartment names to their new amount, in the unit of their bin. | *required* | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------------- | | `ValueError` | If a name is not an expanded compartment of the model, or an amount is not finite or is negative. | Source code in `commol/context/aggregate.py` ```python def update_compartment_amounts( self, process: Process, amounts: Mapping[str, float] ) -> None: """ Upsert per-compartment initial condition overrides. A compartment already declared has its amount replaced; a new one is appended. The engine validates the result against the expansion of the process before it is stored, so a rejected call leaves the structure unchanged. Parameters ---------- process : Process The process of the model, such as ``model.process``. amounts : Mapping[str, float] Dictionary mapping expanded compartment names to their new amount, in the unit of their bin. Raises ------ ValueError If a name is not an expanded compartment of the model, or an amount is not finite or is negative. """ candidate = self.model_copy(deep=True) candidate._upsert_compartment_amounts(amounts) engine_state.initial_state(process, candidate) self.compartment_amounts = candidate.compartment_amounts ``` #### initial_state ```python initial_state(process: Process) -> list[InitialCompartment] ``` Expanded compartments and the amount each one starts with. The engine builds this state from the bin amounts, the stratification fractions and the per-compartment overrides; it is the state a simulation starts from. Parameters: | Name | Type | Description | Default | | --------- | --------- | ------------------------------------------------ | ---------- | | `process` | `Process` | The process of the model, such as model.process. | *required* | Returns: | Type | Description | | -------------------------- | ---------------------------------------------------- | | `list[InitialCompartment]` | One entry per expanded compartment, in engine order. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------- | | `ValueError` | If an amount is not valid or an override names no compartment of the structure. | Source code in `commol/context/aggregate.py` ```python def initial_state(self, process: Process) -> list[InitialCompartment]: """ Expanded compartments and the amount each one starts with. The engine builds this state from the bin amounts, the stratification fractions and the per-compartment overrides; it is the state a simulation starts from. Parameters ---------- process : Process The process of the model, such as ``model.process``. Returns ------- list[InitialCompartment] One entry per expanded compartment, in engine order. Raises ------ ValueError If an amount is not valid or an override names no compartment of the structure. """ return engine_state.initial_state(process, self) ``` #### get_conditioning_categories ```python get_conditioning_categories(process: Process, stratification_id: str) -> tuple[str, ...] ``` Get the categories a stratification is conditioned on. An unconditional stratification subdivides the whole model and has no conditioning categories. A conditional one subdivides only the subgroup its conditions select, and its fractions are relative to that subgroup rather than to the whole model. Parameters: | Name | Type | Description | Default | | ------------------- | --------- | ------------------------------------------------ | ---------- | | `process` | `Process` | The process of the model, such as model.process. | *required* | | `stratification_id` | `str` | Id of a stratification of the process. | *required* | Returns: | Type | Description | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tuple[str, ...]` | Ids of the categories selecting the subgroup this stratification subdivides, empty for an unconditional stratification. A category name another stratification also declares is qualified, as in group:g1. | Raises: | Type | Description | | ------------ | ----------------------------------- | | `ValueError` | If the stratification is not found. | Source code in `commol/context/aggregate.py` ```python def get_conditioning_categories( self, process: Process, stratification_id: str ) -> tuple[str, ...]: """ Get the categories a stratification is conditioned on. An unconditional stratification subdivides the whole model and has no conditioning categories. A conditional one subdivides only the subgroup its conditions select, and its fractions are relative to that subgroup rather than to the whole model. Parameters ---------- process : Process The process of the model, such as ``model.process``. stratification_id : str Id of a stratification of the process. Returns ------- tuple[str, ...] Ids of the categories selecting the subgroup this stratification subdivides, empty for an unconditional stratification. A category name another stratification also declares is qualified, as in ``group:g1``. Raises ------ ValueError If the stratification is not found. """ for stratification in process.stratifications: if stratification.id == stratification_id: return tuple( self.category_id(condition.stratification, condition.category) for condition in (stratification.conditions or []) if condition.category is not None ) raise ValueError( f"Stratification '{stratification_id}' not found. Available: " f"{sorted(s.id for s in process.stratifications)}" ) ``` #### subgroup_total ```python subgroup_total(process: Process, categories: Iterable[str] = ()) -> float ``` Get the initial sum of the compartments of a subgroup. The sum is taken of the state the engine builds, overrides included, over every compartment that has each of the categories. Passing no categories gives the sum of every compartment. It adds every bin it covers, so over bins of different units it has no unit. Parameters: | Name | Type | Description | Default | | ------------ | --------------- | ------------------------------------------------ | ---------- | | `process` | `Process` | The process of the model, such as model.process. | *required* | | `categories` | `Iterable[str]` | Category ids, bare or qualified (group:g1). | `()` | Returns: | Type | Description | | ------- | ------------------------------------------------------ | | `float` | The sum of the compartments of the subgroup at step 0. | Raises: | Type | Description | | ------------ | ------------------------------------------------------- | | `ValueError` | If a category is not found or a bare name is ambiguous. | Source code in `commol/context/aggregate.py` ```python def subgroup_total(self, process: Process, categories: Iterable[str] = ()) -> float: """ Get the initial sum of the compartments of a subgroup. The sum is taken of the state the engine builds, overrides included, over every compartment that has each of the categories. Passing no categories gives the sum of every compartment. It adds every bin it covers, so over bins of different units it has no unit. Parameters ---------- process : Process The process of the model, such as ``model.process``. categories : Iterable[str], optional Category ids, bare or qualified (``group:g1``). Returns ------- float The sum of the compartments of the subgroup at step 0. Raises ------ ValueError If a category is not found or a bare name is ambiguous. """ resolved = [self.resolve_category(c) for c in categories] return sum( compartment.amount for compartment in self.initial_state(process) if all( compartment.categories.get(stratification) == name for stratification, name in resolved ) ) ``` ## Complete Bases: `BaseModel` Every unit is a neighbor of every other. `neighbors_` is the bin total and `neighbors` is `total`, so `k1 * neighbors_B / neighbors` from `A` moves `k1 * A * B / total`. Attributes: | Name | Type | Description | | ------ | --------------------- | --------------------------------- | | `type` | `Literal['complete']` | The tag of the neighbors in JSON. | Source code in `commol/context/neighbors.py` ```python class Complete(BaseModel): """ Every unit is a neighbor of every other. ``neighbors_`` is the bin total and ``neighbors`` is ``total``, so ``k1 * neighbors_B / neighbors`` from ``A`` moves ``k1 * A * B / total``. Attributes ---------- type : Literal["complete"] The tag of the neighbors in JSON. """ model_config = ConfigDict(extra="forbid") type: Literal["complete"] = "complete" ``` ## Groups Bases: `BaseModel` Units are neighbors by the categories of one stratification. In a compartment of category `g`, `neighbors_` is `Σ_g' w[g][g'] × (amount of the bin in g')` and `neighbors` is `Σ_g' w[g][g'] × (amount of every bin in g')`. Attributes: | Name | Type | Description | | ---------------- | ------------------------------- | ---------------------------------------------------------- | | `type` | `Literal['groups']` | The tag of the neighbors in JSON. | | `stratification` | `str` | Id of the stratification whose categories group the units. | | `weights` | \`dict\[str, dict[str, float]\] | None\` | Source code in `commol/context/neighbors.py` ```python class Groups(BaseModel): """ Units are neighbors by the categories of one stratification. In a compartment of category ``g``, ``neighbors_`` is ``Σ_g' w[g][g'] × (amount of the bin in g')`` and ``neighbors`` is ``Σ_g' w[g][g'] × (amount of every bin in g')``. Attributes ---------- type : Literal["groups"] The tag of the neighbors in JSON. stratification : str Id of the stratification whose categories group the units. weights : dict[str, dict[str, float]] | None ``weights[g][g']``, the weight of category ``g'`` for a unit in ``g``: one finite number of at least 0 for every pair of categories. None is the identity: a unit sees only its own category. """ model_config = ConfigDict(extra="forbid") type: Literal["groups"] = "groups" stratification: str weights: dict[str, dict[str, float]] | None = None def __init__( self, stratification: str, weights: Mapping[str, Mapping[str, float]] | None = None, *, type: Literal["groups"] = "groups", ) -> None: """ Group the neighbors of a unit by the categories of a stratification. Parameters ---------- stratification : str Id of the stratification whose categories group the units. weights : Mapping[str, Mapping[str, float]] | None, default=None ``weights[g][g']``, the weight of category ``g'`` for a unit in ``g``. None is the identity. type : {"groups"}, default="groups" The kind of neighbors, as JSON holds it. """ super().__init__( stratification=stratification, weights=None if weights is None else {own: dict(row) for own, row in weights.items()}, type=type, ) ``` ### Methods: #### __init__ ```python __init__(stratification: str, weights: Mapping[str, Mapping[str, float]] | None = None, *, type: Literal['groups'] = 'groups') -> None ``` Group the neighbors of a unit by the categories of a stratification. Parameters: | Name | Type | Description | Default | | ---------------- | ------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------- | | `stratification` | `str` | Id of the stratification whose categories group the units. | *required* | | `weights` | \`Mapping\[str, Mapping[str, float]\] | None\` | weights[g][g'], the weight of category g' for a unit in g. None is the identity. | | `type` | `groups` | The kind of neighbors, as JSON holds it. | `"groups"` | Source code in `commol/context/neighbors.py` ```python def __init__( self, stratification: str, weights: Mapping[str, Mapping[str, float]] | None = None, *, type: Literal["groups"] = "groups", ) -> None: """ Group the neighbors of a unit by the categories of a stratification. Parameters ---------- stratification : str Id of the stratification whose categories group the units. weights : Mapping[str, Mapping[str, float]] | None, default=None ``weights[g][g']``, the weight of category ``g'`` for a unit in ``g``. None is the identity. type : {"groups"}, default="groups" The kind of neighbors, as JSON holds it. """ super().__init__( stratification=stratification, weights=None if weights is None else {own: dict(row) for own, row in weights.items()}, type=type, ) ``` ## InitialCompartment Bases: `NamedTuple` One expanded compartment and the amount it starts with. Attributes: | Name | Type | Description | | ------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | `str` | Expanded compartment name, for example A_g1. | | `bin` | `str` | Bin the compartment expands from. | | `categories` | `dict[str, str]` | Category applied to the compartment, keyed by stratification id. A conditional stratification whose conditions the compartment does not meet is absent. | | `amount` | `float` | Initial amount, including per-compartment overrides. | Source code in `commol/context/_model/engine_state.py` ```python class InitialCompartment(NamedTuple): """ One expanded compartment and the amount it starts with. Attributes ---------- name : str Expanded compartment name, for example ``A_g1``. bin : str Bin the compartment expands from. categories : dict[str, str] Category applied to the compartment, keyed by stratification id. A conditional stratification whose conditions the compartment does not meet is absent. amount : float Initial amount, including per-compartment overrides. """ name: str bin: str categories: dict[str, str] amount: float ``` ## SeriesValue Bases: `BaseModel` An empirical series for a parameter value. Attributes: | Name | Type | Description | | ------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `data` | `list[tuple[float, float]]` | (position, value) points on the axis. Need not be pre-sorted; the engine sorts them. | | `mode` | `str` | How the series is read between its points: "pulse" (the value on the step of a point, 0 on every other step; integer positions only), "step_function" (the value of the last point at or before t), or "linear" (linear interpolation in t). The engine decides which positions its model type accepts: a DifferenceEquations or StochasticSteps model only integer steps. The model rejects a position its engine cannot run when it is built or its parameters change. | Source code in `commol/context/parameter.py` ```python class SeriesValue(BaseModel): """ An empirical series for a parameter value. Attributes ---------- data : list[tuple[float, float]] (position, value) points on the axis. Need not be pre-sorted; the engine sorts them. mode : str How the series is read between its points: ``"pulse"`` (the value on the step of a point, 0 on every other step; integer positions only), ``"step_function"`` (the value of the last point at or before ``t``), or ``"linear"`` (linear interpolation in ``t``). The engine decides which positions its model type accepts: a DifferenceEquations or StochasticSteps model only integer steps. The model rejects a position its engine cannot run when it is built or its parameters change. """ model_config = ConfigDict(extra="forbid") data: list[tuple[float, float]] mode: str = Field(default="pulse", pattern="^(pulse|step_function|linear)$") @field_validator("data") @classmethod def validate_data( cls, value: list[tuple[float, float]] ) -> list[tuple[float, float]]: """ Check the points with the rule of the engine (`check_series` of `crates/commol-model/src/values.rs`): at least one point, each position finite, not negative and once, each value finite. """ commol_rs.model.check_series(value) return value ``` ### Methods: #### validate_data ```python validate_data(value: list[tuple[float, float]]) -> list[tuple[float, float]] ``` Check the points with the rule of the engine (`check_series` of `crates/commol-model/src/values.rs`): at least one point, each position finite, not negative and once, each value finite. Source code in `commol/context/parameter.py` ```python @field_validator("data") @classmethod def validate_data( cls, value: list[tuple[float, float]] ) -> list[tuple[float, float]]: """ Check the points with the rule of the engine (`check_series` of `crates/commol-model/src/values.rs`): at least one point, each position finite, not negative and once, each value finite. """ commol_rs.model.check_series(value) return value ``` ## UnitConsistencyError Bases: `Exception` Raised when unit consistency check fails. Source code in `commol/utils/equations.py` ```python class UnitConsistencyError(Exception): """Raised when unit consistency check fails.""" pass ``` ## Descriptions `Model.describe` returns a `ModelDescription`; its blocks are in `commol.context.description`. ## ModelDescription Bases: `BaseModel` A description of a model, as data. `Model.describe` builds it. `render` turns it into a string, `save` writes it to a file, and a notebook shows its Markdown rendering. Attributes: | Name | Type | Description | | ---------- | --------------- | --------------------------------------------------- | | `title` | `str` | The title, the name of the model. | | `layout` | `str` | The layout the sections follow: "default" or "odd". | | `sections` | `list[Section]` | The sections, in order. | Source code in `commol/context/description.py` ```python class ModelDescription(BaseModel): """ A description of a model, as data. `Model.describe` builds it. `render` turns it into a string, `save` writes it to a file, and a notebook shows its Markdown rendering. Attributes ---------- title : str The title, the name of the model. layout : str The layout the sections follow: ``"default"`` or ``"odd"``. sections : list[Section] The sections, in order. """ model_config = ConfigDict(extra="forbid") title: str layout: str = "default" sections: list[Section] def section(self, key: str) -> Section: """ The section with a key. Parameters ---------- key : str The key of the section, such as ``"dynamics"``. Returns ------- Section The section. Raises ------ KeyError If the description has no section with that key; the message lists the keys. """ for section in self.sections: if section.key == key: return section raise KeyError( f"The description has no section '{key}'. Its sections are " f"{[section.key for section in self.sections]}" ) def graphs(self) -> list[StateGraph]: """ Every state graph of the description, in order. Returns ------- list[StateGraph] The graphs, the expanded ones included. """ return [ block for section in self.sections for block in section.blocks if isinstance(block, StateGraph) ] def render(self, format: DescriptionFormat = "text") -> str: """ The description as a string. Parameters ---------- format : {"text", "markdown", "latex", "json", "dot"}, default="text" ``"text"``: plain text, with the equations in their compact and expanded forms. ``"markdown"``: Markdown, with the state graph of the model as a Mermaid diagram. ``"latex"``: a LaTeX document. ``"json"``: the description as data. ``"dot"``: every state graph in Graphviz DOT, the expanded one included. Returns ------- str The rendering. Raises ------ ValueError If the format is not one of these. """ from commol.api.description import render return render(self, format) def save(self, path: str | Path, format: DescriptionFormat | None = None) -> Path: """ Write the description to a file. Parameters ---------- path : str | Path The file to write. format : {"text", "markdown", "latex", "json", "dot"} | None The format; None takes it from the suffix of ``path``: ``.txt``, ``.md``, ``.tex``, ``.json`` or ``.dot``. Returns ------- Path The path written. Raises ------ ValueError If the format is None and the suffix is not one of these, or the format is not valid. """ from commol.api.description import save return save(self, Path(path), format) def _repr_markdown_(self) -> str: """The Markdown rendering, which a notebook shows.""" return self.render("markdown") ``` ### Methods: #### section ```python section(key: str) -> Section ``` The section with a key. Parameters: | Name | Type | Description | Default | | ----- | ----- | ------------------------------------------- | ---------- | | `key` | `str` | The key of the section, such as "dynamics". | *required* | Returns: | Type | Description | | --------- | ------------ | | `Section` | The section. | Raises: | Type | Description | | ---------- | ---------------------------------------------------------------------------- | | `KeyError` | If the description has no section with that key; the message lists the keys. | Source code in `commol/context/description.py` ```python def section(self, key: str) -> Section: """ The section with a key. Parameters ---------- key : str The key of the section, such as ``"dynamics"``. Returns ------- Section The section. Raises ------ KeyError If the description has no section with that key; the message lists the keys. """ for section in self.sections: if section.key == key: return section raise KeyError( f"The description has no section '{key}'. Its sections are " f"{[section.key for section in self.sections]}" ) ``` #### graphs ```python graphs() -> list[StateGraph] ``` Every state graph of the description, in order. Returns: | Type | Description | | ------------------ | --------------------------------------- | | `list[StateGraph]` | The graphs, the expanded ones included. | Source code in `commol/context/description.py` ```python def graphs(self) -> list[StateGraph]: """ Every state graph of the description, in order. Returns ------- list[StateGraph] The graphs, the expanded ones included. """ return [ block for section in self.sections for block in section.blocks if isinstance(block, StateGraph) ] ``` #### render ```python render(format: DescriptionFormat = 'text') -> str ``` The description as a string. Parameters: | Name | Type | Description | Default | | -------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | | `format` | `(text, markdown, latex, json, dot)` | "text": plain text, with the equations in their compact and expanded forms. "markdown": Markdown, with the state graph of the model as a Mermaid diagram. "latex": a LaTeX document. "json": the description as data. "dot": every state graph in Graphviz DOT, the expanded one included. | `"text"` | Returns: | Type | Description | | ----- | -------------- | | `str` | The rendering. | Raises: | Type | Description | | ------------ | ---------------------------------- | | `ValueError` | If the format is not one of these. | Source code in `commol/context/description.py` ```python def render(self, format: DescriptionFormat = "text") -> str: """ The description as a string. Parameters ---------- format : {"text", "markdown", "latex", "json", "dot"}, default="text" ``"text"``: plain text, with the equations in their compact and expanded forms. ``"markdown"``: Markdown, with the state graph of the model as a Mermaid diagram. ``"latex"``: a LaTeX document. ``"json"``: the description as data. ``"dot"``: every state graph in Graphviz DOT, the expanded one included. Returns ------- str The rendering. Raises ------ ValueError If the format is not one of these. """ from commol.api.description import render return render(self, format) ``` #### save ```python save(path: str | Path, format: DescriptionFormat | None = None) -> Path ``` Write the description to a file. Parameters: | Name | Type | Description | Default | | -------- | ------------------------------------ | ---------------------------------------------------------------------------------- | ------------------ | | `path` | \`str | Path\` | The file to write. | | `format` | `(text, markdown, latex, json, dot)` | The format; None takes it from the suffix of path: .txt, .md, .tex, .json or .dot. | `"text"` | Returns: | Type | Description | | ------ | ----------------- | | `Path` | The path written. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------- | | `ValueError` | If the format is None and the suffix is not one of these, or the format is not valid. | Source code in `commol/context/description.py` ```python def save(self, path: str | Path, format: DescriptionFormat | None = None) -> Path: """ Write the description to a file. Parameters ---------- path : str | Path The file to write. format : {"text", "markdown", "latex", "json", "dot"} | None The format; None takes it from the suffix of ``path``: ``.txt``, ``.md``, ``.tex``, ``.json`` or ``.dot``. Returns ------- Path The path written. Raises ------ ValueError If the format is None and the suffix is not one of these, or the format is not valid. """ from commol.api.description import save return save(self, Path(path), format) ``` ## Section Bases: `BaseModel` One section of a description. Attributes: | Name | Type | Description | | -------- | ------------- | ------------------------------------------------------------------------------ | | `key` | `str` | The key of the section, such as "dynamics". | | `title` | `str` | The title a rendering shows. | | `blocks` | `list[Block]` | The blocks, in order. A section with no blocks is left out of every rendering. | Source code in `commol/context/description.py` ```python class Section(BaseModel): """ One section of a description. Attributes ---------- key : str The key of the section, such as ``"dynamics"``. title : str The title a rendering shows. blocks : list[Block] The blocks, in order. A section with no blocks is left out of every rendering. """ model_config = ConfigDict(extra="forbid") key: str title: str blocks: list[Block] = Field(default_factory=list) ``` ## Text Bases: `BaseModel` A paragraph. Attributes: | Name | Type | Description | | --------- | ----------------- | ----------------------------- | | `type` | `Literal['text']` | The tag of the block. | | `heading` | \`str | None\` | | `text` | `str` | The paragraph, in plain text. | Source code in `commol/context/description.py` ```python class Text(BaseModel): """ A paragraph. Attributes ---------- type : Literal["text"] The tag of the block. heading : str | None A heading above the block. text : str The paragraph, in plain text. """ model_config = ConfigDict(extra="forbid") type: Literal["text"] = "text" heading: str | None = None text: str ``` ## Table Bases: `BaseModel` A table: named columns and rows of cells. Attributes: | Name | Type | Description | | --------- | ------------------ | ---------------------------------------- | | `type` | `Literal['table']` | The tag of the block. | | `heading` | \`str | None\` | | `columns` | `list[str]` | The column names. | | `rows` | `list[list[Cell]]` | The rows, each with one cell per column. | Source code in `commol/context/description.py` ```python class Table(BaseModel): """ A table: named columns and rows of cells. Attributes ---------- type : Literal["table"] The tag of the block. heading : str | None A heading above the block. columns : list[str] The column names. rows : list[list[Cell]] The rows, each with one cell per column. """ model_config = ConfigDict(extra="forbid") type: Literal["table"] = "table" heading: str | None = None columns: list[str] rows: list[list[Cell]] @model_validator(mode="after") def check_row_lengths(self) -> Self: """Each row has one cell per column.""" for index, row in enumerate(self.rows): if len(row) != len(self.columns): raise ValueError( f"Row {index} of the table has {len(row)} cells; the table " f"has {len(self.columns)} columns: {self.columns}" ) return self ``` ### Methods: #### check_row_lengths ```python check_row_lengths() -> Self ``` Each row has one cell per column. Source code in `commol/context/description.py` ```python @model_validator(mode="after") def check_row_lengths(self) -> Self: """Each row has one cell per column.""" for index, row in enumerate(self.rows): if len(row) != len(self.columns): raise ValueError( f"Row {index} of the table has {len(row)} cells; the table " f"has {len(self.columns)} columns: {self.columns}" ) return self ``` ## Equations Bases: `BaseModel` A system of equations or of flows. Attributes: | Name | Type | Description | | --------- | ---------------------- | ---------------------------------- | | `type` | `Literal['equations']` | The tag of the block. | | `heading` | \`str | None\` | | `title` | \`str | None\` | | `groups` | `list[EquationGroup]` | The groups of equations, in order. | Source code in `commol/context/description.py` ```python class Equations(BaseModel): """ A system of equations or of flows. Attributes ---------- type : Literal["equations"] The tag of the block. heading : str | None A heading above the block. title : str | None A line that introduces the groups, such as ``Bin Transitions``. groups : list[EquationGroup] The groups of equations, in order. """ model_config = ConfigDict(extra="forbid") type: Literal["equations"] = "equations" heading: str | None = None title: str | None = None groups: list[EquationGroup] ``` ## EquationGroup Bases: `BaseModel` Equations under one heading, such as the flows of one transition. Attributes: | Name | Type | Description | | ----------- | ---------------- | ----------------------------------------------------------------------------------- | | `heading` | \`str | None\` | | `equations` | `list[Equation]` | The equations, in order. A group with a heading and no equations renders as (none). | Source code in `commol/context/description.py` ```python class EquationGroup(BaseModel): """ Equations under one heading, such as the flows of one transition. Attributes ---------- heading : str | None The heading of the group, or None for equations with no heading. equations : list[Equation] The equations, in order. A group with a heading and no equations renders as ``(none)``. """ model_config = ConfigDict(extra="forbid") heading: str | None = None equations: list[Equation] ``` ## Equation Bases: `BaseModel` One equation, or one flow, in plain text and in LaTeX. Attributes: | Name | Type | Description | | ------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `left` | `str` | The left side in plain text, such as dA/dt or A -> B. | | `right` | `str` | The right side in plain text. | | `left_latex` | `str` | The left side in LaTeX, without math delimiters. | | `right_latex` | `str` | The right side in LaTeX, without math delimiters. | | `relation` | `Literal['equals', 'flow']` | "equals": the left side equals the right side. "flow": the left side names where an amount moves (A -> B) and the right side is the flow. | Source code in `commol/context/description.py` ```python class Equation(BaseModel): """ One equation, or one flow, in plain text and in LaTeX. Attributes ---------- left : str The left side in plain text, such as ``dA/dt`` or ``A -> B``. right : str The right side in plain text. left_latex : str The left side in LaTeX, without math delimiters. right_latex : str The right side in LaTeX, without math delimiters. relation : Literal["equals", "flow"] ``"equals"``: the left side equals the right side. ``"flow"``: the left side names where an amount moves (``A -> B``) and the right side is the flow. """ model_config = ConfigDict(extra="forbid") left: str right: str left_latex: str right_latex: str relation: Literal["equals", "flow"] = "equals" ``` ## StateGraph Bases: `BaseModel` The states of a model and the moves between them. Attributes: | Name | Type | Description | | ---------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `type` | `Literal['state_graph']` | The tag of the block. | | `heading` | \`str | None\` | | `name` | `str` | The name of the graph, such as states; a DOT file names it so. | | `nodes` | `list[GraphNode]` | The states. | | `edges` | `list[GraphEdge]` | The moves between them. | | `expanded` | `bool` | Whether it is the graph of the expanded compartments. The Markdown rendering draws only a graph that is not expanded: an expanded graph can pass what a Mermaid diagram draws. | Source code in `commol/context/description.py` ```python class StateGraph(BaseModel): """ The states of a model and the moves between them. Attributes ---------- type : Literal["state_graph"] The tag of the block. heading : str | None A heading above the block. name : str The name of the graph, such as ``states``; a DOT file names it so. nodes : list[GraphNode] The states. edges : list[GraphEdge] The moves between them. expanded : bool Whether it is the graph of the expanded compartments. The Markdown rendering draws only a graph that is not expanded: an expanded graph can pass what a Mermaid diagram draws. """ model_config = ConfigDict(extra="forbid") type: Literal["state_graph"] = "state_graph" heading: str | None = None name: str nodes: list[GraphNode] edges: list[GraphEdge] expanded: bool = False ``` ## GraphNode Bases: `BaseModel` A node of a `StateGraph`. Attributes: | Name | Type | Description | | ------- | ----- | ---------------------------------------- | | `id` | `str` | The id of the node, unique in its graph. | | `label` | `str` | The label a diagram shows. | | `group` | \`str | None\` | Source code in `commol/context/description.py` ```python class GraphNode(BaseModel): """ A node of a `StateGraph`. Attributes ---------- id : str The id of the node, unique in its graph. label : str The label a diagram shows. group : str | None What the node is, such as ``"bin"`` or a stratification id. """ model_config = ConfigDict(extra="forbid") id: str label: str group: str | None = None ``` ## GraphEdge Bases: `BaseModel` An edge of a `StateGraph`: an amount that moves from a node to another. Attributes: | Name | Type | Description | | -------- | ----- | -------------------------------------------- | | `source` | \`str | None\` | | `target` | \`str | None\` | | `label` | `str` | The label a diagram shows, such as the rate. | Source code in `commol/context/description.py` ```python class GraphEdge(BaseModel): """ An edge of a `StateGraph`: an amount that moves from a node to another. Attributes ---------- source : str | None The id of the node the amount leaves, or None for an inflow. target : str | None The id of the node the amount enters, or None for an outflow. label : str The label a diagram shows, such as the rate. """ model_config = ConfigDict(extra="forbid") source: str | None target: str | None label: str ``` ## Matrix Bases: `BaseModel` A matrix with labelled rows and columns, such as the weights of groups. Attributes: | Name | Type | Description | | --------------- | ------------------- | ------------------------------------------------------------ | | `type` | `Literal['matrix']` | The tag of the block. | | `heading` | \`str | None\` | | `row_labels` | `list[str]` | The label of each row. | | `column_labels` | `list[str]` | The label of each column. | | `values` | `list[list[float]]` | One row of values per row label, one value per column label. | Source code in `commol/context/description.py` ```python class Matrix(BaseModel): """ A matrix with labelled rows and columns, such as the weights of groups. Attributes ---------- type : Literal["matrix"] The tag of the block. heading : str | None A heading above the block. row_labels : list[str] The label of each row. column_labels : list[str] The label of each column. values : list[list[float]] One row of values per row label, one value per column label. """ model_config = ConfigDict(extra="forbid") type: Literal["matrix"] = "matrix" heading: str | None = None row_labels: list[str] column_labels: list[str] values: list[list[float]] @model_validator(mode="after") def check_shape(self) -> Self: """One row of values per row label, one value per column label.""" shape = [len(row) for row in self.values] if shape != [len(self.column_labels)] * len(self.row_labels): raise ValueError( f"A matrix of {len(self.row_labels)} rows and " f"{len(self.column_labels)} columns has rows of {shape} values" ) return self ``` ### Methods: #### check_shape ```python check_shape() -> Self ``` One row of values per row label, one value per column label. Source code in `commol/context/description.py` ```python @model_validator(mode="after") def check_shape(self) -> Self: """One row of values per row label, one value per column label.""" shape = [len(row) for row in self.values] if shape != [len(self.column_labels)] * len(self.row_labels): raise ValueError( f"A matrix of {len(self.row_labels)} rows and " f"{len(self.column_labels)} columns has rows of {shape} values" ) return self ``` # Package API [Back to API Reference](https://munqu.github.io/commol/latest/api/index.md) The version of the installed commol and the commit it was built from. Save them with a result to name the code that made it. Guide: [Check the installation](https://munqu.github.io/commol/latest/getting-started/installation/#check-the-installation). `commol.__version__` is the PEP 440 version, the same as `build_info().version`: the version of a release, or the placeholder version with the commit as a local version (`0.1.0+g`) for a build that is not a release. Commol logs to the logger `commol` and its children, and adds only a `logging.NullHandler`. Your application chooses where the messages go. Guide: [Log messages](https://munqu.github.io/commol/latest/getting-started/quickstart/#log-messages). ## build_info ```python build_info() -> BuildInfo ``` Return the version of the installed commol and the commit it was built from. Save it with a result to name the code that made it. Returns: | Type | Description | | ----------- | ------------------------------------------------------------------- | | `BuildInfo` | The version and the full commit, read when the extension was built. | Source code in `commol/api/build_info.py` ```python def build_info() -> BuildInfo: """ Return the version of the installed commol and the commit it was built from. Save it with a result to name the code that made it. Returns ------- BuildInfo The version and the full commit, read when the extension was built. """ return BuildInfo(version=_commol_rs.__version__, commit=_commol_rs.__commit__) ``` ## BuildInfo Bases: `BaseModel` The version of the installed commol and the commit it was built from. Attributes: | Name | Type | Description | | --------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `version` | `str` | The PEP 440 version, the one commol.__version__ gives. A release gives its version, such as "0.9.0". A build that is not a release, such as an install from a git commit or an editable install, gives the placeholder version of the source with the commit as a local version, such as "0.1.0+g0123abc". | | `commit` | \`str | None\` | Source code in `commol/api/build_info.py` ```python class BuildInfo(BaseModel): """ The version of the installed commol and the commit it was built from. Attributes ---------- version : str The PEP 440 version, the one `commol.__version__` gives. A release gives its version, such as ``"0.9.0"``. A build that is not a release, such as an install from a git commit or an editable install, gives the placeholder version of the source with the commit as a local version, such as ``"0.1.0+g0123abc"``. commit : str | None The full hash of the git commit of the source, or None when the source has no git metadata, such as a source distribution. """ model_config = ConfigDict(extra="forbid", frozen=True) version: str commit: str | None ``` # Parallelism API [Back to API Reference](https://munqu.github.io/commol/latest/api/index.md) The threads of every parallel method and the levels that run in parallel. Guide: [Threads and Parallelism](https://munqu.github.io/commol/latest/guide/parallelism/index.md). ## set_threads ```python set_threads(n: int, levels: Levels | None = None) -> None ``` Set the number of threads of every parallel method, in this context. The setting holds in the current Python thread (a `contextvars` context), not in other threads. Without it, commol uses one thread per core and every level in parallel. Parameters: | Name | Type | Description | Default | | -------- | -------------- | ------------------------------------------------------------------------------------- | ---------- | | `n` | `int` | The number of threads, at least 1. | *required* | | `levels` | `(all, outer)` | Which levels run in parallel when a method nests them; None keeps the current levels. | `"all"` | Raises: | Type | Description | | ------------ | ------------------------------------------ | | `ValueError` | If n is below 1 or levels is neither name. | Source code in `commol/api/parallelism.py` ```python def set_threads(n: int, levels: Levels | None = None) -> None: """ Set the number of threads of every parallel method, in this context. The setting holds in the current Python thread (a `contextvars` context), not in other threads. Without it, commol uses one thread per core and every level in parallel. Parameters ---------- n : int The number of threads, at least 1. levels : {"all", "outer"} | None Which levels run in parallel when a method nests them; `None` keeps the current levels. Raises ------ ValueError If `n` is below 1 or `levels` is neither name. """ commol_rs.parallelism.setting().set(_choice(n, levels)) ``` ## threads ```python threads(n: int | None = None, levels: Levels | None = None) -> Generator[None] ``` Limit the threads of every parallel method within a block. The previous setting comes back when the block ends, also on an error. Parameters: | Name | Type | Description | Default | | -------- | -------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `n` | \`int | None\` | The number of threads, at least 1; None keeps the current number. | | `levels` | `(all, outer)` | Which levels run in parallel when a method nests them; None keeps the current levels. | `"all"` | Yields: | Type | Description | | ------ | -------------------------------- | | `None` | The block runs with the setting. | Raises: | Type | Description | | ------------ | ------------------------------------------ | | `ValueError` | If n is below 1 or levels is neither name. | Source code in `commol/api/parallelism.py` ```python @contextmanager def threads(n: int | None = None, levels: Levels | None = None) -> Generator[None]: """ Limit the threads of every parallel method within a block. The previous setting comes back when the block ends, also on an error. Parameters ---------- n : int | None The number of threads, at least 1; `None` keeps the current number. levels : {"all", "outer"} | None Which levels run in parallel when a method nests them; `None` keeps the current levels. Yields ------ None The block runs with the setting. Raises ------ ValueError If `n` is below 1 or `levels` is neither name. """ variable = commol_rs.parallelism.setting() token = variable.set(_choice(n, levels)) try: yield finally: variable.reset(token) ``` ## get_threads ```python get_threads() -> int ``` The number of threads of the parallel methods in this context. Returns: | Type | Description | | ----- | -------------------------------------------------------- | | `int` | The number set, or the number of cores when none is set. | Source code in `commol/api/parallelism.py` ```python def get_threads() -> int: """ The number of threads of the parallel methods in this context. Returns ------- int The number set, or the number of cores when none is set. """ return commol_rs.parallelism.current().threads ``` ## get_levels ```python get_levels() -> Levels ``` The levels that run in parallel in this context. Returns: | Type | Description | | -------------- | ------------------------------------------ | | `{all, outer}` | The levels set, or "all" when none is set. | Source code in `commol/api/parallelism.py` ```python def get_levels() -> Levels: """ The levels that run in parallel in this context. Returns ------- {"all", "outer"} The levels set, or `"all"` when none is set. """ return cast(Levels, commol_rs.parallelism.current().levels) ``` # Plotting API [Back to API Reference](https://munqu.github.io/commol/latest/api/index.md) The figures are read from the results they draw, and return a matplotlib `Figure`. `results.plot`, `replicates.plot` and `predictive.plot` give the figures of a run, of replicates and of a predictive: `plot.series()`, `plot.cumulative()` and `plot.observations()`. The results of PRCC, Morris, Sobol and polynomial chaos, and the result of a profile likelihood, draw their figure with `plot()`. `plot_compared` draws several runs, replicates or predictives on the same panels. The figures need the optional extra `commol[plot]`. Guide: [Plotting](https://munqu.github.io/commol/latest/guide/simulations/#plotting). ## ResultsPlot The figures of one run, read from `results.plot`. Each figure has one panel per series, or per observed series for `observations`, and returns the matplotlib `Figure`. Given `axes=`, the `Figure` of an earlier figure of commol, it draws each series on the panel of that series instead. Source code in `commol/api/plotting/accessors.py` ```python class ResultsPlot: """ The figures of one run, read from ``results.plot``. Each figure has one panel per series, or per observed series for `observations`, and returns the matplotlib `Figure`. Given ``axes=``, the `Figure` of an earlier figure of commol, it draws each series on the panel of that series instead. """ def __init__(self, results: "SimulationResults") -> None: """ The figures of `results`. Parameters ---------- results : SimulationResults The run. """ self._results = results def series( self, output_file: str | None = None, observed_data: "list[ObservedDataPoint] | None" = None, calibration_result: "CalibrationResult | None" = None, config: "PlotConfig | None" = None, series: list[str] | None = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = "Step", prediction_intervals: "PredictionIntervals | None" = None, axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw each series along the axis, one panel per series. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. observed_data : list[ObservedDataPoint] | None Observations, each drawn as a point on the panel of its label. When the observations of a series share one sum of outputs and one factor and have no window, the line covers every position of the results. Otherwise the model of each observation is read through its own operator (outputs, window, factor) at its own position, as the loss reads it, and the line joins these values. calibration_result : CalibrationResult | None Its ``best_parameters`` give the values of the factors of the observations, which multiply the line; observed values stay in their own units. config : PlotConfig | None Layout and style. If None, the defaults. series : list[str] | None What to draw, one panel each: an output name (such as ``A_g1``), or a bin or accumulator id, which draws the sum of its outputs. An exact output name wins over an id. If None, every output. show_legend : bool Whether each panel shows its legend. position_label : Callable[[float], str] | None Turns a position on the axis into a tick label. If None, a model with an origin (``ModelBuilder(origin=...)``) labels each tick with its date: ``2020-01-02``, with the time when it is not midnight. Otherwise the ticks show the position. tick_every : float | None Labels the plotted positions `tick_every` apart, counted from the first plotted position. If None, every plotted position, or the ticks matplotlib places on a dated axis. x_label : str The label of the x axis. prediction_intervals : PredictionIntervals | None Prediction intervals of a conformal method (``ConformalResult.intervals``). The panel of each of their series, by label, shows the band between their lower and upper ends and their point prediction, dashed; an unbounded interval is left out of the band. axes : Figure | None A figure of commol to draw on: each series is drawn on its panel, the `Axes` whose label is the id of the series. If None, a new figure. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the line of the run, such as ``linewidth``, ``alpha`` or ``linestyle``. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If an entry of ``series`` is a bin or accumulator id of results with no model; the results have no value at the position of an observation read on its own or at the start of its window; or ``axes`` has no panel of a series. KeyError If an entry of ``series`` is neither an output nor a bin or accumulator id of the model. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _run_series return _run_series( self._results, output_file=output_file, observed_data=observed_data, calibration_result=calibration_result, config=config, series=series, show_legend=show_legend, position_label=position_label, tick_every=tick_every, x_label=x_label, prediction_intervals=prediction_intervals, axes=axes, **kwargs, ) def cumulative( self, output_file: str | None = None, observed_data: "list[ObservedDataPoint] | None" = None, calibration_result: "CalibrationResult | None" = None, config: "PlotConfig | None" = None, series: list[str] | None = None, axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw the running total of each series, one panel per series. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. observed_data : list[ObservedDataPoint] | None Observations, drawn as their running total on the panel of their label. An observation with a factor is divided by the value of its factor in ``calibration_result``. calibration_result : CalibrationResult | None Its ``best_parameters`` give the values of the factors. config : PlotConfig | None Layout, size and style. If None, the defaults. series : list[str] | None What to draw, one panel each, as in `series`. If None, every output. axes : Figure | None A figure of commol to draw on, as in `series`. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the line of the run. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a series. KeyError If an entry of ``series`` is neither an output nor a bin or accumulator id of the model. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _run_cumulative return _run_cumulative( self._results, output_file=output_file, observed_data=observed_data, calibration_result=calibration_result, config=config, series=series, axes=axes, **kwargs, ) def observations( self, observed_data: "list[ObservedDataPoint]", output_file: str | None = None, calibration_result: "CalibrationResult | None" = None, config: "PlotConfig | None" = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = "Step", axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw each observed series with the model read through its observation operator, one panel per label. The model is read only at the observations: the value of each observation through its own operator (the sum of its outputs, its window and its factor), as the loss reads it. A value with a window is a total over ``(at - window, at]``, drawn as a step over its window with a marker at ``at``. A value with no window is a marker, joined to the next only as a guide. The run along the whole axis is `series`, on the same figure with ``axes=``. Parameters ---------- observed_data : list[ObservedDataPoint] The observations. Each label is one panel, in the order of its first observation, with the `Axes` label of the label. output_file : str | None Path to save the figure to. If None, the figure is only returned. calibration_result : CalibrationResult | None Its ``best_parameters`` give the values of the factors of the observations, which multiply the model; observed values stay in their own units. config : PlotConfig | None Layout and style. If None, the defaults. show_legend : bool Whether each panel shows its legend. position_label : Callable[[float], str] | None Turns a position into a tick label, as in `series`. tick_every : float | None The distance between labelled observation positions, as in `series`. x_label : str The label of the x axis. axes : Figure | None A figure of commol to draw on: each observed series is drawn on the `Axes` whose label is its label. If None, a new figure. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the steps and the guide of the model, such as ``linewidth`` or ``alpha``. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If there are no observations; the results have no value at the position of an observation or at the start of its window; or ``axes`` has no panel of a label. KeyError If an observation names an output the results do not have. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _run_observations return _run_observations( self._results, observed_data, output_file=output_file, calibration_result=calibration_result, config=config, show_legend=show_legend, position_label=position_label, tick_every=tick_every, x_label=x_label, axes=axes, **kwargs, ) ``` ### Methods: #### __init__ ```python __init__(results: SimulationResults) -> None ``` The figures of `results`. Parameters: | Name | Type | Description | Default | | --------- | ------------------- | ----------- | ---------- | | `results` | `SimulationResults` | The run. | *required* | Source code in `commol/api/plotting/accessors.py` ```python def __init__(self, results: "SimulationResults") -> None: """ The figures of `results`. Parameters ---------- results : SimulationResults The run. """ self._results = results ``` #### series ```python series(output_file: str | None = None, observed_data: list[ObservedDataPoint] | None = None, calibration_result: CalibrationResult | None = None, config: PlotConfig | None = None, series: list[str] | None = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = 'Step', prediction_intervals: PredictionIntervals | None = None, axes: Figure | None = None, **kwargs: str | int | float | bool | None) -> Figure ``` Draw each series along the axis, one panel per series. Parameters: | Name | Type | Description | Default | | ---------------------- | -------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `observed_data` | \`list[ObservedDataPoint] | None\` | Observations, each drawn as a point on the panel of its label. When the observations of a series share one sum of outputs and one factor and have no window, the line covers every position of the results. Otherwise the model of each observation is read through its own operator (outputs, window, factor) at its own position, as the loss reads it, and the line joins these values. | | `calibration_result` | \`CalibrationResult | None\` | Its best_parameters give the values of the factors of the observations, which multiply the line; observed values stay in their own units. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `series` | \`list[str] | None\` | What to draw, one panel each: an output name (such as A_g1), or a bin or accumulator id, which draws the sum of its outputs. An exact output name wins over an id. If None, every output. | | `show_legend` | `bool` | Whether each panel shows its legend. | `True` | | `position_label` | \`Callable\[[float], str\] | None\` | Turns a position on the axis into a tick label. If None, a model with an origin (ModelBuilder(origin=...)) labels each tick with its date: 2020-01-02, with the time when it is not midnight. Otherwise the ticks show the position. | | `tick_every` | \`float | None\` | Labels the plotted positions tick_every apart, counted from the first plotted position. If None, every plotted position, or the ticks matplotlib places on a dated axis. | | `x_label` | `str` | The label of the x axis. | `'Step'` | | `prediction_intervals` | \`PredictionIntervals | None\` | Prediction intervals of a conformal method (ConformalResult.intervals). The panel of each of their series, by label, shows the band between their lower and upper ends and their point prediction, dashed; an unbounded interval is left out of the band. | | `axes` | \`Figure | None\` | A figure of commol to draw on: each series is drawn on its panel, the Axes whose label is the id of the series. If None, a new figure. | | `**kwargs` | \`str | int | float | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If an entry of series is a bin or accumulator id of results with no model; the results have no value at the position of an observation read on its own or at the start of its window; or axes has no panel of a series. | | `KeyError` | If an entry of series is neither an output nor a bin or accumulator id of the model. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/api/plotting/accessors.py` ```python def series( self, output_file: str | None = None, observed_data: "list[ObservedDataPoint] | None" = None, calibration_result: "CalibrationResult | None" = None, config: "PlotConfig | None" = None, series: list[str] | None = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = "Step", prediction_intervals: "PredictionIntervals | None" = None, axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw each series along the axis, one panel per series. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. observed_data : list[ObservedDataPoint] | None Observations, each drawn as a point on the panel of its label. When the observations of a series share one sum of outputs and one factor and have no window, the line covers every position of the results. Otherwise the model of each observation is read through its own operator (outputs, window, factor) at its own position, as the loss reads it, and the line joins these values. calibration_result : CalibrationResult | None Its ``best_parameters`` give the values of the factors of the observations, which multiply the line; observed values stay in their own units. config : PlotConfig | None Layout and style. If None, the defaults. series : list[str] | None What to draw, one panel each: an output name (such as ``A_g1``), or a bin or accumulator id, which draws the sum of its outputs. An exact output name wins over an id. If None, every output. show_legend : bool Whether each panel shows its legend. position_label : Callable[[float], str] | None Turns a position on the axis into a tick label. If None, a model with an origin (``ModelBuilder(origin=...)``) labels each tick with its date: ``2020-01-02``, with the time when it is not midnight. Otherwise the ticks show the position. tick_every : float | None Labels the plotted positions `tick_every` apart, counted from the first plotted position. If None, every plotted position, or the ticks matplotlib places on a dated axis. x_label : str The label of the x axis. prediction_intervals : PredictionIntervals | None Prediction intervals of a conformal method (``ConformalResult.intervals``). The panel of each of their series, by label, shows the band between their lower and upper ends and their point prediction, dashed; an unbounded interval is left out of the band. axes : Figure | None A figure of commol to draw on: each series is drawn on its panel, the `Axes` whose label is the id of the series. If None, a new figure. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the line of the run, such as ``linewidth``, ``alpha`` or ``linestyle``. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If an entry of ``series`` is a bin or accumulator id of results with no model; the results have no value at the position of an observation read on its own or at the start of its window; or ``axes`` has no panel of a series. KeyError If an entry of ``series`` is neither an output nor a bin or accumulator id of the model. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _run_series return _run_series( self._results, output_file=output_file, observed_data=observed_data, calibration_result=calibration_result, config=config, series=series, show_legend=show_legend, position_label=position_label, tick_every=tick_every, x_label=x_label, prediction_intervals=prediction_intervals, axes=axes, **kwargs, ) ``` #### cumulative ```python cumulative(output_file: str | None = None, observed_data: list[ObservedDataPoint] | None = None, calibration_result: CalibrationResult | None = None, config: PlotConfig | None = None, series: list[str] | None = None, axes: Figure | None = None, **kwargs: str | int | float | bool | None) -> Figure ``` Draw the running total of each series, one panel per series. Parameters: | Name | Type | Description | Default | | -------------------- | ------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `observed_data` | \`list[ObservedDataPoint] | None\` | Observations, drawn as their running total on the panel of their label. An observation with a factor is divided by the value of its factor in calibration_result. | | `calibration_result` | \`CalibrationResult | None\` | Its best_parameters give the values of the factors. | | `config` | \`PlotConfig | None\` | Layout, size and style. If None, the defaults. | | `series` | \`list[str] | None\` | What to draw, one panel each, as in series. If None, every output. | | `axes` | \`Figure | None\` | A figure of commol to draw on, as in series. | | `**kwargs` | \`str | int | float | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ------------------------------------------------------------------------------------ | | `ValueError` | If axes has no panel of a series. | | `KeyError` | If an entry of series is neither an output nor a bin or accumulator id of the model. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/api/plotting/accessors.py` ```python def cumulative( self, output_file: str | None = None, observed_data: "list[ObservedDataPoint] | None" = None, calibration_result: "CalibrationResult | None" = None, config: "PlotConfig | None" = None, series: list[str] | None = None, axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw the running total of each series, one panel per series. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. observed_data : list[ObservedDataPoint] | None Observations, drawn as their running total on the panel of their label. An observation with a factor is divided by the value of its factor in ``calibration_result``. calibration_result : CalibrationResult | None Its ``best_parameters`` give the values of the factors. config : PlotConfig | None Layout, size and style. If None, the defaults. series : list[str] | None What to draw, one panel each, as in `series`. If None, every output. axes : Figure | None A figure of commol to draw on, as in `series`. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the line of the run. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a series. KeyError If an entry of ``series`` is neither an output nor a bin or accumulator id of the model. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _run_cumulative return _run_cumulative( self._results, output_file=output_file, observed_data=observed_data, calibration_result=calibration_result, config=config, series=series, axes=axes, **kwargs, ) ``` #### observations ```python observations(observed_data: list[ObservedDataPoint], output_file: str | None = None, calibration_result: CalibrationResult | None = None, config: PlotConfig | None = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = 'Step', axes: Figure | None = None, **kwargs: str | int | float | bool | None) -> Figure ``` Draw each observed series with the model read through its observation operator, one panel per label. The model is read only at the observations: the value of each observation through its own operator (the sum of its outputs, its window and its factor), as the loss reads it. A value with a window is a total over `(at - window, at]`, drawn as a step over its window with a marker at `at`. A value with no window is a marker, joined to the next only as a guide. The run along the whole axis is `series`, on the same figure with `axes=`. Parameters: | Name | Type | Description | Default | | -------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `observed_data` | `list[ObservedDataPoint]` | The observations. Each label is one panel, in the order of its first observation, with the Axes label of the label. | *required* | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `calibration_result` | \`CalibrationResult | None\` | Its best_parameters give the values of the factors of the observations, which multiply the model; observed values stay in their own units. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `show_legend` | `bool` | Whether each panel shows its legend. | `True` | | `position_label` | \`Callable\[[float], str\] | None\` | Turns a position into a tick label, as in series. | | `tick_every` | \`float | None\` | The distance between labelled observation positions, as in series. | | `x_label` | `str` | The label of the x axis. | `'Step'` | | `axes` | \`Figure | None\` | A figure of commol to draw on: each observed series is drawn on the Axes whose label is its label. If None, a new figure. | | `**kwargs` | \`str | int | float | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If there are no observations; the results have no value at the position of an observation or at the start of its window; or axes has no panel of a label. | | `KeyError` | If an observation names an output the results do not have. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/api/plotting/accessors.py` ```python def observations( self, observed_data: "list[ObservedDataPoint]", output_file: str | None = None, calibration_result: "CalibrationResult | None" = None, config: "PlotConfig | None" = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = "Step", axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw each observed series with the model read through its observation operator, one panel per label. The model is read only at the observations: the value of each observation through its own operator (the sum of its outputs, its window and its factor), as the loss reads it. A value with a window is a total over ``(at - window, at]``, drawn as a step over its window with a marker at ``at``. A value with no window is a marker, joined to the next only as a guide. The run along the whole axis is `series`, on the same figure with ``axes=``. Parameters ---------- observed_data : list[ObservedDataPoint] The observations. Each label is one panel, in the order of its first observation, with the `Axes` label of the label. output_file : str | None Path to save the figure to. If None, the figure is only returned. calibration_result : CalibrationResult | None Its ``best_parameters`` give the values of the factors of the observations, which multiply the model; observed values stay in their own units. config : PlotConfig | None Layout and style. If None, the defaults. show_legend : bool Whether each panel shows its legend. position_label : Callable[[float], str] | None Turns a position into a tick label, as in `series`. tick_every : float | None The distance between labelled observation positions, as in `series`. x_label : str The label of the x axis. axes : Figure | None A figure of commol to draw on: each observed series is drawn on the `Axes` whose label is its label. If None, a new figure. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the steps and the guide of the model, such as ``linewidth`` or ``alpha``. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If there are no observations; the results have no value at the position of an observation or at the start of its window; or ``axes`` has no panel of a label. KeyError If an observation names an output the results do not have. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _run_observations return _run_observations( self._results, observed_data, output_file=output_file, calibration_result=calibration_result, config=config, show_legend=show_legend, position_label=position_label, tick_every=tick_every, x_label=x_label, axes=axes, **kwargs, ) ``` ## ReplicatesPlot The figures of the replicates of a stochastic engine, read from `replicates.plot`. The figures are those of `ResultsPlot` for the mean of the replicates; `series` adds the band of the replicates. Source code in `commol/api/plotting/accessors.py` ```python class ReplicatesPlot: """ The figures of the replicates of a stochastic engine, read from ``replicates.plot``. The figures are those of `ResultsPlot` for the mean of the replicates; `series` adds the band of the replicates. """ def __init__(self, replicates: "SimulationReplicates") -> None: """ The figures of `replicates`. Parameters ---------- replicates : SimulationReplicates The replicates. """ self._replicates = replicates def series( self, output_file: str | None = None, observed_data: "list[ObservedDataPoint] | None" = None, calibration_result: "CalibrationResult | None" = None, config: "PlotConfig | None" = None, series: list[str] | None = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = "Step", prediction_intervals: "PredictionIntervals | None" = None, confidence_level: float = 0.95, axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw the mean of each series and the band of the replicates, one panel per series. Each replicate is reduced to the series first, through the operator of its observations, then the band takes the interval of `confidence_level` across them (`member_statistics`). Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. observed_data : list[ObservedDataPoint] | None Observations, as in `ResultsPlot.series`. calibration_result : CalibrationResult | None The values of the factors, as in `ResultsPlot.series`. config : PlotConfig | None Layout and style. If None, the defaults. series : list[str] | None What to draw, as in `ResultsPlot.series`. show_legend : bool Whether each panel shows its legend. position_label : Callable[[float], str] | None Turns a position into a tick label, as in `ResultsPlot.series`. tick_every : float | None The distance between labelled positions, as in `ResultsPlot.series`. x_label : str The label of the x axis. prediction_intervals : PredictionIntervals | None Prediction intervals of a conformal method, as in `ResultsPlot.series`. confidence_level : float The level of the band, strictly between 0 and 1. axes : Figure | None A figure of commol to draw on, as in `ResultsPlot.series`. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the line of the mean. Returns ------- Figure The matplotlib figure. Raises ------ ValueError As `ResultsPlot.series`; or if there are no replicates or the confidence level is out of range. KeyError As `ResultsPlot.series`. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _replicates_series return _replicates_series( self._replicates, output_file=output_file, observed_data=observed_data, calibration_result=calibration_result, config=config, series=series, show_legend=show_legend, position_label=position_label, tick_every=tick_every, x_label=x_label, prediction_intervals=prediction_intervals, confidence_level=confidence_level, axes=axes, **kwargs, ) def cumulative( self, output_file: str | None = None, observed_data: "list[ObservedDataPoint] | None" = None, calibration_result: "CalibrationResult | None" = None, config: "PlotConfig | None" = None, series: list[str] | None = None, axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw the running total of the mean of the replicates, as `ResultsPlot.cumulative` draws one run. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. observed_data : list[ObservedDataPoint] | None Observations, as in `ResultsPlot.cumulative`. calibration_result : CalibrationResult | None The values of the factors, as in `ResultsPlot.cumulative`. config : PlotConfig | None Layout, size and style. If None, the defaults. series : list[str] | None What to draw, as in `ResultsPlot.series`. axes : Figure | None A figure of commol to draw on, as in `ResultsPlot.series`. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the line of the mean. Returns ------- Figure The matplotlib figure. Raises ------ ValueError As `ResultsPlot.cumulative`; or if there are no replicates. KeyError As `ResultsPlot.cumulative`. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _replicates_cumulative return _replicates_cumulative( self._replicates, output_file=output_file, observed_data=observed_data, calibration_result=calibration_result, config=config, series=series, axes=axes, **kwargs, ) def observations( self, observed_data: "list[ObservedDataPoint]", output_file: str | None = None, calibration_result: "CalibrationResult | None" = None, config: "PlotConfig | None" = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = "Step", confidence_level: float = 0.95, axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw each observed series with the mean of the replicates and their band, read through its observation operator, one panel per label. Each replicate is read through the operator of each observation first, as `ResultsPlot.observations` reads a run, then the band takes the interval of `confidence_level` across them (`member_statistics`). A band over a window is a rectangle over that window. The band is a band of the model, with no observation noise, not a prediction interval, and its legend says so. Parameters ---------- observed_data : list[ObservedDataPoint] The observations, as in `ResultsPlot.observations`. output_file : str | None Path to save the figure to. If None, the figure is only returned. calibration_result : CalibrationResult | None The values of the factors, as in `ResultsPlot.observations`. config : PlotConfig | None Layout and style. If None, the defaults. show_legend : bool Whether each panel shows its legend. position_label : Callable[[float], str] | None Turns a position into a tick label, as in `ResultsPlot.series`. tick_every : float | None The distance between labelled positions, as in `ResultsPlot.series`. x_label : str The label of the x axis. confidence_level : float The level of the band, strictly between 0 and 1. axes : Figure | None A figure of commol to draw on, as in `ResultsPlot.observations`. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the steps and the guide of the mean. Returns ------- Figure The matplotlib figure. Raises ------ ValueError As `ResultsPlot.observations`; or if there are no replicates or the confidence level is out of range. KeyError If an observation names an output the replicates do not have. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _replicates_observations return _replicates_observations( self._replicates, observed_data, output_file=output_file, calibration_result=calibration_result, config=config, show_legend=show_legend, position_label=position_label, tick_every=tick_every, x_label=x_label, confidence_level=confidence_level, axes=axes, **kwargs, ) ``` ### Methods: #### __init__ ```python __init__(replicates: SimulationReplicates) -> None ``` The figures of `replicates`. Parameters: | Name | Type | Description | Default | | ------------ | ---------------------- | --------------- | ---------- | | `replicates` | `SimulationReplicates` | The replicates. | *required* | Source code in `commol/api/plotting/accessors.py` ```python def __init__(self, replicates: "SimulationReplicates") -> None: """ The figures of `replicates`. Parameters ---------- replicates : SimulationReplicates The replicates. """ self._replicates = replicates ``` #### series ```python series(output_file: str | None = None, observed_data: list[ObservedDataPoint] | None = None, calibration_result: CalibrationResult | None = None, config: PlotConfig | None = None, series: list[str] | None = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = 'Step', prediction_intervals: PredictionIntervals | None = None, confidence_level: float = 0.95, axes: Figure | None = None, **kwargs: str | int | float | bool | None) -> Figure ``` Draw the mean of each series and the band of the replicates, one panel per series. Each replicate is reduced to the series first, through the operator of its observations, then the band takes the interval of `confidence_level` across them (`member_statistics`). Parameters: | Name | Type | Description | Default | | ---------------------- | -------------------------- | ------------------------------------------------ | --------------------------------------------------------------------- | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `observed_data` | \`list[ObservedDataPoint] | None\` | Observations, as in ResultsPlot.series. | | `calibration_result` | \`CalibrationResult | None\` | The values of the factors, as in ResultsPlot.series. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `series` | \`list[str] | None\` | What to draw, as in ResultsPlot.series. | | `show_legend` | `bool` | Whether each panel shows its legend. | `True` | | `position_label` | \`Callable\[[float], str\] | None\` | Turns a position into a tick label, as in ResultsPlot.series. | | `tick_every` | \`float | None\` | The distance between labelled positions, as in ResultsPlot.series. | | `x_label` | `str` | The label of the x axis. | `'Step'` | | `prediction_intervals` | \`PredictionIntervals | None\` | Prediction intervals of a conformal method, as in ResultsPlot.series. | | `confidence_level` | `float` | The level of the band, strictly between 0 and 1. | `0.95` | | `axes` | \`Figure | None\` | A figure of commol to draw on, as in ResultsPlot.series. | | `**kwargs` | \`str | int | float | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | --------------------------------------------------------------------------------------------- | | `ValueError` | As ResultsPlot.series; or if there are no replicates or the confidence level is out of range. | | `KeyError` | As ResultsPlot.series. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/api/plotting/accessors.py` ```python def series( self, output_file: str | None = None, observed_data: "list[ObservedDataPoint] | None" = None, calibration_result: "CalibrationResult | None" = None, config: "PlotConfig | None" = None, series: list[str] | None = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = "Step", prediction_intervals: "PredictionIntervals | None" = None, confidence_level: float = 0.95, axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw the mean of each series and the band of the replicates, one panel per series. Each replicate is reduced to the series first, through the operator of its observations, then the band takes the interval of `confidence_level` across them (`member_statistics`). Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. observed_data : list[ObservedDataPoint] | None Observations, as in `ResultsPlot.series`. calibration_result : CalibrationResult | None The values of the factors, as in `ResultsPlot.series`. config : PlotConfig | None Layout and style. If None, the defaults. series : list[str] | None What to draw, as in `ResultsPlot.series`. show_legend : bool Whether each panel shows its legend. position_label : Callable[[float], str] | None Turns a position into a tick label, as in `ResultsPlot.series`. tick_every : float | None The distance between labelled positions, as in `ResultsPlot.series`. x_label : str The label of the x axis. prediction_intervals : PredictionIntervals | None Prediction intervals of a conformal method, as in `ResultsPlot.series`. confidence_level : float The level of the band, strictly between 0 and 1. axes : Figure | None A figure of commol to draw on, as in `ResultsPlot.series`. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the line of the mean. Returns ------- Figure The matplotlib figure. Raises ------ ValueError As `ResultsPlot.series`; or if there are no replicates or the confidence level is out of range. KeyError As `ResultsPlot.series`. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _replicates_series return _replicates_series( self._replicates, output_file=output_file, observed_data=observed_data, calibration_result=calibration_result, config=config, series=series, show_legend=show_legend, position_label=position_label, tick_every=tick_every, x_label=x_label, prediction_intervals=prediction_intervals, confidence_level=confidence_level, axes=axes, **kwargs, ) ``` #### cumulative ```python cumulative(output_file: str | None = None, observed_data: list[ObservedDataPoint] | None = None, calibration_result: CalibrationResult | None = None, config: PlotConfig | None = None, series: list[str] | None = None, axes: Figure | None = None, **kwargs: str | int | float | bool | None) -> Figure ``` Draw the running total of the mean of the replicates, as `ResultsPlot.cumulative` draws one run. Parameters: | Name | Type | Description | Default | | -------------------- | ------------------------- | ----------- | ----------------------------------------------------------------- | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `observed_data` | \`list[ObservedDataPoint] | None\` | Observations, as in ResultsPlot.cumulative. | | `calibration_result` | \`CalibrationResult | None\` | The values of the factors, as in ResultsPlot.cumulative. | | `config` | \`PlotConfig | None\` | Layout, size and style. If None, the defaults. | | `series` | \`list[str] | None\` | What to draw, as in ResultsPlot.series. | | `axes` | \`Figure | None\` | A figure of commol to draw on, as in ResultsPlot.series. | | `**kwargs` | \`str | int | float | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ------------------------------------------------------------ | | `ValueError` | As ResultsPlot.cumulative; or if there are no replicates. | | `KeyError` | As ResultsPlot.cumulative. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/api/plotting/accessors.py` ```python def cumulative( self, output_file: str | None = None, observed_data: "list[ObservedDataPoint] | None" = None, calibration_result: "CalibrationResult | None" = None, config: "PlotConfig | None" = None, series: list[str] | None = None, axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw the running total of the mean of the replicates, as `ResultsPlot.cumulative` draws one run. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. observed_data : list[ObservedDataPoint] | None Observations, as in `ResultsPlot.cumulative`. calibration_result : CalibrationResult | None The values of the factors, as in `ResultsPlot.cumulative`. config : PlotConfig | None Layout, size and style. If None, the defaults. series : list[str] | None What to draw, as in `ResultsPlot.series`. axes : Figure | None A figure of commol to draw on, as in `ResultsPlot.series`. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the line of the mean. Returns ------- Figure The matplotlib figure. Raises ------ ValueError As `ResultsPlot.cumulative`; or if there are no replicates. KeyError As `ResultsPlot.cumulative`. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _replicates_cumulative return _replicates_cumulative( self._replicates, output_file=output_file, observed_data=observed_data, calibration_result=calibration_result, config=config, series=series, axes=axes, **kwargs, ) ``` #### observations ```python observations(observed_data: list[ObservedDataPoint], output_file: str | None = None, calibration_result: CalibrationResult | None = None, config: PlotConfig | None = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = 'Step', confidence_level: float = 0.95, axes: Figure | None = None, **kwargs: str | int | float | bool | None) -> Figure ``` Draw each observed series with the mean of the replicates and their band, read through its observation operator, one panel per label. Each replicate is read through the operator of each observation first, as `ResultsPlot.observations` reads a run, then the band takes the interval of `confidence_level` across them (`member_statistics`). A band over a window is a rectangle over that window. The band is a band of the model, with no observation noise, not a prediction interval, and its legend says so. Parameters: | Name | Type | Description | Default | | -------------------- | -------------------------- | ------------------------------------------------- | ------------------------------------------------------------------ | | `observed_data` | `list[ObservedDataPoint]` | The observations, as in ResultsPlot.observations. | *required* | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `calibration_result` | \`CalibrationResult | None\` | The values of the factors, as in ResultsPlot.observations. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `show_legend` | `bool` | Whether each panel shows its legend. | `True` | | `position_label` | \`Callable\[[float], str\] | None\` | Turns a position into a tick label, as in ResultsPlot.series. | | `tick_every` | \`float | None\` | The distance between labelled positions, as in ResultsPlot.series. | | `x_label` | `str` | The label of the x axis. | `'Step'` | | `confidence_level` | `float` | The level of the band, strictly between 0 and 1. | `0.95` | | `axes` | \`Figure | None\` | A figure of commol to draw on, as in ResultsPlot.observations. | | `**kwargs` | \`str | int | float | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | --------------------------------------------------------------------------------------------------- | | `ValueError` | As ResultsPlot.observations; or if there are no replicates or the confidence level is out of range. | | `KeyError` | If an observation names an output the replicates do not have. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/api/plotting/accessors.py` ```python def observations( self, observed_data: "list[ObservedDataPoint]", output_file: str | None = None, calibration_result: "CalibrationResult | None" = None, config: "PlotConfig | None" = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = "Step", confidence_level: float = 0.95, axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw each observed series with the mean of the replicates and their band, read through its observation operator, one panel per label. Each replicate is read through the operator of each observation first, as `ResultsPlot.observations` reads a run, then the band takes the interval of `confidence_level` across them (`member_statistics`). A band over a window is a rectangle over that window. The band is a band of the model, with no observation noise, not a prediction interval, and its legend says so. Parameters ---------- observed_data : list[ObservedDataPoint] The observations, as in `ResultsPlot.observations`. output_file : str | None Path to save the figure to. If None, the figure is only returned. calibration_result : CalibrationResult | None The values of the factors, as in `ResultsPlot.observations`. config : PlotConfig | None Layout and style. If None, the defaults. show_legend : bool Whether each panel shows its legend. position_label : Callable[[float], str] | None Turns a position into a tick label, as in `ResultsPlot.series`. tick_every : float | None The distance between labelled positions, as in `ResultsPlot.series`. x_label : str The label of the x axis. confidence_level : float The level of the band, strictly between 0 and 1. axes : Figure | None A figure of commol to draw on, as in `ResultsPlot.observations`. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the steps and the guide of the mean. Returns ------- Figure The matplotlib figure. Raises ------ ValueError As `ResultsPlot.observations`; or if there are no replicates or the confidence level is out of range. KeyError If an observation names an output the replicates do not have. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _replicates_observations return _replicates_observations( self._replicates, observed_data, output_file=output_file, calibration_result=calibration_result, config=config, show_legend=show_legend, position_label=position_label, tick_every=tick_every, x_label=x_label, confidence_level=confidence_level, axes=axes, **kwargs, ) ``` ## PredictivePlot The figures of a predictive, read from `predictive.plot`. Each panel shows the weighted median of the draws and their band, with no other run; the model of the source names the series. Source code in `commol/api/plotting/accessors.py` ```python class PredictivePlot: """ The figures of a predictive, read from ``predictive.plot``. Each panel shows the weighted median of the draws and their band, with no other run; the model of the source names the series. """ def __init__(self, predictive: "Predictive") -> None: """ The figures of `predictive`. Parameters ---------- predictive : Predictive The predictive. """ self._predictive = predictive def series( self, output_file: str | None = None, observed_data: "list[ObservedDataPoint] | None" = None, config: "PlotConfig | None" = None, series: list[str] | None = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = "Step", prediction_intervals: "PredictionIntervals | None" = None, confidence_level: float = 0.95, axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw the weighted median of the draws of each series and their band, one panel per series. Each draw is read through the observation operator of the problem (the sums, windows and factors of the observations of the series, each at its own position when they do not share one operator with no window), then the draws are reduced with their weights. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. observed_data : list[ObservedDataPoint] | None Observations, each drawn as a point on the panel of its label; they set the operator each draw is read through. config : PlotConfig | None Layout and style. If None, the defaults. series : list[str] | None What to draw, as in `ResultsPlot.series`. show_legend : bool Whether each panel shows its legend. position_label : Callable[[float], str] | None Turns a position into a tick label, as in `ResultsPlot.series`. tick_every : float | None The distance between labelled positions, as in `ResultsPlot.series`. x_label : str The label of the x axis. prediction_intervals : PredictionIntervals | None Prediction intervals of a conformal method, as in `ResultsPlot.series`. confidence_level : float The level of the band, strictly between 0 and 1. axes : Figure | None A figure of commol to draw on, as in `ResultsPlot.series`. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the line of the median. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If an observation names an unknown output or factor, the confidence level is out of range, or ``axes`` has no panel of a series. KeyError If an entry of ``series`` is neither an output nor a bin or accumulator id of the model. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _predictive_series return _predictive_series( self._predictive, output_file=output_file, observed_data=observed_data, config=config, series=series, show_legend=show_legend, position_label=position_label, tick_every=tick_every, x_label=x_label, prediction_intervals=prediction_intervals, confidence_level=confidence_level, axes=axes, **kwargs, ) def cumulative( self, output_file: str | None = None, observed_data: "list[ObservedDataPoint] | None" = None, config: "PlotConfig | None" = None, series: list[str] | None = None, confidence_level: float = 0.95, axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw the weighted median and the band of the running total of each series across the draws, one panel per series. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. observed_data : list[ObservedDataPoint] | None Observations, drawn as their running total on the panel of their label. An observation with a factor is divided by the weighted median of that factor in the sample. config : PlotConfig | None Layout, size and style. If None, the defaults. series : list[str] | None What to draw, as in `ResultsPlot.series`. confidence_level : float The level of the band, strictly between 0 and 1. axes : Figure | None A figure of commol to draw on, as in `ResultsPlot.series`. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the line of the median. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If the confidence level is out of range, or ``axes`` has no panel of a series. KeyError If an entry of ``series`` is neither an output nor a bin or accumulator id of the model. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _predictive_cumulative return _predictive_cumulative( self._predictive, output_file=output_file, observed_data=observed_data, config=config, series=series, confidence_level=confidence_level, axes=axes, **kwargs, ) def observations( self, observed_data: "list[ObservedDataPoint]", output_file: str | None = None, config: "PlotConfig | None" = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = "Step", confidence_level: float = 0.95, axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw each observed series with the weighted median of the draws and their band, read through its observation operator, one panel per label. The values are those of ``at_observations(observed_data, NoNoise())``: each draw's value of each observation through its own operator (the sum of its outputs, its window and the draw's value of its factor), the values the loss compares. A value with a window is drawn as a step over its window with a marker at ``at``, and its band as a rectangle over the same window; a value with no window is a marker, joined only as a guide. The band is a band of the model, with no observation noise, not a prediction interval, and its legend says so. Parameters ---------- observed_data : list[ObservedDataPoint] The observations, as in `ResultsPlot.observations`. output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. show_legend : bool Whether each panel shows its legend. position_label : Callable[[float], str] | None Turns a position into a tick label, as in `ResultsPlot.series`. tick_every : float | None The distance between labelled positions, as in `ResultsPlot.series`. x_label : str The label of the x axis. confidence_level : float The level of the band, strictly between 0 and 1. axes : Figure | None A figure of commol to draw on, as in `ResultsPlot.observations`. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the steps and the guide of the median. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If there are no observations, an observation names an unknown output or factor, the confidence level is out of range, or ``axes`` has no panel of a label. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _predictive_observations return _predictive_observations( self._predictive, observed_data, output_file=output_file, config=config, show_legend=show_legend, position_label=position_label, tick_every=tick_every, x_label=x_label, confidence_level=confidence_level, axes=axes, **kwargs, ) ``` ### Methods: #### __init__ ```python __init__(predictive: Predictive) -> None ``` The figures of `predictive`. Parameters: | Name | Type | Description | Default | | ------------ | ------------ | --------------- | ---------- | | `predictive` | `Predictive` | The predictive. | *required* | Source code in `commol/api/plotting/accessors.py` ```python def __init__(self, predictive: "Predictive") -> None: """ The figures of `predictive`. Parameters ---------- predictive : Predictive The predictive. """ self._predictive = predictive ``` #### series ```python series(output_file: str | None = None, observed_data: list[ObservedDataPoint] | None = None, config: PlotConfig | None = None, series: list[str] | None = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = 'Step', prediction_intervals: PredictionIntervals | None = None, confidence_level: float = 0.95, axes: Figure | None = None, **kwargs: str | int | float | bool | None) -> Figure ``` Draw the weighted median of the draws of each series and their band, one panel per series. Each draw is read through the observation operator of the problem (the sums, windows and factors of the observations of the series, each at its own position when they do not share one operator with no window), then the draws are reduced with their weights. Parameters: | Name | Type | Description | Default | | ---------------------- | -------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `observed_data` | \`list[ObservedDataPoint] | None\` | Observations, each drawn as a point on the panel of its label; they set the operator each draw is read through. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `series` | \`list[str] | None\` | What to draw, as in ResultsPlot.series. | | `show_legend` | `bool` | Whether each panel shows its legend. | `True` | | `position_label` | \`Callable\[[float], str\] | None\` | Turns a position into a tick label, as in ResultsPlot.series. | | `tick_every` | \`float | None\` | The distance between labelled positions, as in ResultsPlot.series. | | `x_label` | `str` | The label of the x axis. | `'Step'` | | `prediction_intervals` | \`PredictionIntervals | None\` | Prediction intervals of a conformal method, as in ResultsPlot.series. | | `confidence_level` | `float` | The level of the band, strictly between 0 and 1. | `0.95` | | `axes` | \`Figure | None\` | A figure of commol to draw on, as in ResultsPlot.series. | | `**kwargs` | \`str | int | float | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If an observation names an unknown output or factor, the confidence level is out of range, or axes has no panel of a series. | | `KeyError` | If an entry of series is neither an output nor a bin or accumulator id of the model. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/api/plotting/accessors.py` ```python def series( self, output_file: str | None = None, observed_data: "list[ObservedDataPoint] | None" = None, config: "PlotConfig | None" = None, series: list[str] | None = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = "Step", prediction_intervals: "PredictionIntervals | None" = None, confidence_level: float = 0.95, axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw the weighted median of the draws of each series and their band, one panel per series. Each draw is read through the observation operator of the problem (the sums, windows and factors of the observations of the series, each at its own position when they do not share one operator with no window), then the draws are reduced with their weights. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. observed_data : list[ObservedDataPoint] | None Observations, each drawn as a point on the panel of its label; they set the operator each draw is read through. config : PlotConfig | None Layout and style. If None, the defaults. series : list[str] | None What to draw, as in `ResultsPlot.series`. show_legend : bool Whether each panel shows its legend. position_label : Callable[[float], str] | None Turns a position into a tick label, as in `ResultsPlot.series`. tick_every : float | None The distance between labelled positions, as in `ResultsPlot.series`. x_label : str The label of the x axis. prediction_intervals : PredictionIntervals | None Prediction intervals of a conformal method, as in `ResultsPlot.series`. confidence_level : float The level of the band, strictly between 0 and 1. axes : Figure | None A figure of commol to draw on, as in `ResultsPlot.series`. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the line of the median. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If an observation names an unknown output or factor, the confidence level is out of range, or ``axes`` has no panel of a series. KeyError If an entry of ``series`` is neither an output nor a bin or accumulator id of the model. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _predictive_series return _predictive_series( self._predictive, output_file=output_file, observed_data=observed_data, config=config, series=series, show_legend=show_legend, position_label=position_label, tick_every=tick_every, x_label=x_label, prediction_intervals=prediction_intervals, confidence_level=confidence_level, axes=axes, **kwargs, ) ``` #### cumulative ```python cumulative(output_file: str | None = None, observed_data: list[ObservedDataPoint] | None = None, config: PlotConfig | None = None, series: list[str] | None = None, confidence_level: float = 0.95, axes: Figure | None = None, **kwargs: str | int | float | bool | None) -> Figure ``` Draw the weighted median and the band of the running total of each series across the draws, one panel per series. Parameters: | Name | Type | Description | Default | | ------------------ | ------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `observed_data` | \`list[ObservedDataPoint] | None\` | Observations, drawn as their running total on the panel of their label. An observation with a factor is divided by the weighted median of that factor in the sample. | | `config` | \`PlotConfig | None\` | Layout, size and style. If None, the defaults. | | `series` | \`list[str] | None\` | What to draw, as in ResultsPlot.series. | | `confidence_level` | `float` | The level of the band, strictly between 0 and 1. | `0.95` | | `axes` | \`Figure | None\` | A figure of commol to draw on, as in ResultsPlot.series. | | `**kwargs` | \`str | int | float | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ------------------------------------------------------------------------------------ | | `ValueError` | If the confidence level is out of range, or axes has no panel of a series. | | `KeyError` | If an entry of series is neither an output nor a bin or accumulator id of the model. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/api/plotting/accessors.py` ```python def cumulative( self, output_file: str | None = None, observed_data: "list[ObservedDataPoint] | None" = None, config: "PlotConfig | None" = None, series: list[str] | None = None, confidence_level: float = 0.95, axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw the weighted median and the band of the running total of each series across the draws, one panel per series. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. observed_data : list[ObservedDataPoint] | None Observations, drawn as their running total on the panel of their label. An observation with a factor is divided by the weighted median of that factor in the sample. config : PlotConfig | None Layout, size and style. If None, the defaults. series : list[str] | None What to draw, as in `ResultsPlot.series`. confidence_level : float The level of the band, strictly between 0 and 1. axes : Figure | None A figure of commol to draw on, as in `ResultsPlot.series`. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the line of the median. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If the confidence level is out of range, or ``axes`` has no panel of a series. KeyError If an entry of ``series`` is neither an output nor a bin or accumulator id of the model. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _predictive_cumulative return _predictive_cumulative( self._predictive, output_file=output_file, observed_data=observed_data, config=config, series=series, confidence_level=confidence_level, axes=axes, **kwargs, ) ``` #### observations ```python observations(observed_data: list[ObservedDataPoint], output_file: str | None = None, config: PlotConfig | None = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = 'Step', confidence_level: float = 0.95, axes: Figure | None = None, **kwargs: str | int | float | bool | None) -> Figure ``` Draw each observed series with the weighted median of the draws and their band, read through its observation operator, one panel per label. The values are those of `at_observations(observed_data, NoNoise())`: each draw's value of each observation through its own operator (the sum of its outputs, its window and the draw's value of its factor), the values the loss compares. A value with a window is drawn as a step over its window with a marker at `at`, and its band as a rectangle over the same window; a value with no window is a marker, joined only as a guide. The band is a band of the model, with no observation noise, not a prediction interval, and its legend says so. Parameters: | Name | Type | Description | Default | | ------------------ | -------------------------- | ------------------------------------------------- | ------------------------------------------------------------------ | | `observed_data` | `list[ObservedDataPoint]` | The observations, as in ResultsPlot.observations. | *required* | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `show_legend` | `bool` | Whether each panel shows its legend. | `True` | | `position_label` | \`Callable\[[float], str\] | None\` | Turns a position into a tick label, as in ResultsPlot.series. | | `tick_every` | \`float | None\` | The distance between labelled positions, as in ResultsPlot.series. | | `x_label` | `str` | The label of the x axis. | `'Step'` | | `confidence_level` | `float` | The level of the band, strictly between 0 and 1. | `0.95` | | `axes` | \`Figure | None\` | A figure of commol to draw on, as in ResultsPlot.observations. | | `**kwargs` | \`str | int | float | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ValueError` | If there are no observations, an observation names an unknown output or factor, the confidence level is out of range, or axes has no panel of a label. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/api/plotting/accessors.py` ```python def observations( self, observed_data: "list[ObservedDataPoint]", output_file: str | None = None, config: "PlotConfig | None" = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = "Step", confidence_level: float = 0.95, axes: "Figure | None" = None, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw each observed series with the weighted median of the draws and their band, read through its observation operator, one panel per label. The values are those of ``at_observations(observed_data, NoNoise())``: each draw's value of each observation through its own operator (the sum of its outputs, its window and the draw's value of its factor), the values the loss compares. A value with a window is drawn as a step over its window with a marker at ``at``, and its band as a rectangle over the same window; a value with no window is a marker, joined only as a guide. The band is a band of the model, with no observation noise, not a prediction interval, and its legend says so. Parameters ---------- observed_data : list[ObservedDataPoint] The observations, as in `ResultsPlot.observations`. output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. show_legend : bool Whether each panel shows its legend. position_label : Callable[[float], str] | None Turns a position into a tick label, as in `ResultsPlot.series`. tick_every : float | None The distance between labelled positions, as in `ResultsPlot.series`. x_label : str The label of the x axis. confidence_level : float The level of the band, strictly between 0 and 1. axes : Figure | None A figure of commol to draw on, as in `ResultsPlot.observations`. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the steps and the guide of the median. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If there are no observations, an observation names an unknown output or factor, the confidence level is out of range, or ``axes`` has no panel of a label. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _predictive_observations return _predictive_observations( self._predictive, observed_data, output_file=output_file, config=config, show_legend=show_legend, position_label=position_label, tick_every=tick_every, x_label=x_label, confidence_level=confidence_level, axes=axes, **kwargs, ) ``` ## plot_compared ```python plot_compared(results: Mapping[str, SimulationResults | SimulationReplicates | Predictive], series: list[str] | None = None, window: float | None = None, observed_data: list[ObservedDataPoint] | None = None, panels: Literal['series', 'observations'] = 'series', axes: Figure | None = None, output_file: str | None = None, config: PlotConfig | None = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = 'Step', confidence_level: float = 0.95, **kwargs: str | int | float | bool | None) -> Figure ``` Draw several results of the same model on the same panels, one panel per series or one per observed series. Each panel has one line per result, labelled with its key and in the same color in every panel (`C0` for the first key, `C1` for the second, …). Replicates show their mean and the band of the replicates, and a predictive the weighted median of its draws and their band, in the color of their key. The observations are drawn once. Parameters: | Name | Type | Description | Default | | ------------------ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `results` | \`Mapping\[str, SimulationResults | SimulationReplicates | Predictive\]\` | | `series` | \`list[str] | None\` | What to draw, one panel each, as in ResultsPlot.series. If None, every output. Only with panels="series". | | `window` | \`float | None\` | The length of a window on the axis. Each line is then the gain of the series over each complete window, as Simulation.windowed_totals reads it, at the end of the window; a band is the band of these gains. If None, each line is the line of the plot.series of its result with observed_data. Only with panels="series". | | `observed_data` | \`list[ObservedDataPoint] | None\` | Observations, each drawn once as a point on the panel of its label. Without window, they also set how each line is read, as in ResultsPlot.series. | | `panels` | `Literal['series', 'observations']` | "series": one panel per entry of series. "observations": one panel per label of observed_data, with each result read only at the observations, through the observation operator of each one, as ResultsPlot.observations reads one result. | `'series'` | | `axes` | \`Figure | None\` | A figure of commol to draw on: each series is drawn on its panel, the Axes whose label is the id of the series (or the label of the observed series). If None, a new figure. | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `show_legend` | `bool` | Whether each panel shows its legend. | `True` | | `position_label` | \`Callable\[[float], str\] | None\` | Turns a position into a tick label, as in ResultsPlot.series. | | `tick_every` | \`float | None\` | The distance between labelled positions, as in ResultsPlot.series. | | `x_label` | `str` | The label of the x axis. | `'Step'` | | `confidence_level` | `float` | The level of the bands of replicates and predictives, strictly between 0 and 1. | `0.95` | | `**kwargs` | \`str | int | float | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If there are no results; a result does not have the outputs of the first (the message names its key and the outputs that differ); window is not positive; the confidence level is out of range; axes has no panel of a series; panels is neither "series" nor "observations"; or, with panels="observations", series or window is given, there are no observations, or a result has no value at the position of an observation or at the start of its window. | | `KeyError` | If an entry of series is neither an output nor a bin or accumulator id of the model. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Examples: ```pycon >>> figure = plot_compared({"k1 = 0.3": results, "k1 = 0.5": faster}) >>> checked = plot_compared( ... {"k1 = 0.3": results, "k1 = 0.5": faster}, ... observed_data=observed, ... panels="observations", ... ) ``` Source code in `commol/api/plotting/compared.py` ```python def plot_compared( results: "Mapping[str, SimulationResults | SimulationReplicates | Predictive]", series: list[str] | None = None, window: float | None = None, observed_data: "list[ObservedDataPoint] | None" = None, panels: Literal["series", "observations"] = "series", axes: "Figure | None" = None, output_file: str | None = None, config: "PlotConfig | None" = None, show_legend: bool = True, position_label: Callable[[float], str] | None = None, tick_every: float | None = None, x_label: str = "Step", confidence_level: float = 0.95, **kwargs: str | int | float | bool | None, ) -> "Figure": """ Draw several results of the same model on the same panels, one panel per series or one per observed series. Each panel has one line per result, labelled with its key and in the same color in every panel (``C0`` for the first key, ``C1`` for the second, …). Replicates show their mean and the band of the replicates, and a predictive the weighted median of its draws and their band, in the color of their key. The observations are drawn once. Parameters ---------- results : Mapping[str, SimulationResults | SimulationReplicates | Predictive] The results, by the label of their lines. Each has the outputs of the first; the model of the first names the series. series : list[str] | None What to draw, one panel each, as in ``ResultsPlot.series``. If None, every output. Only with ``panels="series"``. window : float | None The length of a window on the axis. Each line is then the gain of the series over each complete window, as `Simulation.windowed_totals` reads it, at the end of the window; a band is the band of these gains. If None, each line is the line of the ``plot.series`` of its result with `observed_data`. Only with ``panels="series"``. observed_data : list[ObservedDataPoint] | None Observations, each drawn once as a point on the panel of its label. Without `window`, they also set how each line is read, as in ``ResultsPlot.series``. panels : Literal["series", "observations"] ``"series"``: one panel per entry of `series`. ``"observations"``: one panel per label of `observed_data`, with each result read only at the observations, through the observation operator of each one, as ``ResultsPlot.observations`` reads one result. axes : Figure | None A figure of commol to draw on: each series is drawn on its panel, the `Axes` whose label is the id of the series (or the label of the observed series). If None, a new figure. output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. show_legend : bool Whether each panel shows its legend. position_label : Callable[[float], str] | None Turns a position into a tick label, as in ``ResultsPlot.series``. tick_every : float | None The distance between labelled positions, as in ``ResultsPlot.series``. x_label : str The label of the x axis. confidence_level : float The level of the bands of replicates and predictives, strictly between 0 and 1. **kwargs : str | int | float | bool | None Passed to ``Axes.plot`` for the line of each result, such as ``linewidth``. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If there are no results; a result does not have the outputs of the first (the message names its key and the outputs that differ); `window` is not positive; the confidence level is out of range; ``axes`` has no panel of a series; `panels` is neither ``"series"`` nor ``"observations"``; or, with ``panels="observations"``, `series` or `window` is given, there are no observations, or a result has no value at the position of an observation or at the start of its window. KeyError If an entry of ``series`` is neither an output nor a bin or accumulator id of the model. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). Examples -------- >>> figure = plot_compared({"k1 = 0.3": results, "k1 = 0.5": faster}) >>> checked = plot_compared( ... {"k1 = 0.3": results, "k1 = 0.5": faster}, ... observed_data=observed, ... panels="observations", ... ) """ from commol.api.plotting.figures import _compared_series return _compared_series( results, series=series, window=window, observed_data=observed_data, panels=panels, axes=axes, output_file=output_file, config=config, show_legend=show_legend, position_label=position_label, tick_every=tick_every, x_label=x_label, confidence_level=confidence_level, **kwargs, ) ``` ## plot ```python plot(output_file: str | None = None, config: PlotConfig | None = None, x_label: str = 'Step', axes: Figure | None = None) -> Figure ``` Draw the PRCC of each input: one panel per series. A series of a `Trajectory` draws one line per input over its positions; any other quantity draws one bar per input. Parameters: | Name | Type | Description | Default | | ------------- | ------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `x_label` | `str` | The label of the x axis of a series over positions. | `'Step'` | | `axes` | \`Figure | None\` | A figure of commol to draw on: each panel is the Axes whose label is the name of its series. If None, a new figure. | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ------------------------------------------------------------ | | `ValueError` | If axes has no panel of a series. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/context/inference/global_sensitivity.py` ```python def plot( self, output_file: str | None = None, config: "PlotConfig | None" = None, x_label: str = "Step", axes: "Figure | None" = None, ) -> "Figure": """ Draw the PRCC of each input: one panel per series. A series of a `Trajectory` draws one line per input over its positions; any other quantity draws one bar per input. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. x_label : str The label of the x axis of a series over positions. axes : Figure | None A figure of commol to draw on: each panel is the `Axes` whose label is the name of its series. If None, a new figure. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a series. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _prcc return _prcc( self, output_file=output_file, config=config, x_label=x_label, axes=axes ) ``` ## plot ```python plot(output_file: str | None = None, config: PlotConfig | None = None, axes: Figure | None = None) -> Figure ``` Draw `σ` against `μ*` of each input: one panel per quantity, one labelled point per input. An input far to the right matters; one high up acts through interactions or a non-linear effect. Parameters: | Name | Type | Description | Default | | ------------- | ------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------- | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `axes` | \`Figure | None\` | A figure of commol to draw on: each panel is the Axes whose label is the label of its quantity. If None, a new figure. | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ------------------------------------------------------------ | | `ValueError` | If axes has no panel of a quantity. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/context/inference/global_sensitivity.py` ```python def plot( self, output_file: str | None = None, config: "PlotConfig | None" = None, axes: "Figure | None" = None, ) -> "Figure": """ Draw ``σ`` against ``μ*`` of each input: one panel per quantity, one labelled point per input. An input far to the right matters; one high up acts through interactions or a non-linear effect. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. axes : Figure | None A figure of commol to draw on: each panel is the `Axes` whose label is the label of its quantity. If None, a new figure. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a quantity. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _morris return _morris(self, output_file=output_file, config=config, axes=axes) ``` ## plot ```python plot(output_file: str | None = None, config: PlotConfig | None = None, axes: Figure | None = None) -> Figure ``` Draw the first-order and total Sobol indices: one panel per quantity, one pair of bars per input, with their intervals. Parameters: | Name | Type | Description | Default | | ------------- | ------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------- | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `axes` | \`Figure | None\` | A figure of commol to draw on: each panel is the Axes whose label is the label of its quantity. If None, a new figure. | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ------------------------------------------------------------ | | `ValueError` | If axes has no panel of a quantity. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/context/inference/global_sensitivity.py` ```python def plot( self, output_file: str | None = None, config: "PlotConfig | None" = None, axes: "Figure | None" = None, ) -> "Figure": """ Draw the first-order and total Sobol indices: one panel per quantity, one pair of bars per input, with their intervals. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. axes : Figure | None A figure of commol to draw on: each panel is the `Axes` whose label is the label of its quantity. If None, a new figure. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a quantity. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _sobol return _sobol(self, output_file=output_file, config=config, axes=axes) ``` ## plot ```python plot(output_file: str | None = None, config: PlotConfig | None = None, axes: Figure | None = None) -> Figure ``` Draw the first-order and total Sobol indices of the expansion: one panel per quantity, one pair of bars per input. Parameters: | Name | Type | Description | Default | | ------------- | ------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------- | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `axes` | \`Figure | None\` | A figure of commol to draw on: each panel is the Axes whose label is the label of its quantity. If None, a new figure. | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ------------------------------------------------------------ | | `ValueError` | If axes has no panel of a quantity. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/context/inference/global_sensitivity.py` ```python def plot( self, output_file: str | None = None, config: "PlotConfig | None" = None, axes: "Figure | None" = None, ) -> "Figure": """ Draw the first-order and total Sobol indices of the expansion: one panel per quantity, one pair of bars per input. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. axes : Figure | None A figure of commol to draw on: each panel is the `Axes` whose label is the label of its quantity. If None, a new figure. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a quantity. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _sobol return _sobol(self, output_file=output_file, config=config, axes=axes) ``` ## plot ```python plot(output_file: str | None = None, config: PlotConfig | None = None, axes: Figure | None = None) -> Figure ``` Draw each profile: one panel per parameter or prediction, with the statistic of each point, the threshold, and the ends of the interval. A profile that stays under the threshold up to a bound is open on that side; a flat one stays near 0 over its whole range. Parameters: | Name | Type | Description | Default | | ------------- | ------------ | ----------- | -------------------------------------------------------------------------------------------------------------------- | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `axes` | \`Figure | None\` | A figure of commol to draw on: each panel is the Axes whose label is the name of its profile. If None, a new figure. | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ------------------------------------------------------------ | | `ValueError` | If axes has no panel of a profile. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/context/inference/profiles.py` ```python def plot( self, output_file: str | None = None, config: "PlotConfig | None" = None, axes: "Figure | None" = None, ) -> "Figure": """ Draw each profile: one panel per parameter or prediction, with the statistic of each point, the threshold, and the ends of the interval. A profile that stays under the threshold up to a bound is open on that side; a flat one stays near 0 over its whole range. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. axes : Figure | None A figure of commol to draw on: each panel is the `Axes` whose label is the name of its profile. If None, a new figure. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a profile. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _profiles return _profiles(self, output_file=output_file, config=config, axes=axes) ``` ## PlotConfig Bases: `BaseModel` Configuration for plot layout and styling. Attributes: | Name | Type | Description | | --------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `figsize` | `tuple[float, float]` | Figure size in inches (width, height), of a figure of running totals; the other figures take their size from their panels. | | `dpi` | `int` | Dots per inch for figure resolution. | | `layout` | \`tuple[int, int] | None\` | | `style` | \`str | list[str] | Notes The style applies to the figure of one call only: after the call, `matplotlib.rcParams` is as it was before it, so the figures that you draw with matplotlib keep your style. Given `axes=`, a figure draws on the panels of an earlier figure, and `figsize`, `dpi` and `layout` are those of that figure. Examples: ```pycon >>> # Direct configuration >>> config = PlotConfig(style=["seaborn-v0_8-whitegrid", "seaborn-v0_8-talk"]) ``` ```pycon >>> # Using presets >>> config = PlotConfig.presentation() >>> config = PlotConfig.publication() ``` Source code in `commol/context/visualization.py` ```python class PlotConfig(BaseModel): """ Configuration for plot layout and styling. Attributes ---------- figsize : tuple[float, float] Figure size in inches (width, height), of a figure of running totals; the other figures take their size from their panels. dpi : int Dots per inch for figure resolution. layout : tuple[int, int] | None Panel layout as (rows, cols). If None, computed from the number of panels. style : str | list[str] | None A matplotlib style, or a list of styles applied in order: a name of ``matplotlib.style.available`` (such as ``"ggplot"`` or ``"seaborn-v0_8-whitegrid"``) or the path of a style file. See: https://matplotlib.org/stable/users/explain/customizing.html Notes ----- The style applies to the figure of one call only: after the call, ``matplotlib.rcParams`` is as it was before it, so the figures that you draw with matplotlib keep your style. Given ``axes=``, a figure draws on the panels of an earlier figure, and `figsize`, `dpi` and `layout` are those of that figure. Examples -------- >>> # Direct configuration >>> config = PlotConfig(style=["seaborn-v0_8-whitegrid", "seaborn-v0_8-talk"]) >>> # Using presets >>> config = PlotConfig.presentation() >>> config = PlotConfig.publication() """ model_config = ConfigDict(extra="forbid") figsize: tuple[float, float] = Field( default=(12, 8), description="Figure size in inches (width, height)", ) dpi: int = Field( default=100, description="Dots per inch for figure resolution", ) layout: tuple[int, int] | None = Field( default=None, description="Panel layout as (rows, cols). If None, computed.", ) style: str | list[str] | None = Field( default=None, description="A matplotlib style, or styles applied in order", ) @classmethod def presentation(cls) -> Self: """ Create a configuration optimized for presentations. Large figure size, high DPI, a white grid, the large text of the "talk" style and a palette for colour-blind readers. Returns ------- PlotConfig Configuration with presentation-friendly settings. """ return cls( figsize=(16, 10), dpi=150, style=[ "seaborn-v0_8-whitegrid", "seaborn-v0_8-talk", "seaborn-v0_8-colorblind", ], ) @classmethod def publication(cls) -> Self: """ Create a configuration optimized for academic publications. Smaller figure size suitable for journal columns, high DPI for print quality, ticks and the small text of the "paper" style. Returns ------- PlotConfig Configuration with publication-friendly settings. """ return cls( figsize=(8, 6), dpi=300, style=["seaborn-v0_8-ticks", "seaborn-v0_8-paper"], ) @classmethod def notebook(cls) -> Self: """ Create a configuration optimized for Jupyter notebooks. Balanced defaults for interactive exploration. Returns ------- PlotConfig Configuration with notebook-friendly settings. """ return cls( figsize=(12, 8), dpi=100, style=["seaborn-v0_8-whitegrid", "seaborn-v0_8-notebook"], ) ``` ### Methods: #### presentation ```python presentation() -> Self ``` Create a configuration optimized for presentations. Large figure size, high DPI, a white grid, the large text of the "talk" style and a palette for colour-blind readers. Returns: | Type | Description | | ------------ | -------------------------------------------------- | | `PlotConfig` | Configuration with presentation-friendly settings. | Source code in `commol/context/visualization.py` ```python @classmethod def presentation(cls) -> Self: """ Create a configuration optimized for presentations. Large figure size, high DPI, a white grid, the large text of the "talk" style and a palette for colour-blind readers. Returns ------- PlotConfig Configuration with presentation-friendly settings. """ return cls( figsize=(16, 10), dpi=150, style=[ "seaborn-v0_8-whitegrid", "seaborn-v0_8-talk", "seaborn-v0_8-colorblind", ], ) ``` #### publication ```python publication() -> Self ``` Create a configuration optimized for academic publications. Smaller figure size suitable for journal columns, high DPI for print quality, ticks and the small text of the "paper" style. Returns: | Type | Description | | ------------ | ------------------------------------------------- | | `PlotConfig` | Configuration with publication-friendly settings. | Source code in `commol/context/visualization.py` ```python @classmethod def publication(cls) -> Self: """ Create a configuration optimized for academic publications. Smaller figure size suitable for journal columns, high DPI for print quality, ticks and the small text of the "paper" style. Returns ------- PlotConfig Configuration with publication-friendly settings. """ return cls( figsize=(8, 6), dpi=300, style=["seaborn-v0_8-ticks", "seaborn-v0_8-paper"], ) ``` #### notebook ```python notebook() -> Self ``` Create a configuration optimized for Jupyter notebooks. Balanced defaults for interactive exploration. Returns: | Type | Description | | ------------ | ---------------------------------------------- | | `PlotConfig` | Configuration with notebook-friendly settings. | Source code in `commol/context/visualization.py` ```python @classmethod def notebook(cls) -> Self: """ Create a configuration optimized for Jupyter notebooks. Balanced defaults for interactive exploration. Returns ------- PlotConfig Configuration with notebook-friendly settings. """ return cls( figsize=(12, 8), dpi=100, style=["seaborn-v0_8-whitegrid", "seaborn-v0_8-notebook"], ) ``` # Reports API [Back to API Reference](https://munqu.github.io/commol/latest/api/index.md) Plain-dictionary reports, each a method of the object whose data it describes. Guide: [Reports](https://munqu.github.io/commol/latest/guide/reports/index.md). ## applied_parameters ```python applied_parameters(values: CalibrationResult | Mapping[str, float], problem: CalibrationProblem | None = None, sample: ParameterSample | None = None, confidence_level: float | None = None) -> AppliedParametersReport ``` Report every parameter of the model, and the values of its kind. Call it on the model **after** `apply_calibration_parameters`. Each entry carries the value the model holds and whether calibration set it, so entries the calibration left alone appear alongside the fitted ones. The structure of the model adds the sections of its kind (`ModelStructure.report_sections`); an aggregate model adds `initial_amounts` and `category_splits`. Parameters that are not a single number, such as a formula or a series, are omitted: they are inputs rather than fitted values. `factor` and `noise` parameters have no place in the model, so they are reported from `problem` when it is given. `initial_amounts` gives each bin and each overridden compartment of an aggregate model its `amount` the engine builds and the `unit` of its bin, so bins of different units are reported side by side; no row is a share of a total. A bin left uncalibrated keeps a `None` amount as its "needs calibration" marker even when overrides touch some of its compartments. `category_splits` gives each category its `fraction` of the group it splits, and that `group`: the categories a conditional stratification is conditioned on, empty when it splits every bin. With a `sample` each entry gains an `interval` holding the weighted statistics of its draws (`ParameterSample.summary`). Every entry is in the unit it was calibrated in: a parameter in its own, an amount in the unit of its bin, and a category split as a fraction of its group. Parameters: | Name | Type | Description | Default | | ------------------ | -------------------- | ----------- | ------------------------------------------------------------------------------------------- | | `values` | \`CalibrationResult | Mapping\` | The calibration result, or a mapping of parameter id to value such as one draw of a sample. | | `problem` | \`CalibrationProblem | None\` | The problem the values came from, used to report factor and noise parameters. | | `sample` | \`ParameterSample | None\` | Draws of the parameters, such as the sample of an uncertainty method. | | `confidence_level` | \`float | None\` | Confidence level of the intervals; required with a sample. | Returns: | Type | Description | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AppliedParametersReport` | Key parameters and the sections of the kind (initial_amounts and category_splits for an aggregate model), preceded by confidence_level when there is one. | Raises: | Type | Description | | ------------ | ------------------------------------------------ | | `ValueError` | If a sample is given without a confidence level. | Source code in `commol/context/model.py` ```python def applied_parameters( self, values: CalibrationResult | Mapping[str, float], problem: CalibrationProblem | None = None, sample: "ParameterSample | None" = None, confidence_level: float | None = None, ) -> "AppliedParametersReport": """ Report every parameter of the model, and the values of its kind. Call it on the model **after** `apply_calibration_parameters`. Each entry carries the value the model holds and whether calibration set it, so entries the calibration left alone appear alongside the fitted ones. The structure of the model adds the sections of its kind (`ModelStructure.report_sections`); an aggregate model adds ``initial_amounts`` and ``category_splits``. Parameters that are not a single number, such as a formula or a series, are omitted: they are inputs rather than fitted values. ``factor`` and ``noise`` parameters have no place in the model, so they are reported from `problem` when it is given. ``initial_amounts`` gives each bin and each overridden compartment of an aggregate model its ``amount`` the engine builds and the ``unit`` of its bin, so bins of different units are reported side by side; no row is a share of a total. A bin left uncalibrated keeps a ``None`` amount as its "needs calibration" marker even when overrides touch some of its compartments. ``category_splits`` gives each category its ``fraction`` of the group it splits, and that ``group``: the categories a conditional stratification is conditioned on, empty when it splits every bin. With a `sample` each entry gains an ``interval`` holding the weighted statistics of its draws (`ParameterSample.summary`). Every entry is in the unit it was calibrated in: a parameter in its own, an amount in the unit of its bin, and a category split as a fraction of its group. Parameters ---------- values : CalibrationResult | Mapping The calibration result, or a mapping of parameter id to value such as one draw of a sample. problem : CalibrationProblem | None, optional The problem the values came from, used to report factor and noise parameters. sample : ParameterSample | None, optional Draws of the parameters, such as the sample of an uncertainty method. confidence_level : float | None, optional Confidence level of the intervals; required with a sample. Returns ------- AppliedParametersReport Key ``parameters`` and the sections of the kind (``initial_amounts`` and ``category_splits`` for an aggregate model), preceded by ``confidence_level`` when there is one. Raises ------ ValueError If a sample is given without a confidence level. """ from commol.api.reporting import _applied_parameters_report return _applied_parameters_report( self, values, problem, sample, confidence_level ) ``` ## report ```python report(positions: Sequence[float], window: float, windows: int, observed_data: Iterable[ObservedDataPoint] = (), accumulators: Iterable[str] = (), prediction_intervals: PredictionIntervals | None = None) -> SimulationReport ``` Report the observed data, compartment series and accumulators of the run. The blocks share one window axis, so the observed values, the windowed accumulator increments and the sampled series can be read against each other. Add whatever envelope you need around them, such as a run label, axis labels or summary figures in the terms of your domain. Parameters: | Name | Type | Description | Default | | ---------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `positions` | `Sequence[float]` | Positions the series and cumulative accumulators are sampled at; each must be a position of the results. | *required* | | `window` | `float` | Length of one window on the axis. | *required* | | `windows` | `int` | Number of windows on the observed and windowed axes. | *required* | | `observed_data` | `Iterable[ObservedDataPoint]` | The calibration targets to report alongside the run. | `()` | | `accumulators` | `Iterable[str]` | Accumulator ids to report; their outputs come from model. | `()` | | `prediction_intervals` | \`PredictionIntervals | None\` | Prediction intervals of a conformal method (ConformalResult.intervals), reported as they are. | Returns: | Type | Description | | ------------------ | -------------------------------------------------------------------------------------- | | `SimulationReport` | Keys observed, series and accumulators, then prediction_intervals when they are given. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------------------------------ | | `ValueError` | If the results have no value at a position the report reads, or they have no model and an accumulator is reported. | | `KeyError` | If an accumulator is not a bin or accumulator of the model. | Source code in `commol/api/results.py` ```python def report( self, positions: Sequence[float], window: float, windows: int, observed_data: "Iterable[ObservedDataPoint]" = (), accumulators: Iterable[str] = (), prediction_intervals: "PredictionIntervals | None" = None, ) -> "SimulationReport": """ Report the observed data, compartment series and accumulators of the run. The blocks share one window axis, so the observed values, the windowed accumulator increments and the sampled series can be read against each other. Add whatever envelope you need around them, such as a run label, axis labels or summary figures in the terms of your domain. Parameters ---------- positions : Sequence[float] Positions the series and cumulative accumulators are sampled at; each must be a position of the results. window : float Length of one window on the axis. windows : int Number of windows on the observed and windowed axes. observed_data : Iterable[ObservedDataPoint], optional The calibration targets to report alongside the run. accumulators : Iterable[str], optional Accumulator ids to report; their outputs come from `model`. prediction_intervals : PredictionIntervals | None, optional Prediction intervals of a conformal method (``ConformalResult.intervals``), reported as they are. Returns ------- SimulationReport Keys ``observed``, ``series`` and ``accumulators``, then ``prediction_intervals`` when they are given. Raises ------ ValueError If the results have no value at a position the report reads, or they have no `model` and an accumulator is reported. KeyError If an accumulator is not a bin or accumulator of the model. """ from commol.api.reporting import _ReportRuns, _simulation_report return _simulation_report( _ReportRuns(model=self.model, runs=[self]), positions, window, windows, observed_data, accumulators, prediction_intervals, ) ``` ## report ```python report(confidence_level: float, positions: Sequence[float], window: float, windows: int, observed_data: Iterable[ObservedDataPoint] = (), accumulators: Iterable[str] = (), prediction_intervals: PredictionIntervals | None = None) -> SimulationReport ``` Report the replicates as `SimulationResults.report` reports one run. Every number becomes a `{mean, median, ci_lower, ci_upper, min, max}` block, reduced replicate by replicate; the layout is the one of the report of a run. Parameters: | Name | Type | Description | Default | | ---------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `confidence_level` | `float` | Confidence level of the reported intervals, strictly between 0 and 1. | *required* | | `positions` | `Sequence[float]` | Positions the series and cumulative accumulators are sampled at; each must be a position of the replicates. | *required* | | `window` | `float` | Length of one window on the axis. | *required* | | `windows` | `int` | Number of windows on the observed and windowed axes. | *required* | | `observed_data` | `Iterable[ObservedDataPoint]` | The calibration targets to report alongside the replicates. | `()` | | `accumulators` | `Iterable[str]` | Accumulator ids to report; their outputs come from model. | `()` | | `prediction_intervals` | \`PredictionIntervals | None\` | Prediction intervals of a conformal method (ConformalResult.intervals), reported as they are. | Returns: | Type | Description | | ------------------ | -------------------------------------------------------------------------------------- | | `SimulationReport` | Keys observed, series and accumulators, then prediction_intervals when they are given. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ValueError` | If there are no replicates, the confidence level is out of range, the replicates have no value at a position the report reads, or they have no model and an accumulator is reported. | | `KeyError` | If an accumulator is not a bin or accumulator of the model. | Source code in `commol/api/results.py` ```python def report( self, confidence_level: float, positions: Sequence[float], window: float, windows: int, observed_data: "Iterable[ObservedDataPoint]" = (), accumulators: Iterable[str] = (), prediction_intervals: "PredictionIntervals | None" = None, ) -> "SimulationReport": """ Report the replicates as `SimulationResults.report` reports one run. Every number becomes a ``{mean, median, ci_lower, ci_upper, min, max}`` block, reduced replicate by replicate; the layout is the one of the report of a run. Parameters ---------- confidence_level : float Confidence level of the reported intervals, strictly between 0 and 1. positions : Sequence[float] Positions the series and cumulative accumulators are sampled at; each must be a position of the replicates. window : float Length of one window on the axis. windows : int Number of windows on the observed and windowed axes. observed_data : Iterable[ObservedDataPoint], optional The calibration targets to report alongside the replicates. accumulators : Iterable[str], optional Accumulator ids to report; their outputs come from `model`. prediction_intervals : PredictionIntervals | None, optional Prediction intervals of a conformal method (``ConformalResult.intervals``), reported as they are. Returns ------- SimulationReport Keys ``observed``, ``series`` and ``accumulators``, then ``prediction_intervals`` when they are given. Raises ------ ValueError If there are no replicates, the confidence level is out of range, the replicates have no value at a position the report reads, or they have no `model` and an accumulator is reported. KeyError If an accumulator is not a bin or accumulator of the model. """ from commol.api.reporting import _ReportRuns, _simulation_report if not self: raise ValueError("There are no replicates.") return _simulation_report( _ReportRuns( model=self.model, runs=list(self), confidence_level=confidence_level, ), positions, window, windows, observed_data, accumulators, prediction_intervals, ) ``` ## report ```python report(confidence_level: float, positions: Sequence[float], window: float, windows: int, observed_data: Iterable[ObservedDataPoint] = (), accumulators: Iterable[str] = (), prediction_intervals: PredictionIntervals | None = None) -> SimulationReport ``` Report the runs of the draws as `SimulationResults.report` reports one. Every number becomes a `{mean, median, ci_lower, ci_upper, min, max}` block, reduced draw by draw with the weights of the draws; the layout is the one of the report of a run. Each leaf comes from the draws only, so the report takes no point estimate. The outputs of an accumulator come from the model of the source. Parameters: | Name | Type | Description | Default | | ---------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `confidence_level` | `float` | Confidence level of the reported intervals, strictly between 0 and 1. | *required* | | `positions` | `Sequence[float]` | Positions the series and cumulative accumulators are sampled at; each must be one of positions of the predictive. | *required* | | `window` | `float` | Length of one window on the axis. | *required* | | `windows` | `int` | Number of windows on the observed and windowed axes. | *required* | | `observed_data` | `Iterable[ObservedDataPoint]` | The calibration targets to report alongside the draws. | `()` | | `accumulators` | `Iterable[str]` | Accumulator ids to report. | `()` | | `prediction_intervals` | \`PredictionIntervals | None\` | Prediction intervals of a conformal method (ConformalResult.intervals), reported as they are. | Returns: | Type | Description | | ------------------ | -------------------------------------------------------------------------------------- | | `SimulationReport` | Keys observed, series and accumulators, then prediction_intervals when they are given. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------------------- | | `ValueError` | If the confidence level is out of range, or the predictive has no value at a position the report reads. | | `KeyError` | If an accumulator is not a bin or accumulator of the model. | Source code in `commol/api/inference/predictive.py` ```python def report( self, confidence_level: float, positions: Sequence[float], window: float, windows: int, observed_data: Iterable[ObservedDataPoint] = (), accumulators: Iterable[str] = (), prediction_intervals: "PredictionIntervals | None" = None, ) -> "SimulationReport": """ Report the runs of the draws as `SimulationResults.report` reports one. Every number becomes a ``{mean, median, ci_lower, ci_upper, min, max}`` block, reduced draw by draw with the weights of the draws; the layout is the one of the report of a run. Each leaf comes from the draws only, so the report takes no point estimate. The outputs of an accumulator come from the model of the source. Parameters ---------- confidence_level : float Confidence level of the reported intervals, strictly between 0 and 1. positions : Sequence[float] Positions the series and cumulative accumulators are sampled at; each must be one of `positions` of the predictive. window : float Length of one window on the axis. windows : int Number of windows on the observed and windowed axes. observed_data : Iterable[ObservedDataPoint], optional The calibration targets to report alongside the draws. accumulators : Iterable[str], optional Accumulator ids to report. prediction_intervals : PredictionIntervals | None, optional Prediction intervals of a conformal method (``ConformalResult.intervals``), reported as they are. Returns ------- SimulationReport Keys ``observed``, ``series`` and ``accumulators``, then ``prediction_intervals`` when they are given. Raises ------ ValueError If the confidence level is out of range, or the predictive has no value at a position the report reads. KeyError If an accumulator is not a bin or accumulator of the model. """ from commol.api.reporting import _ReportRuns, _simulation_report return _simulation_report( _ReportRuns( model=self.source.simulation.model_definition, runs=self.runs(), confidence_level=confidence_level, weights=self.weights, ), positions, window, windows, observed_data, accumulators, prediction_intervals, ) ``` ## report ```python report() -> IdentifiabilityReportData ``` Report the identifiability of each parameter as plain data. Returns: | Type | Description | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `IdentifiabilityReportData` | parameters: each parameter, in problem order, with its structural and practical verdicts; sections: the evidence of each method that ran. | Source code in `commol/context/inference/identifiability.py` ```python def report(self) -> "IdentifiabilityReportData": """ Report the identifiability of each parameter as plain data. Returns ------- IdentifiabilityReportData ``parameters``: each parameter, in problem order, with its structural and practical verdicts; ``sections``: the evidence of each method that ran. """ from commol.api.reporting import _identifiability_report return _identifiability_report(self) ``` # Sensitivity API [Back to API Reference](https://munqu.github.io/commol/latest/api/index.md) The sensitivity analysis, what it varies, the quantities of interest it reads from each run, and the base of its methods. Guide: [Sensitivity analysis](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/index.md). ## SensitivityAnalysis The forward questions of a model: how uncertainty in its inputs moves its outputs, and which inputs matter most. A calibration asks which inputs fit the data; an analysis needs no data. It holds the simulation, what varies (`InputSpace`), the replicates of a stochastic engine and the seed of every random number, and every method of one analysis reads the same inputs. The forward model is built in Rust once, when the analysis is created: the values of the inputs reach the engine as a calibration writes them. Source code in `commol/api/sensitivity.py` ```python class SensitivityAnalysis: """ The forward questions of a model: how uncertainty in its inputs moves its outputs, and which inputs matter most. A calibration asks which inputs fit the data; an analysis needs no data. It holds the simulation, what varies (`InputSpace`), the replicates of a stochastic engine and the seed of every random number, and every method of one analysis reads the same inputs. The forward model is built in Rust once, when the analysis is created: the values of the inputs reach the engine as a calibration writes them. """ def __init__( self, simulation: Simulation, inputs: InputSpace, *, replicates: int | None = None, seed: int | None = None, ) -> None: """ Build the analysis of `inputs` on `simulation`. Parameters ---------- simulation : Simulation The model to analyse. inputs : InputSpace What varies. replicates : int | None, optional For a stochastic engine, required, at least 1: how many replicates each point runs. A point of the design of PRCC, Morris, Sobol or polynomial chaos holds the mean of its replicates, on the same seeds at every point; a propagation keeps every replicate, each with a seed of its own. A deterministic engine does not use it, so the same analysis runs on every engine. seed : int | None, optional The seed of the designs and the replicates. Required with a stochastic engine; otherwise None has the engine draw one, which `seed` reports and the analysis logs. Raises ------ ValueError If an input is not a value of the model, the replicates or the seed do not fit the engine, `replicates` is below 1 (a negative number included), or the seed is not a whole number from 0 to ``2**64 - 1``. """ self.simulation: Simulation = simulation self.inputs: InputSpace = inputs self.replicates: int | None = replicates check_values( simulation.model_definition, simulation.engine, inputs.parameters, inputs.initial_condition_remainder, ) self._rust: "ForwardAnalysisProtocol" = commol_rs.inference.ForwardAnalysis( simulation.engine, engine_state.rust_parameters(inputs.parameters), distributions=inputs.rust_distributions(), sample=None if inputs.sample is None else inputs.sample.rust_sample, initial_condition_remainder=inputs.initial_condition_remainder, replicates=replicates, seed=None if seed is None else check_seed(seed), ) if seed is None: logger.info( "No seed set; drew the seed %d, which repeats this analysis.", self._rust.seed, ) @property def rust_analysis(self) -> "ForwardAnalysisProtocol": """ The forward analysis built in Rust, for the runners of methods. Returns ------- ForwardAnalysisProtocol The forward model and the inputs every method reads. """ return self._rust def rust_predictive( self, sample: ParameterSample, positions: list[float] ) -> "PredictiveProtocol": """ The run of every draw of `sample` at `positions`, for a `Predictive`: each replicate of a stochastic engine has a seed of its own, derived from the seed of the analysis, as in a propagation. Parameters ---------- sample : ParameterSample The draws, in the order of the inputs. positions : list[float] Positions to report at, sorted and unique. Returns ------- PredictiveProtocol The runs, computed in Rust. """ return self._rust.predictive(sample.rust_sample, positions) def with_model(self, model: "Model") -> "SensitivityAnalysis": """ The same analysis on another model, with the same seed. The draws of a predictive of the copy take the seeds they take here, so `Scenarios` runs every scenario on common random numbers. Parameters ---------- model : Model The model, such as a copy of this one with other parameter values. Returns ------- SensitivityAnalysis An analysis of `model`, with `inputs`, `replicates` and `seed`. Raises ------ ValueError If an input is not a value of `model`. """ return SensitivityAnalysis( Simulation(model), self.inputs, replicates=self.replicates, seed=self.seed ) def rust_observation_draws( self, sample: ParameterSample, observed_data: list[ObservedDataPoint], noise: ObservationNoise, ) -> "ObservationDrawsProtocol": """ Every draw's value of `observed_data`, for a `Predictive`. Parameters ---------- sample : ParameterSample The draws, in the order of the inputs. observed_data : list[ObservedDataPoint] The observations. noise : NoNoise | ResidualNoise | DistributionNoise The observation noise; only ``NoNoise()``. Returns ------- ObservationDrawsProtocol The draws, computed in Rust. Raises ------ ValueError If the noise is not ``NoNoise()``: residual noise needs the residuals of a fit, distribution noise the observation models of a problem, and an analysis has neither. """ if not isinstance(noise, NoNoise): raise ValueError( f"{type(noise).__name__} needs a calibration: residual noise reads " "the residuals of a fit and distribution noise the observation " "models of a problem, and a sensitivity analysis has no data. Read " "the observations with NoNoise()." ) return self._rust.observation_draws( sample.rust_sample, engine_state.rust_observed_data(observed_data) ) def rust_log_score( self, sample: ParameterSample, # noqa: ARG002 observed_data: list[ObservedDataPoint], # noqa: ARG002 ) -> list[float]: """ Refuse the log score, which `commol.scores.log_score` asks for. Parameters ---------- sample : ParameterSample The draws. observed_data : list[ObservedDataPoint] The observations. Raises ------ ValueError Always: the log score reads the observation models of a problem, and an analysis has no data. """ raise ValueError( "The log score needs the observation models of a calibration problem, " "and a sensitivity analysis has no data." ) @property def seed(self) -> int: """ The seed of the analysis' random numbers. Returns ------- int The seed given, or the seed the engine drew when the analysis was built, which it logs. """ return self._rust.seed def run(self, method: SensitivityMethod[ResultT]) -> ResultT: """ Run a sensitivity method on the inputs of this analysis. Parameters ---------- method : SensitivityMethod[ResultT] The configuration of the method; its type sets the result type. Returns ------- ResultT The method's result. Raises ------ TypeError If `method` is not a sensitivity method; the message names its entry point. ValueError If the method does not take these inputs, such as a method of independent inputs on inputs from a sample, or a setting, a quantity or the model does not fit the method; the message says why. RuntimeError If a run of the design fails. InfeasibleStateError If a point of the design leaves the values its model allows. """ return run_method(self, method, SensitivityMethod) ``` ### Attributes #### rust_analysis ```python rust_analysis: ForwardAnalysisProtocol ``` The forward analysis built in Rust, for the runners of methods. Returns: | Type | Description | | ------------------------- | ---------------------------------------------------- | | `ForwardAnalysisProtocol` | The forward model and the inputs every method reads. | #### seed ```python seed: int ``` The seed of the analysis' random numbers. Returns: | Type | Description | | ----- | --------------------------------------------------------------------------------------- | | `int` | The seed given, or the seed the engine drew when the analysis was built, which it logs. | ### Methods: #### __init__ ```python __init__(simulation: Simulation, inputs: InputSpace, *, replicates: int | None = None, seed: int | None = None) -> None ``` Build the analysis of `inputs` on `simulation`. Parameters: | Name | Type | Description | Default | | ------------ | ------------ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `simulation` | `Simulation` | The model to analyse. | *required* | | `inputs` | `InputSpace` | What varies. | *required* | | `replicates` | \`int | None\` | For a stochastic engine, required, at least 1: how many replicates each point runs. A point of the design of PRCC, Morris, Sobol or polynomial chaos holds the mean of its replicates, on the same seeds at every point; a propagation keeps every replicate, each with a seed of its own. A deterministic engine does not use it, so the same analysis runs on every engine. | | `seed` | \`int | None\` | The seed of the designs and the replicates. Required with a stochastic engine; otherwise None has the engine draw one, which seed reports and the analysis logs. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If an input is not a value of the model, the replicates or the seed do not fit the engine, replicates is below 1 (a negative number included), or the seed is not a whole number from 0 to 2\*\*64 - 1. | Source code in `commol/api/sensitivity.py` ```python def __init__( self, simulation: Simulation, inputs: InputSpace, *, replicates: int | None = None, seed: int | None = None, ) -> None: """ Build the analysis of `inputs` on `simulation`. Parameters ---------- simulation : Simulation The model to analyse. inputs : InputSpace What varies. replicates : int | None, optional For a stochastic engine, required, at least 1: how many replicates each point runs. A point of the design of PRCC, Morris, Sobol or polynomial chaos holds the mean of its replicates, on the same seeds at every point; a propagation keeps every replicate, each with a seed of its own. A deterministic engine does not use it, so the same analysis runs on every engine. seed : int | None, optional The seed of the designs and the replicates. Required with a stochastic engine; otherwise None has the engine draw one, which `seed` reports and the analysis logs. Raises ------ ValueError If an input is not a value of the model, the replicates or the seed do not fit the engine, `replicates` is below 1 (a negative number included), or the seed is not a whole number from 0 to ``2**64 - 1``. """ self.simulation: Simulation = simulation self.inputs: InputSpace = inputs self.replicates: int | None = replicates check_values( simulation.model_definition, simulation.engine, inputs.parameters, inputs.initial_condition_remainder, ) self._rust: "ForwardAnalysisProtocol" = commol_rs.inference.ForwardAnalysis( simulation.engine, engine_state.rust_parameters(inputs.parameters), distributions=inputs.rust_distributions(), sample=None if inputs.sample is None else inputs.sample.rust_sample, initial_condition_remainder=inputs.initial_condition_remainder, replicates=replicates, seed=None if seed is None else check_seed(seed), ) if seed is None: logger.info( "No seed set; drew the seed %d, which repeats this analysis.", self._rust.seed, ) ``` #### rust_predictive ```python rust_predictive(sample: ParameterSample, positions: list[float]) -> PredictiveProtocol ``` The run of every draw of `sample` at `positions`, for a `Predictive`: each replicate of a stochastic engine has a seed of its own, derived from the seed of the analysis, as in a propagation. Parameters: | Name | Type | Description | Default | | ----------- | ----------------- | ------------------------------------------ | ---------- | | `sample` | `ParameterSample` | The draws, in the order of the inputs. | *required* | | `positions` | `list[float]` | Positions to report at, sorted and unique. | *required* | Returns: | Type | Description | | -------------------- | --------------------------- | | `PredictiveProtocol` | The runs, computed in Rust. | Source code in `commol/api/sensitivity.py` ```python def rust_predictive( self, sample: ParameterSample, positions: list[float] ) -> "PredictiveProtocol": """ The run of every draw of `sample` at `positions`, for a `Predictive`: each replicate of a stochastic engine has a seed of its own, derived from the seed of the analysis, as in a propagation. Parameters ---------- sample : ParameterSample The draws, in the order of the inputs. positions : list[float] Positions to report at, sorted and unique. Returns ------- PredictiveProtocol The runs, computed in Rust. """ return self._rust.predictive(sample.rust_sample, positions) ``` #### with_model ```python with_model(model: Model) -> SensitivityAnalysis ``` The same analysis on another model, with the same seed. The draws of a predictive of the copy take the seeds they take here, so `Scenarios` runs every scenario on common random numbers. Parameters: | Name | Type | Description | Default | | ------- | ------- | ------------------------------------------------------------------ | ---------- | | `model` | `Model` | The model, such as a copy of this one with other parameter values. | *required* | Returns: | Type | Description | | --------------------- | ------------------------------------------------------- | | `SensitivityAnalysis` | An analysis of model, with inputs, replicates and seed. | Raises: | Type | Description | | ------------ | ------------------------------------ | | `ValueError` | If an input is not a value of model. | Source code in `commol/api/sensitivity.py` ```python def with_model(self, model: "Model") -> "SensitivityAnalysis": """ The same analysis on another model, with the same seed. The draws of a predictive of the copy take the seeds they take here, so `Scenarios` runs every scenario on common random numbers. Parameters ---------- model : Model The model, such as a copy of this one with other parameter values. Returns ------- SensitivityAnalysis An analysis of `model`, with `inputs`, `replicates` and `seed`. Raises ------ ValueError If an input is not a value of `model`. """ return SensitivityAnalysis( Simulation(model), self.inputs, replicates=self.replicates, seed=self.seed ) ``` #### rust_observation_draws ```python rust_observation_draws(sample: ParameterSample, observed_data: list[ObservedDataPoint], noise: ObservationNoise) -> ObservationDrawsProtocol ``` Every draw's value of `observed_data`, for a `Predictive`. Parameters: | Name | Type | Description | Default | | --------------- | ------------------------- | -------------------------------------- | ------------------- | | `sample` | `ParameterSample` | The draws, in the order of the inputs. | *required* | | `observed_data` | `list[ObservedDataPoint]` | The observations. | *required* | | `noise` | \`NoNoise | ResidualNoise | DistributionNoise\` | Returns: | Type | Description | | -------------------------- | ---------------------------- | | `ObservationDrawsProtocol` | The draws, computed in Rust. | Raises: | Type | Description | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If the noise is not NoNoise(): residual noise needs the residuals of a fit, distribution noise the observation models of a problem, and an analysis has neither. | Source code in `commol/api/sensitivity.py` ```python def rust_observation_draws( self, sample: ParameterSample, observed_data: list[ObservedDataPoint], noise: ObservationNoise, ) -> "ObservationDrawsProtocol": """ Every draw's value of `observed_data`, for a `Predictive`. Parameters ---------- sample : ParameterSample The draws, in the order of the inputs. observed_data : list[ObservedDataPoint] The observations. noise : NoNoise | ResidualNoise | DistributionNoise The observation noise; only ``NoNoise()``. Returns ------- ObservationDrawsProtocol The draws, computed in Rust. Raises ------ ValueError If the noise is not ``NoNoise()``: residual noise needs the residuals of a fit, distribution noise the observation models of a problem, and an analysis has neither. """ if not isinstance(noise, NoNoise): raise ValueError( f"{type(noise).__name__} needs a calibration: residual noise reads " "the residuals of a fit and distribution noise the observation " "models of a problem, and a sensitivity analysis has no data. Read " "the observations with NoNoise()." ) return self._rust.observation_draws( sample.rust_sample, engine_state.rust_observed_data(observed_data) ) ``` #### rust_log_score ```python rust_log_score(sample: ParameterSample, observed_data: list[ObservedDataPoint]) -> list[float] ``` Refuse the log score, which `commol.scores.log_score` asks for. Parameters: | Name | Type | Description | Default | | --------------- | ------------------------- | ----------------- | ---------- | | `sample` | `ParameterSample` | The draws. | *required* | | `observed_data` | `list[ObservedDataPoint]` | The observations. | *required* | Raises: | Type | Description | | ------------ | --------------------------------------------------------------------------------------------- | | `ValueError` | Always: the log score reads the observation models of a problem, and an analysis has no data. | Source code in `commol/api/sensitivity.py` ```python def rust_log_score( self, sample: ParameterSample, # noqa: ARG002 observed_data: list[ObservedDataPoint], # noqa: ARG002 ) -> list[float]: """ Refuse the log score, which `commol.scores.log_score` asks for. Parameters ---------- sample : ParameterSample The draws. observed_data : list[ObservedDataPoint] The observations. Raises ------ ValueError Always: the log score reads the observation models of a problem, and an analysis has no data. """ raise ValueError( "The log score needs the observation models of a calibration problem, " "and a sensitivity analysis has no data." ) ``` #### run ```python run(method: SensitivityMethod[ResultT]) -> ResultT ``` Run a sensitivity method on the inputs of this analysis. Parameters: | Name | Type | Description | Default | | -------- | ---------------------------- | --------------------------------------------------------------- | ---------- | | `method` | `SensitivityMethod[ResultT]` | The configuration of the method; its type sets the result type. | *required* | Returns: | Type | Description | | --------- | -------------------- | | `ResultT` | The method's result. | Raises: | Type | Description | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `TypeError` | If method is not a sensitivity method; the message names its entry point. | | `ValueError` | If the method does not take these inputs, such as a method of independent inputs on inputs from a sample, or a setting, a quantity or the model does not fit the method; the message says why. | | `RuntimeError` | If a run of the design fails. | | `InfeasibleStateError` | If a point of the design leaves the values its model allows. | Source code in `commol/api/sensitivity.py` ```python def run(self, method: SensitivityMethod[ResultT]) -> ResultT: """ Run a sensitivity method on the inputs of this analysis. Parameters ---------- method : SensitivityMethod[ResultT] The configuration of the method; its type sets the result type. Returns ------- ResultT The method's result. Raises ------ TypeError If `method` is not a sensitivity method; the message names its entry point. ValueError If the method does not take these inputs, such as a method of independent inputs on inputs from a sample, or a setting, a quantity or the model does not fit the method; the message says why. RuntimeError If a run of the design fails. InfeasibleStateError If a point of the design leaves the values its model allows. """ return run_method(self, method, SensitivityMethod) ``` ## InputSpace Bases: `BaseModel` What a sensitivity analysis varies. Each input is a `CalibrationParameter`, a model parameter or an initial condition with its bounds, as a calibration sets it. The inputs follow independent distributions, one per input, truncated to its bounds, or are the draws of a sample, with their weights; the inputs of a sample may depend on each other, so only the methods that allow it take one. Build it with `independent`, `uniform_on_bounds` or `from_sample`. Attributes: | Name | Type | Description | | ----------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `parameters` | `list[CalibrationParameter]` | The inputs, in order. A factor or a noise parameter is not an input: both belong to observations, and an analysis has none. | | `distributions` | \`dict[str, Distribution] | None\` | | `sample` | \`ParameterSample | None\` | | `initial_condition_remainder` | \`str | None\` | Source code in `commol/context/inference/global_sensitivity.py` ```python class InputSpace(BaseModel): """ What a sensitivity analysis varies. Each input is a `CalibrationParameter`, a model parameter or an initial condition with its bounds, as a calibration sets it. The inputs follow independent distributions, one per input, truncated to its bounds, or are the draws of a sample, with their weights; the inputs of a sample may depend on each other, so only the methods that allow it take one. Build it with `independent`, `uniform_on_bounds` or `from_sample`. Attributes ---------- parameters : list[CalibrationParameter] The inputs, in order. A factor or a noise parameter is not an input: both belong to observations, and an analysis has none. distributions : dict[str, Distribution] | None The distribution of each input, for independent inputs. sample : ParameterSample | None The draws of the inputs, for inputs from a sample. initial_condition_remainder : str | None The bin or compartment that takes what the initial conditions leave of the sum the model states, as in a calibration problem. """ model_config = ConfigDict(extra="forbid", frozen=True) parameters: list[CalibrationParameter] = Field( min_length=1, description="The inputs, in order." ) distributions: dict[str, Distribution] | None = Field( default=None, description="The distribution of each independent input." ) sample: ParameterSample | None = Field( default=None, description="The draws of inputs from a sample." ) initial_condition_remainder: str | None = Field( default=None, description="Bin or compartment that takes what the initial conditions leave.", ) @model_validator(mode="after") def check_inputs(self) -> Self: """Check the inputs against the distributions or the sample.""" ids = [parameter.id for parameter in self.parameters] repeated = sorted({i for i in ids if ids.count(i) > 1}) if repeated: raise ValueError(f"Inputs repeat the ids {repeated}.") observed = { CalibrationParameterType.FACTOR: ( "a factor, which multiplies an observation" ), CalibrationParameterType.NOISE: ( "a noise parameter, which belongs to an observation model" ), } for parameter in self.parameters: what = observed.get(CalibrationParameterType(parameter.parameter_type)) if what is not None: raise ValueError( f"'{parameter.id}' is {what}; a sensitivity analysis has no " "observations." ) if (self.distributions is None) == (self.sample is None): raise ValueError("Give distributions or a sample, not both.") if self.distributions is not None: missing = [i for i in ids if i not in self.distributions] extra = sorted(set(self.distributions) - set(ids)) if missing: raise ValueError( f"The inputs {missing} have no distribution: give one per " "input, or InputSpace.uniform_on_bounds(parameters)." ) if extra: raise ValueError(f"The distributions of {extra} name no input.") if self.sample is not None: bounds = [(p.min_bound, p.max_bound) for p in self.parameters] if self.sample.names != ids or list(self.sample.bounds) != bounds: raise ValueError( f"The sample has the inputs {self.sample.names} with the bounds " f"{list(self.sample.bounds)}; the parameters are {ids} with the " f"bounds {bounds}, in this order." ) return self @classmethod def independent( cls, parameters: Sequence[CalibrationParameter], distributions: Mapping[str, Distribution], *, initial_condition_remainder: str | None = None, ) -> Self: """ Independent inputs, each with its distribution. Parameters ---------- parameters : Sequence[CalibrationParameter] The inputs, in order. distributions : Mapping[str, Distribution] The distribution of each input, truncated to its bounds. initial_condition_remainder : str | None, optional The bin or compartment that takes what the initial conditions leave. Returns ------- InputSpace The input space. Raises ------ ValueError If an input has no distribution or is a factor, a distribution names no input, or an id repeats. """ return cls( parameters=list(parameters), distributions=dict(distributions), initial_condition_remainder=initial_condition_remainder, ) @classmethod def uniform_on_bounds( cls, parameters: Sequence[CalibrationParameter], *, initial_condition_remainder: str | None = None, ) -> Self: """ Independent inputs, each uniform on its bounds; they must be finite. Parameters ---------- parameters : Sequence[CalibrationParameter] The inputs, in order. initial_condition_remainder : str | None, optional The bin or compartment that takes what the initial conditions leave. Returns ------- InputSpace The input space. """ return cls.independent( parameters, {parameter.id: Uniform() for parameter in parameters}, initial_condition_remainder=initial_condition_remainder, ) @classmethod def from_sample( cls, sample: ParameterSample, parameters: Sequence[CalibrationParameter], *, initial_condition_remainder: str | None = None, ) -> Self: """ The draws of a sample, with their weights, such as a posterior. Its inputs may depend on each other, so only PRCC and propagation take it. The parameters give the type of each value, which a sample does not hold. Parameters ---------- sample : ParameterSample The draws. parameters : Sequence[CalibrationParameter] The inputs, in the order and with the bounds of the sample. initial_condition_remainder : str | None, optional The bin or compartment that takes what the initial conditions leave. Returns ------- InputSpace The input space. Raises ------ ValueError If the sample's names or bounds are not the parameters'. """ return cls( parameters=list(parameters), sample=sample, initial_condition_remainder=initial_condition_remainder, ) @property def names(self) -> list[str]: """The ids of the inputs, in order.""" return [parameter.id for parameter in self.parameters] def rust_distributions(self) -> list[tuple[str, str, list[float]]] | None: """The distributions as the Rust runners read them, or None.""" if self.distributions is None: return None return [self.distributions[name].rust_prior(name) for name in self.names] ``` ### Attributes #### names ```python names: list[str] ``` The ids of the inputs, in order. ### Methods: #### check_inputs ```python check_inputs() -> Self ``` Check the inputs against the distributions or the sample. Source code in `commol/context/inference/global_sensitivity.py` ```python @model_validator(mode="after") def check_inputs(self) -> Self: """Check the inputs against the distributions or the sample.""" ids = [parameter.id for parameter in self.parameters] repeated = sorted({i for i in ids if ids.count(i) > 1}) if repeated: raise ValueError(f"Inputs repeat the ids {repeated}.") observed = { CalibrationParameterType.FACTOR: ( "a factor, which multiplies an observation" ), CalibrationParameterType.NOISE: ( "a noise parameter, which belongs to an observation model" ), } for parameter in self.parameters: what = observed.get(CalibrationParameterType(parameter.parameter_type)) if what is not None: raise ValueError( f"'{parameter.id}' is {what}; a sensitivity analysis has no " "observations." ) if (self.distributions is None) == (self.sample is None): raise ValueError("Give distributions or a sample, not both.") if self.distributions is not None: missing = [i for i in ids if i not in self.distributions] extra = sorted(set(self.distributions) - set(ids)) if missing: raise ValueError( f"The inputs {missing} have no distribution: give one per " "input, or InputSpace.uniform_on_bounds(parameters)." ) if extra: raise ValueError(f"The distributions of {extra} name no input.") if self.sample is not None: bounds = [(p.min_bound, p.max_bound) for p in self.parameters] if self.sample.names != ids or list(self.sample.bounds) != bounds: raise ValueError( f"The sample has the inputs {self.sample.names} with the bounds " f"{list(self.sample.bounds)}; the parameters are {ids} with the " f"bounds {bounds}, in this order." ) return self ``` #### independent ```python independent(parameters: Sequence[CalibrationParameter], distributions: Mapping[str, Distribution], *, initial_condition_remainder: str | None = None) -> Self ``` Independent inputs, each with its distribution. Parameters: | Name | Type | Description | Default | | ----------------------------- | -------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------- | | `parameters` | `Sequence[CalibrationParameter]` | The inputs, in order. | *required* | | `distributions` | `Mapping[str, Distribution]` | The distribution of each input, truncated to its bounds. | *required* | | `initial_condition_remainder` | \`str | None\` | The bin or compartment that takes what the initial conditions leave. | Returns: | Type | Description | | ------------ | ---------------- | | `InputSpace` | The input space. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------------ | | `ValueError` | If an input has no distribution or is a factor, a distribution names no input, or an id repeats. | Source code in `commol/context/inference/global_sensitivity.py` ```python @classmethod def independent( cls, parameters: Sequence[CalibrationParameter], distributions: Mapping[str, Distribution], *, initial_condition_remainder: str | None = None, ) -> Self: """ Independent inputs, each with its distribution. Parameters ---------- parameters : Sequence[CalibrationParameter] The inputs, in order. distributions : Mapping[str, Distribution] The distribution of each input, truncated to its bounds. initial_condition_remainder : str | None, optional The bin or compartment that takes what the initial conditions leave. Returns ------- InputSpace The input space. Raises ------ ValueError If an input has no distribution or is a factor, a distribution names no input, or an id repeats. """ return cls( parameters=list(parameters), distributions=dict(distributions), initial_condition_remainder=initial_condition_remainder, ) ``` #### uniform_on_bounds ```python uniform_on_bounds(parameters: Sequence[CalibrationParameter], *, initial_condition_remainder: str | None = None) -> Self ``` Independent inputs, each uniform on its bounds; they must be finite. Parameters: | Name | Type | Description | Default | | ----------------------------- | -------------------------------- | --------------------- | -------------------------------------------------------------------- | | `parameters` | `Sequence[CalibrationParameter]` | The inputs, in order. | *required* | | `initial_condition_remainder` | \`str | None\` | The bin or compartment that takes what the initial conditions leave. | Returns: | Type | Description | | ------------ | ---------------- | | `InputSpace` | The input space. | Source code in `commol/context/inference/global_sensitivity.py` ```python @classmethod def uniform_on_bounds( cls, parameters: Sequence[CalibrationParameter], *, initial_condition_remainder: str | None = None, ) -> Self: """ Independent inputs, each uniform on its bounds; they must be finite. Parameters ---------- parameters : Sequence[CalibrationParameter] The inputs, in order. initial_condition_remainder : str | None, optional The bin or compartment that takes what the initial conditions leave. Returns ------- InputSpace The input space. """ return cls.independent( parameters, {parameter.id: Uniform() for parameter in parameters}, initial_condition_remainder=initial_condition_remainder, ) ``` #### from_sample ```python from_sample(sample: ParameterSample, parameters: Sequence[CalibrationParameter], *, initial_condition_remainder: str | None = None) -> Self ``` The draws of a sample, with their weights, such as a posterior. Its inputs may depend on each other, so only PRCC and propagation take it. The parameters give the type of each value, which a sample does not hold. Parameters: | Name | Type | Description | Default | | ----------------------------- | -------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------- | | `sample` | `ParameterSample` | The draws. | *required* | | `parameters` | `Sequence[CalibrationParameter]` | The inputs, in the order and with the bounds of the sample. | *required* | | `initial_condition_remainder` | \`str | None\` | The bin or compartment that takes what the initial conditions leave. | Returns: | Type | Description | | ------------ | ---------------- | | `InputSpace` | The input space. | Raises: | Type | Description | | ------------ | -------------------------------------------------------- | | `ValueError` | If the sample's names or bounds are not the parameters'. | Source code in `commol/context/inference/global_sensitivity.py` ```python @classmethod def from_sample( cls, sample: ParameterSample, parameters: Sequence[CalibrationParameter], *, initial_condition_remainder: str | None = None, ) -> Self: """ The draws of a sample, with their weights, such as a posterior. Its inputs may depend on each other, so only PRCC and propagation take it. The parameters give the type of each value, which a sample does not hold. Parameters ---------- sample : ParameterSample The draws. parameters : Sequence[CalibrationParameter] The inputs, in the order and with the bounds of the sample. initial_condition_remainder : str | None, optional The bin or compartment that takes what the initial conditions leave. Returns ------- InputSpace The input space. Raises ------ ValueError If the sample's names or bounds are not the parameters'. """ return cls( parameters=list(parameters), sample=sample, initial_condition_remainder=initial_condition_remainder, ) ``` #### rust_distributions ```python rust_distributions() -> list[tuple[str, str, list[float]]] | None ``` The distributions as the Rust runners read them, or None. Source code in `commol/context/inference/global_sensitivity.py` ```python def rust_distributions(self) -> list[tuple[str, str, list[float]]] | None: """The distributions as the Rust runners read them, or None.""" if self.distributions is None: return None return [self.distributions[name].rust_prior(name) for name in self.names] ``` ## SensitivityMethod Bases: `InferenceMethod[ResultT]`, `Generic[ResultT]` The configuration of a sensitivity method, whose result is `ResultT`. `SensitivityAnalysis.run(method)` runs it on the inputs of the analysis, with no data, and returns its result type. Source code in `commol/context/inference/method.py` ```python class SensitivityMethod(InferenceMethod[ResultT], Generic[ResultT]): """ The configuration of a sensitivity method, whose result is `ResultT`. ``SensitivityAnalysis.run(method)`` runs it on the inputs of the analysis, with no data, and returns its result type. """ ``` ## Propagation Bases: `SensitivityMethod[PropagationResult]`, `RangeChecked` Monte Carlo propagation: run the draws of the inputs and reduce each run to the quantities. With independent inputs the draws are a Latin hypercube of their distributions; with inputs from a sample, the sample is the design, with its weights. Attributes: | Name | Type | Description | | ------------ | ---------------- | -------------------------------------------------------------------------- | | `draws` | \`int | None\` | | `quantities` | `list[Quantity]` | What each run is reduced to; none gives only the sample, for a Predictive. | Source code in `commol/context/inference/global_sensitivity.py` ```python class Propagation(SensitivityMethod[PropagationResult], RangeChecked): """ Monte Carlo propagation: run the draws of the inputs and reduce each run to the quantities. With independent inputs the draws are a Latin hypercube of their distributions; with inputs from a sample, the sample is the design, with its weights. Attributes ---------- draws : int | None The number of points of the Latin hypercube; required with independent inputs, and left unset with inputs from a sample. quantities : list[Quantity] What each run is reduced to; none gives only the sample, for a `Predictive`. """ draws: int | None = Field( default=None, description="Points of the Latin hypercube." ) quantities: list[Quantity] = Field( default_factory=list, description="What each run is reduced to." ) ``` ## PropagationResult Bases: `ResultModel` The draws of the inputs and what each run gives. Its `sample` is a `ParameterSample`, so `Predictive(analysis, result.sample, positions)` gives the bands, plots and reports of the propagation, as for a calibration. Attributes: | Name | Type | Description | | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sample` | `ParameterSample` | The draws of the inputs: a Latin hypercube of their distributions, or the sample of the input space itself. | | `labels` | `list[str]` | The label of each quantity. | | `values` | `list[list[float]]` | The quantities of each run, [run]\[quantity\]: one run per draw, or with a stochastic engine one per replicate of each draw. | | `weights` | `list[float]` | The weight of each run; with a stochastic engine each replicate has an equal share of its draw's weight. They sum to 1. | | `draw_seeds` | `list[int]` | The seed of each draw of sample, in its order. With a stochastic engine, replicate i of draw j runs with the seed replicate i of Simulation.replicates(count, seed=draw_seeds[j]) takes, so those replicates of the model at that draw give the runs of the draw. | | `` A quantity that reads a NaN is NaN in `values`, and `summary` raises on `` | | | | `it` | `` `where` keeps the runs a condition chooses, which conditions the `` | | | `result on that condition.` | | | Source code in `commol/context/inference/global_sensitivity.py` ```python class PropagationResult(ResultModel): """ The draws of the inputs and what each run gives. Its `sample` is a `ParameterSample`, so `Predictive(analysis, result.sample, positions)` gives the bands, plots and reports of the propagation, as for a calibration. Attributes ---------- sample : ParameterSample The draws of the inputs: a Latin hypercube of their distributions, or the sample of the input space itself. labels : list[str] The label of each quantity. values : list[list[float]] The quantities of each run, ``[run][quantity]``: one run per draw, or with a stochastic engine one per replicate of each draw. weights : list[float] The weight of each run; with a stochastic engine each replicate has an equal share of its draw's weight. They sum to 1. draw_seeds : list[int] The seed of each draw of `sample`, in its order. With a stochastic engine, replicate ``i`` of draw ``j`` runs with the seed replicate ``i`` of ``Simulation.replicates(count, seed=draw_seeds[j])`` takes, so those replicates of the model at that draw give the runs of the draw. A quantity that reads a NaN is NaN in `values`, and `summary` raises on it: `where` keeps the runs a condition chooses, which conditions the result on that condition. """ model_config = ConfigDict(extra="forbid", frozen=True) sample: ParameterSample labels: list[str] values: list[list[float]] weights: list[float] draw_seeds: list[int] @property def effective_size(self) -> float: """ Kish's effective size of the weights of the runs, ``(Σw)²/Σw²``: the number of equally weighted runs that carry as much information. """ return commol_rs.inference.effective_size(self.weights) def where(self, mask: ArrayLike) -> "PropagationResult": """ The runs where `mask` is true, with their weights renormalised to sum to 1. A filter conditions the result: a summary of the runs it keeps is the distribution of each quantity given the condition of the mask, such as ``np.isfinite(values[:, i])`` or a compartment above a threshold, not the distribution over every run. `effective_size` says how many runs the kept weights are worth. `sample` and `draw_seeds` stay those of the draws of the inputs. Parameters ---------- mask : ArrayLike One boolean per run, in the order of `values`. Returns ------- PropagationResult The kept runs, their values and their renormalised weights. Raises ------ ValueError If `mask` has not one boolean per run, keeps no run, or keeps only runs of weight 0. """ keep = np.asarray(mask) if keep.dtype != np.bool_ or keep.shape != (len(self.values),): raise ValueError( f"The mask has one boolean per run: {len(self.values)} runs, got " f"an array of {keep.dtype} with the shape {keep.shape}" ) weights = np.asarray(self.weights, dtype=float)[keep] if weights.sum() <= 0.0: raise ValueError( "The mask keeps no run of positive weight: a summary needs at least one" ) return self.model_copy( update={ "values": [row for row, kept in zip(self.values, keep) if kept], "weights": (weights / weights.sum()).tolist(), } ) def summary(self, confidence_level: float) -> list[QuantitySummary]: """ The weighted distribution of each quantity across the runs. Parameters ---------- confidence_level : float Strictly between 0 and 1. Returns ------- list[QuantitySummary] One summary per quantity, in the order of `labels`. Raises ------ ValueError If the confidence level is not strictly between 0 and 1, or a quantity is NaN in a run: `where` keeps the runs a condition chooses. """ # The level is checked before the values, also when there are none. commol_rs.inference.interval_levels(confidence_level) summaries = [] for index, label in enumerate(self.labels): column = [row[index] for row in self.values] try: summary = commol_rs.inference.weighted_summary( column, self.weights, confidence_level ) except ValueError as error: raise ValueError( f"Quantity '{label}', in the runs of values counted from 0: " f"{error}. where(mask) keeps the runs a condition chooses." ) from error summaries.append( QuantitySummary( label=label, confidence_level=confidence_level, mean=summary.mean, median=summary.median, lower=summary.lower, upper=summary.upper, ) ) return summaries ``` ### Attributes #### effective_size ```python effective_size: float ``` Kish's effective size of the weights of the runs, `(Σw)²/Σw²`: the number of equally weighted runs that carry as much information. ### Methods: #### where ```python where(mask: ArrayLike) -> PropagationResult ``` The runs where `mask` is true, with their weights renormalised to sum to 1. A filter conditions the result: a summary of the runs it keeps is the distribution of each quantity given the condition of the mask, such as `np.isfinite(values[:, i])` or a compartment above a threshold, not the distribution over every run. `effective_size` says how many runs the kept weights are worth. `sample` and `draw_seeds` stay those of the draws of the inputs. Parameters: | Name | Type | Description | Default | | ------ | ----------- | -------------------------------------------- | ---------- | | `mask` | `ArrayLike` | One boolean per run, in the order of values. | *required* | Returns: | Type | Description | | ------------------- | ----------------------------------------------------------- | | `PropagationResult` | The kept runs, their values and their renormalised weights. | Raises: | Type | Description | | ------------ | ---------------------------------------------------------------------------------- | | `ValueError` | If mask has not one boolean per run, keeps no run, or keeps only runs of weight 0. | Source code in `commol/context/inference/global_sensitivity.py` ```python def where(self, mask: ArrayLike) -> "PropagationResult": """ The runs where `mask` is true, with their weights renormalised to sum to 1. A filter conditions the result: a summary of the runs it keeps is the distribution of each quantity given the condition of the mask, such as ``np.isfinite(values[:, i])`` or a compartment above a threshold, not the distribution over every run. `effective_size` says how many runs the kept weights are worth. `sample` and `draw_seeds` stay those of the draws of the inputs. Parameters ---------- mask : ArrayLike One boolean per run, in the order of `values`. Returns ------- PropagationResult The kept runs, their values and their renormalised weights. Raises ------ ValueError If `mask` has not one boolean per run, keeps no run, or keeps only runs of weight 0. """ keep = np.asarray(mask) if keep.dtype != np.bool_ or keep.shape != (len(self.values),): raise ValueError( f"The mask has one boolean per run: {len(self.values)} runs, got " f"an array of {keep.dtype} with the shape {keep.shape}" ) weights = np.asarray(self.weights, dtype=float)[keep] if weights.sum() <= 0.0: raise ValueError( "The mask keeps no run of positive weight: a summary needs at least one" ) return self.model_copy( update={ "values": [row for row, kept in zip(self.values, keep) if kept], "weights": (weights / weights.sum()).tolist(), } ) ``` #### summary ```python summary(confidence_level: float) -> list[QuantitySummary] ``` The weighted distribution of each quantity across the runs. Parameters: | Name | Type | Description | Default | | ------------------ | ------- | ------------------------- | ---------- | | `confidence_level` | `float` | Strictly between 0 and 1. | *required* | Returns: | Type | Description | | ----------------------- | ------------------------------------------------- | | `list[QuantitySummary]` | One summary per quantity, in the order of labels. | Raises: | Type | Description | | ------------ | --------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If the confidence level is not strictly between 0 and 1, or a quantity is NaN in a run: where keeps the runs a condition chooses. | Source code in `commol/context/inference/global_sensitivity.py` ```python def summary(self, confidence_level: float) -> list[QuantitySummary]: """ The weighted distribution of each quantity across the runs. Parameters ---------- confidence_level : float Strictly between 0 and 1. Returns ------- list[QuantitySummary] One summary per quantity, in the order of `labels`. Raises ------ ValueError If the confidence level is not strictly between 0 and 1, or a quantity is NaN in a run: `where` keeps the runs a condition chooses. """ # The level is checked before the values, also when there are none. commol_rs.inference.interval_levels(confidence_level) summaries = [] for index, label in enumerate(self.labels): column = [row[index] for row in self.values] try: summary = commol_rs.inference.weighted_summary( column, self.weights, confidence_level ) except ValueError as error: raise ValueError( f"Quantity '{label}', in the runs of values counted from 0: " f"{error}. where(mask) keeps the runs a condition chooses." ) from error summaries.append( QuantitySummary( label=label, confidence_level=confidence_level, mean=summary.mean, median=summary.median, lower=summary.lower, upper=summary.upper, ) ) return summaries ``` ## QuantitySummary Bases: `ResultModel` The weighted distribution of one quantity across the runs of a propagation. Attributes: | Name | Type | Description | | ------------------ | ------- | ---------------------------------- | | `label` | `str` | The quantity. | | `confidence_level` | `float` | The level of the central interval. | | `mean` | `float` | The weighted mean. | | `median` | `float` | The weighted median. | | `lower` | `float` | The quantile (1 − c)/2. | | `upper` | `float` | The quantile (1 + c)/2. | Source code in `commol/context/inference/global_sensitivity.py` ```python class QuantitySummary(ResultModel): """ The weighted distribution of one quantity across the runs of a propagation. Attributes ---------- label : str The quantity. confidence_level : float The level of the central interval. mean : float The weighted mean. median : float The weighted median. lower : float The quantile ``(1 − c)/2``. upper : float The quantile ``(1 + c)/2``. """ model_config = ConfigDict(extra="forbid", frozen=True) label: str confidence_level: float mean: float median: float lower: float upper: float ``` ## PRCC Bases: `SensitivityMethod[PRCCResult]`, `RangeChecked` Latin hypercube sampling with partial rank correlation coefficients (Marino et al. 2008): how steadily each quantity rises or falls with each input. With independent inputs the draws are a Latin hypercube of their distributions; with inputs from a sample, the sample is the design, and the ranks, the correlations and the test read its weights. A `Trajectory` gives the coefficients over the axis. Attributes: | Name | Type | Description | | ------------ | ---------------- | ---------------------------- | | `draws` | \`int | None\` | | `quantities` | `list[Quantity]` | What each run is reduced to. | Source code in `commol/context/inference/global_sensitivity.py` ```python class PRCC(SensitivityMethod[PRCCResult], RangeChecked): """ Latin hypercube sampling with partial rank correlation coefficients (Marino et al. 2008): how steadily each quantity rises or falls with each input. With independent inputs the draws are a Latin hypercube of their distributions; with inputs from a sample, the sample is the design, and the ranks, the correlations and the test read its weights. A `Trajectory` gives the coefficients over the axis. Attributes ---------- draws : int | None The number of points of the Latin hypercube; required with independent inputs, and left unset with inputs from a sample. quantities : list[Quantity] What each run is reduced to. """ draws: int | None = Field( default=None, description="Points of the Latin hypercube." ) quantities: list[Quantity] = Field( min_length=1, description="What each run is reduced to." ) ``` ## PRCCResult Bases: `ResultModel` The partial rank correlation of each input with each quantity. A coefficient near 1 or −1 says the quantity rises or falls steadily with the input once the other inputs are accounted for; near 0, that it does not move steadily with it. The p-value tests the coefficient against 0 with Kish's effective size of the draws. Attributes: | Name | Type | Description | | ---------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- | | `sample` | `ParameterSample` | The draws the coefficients come from. | | `inputs` | `list[str]` | The inputs, in order. | | `labels` | `list[str]` | The label of each quantity. | | `series` | `list[str]` | The series of each quantity: its label, or the name of its Trajectory. | | `positions` | \`list\[float | None\]\` | | `coefficients` | `list[list[float]]` | The PRCC of each input for each quantity, [quantity][input]; NaN for a quantity with one value in every draw. | | `p_values` | `list[list[float]]` | The two-sided p-value of each coefficient, [quantity][input]. | | `effective_size` | `float` | Kish's effective size of the draws: their number with equal weights. | Source code in `commol/context/inference/global_sensitivity.py` ```python class PRCCResult(ResultModel): """ The partial rank correlation of each input with each quantity. A coefficient near 1 or −1 says the quantity rises or falls steadily with the input once the other inputs are accounted for; near 0, that it does not move steadily with it. The p-value tests the coefficient against 0 with Kish's effective size of the draws. Attributes ---------- sample : ParameterSample The draws the coefficients come from. inputs : list[str] The inputs, in order. labels : list[str] The label of each quantity. series : list[str] The series of each quantity: its label, or the name of its `Trajectory`. positions : list[float | None] The position of each quantity of a `Trajectory`, None for the others. coefficients : list[list[float]] The PRCC of each input for each quantity, ``[quantity][input]``; NaN for a quantity with one value in every draw. p_values : list[list[float]] The two-sided p-value of each coefficient, ``[quantity][input]``. effective_size : float Kish's effective size of the draws: their number with equal weights. """ model_config = ConfigDict(extra="forbid", frozen=True) sample: ParameterSample inputs: list[str] labels: list[str] series: list[str] positions: list[float | None] coefficients: list[list[float]] p_values: list[list[float]] effective_size: float def plot( self, output_file: str | None = None, config: "PlotConfig | None" = None, x_label: str = "Step", axes: "Figure | None" = None, ) -> "Figure": """ Draw the PRCC of each input: one panel per series. A series of a `Trajectory` draws one line per input over its positions; any other quantity draws one bar per input. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. x_label : str The label of the x axis of a series over positions. axes : Figure | None A figure of commol to draw on: each panel is the `Axes` whose label is the name of its series. If None, a new figure. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a series. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _prcc return _prcc( self, output_file=output_file, config=config, x_label=x_label, axes=axes ) ``` ### Methods: #### plot ```python plot(output_file: str | None = None, config: PlotConfig | None = None, x_label: str = 'Step', axes: Figure | None = None) -> Figure ``` Draw the PRCC of each input: one panel per series. A series of a `Trajectory` draws one line per input over its positions; any other quantity draws one bar per input. Parameters: | Name | Type | Description | Default | | ------------- | ------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `x_label` | `str` | The label of the x axis of a series over positions. | `'Step'` | | `axes` | \`Figure | None\` | A figure of commol to draw on: each panel is the Axes whose label is the name of its series. If None, a new figure. | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ------------------------------------------------------------ | | `ValueError` | If axes has no panel of a series. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/context/inference/global_sensitivity.py` ```python def plot( self, output_file: str | None = None, config: "PlotConfig | None" = None, x_label: str = "Step", axes: "Figure | None" = None, ) -> "Figure": """ Draw the PRCC of each input: one panel per series. A series of a `Trajectory` draws one line per input over its positions; any other quantity draws one bar per input. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. x_label : str The label of the x axis of a series over positions. axes : Figure | None A figure of commol to draw on: each panel is the `Axes` whose label is the name of its series. If None, a new figure. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a series. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _prcc return _prcc( self, output_file=output_file, config=config, x_label=x_label, axes=axes ) ``` ## Morris Bases: `SensitivityMethod[MorrisResult]`, `RangeChecked` Morris screening (Morris 1991), with the `μ*` of Campolongo, Cariboni and Saltelli (2007): which inputs matter, in few runs. Each trajectory starts at a random level of every input and moves the inputs one at a time, by half the levels; the levels are the centres of strata of equal probability of each distribution. It needs independent inputs: a step that moves one input breaks the dependence of a sample. Attributes: | Name | Type | Description | | -------------- | ---------------- | --------------------------------------------------------------------------- | | `trajectories` | `int` | The number of trajectories; each runs one more point than there are inputs. | | `levels` | `int` | The number of levels of each input; even, at least 2. | | `quantities` | `list[Quantity]` | What each run is reduced to. | Source code in `commol/context/inference/global_sensitivity.py` ```python class Morris(SensitivityMethod[MorrisResult], RangeChecked): """ Morris screening (Morris 1991), with the ``μ*`` of Campolongo, Cariboni and Saltelli (2007): which inputs matter, in few runs. Each trajectory starts at a random level of every input and moves the inputs one at a time, by half the levels; the levels are the centres of strata of equal probability of each distribution. It needs independent inputs: a step that moves one input breaks the dependence of a sample. Attributes ---------- trajectories : int The number of trajectories; each runs one more point than there are inputs. levels : int The number of levels of each input; even, at least 2. quantities : list[Quantity] What each run is reduced to. """ trajectories: int = Field(description="The number of trajectories.") levels: int = Field(description="The number of levels; even.") quantities: list[Quantity] = Field( min_length=1, description="What each run is reduced to." ) ``` ## MorrisResult Bases: `ResultModel` The Morris indices of each input for each quantity. The elementary effects are on the scale of probabilities: a step moves an input by half the range of `F(x)`, so the indices of inputs of different units compare. Rank the inputs by `mu_star`; a `sigma` near or above `mu_star` says the effect of the input depends on the other inputs or is not linear. Attributes: | Name | Type | Description | | --------- | ------------------- | --------------------------------------------------------------------------------------------- | | `inputs` | `list[str]` | The inputs, in order. | | `labels` | `list[str]` | The label of each quantity. | | `mu` | `list[list[float]]` | The mean elementary effect, [quantity][input]. | | `mu_star` | `list[list[float]]` | The mean absolute elementary effect, [quantity][input]. | | `sigma` | `list[list[float]]` | The standard deviation of the elementary effects, [quantity][input]; NaN with one trajectory. | | `runs` | `int` | The number of points run: one more than the inputs per trajectory. | Source code in `commol/context/inference/global_sensitivity.py` ```python class MorrisResult(ResultModel): """ The Morris indices of each input for each quantity. The elementary effects are on the scale of probabilities: a step moves an input by half the range of `F(x)`, so the indices of inputs of different units compare. Rank the inputs by `mu_star`; a `sigma` near or above `mu_star` says the effect of the input depends on the other inputs or is not linear. Attributes ---------- inputs : list[str] The inputs, in order. labels : list[str] The label of each quantity. mu : list[list[float]] The mean elementary effect, ``[quantity][input]``. mu_star : list[list[float]] The mean absolute elementary effect, ``[quantity][input]``. sigma : list[list[float]] The standard deviation of the elementary effects, ``[quantity][input]``; NaN with one trajectory. runs : int The number of points run: one more than the inputs per trajectory. """ model_config = ConfigDict(extra="forbid", frozen=True) inputs: list[str] labels: list[str] mu: list[list[float]] mu_star: list[list[float]] sigma: list[list[float]] runs: int def plot( self, output_file: str | None = None, config: "PlotConfig | None" = None, axes: "Figure | None" = None, ) -> "Figure": """ Draw ``σ`` against ``μ*`` of each input: one panel per quantity, one labelled point per input. An input far to the right matters; one high up acts through interactions or a non-linear effect. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. axes : Figure | None A figure of commol to draw on: each panel is the `Axes` whose label is the label of its quantity. If None, a new figure. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a quantity. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _morris return _morris(self, output_file=output_file, config=config, axes=axes) ``` ### Methods: #### plot ```python plot(output_file: str | None = None, config: PlotConfig | None = None, axes: Figure | None = None) -> Figure ``` Draw `σ` against `μ*` of each input: one panel per quantity, one labelled point per input. An input far to the right matters; one high up acts through interactions or a non-linear effect. Parameters: | Name | Type | Description | Default | | ------------- | ------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------- | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `axes` | \`Figure | None\` | A figure of commol to draw on: each panel is the Axes whose label is the label of its quantity. If None, a new figure. | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ------------------------------------------------------------ | | `ValueError` | If axes has no panel of a quantity. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/context/inference/global_sensitivity.py` ```python def plot( self, output_file: str | None = None, config: "PlotConfig | None" = None, axes: "Figure | None" = None, ) -> "Figure": """ Draw ``σ`` against ``μ*`` of each input: one panel per quantity, one labelled point per input. An input far to the right matters; one high up acts through interactions or a non-linear effect. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. axes : Figure | None A figure of commol to draw on: each panel is the `Axes` whose label is the label of its quantity. If None, a new figure. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a quantity. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _morris return _morris(self, output_file=output_file, config=config, axes=axes) ``` ## Sobol Bases: `SensitivityMethod[SobolResult]`, `RangeChecked` Sobol indices on the design of Saltelli (2010): first-order indices by the estimator of Saltelli (2010) and total indices by the estimator of Jansen (1999), with intervals from a bootstrap of the rows. The base matrices hold independent Monte Carlo draws, so the bootstrap intervals are valid. It needs independent inputs: with dependent inputs the indices lose their meaning and no longer add up; PRCC and propagation take a sample. Attributes: | Name | Type | Description | | ------------------ | ---------------- | ---------------------------------------------------------------------------- | | `draws` | `int` | The rows of each base matrix; the analysis runs draws · (inputs + 2) points. | | `replicates` | `int` | The bootstrap replicates of the intervals, at least 2. | | `confidence_level` | `float` | The level of the intervals, strictly between 0 and 1. | | `quantities` | `list[Quantity]` | What each run is reduced to. | Source code in `commol/context/inference/global_sensitivity.py` ```python class Sobol(SensitivityMethod[SobolResult], RangeChecked): """ Sobol indices on the design of Saltelli (2010): first-order indices by the estimator of Saltelli (2010) and total indices by the estimator of Jansen (1999), with intervals from a bootstrap of the rows. The base matrices hold independent Monte Carlo draws, so the bootstrap intervals are valid. It needs independent inputs: with dependent inputs the indices lose their meaning and no longer add up; PRCC and propagation take a sample. Attributes ---------- draws : int The rows of each base matrix; the analysis runs ``draws · (inputs + 2)`` points. replicates : int The bootstrap replicates of the intervals, at least 2. confidence_level : float The level of the intervals, strictly between 0 and 1. quantities : list[Quantity] What each run is reduced to. """ draws: int = Field(description="Rows of each base matrix.") replicates: int = Field(description="Bootstrap replicates.") confidence_level: float = Field(description="Level of the intervals.") quantities: list[Quantity] = Field( min_length=1, description="What each run is reduced to." ) ``` ## SobolResult Bases: `ResultModel` The Sobol indices of each input for each quantity, with their bootstrap intervals. The first-order index is the share of the variance of the quantity the input explains alone; the total index adds every interaction the input takes part in. A total index near 0 says the input can be fixed; a total index well above the first-order one says the input acts through interactions. Attributes: | Name | Type | Description | | -------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `inputs` | `list[str]` | The inputs, in order. | | `labels` | `list[str]` | The label of each quantity. | | `confidence_level` | `float` | The level of the intervals. | | `first, total` | `list[list[float]]` | The first-order and total indices, [quantity][input]; NaN for a quantity of no variance. | | `first_lower, first_upper` | `list[list[float]]` | The ends of the interval of each first-order index; NaN when a bootstrap replicate of the rows has no variance, which more draws avoid. | | `total_lower, total_upper` | `list[list[float]]` | The ends of the interval of each total index. | | `runs` | `int` | The number of points run: draws · (inputs + 2). | Source code in `commol/context/inference/global_sensitivity.py` ```python class SobolResult(ResultModel): """ The Sobol indices of each input for each quantity, with their bootstrap intervals. The first-order index is the share of the variance of the quantity the input explains alone; the total index adds every interaction the input takes part in. A total index near 0 says the input can be fixed; a total index well above the first-order one says the input acts through interactions. Attributes ---------- inputs : list[str] The inputs, in order. labels : list[str] The label of each quantity. confidence_level : float The level of the intervals. first, total : list[list[float]] The first-order and total indices, ``[quantity][input]``; NaN for a quantity of no variance. first_lower, first_upper : list[list[float]] The ends of the interval of each first-order index; NaN when a bootstrap replicate of the rows has no variance, which more `draws` avoid. total_lower, total_upper : list[list[float]] The ends of the interval of each total index. runs : int The number of points run: ``draws · (inputs + 2)``. """ model_config = ConfigDict(extra="forbid", frozen=True) inputs: list[str] labels: list[str] confidence_level: float first: list[list[float]] total: list[list[float]] first_lower: list[list[float]] first_upper: list[list[float]] total_lower: list[list[float]] total_upper: list[list[float]] runs: int def plot( self, output_file: str | None = None, config: "PlotConfig | None" = None, axes: "Figure | None" = None, ) -> "Figure": """ Draw the first-order and total Sobol indices: one panel per quantity, one pair of bars per input, with their intervals. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. axes : Figure | None A figure of commol to draw on: each panel is the `Axes` whose label is the label of its quantity. If None, a new figure. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a quantity. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _sobol return _sobol(self, output_file=output_file, config=config, axes=axes) ``` ### Methods: #### plot ```python plot(output_file: str | None = None, config: PlotConfig | None = None, axes: Figure | None = None) -> Figure ``` Draw the first-order and total Sobol indices: one panel per quantity, one pair of bars per input, with their intervals. Parameters: | Name | Type | Description | Default | | ------------- | ------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------- | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `axes` | \`Figure | None\` | A figure of commol to draw on: each panel is the Axes whose label is the label of its quantity. If None, a new figure. | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ------------------------------------------------------------ | | `ValueError` | If axes has no panel of a quantity. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/context/inference/global_sensitivity.py` ```python def plot( self, output_file: str | None = None, config: "PlotConfig | None" = None, axes: "Figure | None" = None, ) -> "Figure": """ Draw the first-order and total Sobol indices: one panel per quantity, one pair of bars per input, with their intervals. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. axes : Figure | None A figure of commol to draw on: each panel is the `Axes` whose label is the label of its quantity. If None, a new figure. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a quantity. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _sobol return _sobol(self, output_file=output_file, config=config, axes=axes) ``` ## PolynomialChaos Bases: `SensitivityMethod[ChaosResult]`, `RangeChecked` A polynomial chaos expansion (Sudret 2008): a polynomial surrogate of each quantity, and the Sobol indices of its coefficients, in far fewer runs than `Sobol` when the quantity is smooth. Every input goes to `u = F(x)`, uniform on `[0, 1]` through its distribution, so one basis of orthonormal Legendre polynomials fits every distribution, truncated ones included. The coefficients come from least squares on a Latin hypercube; the leave-one-out error says how well the expansion predicts the model. It needs independent inputs. The basis keeps every term whose degrees `α` have `Σ α_i^q ≤ degree^q` and that holds at most `max_interaction` inputs. The defaults keep the total-degree basis, of `(k + degree)! / (k! degree!)` terms in `k` inputs; a truncation keeps the terms of low interaction order, so a basis of many inputs needs far fewer terms and runs. The number of terms is known before any run. Attributes: | Name | Type | Description | | ----------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `degree` | `int` | The degree of the basis: the largest degree of an input in a term, and the total degree of a term when q_norm is 1. | | `q_norm` | `float` | The q of the hyperbolic truncation, in (0, 1\]: below 1, a term of several inputs keeps a lower total degree than a term of one. | | `max_interaction` | \`int | None\` | | `draws` | `int` | The points of the Latin hypercube; more than the terms, and two or three times as many is the usual choice. | | `quantities` | `list[Quantity]` | What each run is reduced to. | Source code in `commol/context/inference/global_sensitivity.py` ```python class PolynomialChaos(SensitivityMethod[ChaosResult], RangeChecked): """ A polynomial chaos expansion (Sudret 2008): a polynomial surrogate of each quantity, and the Sobol indices of its coefficients, in far fewer runs than `Sobol` when the quantity is smooth. Every input goes to ``u = F(x)``, uniform on ``[0, 1]`` through its distribution, so one basis of orthonormal Legendre polynomials fits every distribution, truncated ones included. The coefficients come from least squares on a Latin hypercube; the leave-one-out error says how well the expansion predicts the model. It needs independent inputs. The basis keeps every term whose degrees ``α`` have ``Σ α_i^q ≤ degree^q`` and that holds at most ``max_interaction`` inputs. The defaults keep the total-degree basis, of ``(k + degree)! / (k! degree!)`` terms in ``k`` inputs; a truncation keeps the terms of low interaction order, so a basis of many inputs needs far fewer terms and runs. The number of terms is known before any run. Attributes ---------- degree : int The degree of the basis: the largest degree of an input in a term, and the total degree of a term when ``q_norm`` is 1. q_norm : float The q of the hyperbolic truncation, in ``(0, 1]``: below 1, a term of several inputs keeps a lower total degree than a term of one. max_interaction : int | None The largest number of inputs of a term; None for no limit. draws : int The points of the Latin hypercube; more than the terms, and two or three times as many is the usual choice. quantities : list[Quantity] What each run is reduced to. """ degree: int = Field(description="The degree of the basis.") q_norm: float = Field(default=1.0, description="The q of the q-norm of the basis.") max_interaction: int | None = Field( default=None, description="The largest number of inputs of a term." ) draws: int = Field(description="Points of the Latin hypercube.") quantities: list[Quantity] = Field( min_length=1, description="What each run is reduced to." ) ``` ## ChaosResult Bases: `ResultModel` A polynomial chaos expansion of each quantity, its accuracy and the Sobol indices of its coefficients. The expansion is a surrogate of the model: `predict` gives the quantities at any values of the inputs inside their bounds, at the cost of a polynomial. Read `loo_error` first: the indices are those of the expansion, and they are those of the model only as far as the expansion predicts it. Attributes: | Name | Type | Description | | ----------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------ | | `inputs` | `list[str]` | The inputs, in order. | | `input_space` | `InputSpace` | The inputs and their distributions, which predict maps values through. | | `labels` | `list[str]` | The label of each quantity. | | `degree` | `int` | The degree of the basis: the bound of the q-norm of every term. | | `q_norm` | `float` | The q of the q-norm of the basis; 1 for the total-degree basis. | | `max_interaction` | \`int | None\` | | `indices` | `list[list[int]]` | The degree of each input in each term, [term][input]; term 0 is the constant. | | `coefficients` | `list[list[float]]` | The coefficient of each term for each quantity, [quantity][term], on orthonormal Legendre polynomials of u = F(x). | | `loo_error` | `list[float]` | The leave-one-out error of each quantity, relative to its variance; NaN for a quantity of no variance. | | `mean, variance` | `list[float]` | The mean and the variance of each quantity under the expansion. | | `first, total` | `list[list[float]]` | The first-order and total Sobol indices, [quantity][input]; NaN for a quantity of no variance. | | `runs` | `int` | The number of points run. | Source code in `commol/context/inference/global_sensitivity.py` ```python class ChaosResult(ResultModel): """ A polynomial chaos expansion of each quantity, its accuracy and the Sobol indices of its coefficients. The expansion is a surrogate of the model: `predict` gives the quantities at any values of the inputs inside their bounds, at the cost of a polynomial. Read `loo_error` first: the indices are those of the expansion, and they are those of the model only as far as the expansion predicts it. Attributes ---------- inputs : list[str] The inputs, in order. input_space : InputSpace The inputs and their distributions, which `predict` maps values through. labels : list[str] The label of each quantity. degree : int The degree of the basis: the bound of the q-norm of every term. q_norm : float The q of the q-norm of the basis; 1 for the total-degree basis. max_interaction : int | None The largest number of inputs of a term; None for no limit. indices : list[list[int]] The degree of each input in each term, ``[term][input]``; term 0 is the constant. coefficients : list[list[float]] The coefficient of each term for each quantity, ``[quantity][term]``, on orthonormal Legendre polynomials of ``u = F(x)``. loo_error : list[float] The leave-one-out error of each quantity, relative to its variance; NaN for a quantity of no variance. mean, variance : list[float] The mean and the variance of each quantity under the expansion. first, total : list[list[float]] The first-order and total Sobol indices, ``[quantity][input]``; NaN for a quantity of no variance. runs : int The number of points run. """ model_config = ConfigDict(extra="forbid", frozen=True) inputs: list[str] input_space: InputSpace labels: list[str] degree: int q_norm: float max_interaction: int | None indices: list[list[int]] coefficients: list[list[float]] loo_error: list[float] mean: list[float] variance: list[float] first: list[list[float]] total: list[list[float]] runs: int def plot( self, output_file: str | None = None, config: "PlotConfig | None" = None, axes: "Figure | None" = None, ) -> "Figure": """ Draw the first-order and total Sobol indices of the expansion: one panel per quantity, one pair of bars per input. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. axes : Figure | None A figure of commol to draw on: each panel is the `Axes` whose label is the label of its quantity. If None, a new figure. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a quantity. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _sobol return _sobol(self, output_file=output_file, config=config, axes=axes) def predict(self, values: Sequence[Sequence[float]]) -> list[list[float]]: """ The quantities the expansion gives at each set of input values. Parameters ---------- values : Sequence[Sequence[float]] One value per input, in the order of `inputs`, for each point, each inside the bounds of its input. Returns ------- list[list[float]] The value of each quantity at each point, ``[point][quantity]``. Raises ------ ValueError If a point has not one value per input or a value outside the bounds of its input, the truncation is not valid, or the coefficients do not fit the basis. """ return commol_rs.inference.chaos_evaluate( engine_state.rust_parameters(self.input_space.parameters), self.input_space.rust_distributions() or [], self.degree, self.q_norm, self.max_interaction, self.coefficients, [list(point) for point in values], ) ``` ### Methods: #### plot ```python plot(output_file: str | None = None, config: PlotConfig | None = None, axes: Figure | None = None) -> Figure ``` Draw the first-order and total Sobol indices of the expansion: one panel per quantity, one pair of bars per input. Parameters: | Name | Type | Description | Default | | ------------- | ------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------- | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `axes` | \`Figure | None\` | A figure of commol to draw on: each panel is the Axes whose label is the label of its quantity. If None, a new figure. | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ------------------------------------------------------------ | | `ValueError` | If axes has no panel of a quantity. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/context/inference/global_sensitivity.py` ```python def plot( self, output_file: str | None = None, config: "PlotConfig | None" = None, axes: "Figure | None" = None, ) -> "Figure": """ Draw the first-order and total Sobol indices of the expansion: one panel per quantity, one pair of bars per input. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. axes : Figure | None A figure of commol to draw on: each panel is the `Axes` whose label is the label of its quantity. If None, a new figure. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a quantity. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _sobol return _sobol(self, output_file=output_file, config=config, axes=axes) ``` #### predict ```python predict(values: Sequence[Sequence[float]]) -> list[list[float]] ``` The quantities the expansion gives at each set of input values. Parameters: | Name | Type | Description | Default | | -------- | --------------------------- | ------------------------------------------------------------------------------------------------- | ---------- | | `values` | `Sequence[Sequence[float]]` | One value per input, in the order of inputs, for each point, each inside the bounds of its input. | *required* | Returns: | Type | Description | | ------------------- | ------------------------------------------------------------ | | `list[list[float]]` | The value of each quantity at each point, [point][quantity]. | Raises: | Type | Description | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If a point has not one value per input or a value outside the bounds of its input, the truncation is not valid, or the coefficients do not fit the basis. | Source code in `commol/context/inference/global_sensitivity.py` ```python def predict(self, values: Sequence[Sequence[float]]) -> list[list[float]]: """ The quantities the expansion gives at each set of input values. Parameters ---------- values : Sequence[Sequence[float]] One value per input, in the order of `inputs`, for each point, each inside the bounds of its input. Returns ------- list[list[float]] The value of each quantity at each point, ``[point][quantity]``. Raises ------ ValueError If a point has not one value per input or a value outside the bounds of its input, the truncation is not valid, or the coefficients do not fit the basis. """ return commol_rs.inference.chaos_evaluate( engine_state.rust_parameters(self.input_space.parameters), self.input_space.rust_distributions() or [], self.degree, self.q_norm, self.max_interaction, self.coefficients, [list(point) for point in values], ) ``` ## Level Bases: `_Outputs` The level of some outputs: their sum at each position. A quantity given a name or a list of names reads this series. Attributes: | Name | Type | Description | | --------- | ----------- | ---------------------------------------------- | | `outputs` | `list[str]` | The outputs summed; one name is a list of one. | Source code in `commol/context/inference/quantities.py` ```python class Level(_Outputs): """ The level of some outputs: their sum at each position. A quantity given a name or a list of names reads this series. Attributes ---------- outputs : list[str] The outputs summed; one name is a list of one. """ kind: Literal["level"] = "level" def __init__( self, outputs: str | list[str], *, kind: Literal["level"] = "level" ) -> None: BaseModel.__init__(self, outputs=outputs, kind=kind) @property def text(self) -> str: """The series as a label reads it: the outputs.""" return self.output_name def check_ends(self, positions: Sequence[float]) -> None: """A level reads any position.""" ``` ### Attributes #### text ```python text: str ``` The series as a label reads it: the outputs. ### Methods: #### check_ends ```python check_ends(positions: Sequence[float]) -> None ``` A level reads any position. Source code in `commol/context/inference/quantities.py` ```python def check_ends(self, positions: Sequence[float]) -> None: """A level reads any position.""" ``` ## Increments Bases: `_Outputs`, `RangeChecked` The increments of some outputs: at each end, the gain of their sum over `(end - window, end]`, as an observation with the window `window` reads it. The positions of a quantity of increments are window ends, each at least `window` within the rounding of the window (`window_start` of `crates/commol-calibration/src/observation.rs`), so no window starts before 0. Attributes: | Name | Type | Description | | --------- | ----------- | ---------------------------------------------- | | `outputs` | `list[str]` | The outputs summed; one name is a list of one. | | `window` | `float` | The width of each window, above 0. | Source code in `commol/context/inference/quantities.py` ```python class Increments(_Outputs, RangeChecked): """ The increments of some outputs: at each end, the gain of their sum over ``(end - window, end]``, as an observation with the window `window` reads it. The positions of a quantity of increments are window ends, each at least `window` within the rounding of the window (`window_start` of `crates/commol-calibration/src/observation.rs`), so no window starts before 0. Attributes ---------- outputs : list[str] The outputs summed; one name is a list of one. window : float The width of each window, above 0. """ kind: Literal["increments"] = "increments" window: float = Field(description="The width.") def __init__( self, outputs: str | list[str], window: float, *, kind: Literal["increments"] = "increments", ) -> None: BaseModel.__init__(self, outputs=outputs, window=window, kind=kind) @property def text(self) -> str: """The series as a label reads it: the gain over windows of a width.""" return f"{self.output_name} gain over windows of {_position_text(self.window)}" def check_ends(self, positions: Sequence[float]) -> None: """ No window starts before 0, with the rule of the engine (`check_window_end` of `crates/commol-calibration/src/observation.rs`). Raises ------ ValueError With the message of the engine, if a window starts before 0. """ for at in positions: commol_rs.calibration.check_window_end(at, self.window) ``` ### Attributes #### text ```python text: str ``` The series as a label reads it: the gain over windows of a width. ### Methods: #### check_ends ```python check_ends(positions: Sequence[float]) -> None ``` No window starts before 0, with the rule of the engine (`check_window_end` of `crates/commol-calibration/src/observation.rs`). Raises: | Type | Description | | ------------ | ------------------------------------------------------------ | | `ValueError` | With the message of the engine, if a window starts before 0. | Source code in `commol/context/inference/quantities.py` ```python def check_ends(self, positions: Sequence[float]) -> None: """ No window starts before 0, with the rule of the engine (`check_window_end` of `crates/commol-calibration/src/observation.rs`). Raises ------ ValueError With the message of the engine, if a window starts before 0. """ for at in positions: commol_rs.calibration.check_window_end(at, self.window) ``` ## ValueAt Bases: `_OfSeries`, `_OneScalar`, `RangeChecked` The value of a series at a position: the level of the outputs, or their gain over the window that ends there. Attributes: | Name | Type | Description | | -------- | ------- | ----------------------------------------------------- | | `series` | \`Level | Increments\` | | `at` | `float` | The position; with Increments, the end of the window. | | `label` | \`str | None\` | Source code in `commol/context/inference/quantities.py` ```python class ValueAt(_OfSeries, _OneScalar, RangeChecked): """ The value of a series at a position: the level of the outputs, or their gain over the window that ends there. Attributes ---------- series : Level | Increments The series read; a name or a list of names is their `Level`. at : float The position; with `Increments`, the end of the window. label : str | None The name of the quantity in results and plots. If None, a name that says what the quantity reads. """ kind: Literal["value_at"] = "value_at" at: float = Field(description="The position.") @model_validator(mode="after") def window_after_zero(self) -> Self: """The window of increments ends at least its width after 0.""" self.series.check_ends([self.at]) return self def default_label(self) -> str: """The series at `at`.""" return f"{self.series.text} at {_position_text(self.at)}" ``` ### Methods: #### window_after_zero ```python window_after_zero() -> Self ``` The window of increments ends at least its width after 0. Source code in `commol/context/inference/quantities.py` ```python @model_validator(mode="after") def window_after_zero(self) -> Self: """The window of increments ends at least its width after 0.""" self.series.check_ends([self.at]) return self ``` #### default_label ```python default_label() -> str ``` The series at `at`. Source code in `commol/context/inference/quantities.py` ```python def default_label(self) -> str: """The series at `at`.""" return f"{self.series.text} at {_position_text(self.at)}" ``` ## Gain Bases: `_Outputs`, `_OneScalar`, `RangeChecked` The value at `end` minus the value at `start`: what entered a cumulative output over `(start, end]`, as an observation with the window `end - start` that starts at exactly `start` reads it. Attributes: | Name | Type | Description | | --------- | ----------- | ---------------------------------------------- | | `outputs` | `list[str]` | The outputs summed; one name is a list of one. | | `start` | `float` | The start of the window. | | `end` | `float` | The end of the window, after start. | | `label` | \`str | None\` | Source code in `commol/context/inference/quantities.py` ```python class Gain(_Outputs, _OneScalar, RangeChecked): """ The value at `end` minus the value at `start`: what entered a cumulative output over ``(start, end]``, as an observation with the window ``end - start`` that starts at exactly `start` reads it. Attributes ---------- outputs : list[str] The outputs summed; one name is a list of one. start : float The start of the window. end : float The end of the window, after `start`. label : str | None The name of the quantity in results and plots. If None, a name that says what the quantity reads. """ kind: Literal["gain"] = "gain" start: float = Field(description="The start of the window.") end: float = Field(description="The end of the window.") @model_validator(mode="after") def end_after_start(self) -> Self: """A window ends after it starts.""" if self.end <= self.start: raise ValueError( f"A gain ends after it starts, got start {self.start} and end " f"{self.end}" ) return self def default_label(self) -> str: """The gain of `outputs` over the window.""" start, end = _position_text(self.start), _position_text(self.end) return f"{self.output_name} gain over ({start}, {end}]" ``` ### Methods: #### end_after_start ```python end_after_start() -> Self ``` A window ends after it starts. Source code in `commol/context/inference/quantities.py` ```python @model_validator(mode="after") def end_after_start(self) -> Self: """A window ends after it starts.""" if self.end <= self.start: raise ValueError( f"A gain ends after it starts, got start {self.start} and end " f"{self.end}" ) return self ``` #### default_label ```python default_label() -> str ``` The gain of `outputs` over the window. Source code in `commol/context/inference/quantities.py` ```python def default_label(self) -> str: """The gain of `outputs` over the window.""" start, end = _position_text(self.start), _position_text(self.end) return f"{self.output_name} gain over ({start}, {end}]" ``` ## Peak Bases: `_AtPositions`, `_OneScalar`, `RangeChecked` The largest value of a series at the positions. The series decides what the peak is: the peak of `Increments(acc, window=7)` at the ends 7, 14, ... is the largest gain of a window of 7, while the peak of the level of an accumulator, which never falls, is its value at the last position. A NaN at a position makes the peak NaN. Attributes: | Name | Type | Description | | ----------- | ------------- | ---------------------------------------------------------------- | | `series` | \`Level | Increments\` | | `positions` | `list[float]` | The positions compared, ascending; with Increments, window ends. | | `label` | \`str | None\` | Source code in `commol/context/inference/quantities.py` ```python class Peak(_AtPositions, _OneScalar, RangeChecked): """ The largest value of a series at the positions. The series decides what the peak is: the peak of `Increments(acc, window=7)` at the ends 7, 14, ... is the largest gain of a window of 7, while the peak of the level of an accumulator, which never falls, is its value at the last position. A NaN at a position makes the peak NaN. Attributes ---------- series : Level | Increments The series read; a name or a list of names is their `Level`. positions : list[float] The positions compared, ascending; with `Increments`, window ends. label : str | None The name of the quantity in results and plots. If None, a name that says what the quantity reads. """ kind: Literal["peak"] = "peak" def default_label(self) -> str: """The peak of the series.""" return f"peak of {self.series.text}" ``` ### Methods: #### default_label ```python default_label() -> str ``` The peak of the series. Source code in `commol/context/inference/quantities.py` ```python def default_label(self) -> str: """The peak of the series.""" return f"peak of {self.series.text}" ``` ## PeakPosition Bases: `_AtPositions`, `_OneScalar`, `RangeChecked` The first of the positions where the value of a series is largest; NaN when the value at a position is NaN. Attributes: | Name | Type | Description | | ----------- | ------------- | ---------------------------------------------------------------- | | `series` | \`Level | Increments\` | | `positions` | `list[float]` | The positions compared, ascending; with Increments, window ends. | | `label` | \`str | None\` | Source code in `commol/context/inference/quantities.py` ```python class PeakPosition(_AtPositions, _OneScalar, RangeChecked): """ The first of the positions where the value of a series is largest; NaN when the value at a position is NaN. Attributes ---------- series : Level | Increments The series read; a name or a list of names is their `Level`. positions : list[float] The positions compared, ascending; with `Increments`, window ends. label : str | None The name of the quantity in results and plots. If None, a name that says what the quantity reads. """ kind: Literal["peak_position"] = "peak_position" def default_label(self) -> str: """The position of the peak of the series.""" return f"position of the peak of {self.series.text}" ``` ### Methods: #### default_label ```python default_label() -> str ``` The position of the peak of the series. Source code in `commol/context/inference/quantities.py` ```python def default_label(self) -> str: """The position of the peak of the series.""" return f"position of the peak of {self.series.text}" ``` ## Trajectory Bases: `_AtPositions`, `RangeChecked` The value of a series at each position: one scalar per position, for an analysis over the axis. Attributes: | Name | Type | Description | | ----------- | ------------- | ------------------------------------------------------- | | `series` | \`Level | Increments\` | | `positions` | `list[float]` | The positions, ascending; with Increments, window ends. | | `label` | \`str | None\` | Source code in `commol/context/inference/quantities.py` ```python class Trajectory(_AtPositions, RangeChecked): """ The value of a series at each position: one scalar per position, for an analysis over the axis. Attributes ---------- series : Level | Increments The series read; a name or a list of names is their `Level`. positions : list[float] The positions, ascending; with `Increments`, window ends. label : str | None The name of the series; each scalar is named after it and its position. """ kind: Literal["trajectory"] = "trajectory" def _name(self) -> str: """The name of the series.""" return self.label or self.series.text def labels(self) -> list[str]: """The name and each position.""" return [f"{self._name()} at {_position_text(at)}" for at in self.positions] def axes(self) -> list[tuple[str, float | None]]: """Its name and each position.""" return [(self._name(), at) for at in self.positions] ``` ### Methods: #### labels ```python labels() -> list[str] ``` The name and each position. Source code in `commol/context/inference/quantities.py` ```python def labels(self) -> list[str]: """The name and each position.""" return [f"{self._name()} at {_position_text(at)}" for at in self.positions] ``` #### axes ```python axes() -> list[tuple[str, float | None]] ``` Its name and each position. Source code in `commol/context/inference/quantities.py` ```python def axes(self) -> list[tuple[str, float | None]]: """Its name and each position.""" return [(self._name(), at) for at in self.positions] ``` ## Ratio Bases: `_OneScalar` The ratio of two quantities of one value each, numerator N over denominator D. The ratio of a run whose denominator is 0 is NaN, also when the numerator is not 0, and follows the NaN rule of every method. A method that reduces the points of a design (`PRCC`, `Morris`, `Sobol`, `PolynomialChaos`) reads one value per point; with a stochastic engine, `replicate_rule` says how the replicates of a point combine. A `Propagation`, the `Scenarios` and the quantities of one run keep the ratio of each run. Attributes: | Name | Type | Description | | ---------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `numerator` | \`ValueAt | Gain | | `denominator` | \`ValueAt | Gain | | `replicate_rule` | `{within, within_positive, pooled}` | "within": the mean of the ratio of each replicate, E[N/D]; a replicate with D = 0 leaves the point with no value. "within_positive": the mean of the ratio of the replicates with D > 0, E\[N/D | | `label` | \`str | None\` | Source code in `commol/context/inference/quantities.py` ```python class Ratio(_OneScalar): """ The ratio of two quantities of one value each, numerator N over denominator D. The ratio of a run whose denominator is 0 is NaN, also when the numerator is not 0, and follows the NaN rule of every method. A method that reduces the points of a design (`PRCC`, `Morris`, `Sobol`, `PolynomialChaos`) reads one value per point; with a stochastic engine, `replicate_rule` says how the replicates of a point combine. A `Propagation`, the `Scenarios` and the quantities of one run keep the ratio of each run. Attributes ---------- numerator : ValueAt | Gain | Peak The quantity divided. denominator : ValueAt | Gain | Peak The quantity it is divided by. replicate_rule : {"within", "within_positive", "pooled"} ``"within"``: the mean of the ratio of each replicate, E[N/D]; a replicate with D = 0 leaves the point with no value. ``"within_positive"``: the mean of the ratio of the replicates with D > 0, E[N/D | D > 0]; a point with no such replicate has no value. ``"pooled"``: the ratio of the means, E[N]/E[D]; a point with E[D] = 0 has no value. With a deterministic engine, the three give N/D, and a point with D = 0 has no value. label : str | None The name of the quantity in results and plots. If None, a name that says what the quantity reads. """ kind: Literal["ratio"] = "ratio" numerator: _Operand = Field(description="The quantity divided.") denominator: _Operand = Field(description="The quantity it is divided by.") replicate_rule: ReplicateRule = Field( description="How the replicates of a stochastic run combine." ) @field_validator("numerator", "denominator") @classmethod def one_value(cls, value: QuantityBase) -> QuantityBase: """An operand gives one value per run.""" if isinstance(value, PeakPosition | Trajectory): raise ValueError( f"A Ratio divides quantities of one value each (ValueAt, Gain, " f"Peak), got the {type(value).__name__} '{value.labels()[0]}'" ) return value def default_label(self) -> str: """The numerator over the denominator.""" numerator = self.numerator.labels()[0] denominator = self.denominator.labels()[0] return f"({numerator}) / ({denominator})" def rust_json(self) -> str: """The ratio as Rust reads it: its JSON, without the labels.""" return self.model_dump_json( exclude={"label": True, "numerator": {"label"}, "denominator": {"label"}} ) ``` ### Methods: #### one_value ```python one_value(value: QuantityBase) -> QuantityBase ``` An operand gives one value per run. Source code in `commol/context/inference/quantities.py` ```python @field_validator("numerator", "denominator") @classmethod def one_value(cls, value: QuantityBase) -> QuantityBase: """An operand gives one value per run.""" if isinstance(value, PeakPosition | Trajectory): raise ValueError( f"A Ratio divides quantities of one value each (ValueAt, Gain, " f"Peak), got the {type(value).__name__} '{value.labels()[0]}'" ) return value ``` #### default_label ```python default_label() -> str ``` The numerator over the denominator. Source code in `commol/context/inference/quantities.py` ```python def default_label(self) -> str: """The numerator over the denominator.""" numerator = self.numerator.labels()[0] denominator = self.denominator.labels()[0] return f"({numerator}) / ({denominator})" ``` #### rust_json ```python rust_json() -> str ``` The ratio as Rust reads it: its JSON, without the labels. Source code in `commol/context/inference/quantities.py` ```python def rust_json(self) -> str: """The ratio as Rust reads it: its JSON, without the labels.""" return self.model_dump_json( exclude={"label": True, "numerator": {"label"}, "denominator": {"label"}} ) ``` ## quantity_schedule ```python quantity_schedule(quantities: Sequence[QuantityBase]) -> list[float] ``` The positions a run must report at for `quantities` to read it. They are every position a quantity reads and the start of every window, sorted, each once. A `SimulationResults` of `Simulation.run_at(quantity_schedule(quantities))` holds them all, so `SimulationResults.quantities` reads it; a `Propagation` runs at the same positions. Parameters: | Name | Type | Description | Default | | ------------ | -------------------- | --------------- | ---------- | | `quantities` | `Sequence[Quantity]` | The quantities. | *required* | Returns: | Type | Description | | ------------- | ------------------------- | | `list[float]` | The positions, ascending. | Raises: | Type | Description | | ------------ | ------------------------ | | `ValueError` | If there is no quantity. | Source code in `commol/context/inference/quantities.py` ```python def quantity_schedule(quantities: Sequence[QuantityBase]) -> list[float]: """ The positions a run must report at for `quantities` to read it. They are every position a quantity reads and the start of every window, sorted, each once. A `SimulationResults` of ``Simulation.run_at(quantity_schedule(quantities))`` holds them all, so `SimulationResults.quantities` reads it; a `Propagation` runs at the same positions. Parameters ---------- quantities : Sequence[Quantity] The quantities. Returns ------- list[float] The positions, ascending. Raises ------ ValueError If there is no quantity. """ if not quantities: raise ValueError("Give at least one quantity of interest.") return commol_rs.inference.quantity_schedule(rust_quantities(quantities)) ``` # Simulation API [Back to API Reference](https://munqu.github.io/commol/latest/api/index.md) Runs a model and reads its outputs. Guide: [Running Simulations](https://munqu.github.io/commol/latest/guide/simulations/index.md). ## Simulation A Facade for running a simulation from a defined Model. The engine follows the model: `run` and `engine` rebuild it when `model_definition` no longer serializes to the JSON the engine was built from, for example after `Model.update_parameters`. Source code in `commol/api/simulation.py` ```python class Simulation: """ A Facade for running a simulation from a defined Model. The engine follows the model: ``run`` and ``engine`` rebuild it when ``model_definition`` no longer serializes to the JSON the engine was built from, for example after ``Model.update_parameters``. """ def __init__(self, model: Model) -> None: """ Initializes the simulation engine from a Pydantic Model definition. Parameters ---------- model : Model A fully constructed and validated model object, of any kind. None values for parameters/initial conditions if used for calibration. Raises ------ TypeError If the structure of the model is not a `ModelStructure`. """ if not isinstance(model.structure, ModelStructure): raise TypeError( f"The structure of model '{model.name}' is a " f"{type(model.structure).__name__}, not a ModelStructure. A model " "holds the structure of its kind, such as an Aggregate." ) logger.info(f"Initializing Simulation with model: '{model.name}'") self.model_definition: Model = model self._build_engine(model.model_dump_json()) def _build_engine(self, model_json: str) -> None: """Build the engine and its output names from a model JSON.""" self._model_json: str = model_json self._engine: "EngineProtocol" = build_engine(model_json) self._simulation_outputs: list[str] = self._engine.output_names logger.info( f"Simulation engine ready. Outputs: {len(self._simulation_outputs)}" ) def _sync_engine(self) -> None: """Rebuild the engine when the model changed since it was built.""" model_json = self.model_definition.model_dump_json() if model_json != self._model_json: logger.info("Model changed since the engine was built; rebuilding it.") self._build_engine(model_json) def _validate_all_parameters_calibrated(self, model: Model) -> None: """ Validates that every parameter, and every value of the model kind, has a value (is calibrated). Parameters ---------- model : Model The model to validate. Raises ------ ValueError If any parameter, or any value of the model kind such as an initial condition, has a None value. """ uncalibrated_params = model.get_uncalibrated_parameters() uncalibrated_ics = model.structure.uncalibrated_values() errors = [] if uncalibrated_params: errors.append( f"Parameters requiring calibration: {', '.join(uncalibrated_params)}" ) if uncalibrated_ics: errors.append( f"Initial conditions requiring calibration: " f"{', '.join(uncalibrated_ics)}" ) if errors: raise ValueError( f"Cannot run Simulation: {'; '.join(errors)}. " f"Please calibrate these values before running a simulation." ) @property def is_stochastic(self) -> bool: """Whether the engine of the model draws random numbers.""" return commol_rs.engine.EngineType( self.model_definition.engine.type ).is_stochastic def _seed_run(self, seed: int | None) -> int | None: """ Seed the engine of a stochastic model, which draws a seed for None; the seed it runs with, or None for a deterministic engine. """ if not self.is_stochastic: return None return self._engine.set_seed(None if seed is None else check_seed(seed)) def run(self, num_steps: int, *, seed: int | None = None) -> SimulationResults: """ Runs the simulation from the initial state on its default grid. Every call starts at position 0 with the current ``model_definition``, so two calls with the same model, and the same seed for a stochastic engine, give the same result. To continue a run, call ``simulation.engine.step()``. Parameters ---------- num_steps : int The number of steps: the results hold the positions ``0, 1, ..., num_steps``. seed : int | None, optional The seed of a stochastic engine, a whole number from 0 to ``2**64 - 1``. None draws one, which ``results.seed`` keeps, so every run can be repeated. A deterministic engine ignores it. Returns ------- SimulationResults One value per position for each output, the positions, the seed of a stochastic run, and the model. Raises ------ InfeasibleStateError If a step leaves an amount below zero; the message names the compartment, the step and the flows out of it. ValueError If a parameter or initial condition has no value, or the seed is not a whole number from 0 to ``2**64 - 1``. RuntimeError If a rate cannot be evaluated or is not a number; the message names the flow and the position. """ self._validate_all_parameters_calibrated(self.model_definition) self._sync_engine() used_seed = self._seed_run(seed) self._engine.reset() logger.info(f"Running simulation for {num_steps} steps.") start = time.time() positions, rows = self._engine.run(num_steps) logger.info(f"Simulation complete. It took {time.time() - start} seconds.") return SimulationResults.from_rows( positions, self._simulation_outputs, rows, seed=used_seed, model=self.model_definition, ) def run_at( self, at: Sequence[float], *, seed: int | None = None ) -> SimulationResults: """ Runs the simulation from the initial state and reports at given positions. The positions are on the axis, the independent variable of the model. The engine decides which positions its model type reports at: a DifferentialEquations model at any finite, non-negative position, from the dense output of its solver; a DifferenceEquations model only at integer steps. Parameters ---------- at : Sequence[float] Sorted, unique positions to report at. seed : int | None, optional The seed of a stochastic engine; see `run`. Returns ------- SimulationResults One value per position for each output, the positions, the seed of a stochastic run, and the model. Raises ------ InfeasibleStateError If a step leaves an amount below zero; the message names the compartment, the step and the flows out of it. ValueError If a parameter or initial condition has no value, the positions are not sorted and unique, or the engine does not report at one of them; the message is the engine's. RuntimeError If a rate cannot be evaluated or is not a number; the message names the flow and the position. """ positions = [float(position) for position in at] self._validate_all_parameters_calibrated(self.model_definition) self._sync_engine() used_seed = self._seed_run(seed) self._engine.reset() rows = self._engine.run_at(positions) return SimulationResults.from_rows( positions, self._simulation_outputs, rows, seed=used_seed, model=self.model_definition, ) def replicates(self, count: int, *, seed: int | None = None) -> "ReplicateRuns": """ Replicates of the runs of a stochastic engine. The returned object has ``run`` and ``run_at``, as `Simulation`, and they return `SimulationReplicates`: ``count`` runs, each with its own seed, run in parallel in the engine. Replicate ``i`` takes the seed derived from ``seed`` and ``i`` (the SplitMix64 of the SplitMix64 of ``seed`` plus ``i``), so two seeds share no replicate, and a replicate gives the same run however many replicates run. Parameters ---------- count : int How many replicates, at least 1. seed : int | None, optional The seed the replicate seeds derive from, a whole number from 0 to ``2**64 - 1``. None draws one, which the replicates keep. Returns ------- ReplicateRuns The runner of the replicates. Raises ------ ValueError If the engine is deterministic (every replicate would be equal), ``count`` is not a whole number or is below 1, or the seed is not valid. """ if not self.is_stochastic: raise ValueError( f"The {self.model_definition.engine.type} engine is deterministic: " "every replicate would be equal. Replicates need a stochastic engine." ) if isinstance(count, bool) or not isinstance(count, int): raise ValueError(f"count is a whole number, got {count!r}") return ReplicateRuns(self, count, None if seed is None else check_seed(seed)) @property def engine(self) -> "EngineProtocol": """ The engine for the current ``model_definition``. It is rebuilt, at step 0, when the model changed since it was built. Otherwise it is the same object, in the state the last ``run`` or ``step`` left it. """ self._sync_engine() return self._engine @property def simulation_outputs(self) -> list[str]: """Names of the columns returned by simulation runs.""" self._sync_engine() return self._simulation_outputs def outputs_for(self, id: str) -> list[str]: """ Names of the outputs a source id expands into. The structure of the model groups its outputs by source (`ModelStructure.outputs_by_source`): for an aggregate model, by bin and accumulator. Parameters ---------- id : str A source id, such as a bin id or an accumulator id. Returns ------- list[str] Output names for that id, in the order the engine reports them. Raises ------ KeyError If `id` is not a source of the model. """ return outputs_for(self.model_definition, id) def group_outputs(self, ids: Iterable[str]) -> dict[str, list[str]]: """ Group output names by the bin or accumulator they belong to. Parameters ---------- ids : Iterable[str] Bin and accumulator ids. Returns ------- dict[str, list[str]] Each id mapped to its output names, keeping the order given. Raises ------ KeyError If an id is not a bin or accumulator of the model. """ return {id: self.outputs_for(id) for id in ids} def _series_names(self, ids: Iterable[str]) -> list[str]: """ The outputs the ids stand for, in the order given. A source id, such as a bin or accumulator id, stands for all its outputs (`outputs_for`); an output name stands for itself. Raises ------ KeyError If an id is neither a source nor an output of the model. """ model = self.model_definition by_source = model.structure.outputs_by_source(model) outputs = [name for names in by_source.values() for name in names] names: list[str] = [] for id in ids: if id in by_source: names.extend(by_source[id]) elif id in outputs: names.append(id) else: raise KeyError( f"'{id}' is not a bin, accumulator or output of model " f"'{model.name}'. Bins and accumulators: {sorted(by_source)}. " f"Outputs: {outputs}" ) return names def total_series( self, results: Mapping[str, Sequence[float]], ids: Iterable[str], ) -> list[float]: """ Sum the given outputs at each position. Parameters ---------- results : Mapping[str, Sequence[float]] Simulation results, such as those of `run` or `run_at`. ids : Iterable[str] Bin and accumulator ids, each for all its outputs (`outputs_for`), and output names, each for itself. Their outputs are summed together. Returns ------- list[float] One value per position of `results`. Raises ------ KeyError If an id is not a bin, accumulator or output of the model, or an expected output is missing from `results`. """ return total_series(results, self._series_names(ids)) def windowed_totals( self, results: SimulationResults, ids: Iterable[str], window: float, at: Iterable[float] | None = None, ) -> list[float]: """ Amount the given accumulators gained over each window. Parameters ---------- results : SimulationResults Simulation results, with their positions. ids : Iterable[str] Accumulator ids, each for all its outputs, and output names, each for itself. Their outputs are summed before windowing. window : float Length of one window on the axis. at : Iterable[float] | None, optional Positions at which windows close. Defaults to every complete window of the results. Pass the observation positions to reproduce exactly the values a calibration used. Returns ------- list[float] One increment per window. Raises ------ KeyError If an id is not a bin, accumulator or output of the model, or an expected output is missing from `results`. ValueError If `window` is not positive, or the results have no value at the end or the start of a requested window. """ return windowed_totals( self.total_series(results, ids), results.positions, window, at ) def sample( self, results: SimulationResults, ids: Iterable[str], at: Iterable[float], ) -> list[float]: """ Value of the given bins and accumulators at each position of `at`. A bin holds an amount at a position, so the value is read at that position, not over the window before it; `windowed_totals` reads the gain of an accumulator over a window. Parameters ---------- results : SimulationResults Simulation results, with their positions. ids : Iterable[str] Bin and accumulator ids, each for all its outputs (`outputs_for`), and output names, each for itself. Their outputs are summed before they are read. at : Iterable[float] Positions to read, each a position of `results`. Returns ------- list[float] One value per position in `at`. Raises ------ KeyError If an id is not a bin, accumulator or output of the model, or an expected output is missing from `results`. ValueError If the results have no value at a position in `at`. """ return sample(self.total_series(results, ids), results.positions, at) ``` ### Attributes #### is_stochastic ```python is_stochastic: bool ``` Whether the engine of the model draws random numbers. #### engine ```python engine: EngineProtocol ``` The engine for the current `model_definition`. It is rebuilt, at step 0, when the model changed since it was built. Otherwise it is the same object, in the state the last `run` or `step` left it. #### simulation_outputs ```python simulation_outputs: list[str] ``` Names of the columns returned by simulation runs. ### Methods: #### __init__ ```python __init__(model: Model) -> None ``` Initializes the simulation engine from a Pydantic Model definition. Parameters: | Name | Type | Description | Default | | ------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `model` | `Model` | A fully constructed and validated model object, of any kind. None values for parameters/initial conditions if used for calibration. | *required* | Raises: | Type | Description | | ----------- | ------------------------------------------------------ | | `TypeError` | If the structure of the model is not a ModelStructure. | Source code in `commol/api/simulation.py` ```python def __init__(self, model: Model) -> None: """ Initializes the simulation engine from a Pydantic Model definition. Parameters ---------- model : Model A fully constructed and validated model object, of any kind. None values for parameters/initial conditions if used for calibration. Raises ------ TypeError If the structure of the model is not a `ModelStructure`. """ if not isinstance(model.structure, ModelStructure): raise TypeError( f"The structure of model '{model.name}' is a " f"{type(model.structure).__name__}, not a ModelStructure. A model " "holds the structure of its kind, such as an Aggregate." ) logger.info(f"Initializing Simulation with model: '{model.name}'") self.model_definition: Model = model self._build_engine(model.model_dump_json()) ``` #### run ```python run(num_steps: int, *, seed: int | None = None) -> SimulationResults ``` Runs the simulation from the initial state on its default grid. Every call starts at position 0 with the current `model_definition`, so two calls with the same model, and the same seed for a stochastic engine, give the same result. To continue a run, call `simulation.engine.step()`. Parameters: | Name | Type | Description | Default | | ----------- | ----- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `num_steps` | `int` | The number of steps: the results hold the positions 0, 1, ..., num_steps. | *required* | | `seed` | \`int | None\` | The seed of a stochastic engine, a whole number from 0 to 2\*\*64 - 1. None draws one, which results.seed keeps, so every run can be repeated. A deterministic engine ignores it. | Returns: | Type | Description | | ------------------- | --------------------------------------------------------------------------------------------------- | | `SimulationResults` | One value per position for each output, the positions, the seed of a stochastic run, and the model. | Raises: | Type | Description | | ---------------------- | ----------------------------------------------------------------------------------------------------------- | | `InfeasibleStateError` | If a step leaves an amount below zero; the message names the compartment, the step and the flows out of it. | | `ValueError` | If a parameter or initial condition has no value, or the seed is not a whole number from 0 to 2\*\*64 - 1. | | `RuntimeError` | If a rate cannot be evaluated or is not a number; the message names the flow and the position. | Source code in `commol/api/simulation.py` ```python def run(self, num_steps: int, *, seed: int | None = None) -> SimulationResults: """ Runs the simulation from the initial state on its default grid. Every call starts at position 0 with the current ``model_definition``, so two calls with the same model, and the same seed for a stochastic engine, give the same result. To continue a run, call ``simulation.engine.step()``. Parameters ---------- num_steps : int The number of steps: the results hold the positions ``0, 1, ..., num_steps``. seed : int | None, optional The seed of a stochastic engine, a whole number from 0 to ``2**64 - 1``. None draws one, which ``results.seed`` keeps, so every run can be repeated. A deterministic engine ignores it. Returns ------- SimulationResults One value per position for each output, the positions, the seed of a stochastic run, and the model. Raises ------ InfeasibleStateError If a step leaves an amount below zero; the message names the compartment, the step and the flows out of it. ValueError If a parameter or initial condition has no value, or the seed is not a whole number from 0 to ``2**64 - 1``. RuntimeError If a rate cannot be evaluated or is not a number; the message names the flow and the position. """ self._validate_all_parameters_calibrated(self.model_definition) self._sync_engine() used_seed = self._seed_run(seed) self._engine.reset() logger.info(f"Running simulation for {num_steps} steps.") start = time.time() positions, rows = self._engine.run(num_steps) logger.info(f"Simulation complete. It took {time.time() - start} seconds.") return SimulationResults.from_rows( positions, self._simulation_outputs, rows, seed=used_seed, model=self.model_definition, ) ``` #### run_at ```python run_at(at: Sequence[float], *, seed: int | None = None) -> SimulationResults ``` Runs the simulation from the initial state and reports at given positions. The positions are on the axis, the independent variable of the model. The engine decides which positions its model type reports at: a DifferentialEquations model at any finite, non-negative position, from the dense output of its solver; a DifferenceEquations model only at integer steps. Parameters: | Name | Type | Description | Default | | ------ | ----------------- | -------------------------------------- | ----------------------------------------- | | `at` | `Sequence[float]` | Sorted, unique positions to report at. | *required* | | `seed` | \`int | None\` | The seed of a stochastic engine; see run. | Returns: | Type | Description | | ------------------- | --------------------------------------------------------------------------------------------------- | | `SimulationResults` | One value per position for each output, the positions, the seed of a stochastic run, and the model. | Raises: | Type | Description | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `InfeasibleStateError` | If a step leaves an amount below zero; the message names the compartment, the step and the flows out of it. | | `ValueError` | If a parameter or initial condition has no value, the positions are not sorted and unique, or the engine does not report at one of them; the message is the engine's. | | `RuntimeError` | If a rate cannot be evaluated or is not a number; the message names the flow and the position. | Source code in `commol/api/simulation.py` ```python def run_at( self, at: Sequence[float], *, seed: int | None = None ) -> SimulationResults: """ Runs the simulation from the initial state and reports at given positions. The positions are on the axis, the independent variable of the model. The engine decides which positions its model type reports at: a DifferentialEquations model at any finite, non-negative position, from the dense output of its solver; a DifferenceEquations model only at integer steps. Parameters ---------- at : Sequence[float] Sorted, unique positions to report at. seed : int | None, optional The seed of a stochastic engine; see `run`. Returns ------- SimulationResults One value per position for each output, the positions, the seed of a stochastic run, and the model. Raises ------ InfeasibleStateError If a step leaves an amount below zero; the message names the compartment, the step and the flows out of it. ValueError If a parameter or initial condition has no value, the positions are not sorted and unique, or the engine does not report at one of them; the message is the engine's. RuntimeError If a rate cannot be evaluated or is not a number; the message names the flow and the position. """ positions = [float(position) for position in at] self._validate_all_parameters_calibrated(self.model_definition) self._sync_engine() used_seed = self._seed_run(seed) self._engine.reset() rows = self._engine.run_at(positions) return SimulationResults.from_rows( positions, self._simulation_outputs, rows, seed=used_seed, model=self.model_definition, ) ``` #### replicates ```python replicates(count: int, *, seed: int | None = None) -> ReplicateRuns ``` Replicates of the runs of a stochastic engine. The returned object has `run` and `run_at`, as `Simulation`, and they return `SimulationReplicates`: `count` runs, each with its own seed, run in parallel in the engine. Replicate `i` takes the seed derived from `seed` and `i` (the SplitMix64 of the SplitMix64 of `seed` plus `i`), so two seeds share no replicate, and a replicate gives the same run however many replicates run. Parameters: | Name | Type | Description | Default | | ------- | ----- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `count` | `int` | How many replicates, at least 1. | *required* | | `seed` | \`int | None\` | The seed the replicate seeds derive from, a whole number from 0 to 2\*\*64 - 1. None draws one, which the replicates keep. | Returns: | Type | Description | | --------------- | ----------------------------- | | `ReplicateRuns` | The runner of the replicates. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If the engine is deterministic (every replicate would be equal), count is not a whole number or is below 1, or the seed is not valid. | Source code in `commol/api/simulation.py` ```python def replicates(self, count: int, *, seed: int | None = None) -> "ReplicateRuns": """ Replicates of the runs of a stochastic engine. The returned object has ``run`` and ``run_at``, as `Simulation`, and they return `SimulationReplicates`: ``count`` runs, each with its own seed, run in parallel in the engine. Replicate ``i`` takes the seed derived from ``seed`` and ``i`` (the SplitMix64 of the SplitMix64 of ``seed`` plus ``i``), so two seeds share no replicate, and a replicate gives the same run however many replicates run. Parameters ---------- count : int How many replicates, at least 1. seed : int | None, optional The seed the replicate seeds derive from, a whole number from 0 to ``2**64 - 1``. None draws one, which the replicates keep. Returns ------- ReplicateRuns The runner of the replicates. Raises ------ ValueError If the engine is deterministic (every replicate would be equal), ``count`` is not a whole number or is below 1, or the seed is not valid. """ if not self.is_stochastic: raise ValueError( f"The {self.model_definition.engine.type} engine is deterministic: " "every replicate would be equal. Replicates need a stochastic engine." ) if isinstance(count, bool) or not isinstance(count, int): raise ValueError(f"count is a whole number, got {count!r}") return ReplicateRuns(self, count, None if seed is None else check_seed(seed)) ``` #### outputs_for ```python outputs_for(id: str) -> list[str] ``` Names of the outputs a source id expands into. The structure of the model groups its outputs by source (`ModelStructure.outputs_by_source`): for an aggregate model, by bin and accumulator. Parameters: | Name | Type | Description | Default | | ---- | ----- | --------------------------------------------------- | ---------- | | `id` | `str` | A source id, such as a bin id or an accumulator id. | *required* | Returns: | Type | Description | | ----------- | --------------------------------------------------------------- | | `list[str]` | Output names for that id, in the order the engine reports them. | Raises: | Type | Description | | ---------- | ----------------------------------- | | `KeyError` | If id is not a source of the model. | Source code in `commol/api/simulation.py` ```python def outputs_for(self, id: str) -> list[str]: """ Names of the outputs a source id expands into. The structure of the model groups its outputs by source (`ModelStructure.outputs_by_source`): for an aggregate model, by bin and accumulator. Parameters ---------- id : str A source id, such as a bin id or an accumulator id. Returns ------- list[str] Output names for that id, in the order the engine reports them. Raises ------ KeyError If `id` is not a source of the model. """ return outputs_for(self.model_definition, id) ``` #### group_outputs ```python group_outputs(ids: Iterable[str]) -> dict[str, list[str]] ``` Group output names by the bin or accumulator they belong to. Parameters: | Name | Type | Description | Default | | ----- | --------------- | ------------------------ | ---------- | | `ids` | `Iterable[str]` | Bin and accumulator ids. | *required* | Returns: | Type | Description | | ---------------------- | ------------------------------------------------------------ | | `dict[str, list[str]]` | Each id mapped to its output names, keeping the order given. | Raises: | Type | Description | | ---------- | -------------------------------------------------- | | `KeyError` | If an id is not a bin or accumulator of the model. | Source code in `commol/api/simulation.py` ```python def group_outputs(self, ids: Iterable[str]) -> dict[str, list[str]]: """ Group output names by the bin or accumulator they belong to. Parameters ---------- ids : Iterable[str] Bin and accumulator ids. Returns ------- dict[str, list[str]] Each id mapped to its output names, keeping the order given. Raises ------ KeyError If an id is not a bin or accumulator of the model. """ return {id: self.outputs_for(id) for id in ids} ``` #### total_series ```python total_series(results: Mapping[str, Sequence[float]], ids: Iterable[str]) -> list[float] ``` Sum the given outputs at each position. Parameters: | Name | Type | Description | Default | | --------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `results` | `Mapping[str, Sequence[float]]` | Simulation results, such as those of run or run_at. | *required* | | `ids` | `Iterable[str]` | Bin and accumulator ids, each for all its outputs (outputs_for), and output names, each for itself. Their outputs are summed together. | *required* | Returns: | Type | Description | | ------------- | ---------------------------------- | | `list[float]` | One value per position of results. | Raises: | Type | Description | | ---------- | --------------------------------------------------------------------------------------------------------- | | `KeyError` | If an id is not a bin, accumulator or output of the model, or an expected output is missing from results. | Source code in `commol/api/simulation.py` ```python def total_series( self, results: Mapping[str, Sequence[float]], ids: Iterable[str], ) -> list[float]: """ Sum the given outputs at each position. Parameters ---------- results : Mapping[str, Sequence[float]] Simulation results, such as those of `run` or `run_at`. ids : Iterable[str] Bin and accumulator ids, each for all its outputs (`outputs_for`), and output names, each for itself. Their outputs are summed together. Returns ------- list[float] One value per position of `results`. Raises ------ KeyError If an id is not a bin, accumulator or output of the model, or an expected output is missing from `results`. """ return total_series(results, self._series_names(ids)) ``` #### windowed_totals ```python windowed_totals(results: SimulationResults, ids: Iterable[str], window: float, at: Iterable[float] | None = None) -> list[float] ``` Amount the given accumulators gained over each window. Parameters: | Name | Type | Description | Default | | --------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `results` | `SimulationResults` | Simulation results, with their positions. | *required* | | `ids` | `Iterable[str]` | Accumulator ids, each for all its outputs, and output names, each for itself. Their outputs are summed before windowing. | *required* | | `window` | `float` | Length of one window on the axis. | *required* | | `at` | \`Iterable[float] | None\` | Positions at which windows close. Defaults to every complete window of the results. Pass the observation positions to reproduce exactly the values a calibration used. | Returns: | Type | Description | | ------------- | ------------------------- | | `list[float]` | One increment per window. | Raises: | Type | Description | | ------------ | --------------------------------------------------------------------------------------------------------- | | `KeyError` | If an id is not a bin, accumulator or output of the model, or an expected output is missing from results. | | `ValueError` | If window is not positive, or the results have no value at the end or the start of a requested window. | Source code in `commol/api/simulation.py` ```python def windowed_totals( self, results: SimulationResults, ids: Iterable[str], window: float, at: Iterable[float] | None = None, ) -> list[float]: """ Amount the given accumulators gained over each window. Parameters ---------- results : SimulationResults Simulation results, with their positions. ids : Iterable[str] Accumulator ids, each for all its outputs, and output names, each for itself. Their outputs are summed before windowing. window : float Length of one window on the axis. at : Iterable[float] | None, optional Positions at which windows close. Defaults to every complete window of the results. Pass the observation positions to reproduce exactly the values a calibration used. Returns ------- list[float] One increment per window. Raises ------ KeyError If an id is not a bin, accumulator or output of the model, or an expected output is missing from `results`. ValueError If `window` is not positive, or the results have no value at the end or the start of a requested window. """ return windowed_totals( self.total_series(results, ids), results.positions, window, at ) ``` #### sample ```python sample(results: SimulationResults, ids: Iterable[str], at: Iterable[float]) -> list[float] ``` Value of the given bins and accumulators at each position of `at`. A bin holds an amount at a position, so the value is read at that position, not over the window before it; `windowed_totals` reads the gain of an accumulator over a window. Parameters: | Name | Type | Description | Default | | --------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `results` | `SimulationResults` | Simulation results, with their positions. | *required* | | `ids` | `Iterable[str]` | Bin and accumulator ids, each for all its outputs (outputs_for), and output names, each for itself. Their outputs are summed before they are read. | *required* | | `at` | `Iterable[float]` | Positions to read, each a position of results. | *required* | Returns: | Type | Description | | ------------- | ----------------------------- | | `list[float]` | One value per position in at. | Raises: | Type | Description | | ------------ | --------------------------------------------------------------------------------------------------------- | | `KeyError` | If an id is not a bin, accumulator or output of the model, or an expected output is missing from results. | | `ValueError` | If the results have no value at a position in at. | Source code in `commol/api/simulation.py` ```python def sample( self, results: SimulationResults, ids: Iterable[str], at: Iterable[float], ) -> list[float]: """ Value of the given bins and accumulators at each position of `at`. A bin holds an amount at a position, so the value is read at that position, not over the window before it; `windowed_totals` reads the gain of an accumulator over a window. Parameters ---------- results : SimulationResults Simulation results, with their positions. ids : Iterable[str] Bin and accumulator ids, each for all its outputs (`outputs_for`), and output names, each for itself. Their outputs are summed before they are read. at : Iterable[float] Positions to read, each a position of `results`. Returns ------- list[float] One value per position in `at`. Raises ------ KeyError If an id is not a bin, accumulator or output of the model, or an expected output is missing from `results`. ValueError If the results have no value at a position in `at`. """ return sample(self.total_series(results, ids), results.positions, at) ``` ## SimulationResults Bases: `ComparedByValue`, `dict[str, list[float]]` Values of every output at each position on the axis. A `dict` from output name to one value per position, so `results["A"]`, `json.dumps(results)` and `pandas.DataFrame(results)` work as for any mapping. The positions the values were read at are in :attr:`positions`. Two results are equal when their values (a NaN equal to a NaN), their positions, their seed and their model are (`ComparedByValue`); a plain `dict` is not a result, so it is not equal to one. Results can change, so they have no hash. Attributes: | Name | Type | Description | | ----------- | ------------- | ---------------------------------------------------------------------------------------- | | `positions` | `list[float]` | Positions on the axis, ascending. results[name][i] is the value of name at positions[i]. | | `seed` | \`int | None\` | | `model` | \`Model | None\` | Source code in `commol/api/results.py` ```python class SimulationResults(ComparedByValue, dict[str, list[float]]): """ Values of every output at each position on the axis. A ``dict`` from output name to one value per position, so ``results["A"]``, ``json.dumps(results)`` and ``pandas.DataFrame(results)`` work as for any mapping. The positions the values were read at are in :attr:`positions`. Two results are equal when their values (a NaN equal to a NaN), their positions, their seed and their model are (`ComparedByValue`); a plain ``dict`` is not a result, so it is not equal to one. Results can change, so they have no hash. Attributes ---------- positions : list[float] Positions on the axis, ascending. ``results[name][i]`` is the value of ``name`` at ``positions[i]``. seed : int | None The seed of the run of a stochastic engine, which repeats it; None for a deterministic engine, whose runs depend on no seed. model : Model | None The model of the run, which `Simulation.run` and `Simulation.run_at` set; None for results built by hand. A report reads the outputs of each bin and accumulator from its structure, never the values of its parameters. """ positions: list[float] seed: int | None model: "Model | None" def __init__( self, positions: Iterable[float], values: Mapping[str, Sequence[float]] | None = None, seed: int | None = None, model: "Model | None" = None, ) -> None: """ Results at the given positions. Parameters ---------- positions : Iterable[float] Positions on the axis, ascending. values : Mapping[str, Sequence[float]] | None, optional One value per position for each output. seed : int | None, optional The seed of the run, or None when it depends on no seed. model : Model | None, optional The model of the run, or None when no model is known. Raises ------ ValueError If an output does not have one value per position. """ super().__init__( {name: list(series) for name, series in (values or {}).items()} ) self.positions = [float(position) for position in positions] self.seed = seed self.model = model for name, series in self.items(): if len(series) != len(self.positions): raise ValueError( f"Output '{name}' has {len(series)} values for " f"{len(self.positions)} positions." ) @classmethod def from_rows( cls, positions: Sequence[float], names: Sequence[str], rows: Sequence[Sequence[float]], seed: int | None = None, model: "Model | None" = None, ) -> "SimulationResults": """ Results from one row of output values per position. Parameters ---------- positions : Sequence[float] Positions on the axis, ascending. names : Sequence[str] Output names, in the order of each row. rows : Sequence[Sequence[float]] One row per position. seed : int | None, optional The seed of the run, or None when it depends on no seed. model : Model | None, optional The model of the run, or None when no model is known. Returns ------- SimulationResults One series per output name. """ columns = list(zip(*rows)) if rows else [() for _ in names] return cls( positions, dict(zip(names, columns, strict=True)), seed=seed, model=model ) def rows(self) -> list[list[float]]: """ Every output at each position, one row per position. Returns ------- list[list[float]] ``rows()[i][j]`` is the value of the ``j``-th output, in key order (the order of ``Simulation.simulation_outputs``), at ``positions[i]``. """ return [list(row) for row in zip(*self.values())] if self else [] def quantities(self, quantities: "Sequence[QuantityBase]") -> dict[str, float]: """ The quantities of interest of this run, as a `Propagation` reduces each of its runs. The run must hold every position the quantities read: the results of ``Simulation.run_at(quantity_schedule(quantities))`` hold them all. Each position is found within ``1e-9``, as an observation finds its row (`index_at` of `crates/commol-calibration/src/observation.rs`), and Rust reduces the rows with the code of `Propagation`, so the values equal those of a `Propagation` of a sample of one draw with the parameters of this run. On the ODE engine, `Simulation.run` reports a position that is not a breakpoint by interpolation, so a value read from it can differ from that of a run that ends there by about the relative tolerance of the solver, and `PeakPosition` can change on a near-tie. Parameters ---------- quantities : Sequence[Quantity] The quantities; a `Trajectory` gives one scalar per position. Returns ------- dict[str, float] One value per label of the quantities, in their order. Raises ------ ValueError If there is no quantity, two scalars have one label, a quantity names an output the results do not have, or the run lacks a position a quantity reads; the message lists every such position with its quantity and says to call `Simulation.run_at`. """ return quantities_of_runs([self], self.positions, quantities)[0] @property def plot(self) -> ResultsPlot: """ The figures of the run: ``plot.series()`` and ``plot.cumulative()``. They name each series from `model`; results with no model draw only their outputs. """ return ResultsPlot(self) def report( self, positions: Sequence[float], window: float, windows: int, observed_data: "Iterable[ObservedDataPoint]" = (), accumulators: Iterable[str] = (), prediction_intervals: "PredictionIntervals | None" = None, ) -> "SimulationReport": """ Report the observed data, compartment series and accumulators of the run. The blocks share one window axis, so the observed values, the windowed accumulator increments and the sampled series can be read against each other. Add whatever envelope you need around them, such as a run label, axis labels or summary figures in the terms of your domain. Parameters ---------- positions : Sequence[float] Positions the series and cumulative accumulators are sampled at; each must be a position of the results. window : float Length of one window on the axis. windows : int Number of windows on the observed and windowed axes. observed_data : Iterable[ObservedDataPoint], optional The calibration targets to report alongside the run. accumulators : Iterable[str], optional Accumulator ids to report; their outputs come from `model`. prediction_intervals : PredictionIntervals | None, optional Prediction intervals of a conformal method (``ConformalResult.intervals``), reported as they are. Returns ------- SimulationReport Keys ``observed``, ``series`` and ``accumulators``, then ``prediction_intervals`` when they are given. Raises ------ ValueError If the results have no value at a position the report reads, or they have no `model` and an accumulator is reported. KeyError If an accumulator is not a bin or accumulator of the model. """ from commol.api.reporting import _ReportRuns, _simulation_report return _simulation_report( _ReportRuns(model=self.model, runs=[self]), positions, window, windows, observed_data, accumulators, prediction_intervals, ) @override def compared_values(self) -> object: """The values, the positions, the seed and the model.""" return dict(self), self.positions, self.seed, self.model def __repr__(self) -> str: """The positions, the seed and the values.""" seed = "" if self.seed is None else f"seed={self.seed!r}, " return ( f"SimulationResults(positions={self.positions!r}, {seed}" f"{dict.__repr__(self)})" ) ``` ### Attributes #### plot ```python plot: ResultsPlot ``` The figures of the run: `plot.series()` and `plot.cumulative()`. They name each series from `model`; results with no model draw only their outputs. ### Methods: #### __init__ ```python __init__(positions: Iterable[float], values: Mapping[str, Sequence[float]] | None = None, seed: int | None = None, model: Model | None = None) -> None ``` Results at the given positions. Parameters: | Name | Type | Description | Default | | ----------- | --------------------------------- | --------------------------------- | -------------------------------------------------------- | | `positions` | `Iterable[float]` | Positions on the axis, ascending. | *required* | | `values` | \`Mapping\[str, Sequence[float]\] | None\` | One value per position for each output. | | `seed` | \`int | None\` | The seed of the run, or None when it depends on no seed. | | `model` | \`Model | None\` | The model of the run, or None when no model is known. | Raises: | Type | Description | | ------------ | -------------------------------------------------- | | `ValueError` | If an output does not have one value per position. | Source code in `commol/api/results.py` ```python def __init__( self, positions: Iterable[float], values: Mapping[str, Sequence[float]] | None = None, seed: int | None = None, model: "Model | None" = None, ) -> None: """ Results at the given positions. Parameters ---------- positions : Iterable[float] Positions on the axis, ascending. values : Mapping[str, Sequence[float]] | None, optional One value per position for each output. seed : int | None, optional The seed of the run, or None when it depends on no seed. model : Model | None, optional The model of the run, or None when no model is known. Raises ------ ValueError If an output does not have one value per position. """ super().__init__( {name: list(series) for name, series in (values or {}).items()} ) self.positions = [float(position) for position in positions] self.seed = seed self.model = model for name, series in self.items(): if len(series) != len(self.positions): raise ValueError( f"Output '{name}' has {len(series)} values for " f"{len(self.positions)} positions." ) ``` #### from_rows ```python from_rows(positions: Sequence[float], names: Sequence[str], rows: Sequence[Sequence[float]], seed: int | None = None, model: Model | None = None) -> SimulationResults ``` Results from one row of output values per position. Parameters: | Name | Type | Description | Default | | ----------- | --------------------------- | --------------------------------------- | -------------------------------------------------------- | | `positions` | `Sequence[float]` | Positions on the axis, ascending. | *required* | | `names` | `Sequence[str]` | Output names, in the order of each row. | *required* | | `rows` | `Sequence[Sequence[float]]` | One row per position. | *required* | | `seed` | \`int | None\` | The seed of the run, or None when it depends on no seed. | | `model` | \`Model | None\` | The model of the run, or None when no model is known. | Returns: | Type | Description | | ------------------- | --------------------------- | | `SimulationResults` | One series per output name. | Source code in `commol/api/results.py` ```python @classmethod def from_rows( cls, positions: Sequence[float], names: Sequence[str], rows: Sequence[Sequence[float]], seed: int | None = None, model: "Model | None" = None, ) -> "SimulationResults": """ Results from one row of output values per position. Parameters ---------- positions : Sequence[float] Positions on the axis, ascending. names : Sequence[str] Output names, in the order of each row. rows : Sequence[Sequence[float]] One row per position. seed : int | None, optional The seed of the run, or None when it depends on no seed. model : Model | None, optional The model of the run, or None when no model is known. Returns ------- SimulationResults One series per output name. """ columns = list(zip(*rows)) if rows else [() for _ in names] return cls( positions, dict(zip(names, columns, strict=True)), seed=seed, model=model ) ``` #### rows ```python rows() -> list[list[float]] ``` Every output at each position, one row per position. Returns: | Type | Description | | ------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `list[list[float]]` | rows()[i][j] is the value of the j-th output, in key order (the order of Simulation.simulation_outputs), at positions[i]. | Source code in `commol/api/results.py` ```python def rows(self) -> list[list[float]]: """ Every output at each position, one row per position. Returns ------- list[list[float]] ``rows()[i][j]`` is the value of the ``j``-th output, in key order (the order of ``Simulation.simulation_outputs``), at ``positions[i]``. """ return [list(row) for row in zip(*self.values())] if self else [] ``` #### quantities ```python quantities(quantities: Sequence[QuantityBase]) -> dict[str, float] ``` The quantities of interest of this run, as a `Propagation` reduces each of its runs. The run must hold every position the quantities read: the results of `Simulation.run_at(quantity_schedule(quantities))` hold them all. Each position is found within `1e-9`, as an observation finds its row (`index_at` of `crates/commol-calibration/src/observation.rs`), and Rust reduces the rows with the code of `Propagation`, so the values equal those of a `Propagation` of a sample of one draw with the parameters of this run. On the ODE engine, `Simulation.run` reports a position that is not a breakpoint by interpolation, so a value read from it can differ from that of a run that ends there by about the relative tolerance of the solver, and `PeakPosition` can change on a near-tie. Parameters: | Name | Type | Description | Default | | ------------ | -------------------- | ----------------------------------------------------------- | ---------- | | `quantities` | `Sequence[Quantity]` | The quantities; a Trajectory gives one scalar per position. | *required* | Returns: | Type | Description | | ------------------ | ------------------------------------------------------ | | `dict[str, float]` | One value per label of the quantities, in their order. | Raises: | Type | Description | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If there is no quantity, two scalars have one label, a quantity names an output the results do not have, or the run lacks a position a quantity reads; the message lists every such position with its quantity and says to call Simulation.run_at. | Source code in `commol/api/results.py` ```python def quantities(self, quantities: "Sequence[QuantityBase]") -> dict[str, float]: """ The quantities of interest of this run, as a `Propagation` reduces each of its runs. The run must hold every position the quantities read: the results of ``Simulation.run_at(quantity_schedule(quantities))`` hold them all. Each position is found within ``1e-9``, as an observation finds its row (`index_at` of `crates/commol-calibration/src/observation.rs`), and Rust reduces the rows with the code of `Propagation`, so the values equal those of a `Propagation` of a sample of one draw with the parameters of this run. On the ODE engine, `Simulation.run` reports a position that is not a breakpoint by interpolation, so a value read from it can differ from that of a run that ends there by about the relative tolerance of the solver, and `PeakPosition` can change on a near-tie. Parameters ---------- quantities : Sequence[Quantity] The quantities; a `Trajectory` gives one scalar per position. Returns ------- dict[str, float] One value per label of the quantities, in their order. Raises ------ ValueError If there is no quantity, two scalars have one label, a quantity names an output the results do not have, or the run lacks a position a quantity reads; the message lists every such position with its quantity and says to call `Simulation.run_at`. """ return quantities_of_runs([self], self.positions, quantities)[0] ``` #### report ```python report(positions: Sequence[float], window: float, windows: int, observed_data: Iterable[ObservedDataPoint] = (), accumulators: Iterable[str] = (), prediction_intervals: PredictionIntervals | None = None) -> SimulationReport ``` Report the observed data, compartment series and accumulators of the run. The blocks share one window axis, so the observed values, the windowed accumulator increments and the sampled series can be read against each other. Add whatever envelope you need around them, such as a run label, axis labels or summary figures in the terms of your domain. Parameters: | Name | Type | Description | Default | | ---------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `positions` | `Sequence[float]` | Positions the series and cumulative accumulators are sampled at; each must be a position of the results. | *required* | | `window` | `float` | Length of one window on the axis. | *required* | | `windows` | `int` | Number of windows on the observed and windowed axes. | *required* | | `observed_data` | `Iterable[ObservedDataPoint]` | The calibration targets to report alongside the run. | `()` | | `accumulators` | `Iterable[str]` | Accumulator ids to report; their outputs come from model. | `()` | | `prediction_intervals` | \`PredictionIntervals | None\` | Prediction intervals of a conformal method (ConformalResult.intervals), reported as they are. | Returns: | Type | Description | | ------------------ | -------------------------------------------------------------------------------------- | | `SimulationReport` | Keys observed, series and accumulators, then prediction_intervals when they are given. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------------------------------ | | `ValueError` | If the results have no value at a position the report reads, or they have no model and an accumulator is reported. | | `KeyError` | If an accumulator is not a bin or accumulator of the model. | Source code in `commol/api/results.py` ```python def report( self, positions: Sequence[float], window: float, windows: int, observed_data: "Iterable[ObservedDataPoint]" = (), accumulators: Iterable[str] = (), prediction_intervals: "PredictionIntervals | None" = None, ) -> "SimulationReport": """ Report the observed data, compartment series and accumulators of the run. The blocks share one window axis, so the observed values, the windowed accumulator increments and the sampled series can be read against each other. Add whatever envelope you need around them, such as a run label, axis labels or summary figures in the terms of your domain. Parameters ---------- positions : Sequence[float] Positions the series and cumulative accumulators are sampled at; each must be a position of the results. window : float Length of one window on the axis. windows : int Number of windows on the observed and windowed axes. observed_data : Iterable[ObservedDataPoint], optional The calibration targets to report alongside the run. accumulators : Iterable[str], optional Accumulator ids to report; their outputs come from `model`. prediction_intervals : PredictionIntervals | None, optional Prediction intervals of a conformal method (``ConformalResult.intervals``), reported as they are. Returns ------- SimulationReport Keys ``observed``, ``series`` and ``accumulators``, then ``prediction_intervals`` when they are given. Raises ------ ValueError If the results have no value at a position the report reads, or they have no `model` and an accumulator is reported. KeyError If an accumulator is not a bin or accumulator of the model. """ from commol.api.reporting import _ReportRuns, _simulation_report return _simulation_report( _ReportRuns(model=self.model, runs=[self]), positions, window, windows, observed_data, accumulators, prediction_intervals, ) ``` #### compared_values ```python compared_values() -> object ``` The values, the positions, the seed and the model. Source code in `commol/api/results.py` ```python @override def compared_values(self) -> object: """The values, the positions, the seed and the model.""" return dict(self), self.positions, self.seed, self.model ``` #### __repr__ ```python __repr__() -> str ``` The positions, the seed and the values. Source code in `commol/api/results.py` ```python def __repr__(self) -> str: """The positions, the seed and the values.""" seed = "" if self.seed is None else f"seed={self.seed!r}, " return ( f"SimulationResults(positions={self.positions!r}, {seed}" f"{dict.__repr__(self)})" ) ``` ## ReplicateRuns Runs replicates of a stochastic model; `Simulation.replicates` makes it. Attributes: | Name | Type | Description | | ------------ | ------------ | ---------------------------------------------- | | `simulation` | `Simulation` | The simulation whose model the replicates run. | | `count` | `int` | How many replicates. | | `seed` | \`int | None\` | Source code in `commol/api/simulation.py` ```python class ReplicateRuns: """ Runs replicates of a stochastic model; `Simulation.replicates` makes it. Attributes ---------- simulation : Simulation The simulation whose model the replicates run. count : int How many replicates. seed : int | None The seed the replicate seeds derive from; None draws one at each run, which the replicates keep (`SimulationReplicates.seed`). """ def __init__(self, simulation: Simulation, count: int, seed: int | None) -> None: """ The replicates of a simulation. Parameters ---------- simulation : Simulation The simulation whose model the replicates run. count : int How many replicates. seed : int | None The seed the replicate seeds derive from; None draws one at each run. Raises ------ ValueError With the message of the engine, if ``count`` is below 1 or the seed is outside its range. """ commol_rs.core.check_ranges("ReplicateRuns", {"count": count, "seed": seed}) self.simulation = simulation self.count = count self.seed = seed def _replicates(self, grid: int | list[float]) -> SimulationReplicates: """The replicates on ``grid``: a number of steps or the positions.""" simulation = self.simulation simulation._validate_all_parameters_calibrated(simulation.model_definition) engine = simulation.engine seed, positions, seeds, runs = engine.run_replicates( self.seed, self.count, grid ) outputs = simulation.simulation_outputs model = simulation.model_definition return SimulationReplicates( ( SimulationResults.from_rows( positions, outputs, rows, seed=replicate_seed, model=model ) for replicate_seed, rows in zip(seeds, runs, strict=True) ), seed=seed, positions=positions, model=model, ) def run(self, num_steps: int) -> SimulationReplicates: """ Every replicate from the initial state, on the default grid. Parameters ---------- num_steps : int The number of steps: each replicate holds the positions ``0, 1, ..., num_steps``. Returns ------- SimulationReplicates One `SimulationResults` per replicate, each with its seed. Raises ------ InfeasibleStateError If a replicate leaves the values the model allows. ValueError If a parameter or a value of the model has no value. """ return self._replicates(num_steps) def run_at(self, at: Sequence[float]) -> SimulationReplicates: """ Every replicate from the initial state, reported at given positions. Parameters ---------- at : Sequence[float] Sorted, unique positions to report at. Returns ------- SimulationReplicates One `SimulationResults` per replicate, each with its seed. Raises ------ InfeasibleStateError If a replicate leaves the values the model allows. ValueError If a parameter has no value, the positions are not sorted and unique, or the engine does not report at one of them. """ return self._replicates([float(position) for position in at]) ``` ### Methods: #### __init__ ```python __init__(simulation: Simulation, count: int, seed: int | None) -> None ``` The replicates of a simulation. Parameters: | Name | Type | Description | Default | | ------------ | ------------ | ---------------------------------------------- | --------------------------------------------------------------------- | | `simulation` | `Simulation` | The simulation whose model the replicates run. | *required* | | `count` | `int` | How many replicates. | *required* | | `seed` | \`int | None\` | The seed the replicate seeds derive from; None draws one at each run. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------- | | `ValueError` | With the message of the engine, if count is below 1 or the seed is outside its range. | Source code in `commol/api/simulation.py` ```python def __init__(self, simulation: Simulation, count: int, seed: int | None) -> None: """ The replicates of a simulation. Parameters ---------- simulation : Simulation The simulation whose model the replicates run. count : int How many replicates. seed : int | None The seed the replicate seeds derive from; None draws one at each run. Raises ------ ValueError With the message of the engine, if ``count`` is below 1 or the seed is outside its range. """ commol_rs.core.check_ranges("ReplicateRuns", {"count": count, "seed": seed}) self.simulation = simulation self.count = count self.seed = seed ``` #### run ```python run(num_steps: int) -> SimulationReplicates ``` Every replicate from the initial state, on the default grid. Parameters: | Name | Type | Description | Default | | ----------- | ----- | ----------------------------------------------------------------------------- | ---------- | | `num_steps` | `int` | The number of steps: each replicate holds the positions 0, 1, ..., num_steps. | *required* | Returns: | Type | Description | | ---------------------- | -------------------------------------------------------- | | `SimulationReplicates` | One SimulationResults per replicate, each with its seed. | Raises: | Type | Description | | ---------------------- | ---------------------------------------------------- | | `InfeasibleStateError` | If a replicate leaves the values the model allows. | | `ValueError` | If a parameter or a value of the model has no value. | Source code in `commol/api/simulation.py` ```python def run(self, num_steps: int) -> SimulationReplicates: """ Every replicate from the initial state, on the default grid. Parameters ---------- num_steps : int The number of steps: each replicate holds the positions ``0, 1, ..., num_steps``. Returns ------- SimulationReplicates One `SimulationResults` per replicate, each with its seed. Raises ------ InfeasibleStateError If a replicate leaves the values the model allows. ValueError If a parameter or a value of the model has no value. """ return self._replicates(num_steps) ``` #### run_at ```python run_at(at: Sequence[float]) -> SimulationReplicates ``` Every replicate from the initial state, reported at given positions. Parameters: | Name | Type | Description | Default | | ---- | ----------------- | -------------------------------------- | ---------- | | `at` | `Sequence[float]` | Sorted, unique positions to report at. | *required* | Returns: | Type | Description | | ---------------------- | -------------------------------------------------------- | | `SimulationReplicates` | One SimulationResults per replicate, each with its seed. | Raises: | Type | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------- | | `InfeasibleStateError` | If a replicate leaves the values the model allows. | | `ValueError` | If a parameter has no value, the positions are not sorted and unique, or the engine does not report at one of them. | Source code in `commol/api/simulation.py` ```python def run_at(self, at: Sequence[float]) -> SimulationReplicates: """ Every replicate from the initial state, reported at given positions. Parameters ---------- at : Sequence[float] Sorted, unique positions to report at. Returns ------- SimulationReplicates One `SimulationResults` per replicate, each with its seed. Raises ------ InfeasibleStateError If a replicate leaves the values the model allows. ValueError If a parameter has no value, the positions are not sorted and unique, or the engine does not report at one of them. """ return self._replicates([float(position) for position in at]) ``` ## SimulationReplicates Bases: `ComparedByValue`, `list[SimulationResults]` Replicates of a run of a stochastic engine, one `SimulationResults` each. A `list` of runs at the same positions, each with its own seed: `replicates[i].seed` repeats replicate `i`. The statistics reduce the replicates position by position, and `member_statistics` reduces them as `ci_percentiles` requires. Two replicates are equal when their runs, their seed, their positions and their model are (`ComparedByValue`); a plain `list` of the same runs is not equal to them. They have no hash. Attributes: | Name | Type | Description | | ----------- | ------------- | ------------------------------------------------- | | `seed` | `int` | The seed the replicate seeds derive from. | | `positions` | `list[float]` | Positions on the axis, shared by every replicate. | | `model` | \`Model | None\` | Source code in `commol/api/results.py` ```python class SimulationReplicates(ComparedByValue, list[SimulationResults]): """ Replicates of a run of a stochastic engine, one `SimulationResults` each. A ``list`` of runs at the same positions, each with its own seed: ``replicates[i].seed`` repeats replicate ``i``. The statistics reduce the replicates position by position, and `member_statistics` reduces them as ``ci_percentiles`` requires. Two replicates are equal when their runs, their seed, their positions and their model are (`ComparedByValue`); a plain ``list`` of the same runs is not equal to them. They have no hash. Attributes ---------- seed : int The seed the replicate seeds derive from. positions : list[float] Positions on the axis, shared by every replicate. model : Model | None The model of the replicates, which `Simulation.replicates` sets; None for replicates built by hand. """ seed: int positions: list[float] model: "Model | None" def __init__( self, runs: Iterable[SimulationResults], seed: int, positions: Sequence[float], model: "Model | None" = None, ) -> None: """ Replicates of one run. Parameters ---------- runs : Iterable[SimulationResults] One run per replicate, at `positions`. seed : int The seed the replicate seeds derive from. positions : Sequence[float] Positions on the axis, ascending. model : Model | None, optional The model of the replicates, or None when no model is known. Raises ------ ValueError If a run is not at `positions`. """ super().__init__(runs) self.seed = seed self.positions = [float(position) for position in positions] self.model = model for index, run in enumerate(self): if run.positions != self.positions: raise ValueError( f"Replicate {index} is at other positions than the replicates." ) @property def seeds(self) -> list[int]: """The seed of each replicate, in order.""" return [run.seed for run in self if run.seed is not None] @property def plot(self) -> ReplicatesPlot: """ The figures of the replicates: ``plot.series()``, their mean and band, and ``plot.cumulative()``. """ return ReplicatesPlot(self) def _values(self, name: str) -> np.ndarray: return np.asarray([run[name] for run in self], dtype=float) def mean(self) -> SimulationResults: """ The mean of the replicates at each position, for each output. Returns ------- SimulationResults One series of means per output, with no seed. Raises ------ ValueError If there are no replicates. """ names = list(self[0]) if self else [] if not names: raise ValueError("There are no replicates.") return SimulationResults( self.positions, {name: self._values(name).mean(axis=0).tolist() for name in names}, model=self.model, ) def quantiles(self, levels: Sequence[float]) -> dict[float, SimulationResults]: """ Quantiles of the replicates at each position, for each output. The quantile is the one convention of commol (`weighted_quantiles` with equal weights, NumPy's ``method="hazen"``). Parameters ---------- levels : Sequence[float] Quantile levels, each between 0 and 1, such as ``[0.05, 0.5, 0.95]``. Returns ------- dict[float, SimulationResults] The results of each level. Raises ------ ValueError If a level is outside ``[0, 1]`` or there are no replicates. """ commol_rs.inference.check_levels(list(levels)) if not self: raise ValueError("There are no replicates.") from commol.api.inference.intervals import weighted_quantiles names = list(self[0]) per_name = { name: [ weighted_quantiles(column.tolist(), levels) for column in self._values(name).T ] for name in names } return { level: SimulationResults( self.positions, { name: [at_position[index] for at_position in per_name[name]] for name in names }, model=self.model, ) for index, level in enumerate(levels) } def quantities( self, quantities: "Sequence[QuantityBase]" ) -> list[dict[str, float]]: """ The quantities of interest of each replicate, as `SimulationResults.quantities` reads one run. Each replicate is reduced on its own, with no mean: a summary across the replicates is a reduction of these values, such as `member_statistics`. A stochastic `Propagation` gives draw ``j`` the seed ``draw_seeds[j]`` and runs its replicate ``i`` with the seed replicate ``i`` of ``Simulation.replicates(count, seed=draw_seeds[j])`` takes. So those replicates, of the model at the values of draw ``j``, give the quantities of the runs of that draw. Parameters ---------- quantities : Sequence[Quantity] The quantities; a `Trajectory` gives one scalar per position. Returns ------- list[dict[str, float]] One dict per replicate, in seed order, with one value per label of the quantities. Raises ------ ValueError If there are no replicates, there is no quantity, two scalars have one label, a quantity names an output the replicates do not have, or they lack a position a quantity reads. """ if not self: raise ValueError("There are no replicates.") return quantities_of_runs(self, self.positions, quantities) def member_statistics( self, output: str, confidence_level: float ) -> dict[str, list[float]]: """ The spread of one output across the replicates, at each position. Parameters ---------- output : str An output name. confidence_level : float Confidence level of the interval, strictly between 0 and 1. Returns ------- dict[str, list[float]] Keys ``mean``, ``median``, ``ci_lower``, ``ci_upper``, ``min`` and ``max``, one value per position (`member_statistics`). Raises ------ KeyError If no replicate has the output. ValueError If the confidence level is out of range or there are no replicates. """ from commol.api.inference.intervals import member_statistics if self and output not in self[0]: raise KeyError( f"'{output}' is not an output of the replicates. " f"Outputs: {sorted(self[0])}" ) return member_statistics([run[output] for run in self], confidence_level) def report( self, confidence_level: float, positions: Sequence[float], window: float, windows: int, observed_data: "Iterable[ObservedDataPoint]" = (), accumulators: Iterable[str] = (), prediction_intervals: "PredictionIntervals | None" = None, ) -> "SimulationReport": """ Report the replicates as `SimulationResults.report` reports one run. Every number becomes a ``{mean, median, ci_lower, ci_upper, min, max}`` block, reduced replicate by replicate; the layout is the one of the report of a run. Parameters ---------- confidence_level : float Confidence level of the reported intervals, strictly between 0 and 1. positions : Sequence[float] Positions the series and cumulative accumulators are sampled at; each must be a position of the replicates. window : float Length of one window on the axis. windows : int Number of windows on the observed and windowed axes. observed_data : Iterable[ObservedDataPoint], optional The calibration targets to report alongside the replicates. accumulators : Iterable[str], optional Accumulator ids to report; their outputs come from `model`. prediction_intervals : PredictionIntervals | None, optional Prediction intervals of a conformal method (``ConformalResult.intervals``), reported as they are. Returns ------- SimulationReport Keys ``observed``, ``series`` and ``accumulators``, then ``prediction_intervals`` when they are given. Raises ------ ValueError If there are no replicates, the confidence level is out of range, the replicates have no value at a position the report reads, or they have no `model` and an accumulator is reported. KeyError If an accumulator is not a bin or accumulator of the model. """ from commol.api.reporting import _ReportRuns, _simulation_report if not self: raise ValueError("There are no replicates.") return _simulation_report( _ReportRuns( model=self.model, runs=list(self), confidence_level=confidence_level, ), positions, window, windows, observed_data, accumulators, prediction_intervals, ) @override def compared_values(self) -> object: """The runs, the seed, the positions and the model.""" return list(self), self.seed, self.positions, self.model def __repr__(self) -> str: """The number of replicates, their seed and their positions.""" return ( f"SimulationReplicates({len(self)} replicates, seed={self.seed!r}, " f"positions={self.positions!r})" ) ``` ### Attributes #### seeds ```python seeds: list[int] ``` The seed of each replicate, in order. #### plot ```python plot: ReplicatesPlot ``` The figures of the replicates: `plot.series()`, their mean and band, and `plot.cumulative()`. ### Methods: #### __init__ ```python __init__(runs: Iterable[SimulationResults], seed: int, positions: Sequence[float], model: Model | None = None) -> None ``` Replicates of one run. Parameters: | Name | Type | Description | Default | | ----------- | ----------------------------- | ----------------------------------------- | ------------------------------------------------------------ | | `runs` | `Iterable[SimulationResults]` | One run per replicate, at positions. | *required* | | `seed` | `int` | The seed the replicate seeds derive from. | *required* | | `positions` | `Sequence[float]` | Positions on the axis, ascending. | *required* | | `model` | \`Model | None\` | The model of the replicates, or None when no model is known. | Raises: | Type | Description | | ------------ | ----------------------------- | | `ValueError` | If a run is not at positions. | Source code in `commol/api/results.py` ```python def __init__( self, runs: Iterable[SimulationResults], seed: int, positions: Sequence[float], model: "Model | None" = None, ) -> None: """ Replicates of one run. Parameters ---------- runs : Iterable[SimulationResults] One run per replicate, at `positions`. seed : int The seed the replicate seeds derive from. positions : Sequence[float] Positions on the axis, ascending. model : Model | None, optional The model of the replicates, or None when no model is known. Raises ------ ValueError If a run is not at `positions`. """ super().__init__(runs) self.seed = seed self.positions = [float(position) for position in positions] self.model = model for index, run in enumerate(self): if run.positions != self.positions: raise ValueError( f"Replicate {index} is at other positions than the replicates." ) ``` #### mean ```python mean() -> SimulationResults ``` The mean of the replicates at each position, for each output. Returns: | Type | Description | | ------------------- | --------------------------------------------- | | `SimulationResults` | One series of means per output, with no seed. | Raises: | Type | Description | | ------------ | --------------------------- | | `ValueError` | If there are no replicates. | Source code in `commol/api/results.py` ```python def mean(self) -> SimulationResults: """ The mean of the replicates at each position, for each output. Returns ------- SimulationResults One series of means per output, with no seed. Raises ------ ValueError If there are no replicates. """ names = list(self[0]) if self else [] if not names: raise ValueError("There are no replicates.") return SimulationResults( self.positions, {name: self._values(name).mean(axis=0).tolist() for name in names}, model=self.model, ) ``` #### quantiles ```python quantiles(levels: Sequence[float]) -> dict[float, SimulationResults] ``` Quantiles of the replicates at each position, for each output. The quantile is the one convention of commol (`weighted_quantiles` with equal weights, NumPy's `method="hazen"`). Parameters: | Name | Type | Description | Default | | -------- | ----------------- | ----------------------------------------------------------------- | ---------- | | `levels` | `Sequence[float]` | Quantile levels, each between 0 and 1, such as [0.05, 0.5, 0.95]. | *required* | Returns: | Type | Description | | -------------------------------- | -------------------------- | | `dict[float, SimulationResults]` | The results of each level. | Raises: | Type | Description | | ------------ | -------------------------------------------------------- | | `ValueError` | If a level is outside [0, 1] or there are no replicates. | Source code in `commol/api/results.py` ```python def quantiles(self, levels: Sequence[float]) -> dict[float, SimulationResults]: """ Quantiles of the replicates at each position, for each output. The quantile is the one convention of commol (`weighted_quantiles` with equal weights, NumPy's ``method="hazen"``). Parameters ---------- levels : Sequence[float] Quantile levels, each between 0 and 1, such as ``[0.05, 0.5, 0.95]``. Returns ------- dict[float, SimulationResults] The results of each level. Raises ------ ValueError If a level is outside ``[0, 1]`` or there are no replicates. """ commol_rs.inference.check_levels(list(levels)) if not self: raise ValueError("There are no replicates.") from commol.api.inference.intervals import weighted_quantiles names = list(self[0]) per_name = { name: [ weighted_quantiles(column.tolist(), levels) for column in self._values(name).T ] for name in names } return { level: SimulationResults( self.positions, { name: [at_position[index] for at_position in per_name[name]] for name in names }, model=self.model, ) for index, level in enumerate(levels) } ``` #### quantities ```python quantities(quantities: Sequence[QuantityBase]) -> list[dict[str, float]] ``` The quantities of interest of each replicate, as `SimulationResults.quantities` reads one run. Each replicate is reduced on its own, with no mean: a summary across the replicates is a reduction of these values, such as `member_statistics`. A stochastic `Propagation` gives draw `j` the seed `draw_seeds[j]` and runs its replicate `i` with the seed replicate `i` of `Simulation.replicates(count, seed=draw_seeds[j])` takes. So those replicates, of the model at the values of draw `j`, give the quantities of the runs of that draw. Parameters: | Name | Type | Description | Default | | ------------ | -------------------- | ----------------------------------------------------------- | ---------- | | `quantities` | `Sequence[Quantity]` | The quantities; a Trajectory gives one scalar per position. | *required* | Returns: | Type | Description | | ------------------------ | ---------------------------------------------------------------------------------- | | `list[dict[str, float]]` | One dict per replicate, in seed order, with one value per label of the quantities. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ValueError` | If there are no replicates, there is no quantity, two scalars have one label, a quantity names an output the replicates do not have, or they lack a position a quantity reads. | Source code in `commol/api/results.py` ```python def quantities( self, quantities: "Sequence[QuantityBase]" ) -> list[dict[str, float]]: """ The quantities of interest of each replicate, as `SimulationResults.quantities` reads one run. Each replicate is reduced on its own, with no mean: a summary across the replicates is a reduction of these values, such as `member_statistics`. A stochastic `Propagation` gives draw ``j`` the seed ``draw_seeds[j]`` and runs its replicate ``i`` with the seed replicate ``i`` of ``Simulation.replicates(count, seed=draw_seeds[j])`` takes. So those replicates, of the model at the values of draw ``j``, give the quantities of the runs of that draw. Parameters ---------- quantities : Sequence[Quantity] The quantities; a `Trajectory` gives one scalar per position. Returns ------- list[dict[str, float]] One dict per replicate, in seed order, with one value per label of the quantities. Raises ------ ValueError If there are no replicates, there is no quantity, two scalars have one label, a quantity names an output the replicates do not have, or they lack a position a quantity reads. """ if not self: raise ValueError("There are no replicates.") return quantities_of_runs(self, self.positions, quantities) ``` #### member_statistics ```python member_statistics(output: str, confidence_level: float) -> dict[str, list[float]] ``` The spread of one output across the replicates, at each position. Parameters: | Name | Type | Description | Default | | ------------------ | ------- | ----------------------------------------------------------- | ---------- | | `output` | `str` | An output name. | *required* | | `confidence_level` | `float` | Confidence level of the interval, strictly between 0 and 1. | *required* | Returns: | Type | Description | | ------------------------ | ----------------------------------------------------------------------------------------------- | | `dict[str, list[float]]` | Keys mean, median, ci_lower, ci_upper, min and max, one value per position (member_statistics). | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------- | | `KeyError` | If no replicate has the output. | | `ValueError` | If the confidence level is out of range or there are no replicates. | Source code in `commol/api/results.py` ```python def member_statistics( self, output: str, confidence_level: float ) -> dict[str, list[float]]: """ The spread of one output across the replicates, at each position. Parameters ---------- output : str An output name. confidence_level : float Confidence level of the interval, strictly between 0 and 1. Returns ------- dict[str, list[float]] Keys ``mean``, ``median``, ``ci_lower``, ``ci_upper``, ``min`` and ``max``, one value per position (`member_statistics`). Raises ------ KeyError If no replicate has the output. ValueError If the confidence level is out of range or there are no replicates. """ from commol.api.inference.intervals import member_statistics if self and output not in self[0]: raise KeyError( f"'{output}' is not an output of the replicates. " f"Outputs: {sorted(self[0])}" ) return member_statistics([run[output] for run in self], confidence_level) ``` #### report ```python report(confidence_level: float, positions: Sequence[float], window: float, windows: int, observed_data: Iterable[ObservedDataPoint] = (), accumulators: Iterable[str] = (), prediction_intervals: PredictionIntervals | None = None) -> SimulationReport ``` Report the replicates as `SimulationResults.report` reports one run. Every number becomes a `{mean, median, ci_lower, ci_upper, min, max}` block, reduced replicate by replicate; the layout is the one of the report of a run. Parameters: | Name | Type | Description | Default | | ---------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `confidence_level` | `float` | Confidence level of the reported intervals, strictly between 0 and 1. | *required* | | `positions` | `Sequence[float]` | Positions the series and cumulative accumulators are sampled at; each must be a position of the replicates. | *required* | | `window` | `float` | Length of one window on the axis. | *required* | | `windows` | `int` | Number of windows on the observed and windowed axes. | *required* | | `observed_data` | `Iterable[ObservedDataPoint]` | The calibration targets to report alongside the replicates. | `()` | | `accumulators` | `Iterable[str]` | Accumulator ids to report; their outputs come from model. | `()` | | `prediction_intervals` | \`PredictionIntervals | None\` | Prediction intervals of a conformal method (ConformalResult.intervals), reported as they are. | Returns: | Type | Description | | ------------------ | -------------------------------------------------------------------------------------- | | `SimulationReport` | Keys observed, series and accumulators, then prediction_intervals when they are given. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ValueError` | If there are no replicates, the confidence level is out of range, the replicates have no value at a position the report reads, or they have no model and an accumulator is reported. | | `KeyError` | If an accumulator is not a bin or accumulator of the model. | Source code in `commol/api/results.py` ```python def report( self, confidence_level: float, positions: Sequence[float], window: float, windows: int, observed_data: "Iterable[ObservedDataPoint]" = (), accumulators: Iterable[str] = (), prediction_intervals: "PredictionIntervals | None" = None, ) -> "SimulationReport": """ Report the replicates as `SimulationResults.report` reports one run. Every number becomes a ``{mean, median, ci_lower, ci_upper, min, max}`` block, reduced replicate by replicate; the layout is the one of the report of a run. Parameters ---------- confidence_level : float Confidence level of the reported intervals, strictly between 0 and 1. positions : Sequence[float] Positions the series and cumulative accumulators are sampled at; each must be a position of the replicates. window : float Length of one window on the axis. windows : int Number of windows on the observed and windowed axes. observed_data : Iterable[ObservedDataPoint], optional The calibration targets to report alongside the replicates. accumulators : Iterable[str], optional Accumulator ids to report; their outputs come from `model`. prediction_intervals : PredictionIntervals | None, optional Prediction intervals of a conformal method (``ConformalResult.intervals``), reported as they are. Returns ------- SimulationReport Keys ``observed``, ``series`` and ``accumulators``, then ``prediction_intervals`` when they are given. Raises ------ ValueError If there are no replicates, the confidence level is out of range, the replicates have no value at a position the report reads, or they have no `model` and an accumulator is reported. KeyError If an accumulator is not a bin or accumulator of the model. """ from commol.api.reporting import _ReportRuns, _simulation_report if not self: raise ValueError("There are no replicates.") return _simulation_report( _ReportRuns( model=self.model, runs=list(self), confidence_level=confidence_level, ), positions, window, windows, observed_data, accumulators, prediction_intervals, ) ``` #### compared_values ```python compared_values() -> object ``` The runs, the seed, the positions and the model. Source code in `commol/api/results.py` ```python @override def compared_values(self) -> object: """The runs, the seed, the positions and the model.""" return list(self), self.seed, self.positions, self.model ``` #### __repr__ ```python __repr__() -> str ``` The number of replicates, their seed and their positions. Source code in `commol/api/results.py` ```python def __repr__(self) -> str: """The number of replicates, their seed and their positions.""" return ( f"SimulationReplicates({len(self)} replicates, seed={self.seed!r}, " f"positions={self.positions!r})" ) ``` ## ODESolver Bases: `BaseModel` Settings of the `DifferentialEquations` engine. Attributes: | Name | Type | Description | | -------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `method` | `Literal['tsit45', 'bdf']` | "tsit45": explicit Tsitouras 5(4) Runge-Kutta, for non-stiff models. "bdf": backward differentiation formulas, for stiff models. | | `rtol` | `float` | Relative tolerance of each state component. | | `atol` | `float` | Absolute tolerance of each state component, in the unit of the bins. | Source code in `commol/context/engine.py` ```python class ODESolver(BaseModel): """ Settings of the ``DifferentialEquations`` engine. Attributes ---------- method : Literal["tsit45", "bdf"] ``"tsit45"``: explicit Tsitouras 5(4) Runge-Kutta, for non-stiff models. ``"bdf"``: backward differentiation formulas, for stiff models. rtol : float Relative tolerance of each state component. atol : float Absolute tolerance of each state component, in the unit of the bins. """ model_config = ConfigDict(extra="forbid") method: Literal["tsit45", "bdf"] = Field( default="tsit45", description="Integration method." ) rtol: float = Field( default=commol_rs.engine.ODE_DEFAULT_RTOL, description="Relative tolerance." ) atol: float = Field( default=commol_rs.engine.ODE_DEFAULT_ATOL, description="Absolute tolerance." ) @model_validator(mode="after") def check_with_the_engine(self) -> Self: """Ask the engine's list whether it takes the settings.""" return _checked(self) ``` ### Methods: #### check_with_the_engine ```python check_with_the_engine() -> Self ``` Ask the engine's list whether it takes the settings. Source code in `commol/context/engine.py` ```python @model_validator(mode="after") def check_with_the_engine(self) -> Self: """Ask the engine's list whether it takes the settings.""" return _checked(self) ``` ## StochasticEventsSettings Bases: `BaseModel` Settings of the `StochasticEvents` engine. Attributes: | Name | Type | Description | | ----------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `method` | `Literal['direct', 'tau_leaping']` | "direct": Gillespie's direct method, one event at a time, exact. "tau_leaping": a Poisson number of events of each flow in each leap, an approximation that is faster when events are many. | | `tau` | \`float | None\` | | `epsilon` | `float` | How much a propensity may change in one chosen leap, relative to its value. | | `tolerance` | `float` | Relative tolerance of the time of an event when a rate reads t. | | `rounding` | \`Rounding | None\` | Source code in `commol/context/engine.py` ```python class StochasticEventsSettings(BaseModel): """ Settings of the ``StochasticEvents`` engine. Attributes ---------- method : Literal["direct", "tau_leaping"] ``"direct"``: Gillespie's direct method, one event at a time, exact. ``"tau_leaping"``: a Poisson number of events of each flow in each leap, an approximation that is faster when events are many. tau : float | None The length of a leap of ``"tau_leaping"``. None chooses each leap as Cao, Gillespie and Petzold (2006) do, from `epsilon`. epsilon : float How much a propensity may change in one chosen leap, relative to its value. tolerance : float Relative tolerance of the time of an event when a rate reads ``t``. rounding : Rounding | None How to make whole the initial amounts that are not. None requires whole amounts. """ model_config = ConfigDict(extra="forbid") method: Literal["direct", "tau_leaping"] = Field( default="direct", description="Simulation method." ) tau: float | None = Field( default=None, description="Length of a leap of tau-leaping." ) epsilon: float = Field( default=commol_rs.engine.STOCHASTIC_EVENTS_DEFAULT_EPSILON, description="Bound of an adaptive leap.", ) tolerance: float = Field( default=commol_rs.engine.STOCHASTIC_EVENTS_DEFAULT_TOLERANCE, description="Relative tolerance of an event time.", ) rounding: Rounding | None = Field( default=None, description="How to make the initial amounts whole." ) @model_validator(mode="after") def check_with_the_engine(self) -> Self: """Ask the engine's list whether it takes the settings.""" return _checked(self) ``` ### Methods: #### check_with_the_engine ```python check_with_the_engine() -> Self ``` Ask the engine's list whether it takes the settings. Source code in `commol/context/engine.py` ```python @model_validator(mode="after") def check_with_the_engine(self) -> Self: """Ask the engine's list whether it takes the settings.""" return _checked(self) ``` ## StochasticStepsSettings Bases: `BaseModel` Settings of the `StochasticSteps` engine. Attributes: | Name | Type | Description | | ---------- | ---------- | ----------- | | `rounding` | \`Rounding | None\` | Source code in `commol/context/engine.py` ```python class StochasticStepsSettings(BaseModel): """ Settings of the ``StochasticSteps`` engine. Attributes ---------- rounding : Rounding | None How to make whole the initial amounts that are not. None requires whole amounts. """ model_config = ConfigDict(extra="forbid") rounding: Rounding | None = Field( default=None, description="How to make the initial amounts whole." ) @model_validator(mode="after") def check_with_the_engine(self) -> Self: """Ask the engine's list whether it takes the settings.""" return _checked(self) ``` ### Methods: #### check_with_the_engine ```python check_with_the_engine() -> Self ``` Ask the engine's list whether it takes the settings. Source code in `commol/context/engine.py` ```python @model_validator(mode="after") def check_with_the_engine(self) -> Self: """Ask the engine's list whether it takes the settings.""" return _checked(self) ``` ## InfeasibleStateError ```python InfeasibleStateError = _engine.InfeasibleStateError ``` A run left the values its model allows, such as an amount below zero. A difference-equations step or an impulse that leaves a compartment below zero raises it. A stochastic engine raises it for a rate it cannot draw, such as a negative rate, an impulse fraction outside 0 to 1, or an absolute impulse that is not a whole number. It is a `ValueError`; a calibration treats such a run as infeasible. ## Windows and positions ## windowed_totals ```python windowed_totals(series: Sequence[float], positions: Sequence[float], window: float, at: Iterable[float] | None = None) -> list[float] ``` Amount a cumulative series gained over each window. Each value is the series at the end of the window minus the series at its start, `at - window`, each found with the tolerance of the axis: the rule of a windowed observation (`window_increments` of `crates/commol-calibration/src/observation.rs`), so the value is the prediction a calibration compares an observation against when that observation sets `window`. Parameters: | Name | Type | Description | Default | | ----------- | ----------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `series` | `Sequence[float]` | A cumulative series, one value per position. | *required* | | `positions` | `Sequence[float]` | Positions of the series, ascending, such as SimulationResults.positions. | *required* | | `window` | `float` | Length of one window on the axis. | *required* | | `at` | \`Iterable[float] | None\` | Positions at which windows close. Defaults to every complete window of the series (window_ends of the engine, as AxisUnits.window_ends gives it). Pass the observation positions to reproduce exactly the values a calibration used. | Returns: | Type | Description | | ------------- | ------------------------------------- | | `list[float]` | One increment per requested position. | Raises: | Type | Description | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If series and positions differ in length, window is not a finite, positive length (the message of the engine), a window starts before position 0, or the series has no value at the end or the start of a requested window. | Source code in `commol/api/windows.py` ```python def windowed_totals( series: Sequence[float], positions: Sequence[float], window: float, at: Iterable[float] | None = None, ) -> list[float]: """ Amount a cumulative series gained over each window. Each value is the series at the end of the window minus the series at its start, ``at - window``, each found with the tolerance of the axis: the rule of a windowed observation (`window_increments` of `crates/commol-calibration/src/observation.rs`), so the value is the prediction a calibration compares an observation against when that observation sets ``window``. Parameters ---------- series : Sequence[float] A cumulative series, one value per position. positions : Sequence[float] Positions of the series, ascending, such as ``SimulationResults.positions``. window : float Length of one window on the axis. at : Iterable[float] | None, optional Positions at which windows close. Defaults to every complete window of the series (`window_ends` of the engine, as `AxisUnits.window_ends` gives it). Pass the observation positions to reproduce exactly the values a calibration used. Returns ------- list[float] One increment per requested position. Raises ------ ValueError If `series` and `positions` differ in length, `window` is not a finite, positive length (the message of the engine), a window starts before position 0, or the series has no value at the end or the start of a requested window. """ if len(series) != len(positions): raise ValueError( f"The series has {len(series)} values for {len(positions)} positions." ) ends = ( commol_rs.calibration.window_ends(window, positions[-1] if positions else 0.0) if at is None else list(at) ) return commol_rs.calibration.window_increments( [float(value) for value in series], [float(position) for position in positions], float(window), [float(end) for end in ends], ) ``` ## sample ```python sample(series: Sequence[float], positions: Sequence[float], at: Iterable[float]) -> list[float] ``` Read a series at the given positions. Compartment counts are instantaneous, so they are read at the position rather than averaged over the interval leading to it. Each position is found with the tolerance of the axis, as an observation finds its row (`index_at` of `crates/commol-calibration/src/observation.rs`), so \`0.1 - 0.2`reads the value at`0.3\`. Parameters: | Name | Type | Description | Default | | ----------- | ----------------- | ----------------------------------- | ---------- | | `series` | `Sequence[float]` | One value per position. | *required* | | `positions` | `Sequence[float]` | Positions of the series, ascending. | *required* | | `at` | `Iterable[float]` | Positions to read. | *required* | Returns: | Type | Description | | ------------- | ----------------------------- | | `list[float]` | One value per position in at. | Raises: | Type | Description | | ------------ | -------------------------------------------- | | `ValueError` | If a position in at is not one of positions. | Source code in `commol/api/results.py` ```python def sample( series: Sequence[float], positions: Sequence[float], at: Iterable[float] ) -> list[float]: """ Read a series at the given positions. Compartment counts are instantaneous, so they are read at the position rather than averaged over the interval leading to it. Each position is found with the tolerance of the axis, as an observation finds its row (`index_at` of `crates/commol-calibration/src/observation.rs`), so ``0.1 + 0.2`` reads the value at ``0.3``. Parameters ---------- series : Sequence[float] One value per position. positions : Sequence[float] Positions of the series, ascending. at : Iterable[float] Positions to read. Returns ------- list[float] One value per position in `at`. Raises ------ ValueError If a position in `at` is not one of `positions`. """ rows = commol_rs.calibration.index_at( [float(position) for position in positions], [float(position) for position in at], ) return [series[row] for row in rows] ``` # Uncertainty API [Back to API Reference](https://munqu.github.io/commol/latest/api/index.md) What every uncertainty method takes and returns: the configuration bases, the weighted parameter sample, the predictive and its observation noise, and the scenarios. Then the methods of each family with their results (resampling, profile likelihood, sequential Monte Carlo, local approximations and conformal prediction), the scores and the one quantile. Guide: [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md). ## InferenceMethod Bases: `BaseModel`, `Generic[ResultT]` The configuration of a method that reads a calibration problem or a sensitivity analysis, whose result is `ResultT`. A method subclasses one of its kinds, `UncertaintyMethod`, `IdentifiabilityMethod` or `SensitivityMethod`, with its result type, and registers its runner (`commol.api.inference.registry.register`). Source code in `commol/context/inference/method.py` ```python class InferenceMethod(BaseModel, Generic[ResultT]): """ The configuration of a method that reads a calibration problem or a sensitivity analysis, whose result is `ResultT`. A method subclasses one of its kinds, `UncertaintyMethod`, `IdentifiabilityMethod` or `SensitivityMethod`, with its result type, and registers its runner (`commol.api.inference.registry.register`). """ model_config = ConfigDict(extra="forbid", frozen=True) ``` ## UncertaintyMethod Bases: `InferenceMethod[ResultT]`, `Generic[ResultT]` The configuration of an uncertainty method, whose result is `ResultT`. A method subclasses it with its result type, for example `class BayesianBootstrap(UncertaintyMethod[BootstrapResult])`, and registers its runner (`commol.api.inference.registry.register`). `Calibrator.uncertainty(method)` returns that result type, so a static type checker infers it from the configuration's type. Source code in `commol/context/inference/method.py` ```python class UncertaintyMethod(InferenceMethod[ResultT], Generic[ResultT]): """ The configuration of an uncertainty method, whose result is `ResultT`. A method subclasses it with its result type, for example ``class BayesianBootstrap(UncertaintyMethod[BootstrapResult])``, and registers its runner (`commol.api.inference.registry.register`). ``Calibrator.uncertainty(method)`` returns that result type, so a static type checker infers it from the configuration's type. """ ``` ## ParameterSample Bases: `ResultModel` Draws of the parameters of a calibration problem, each with a weight. A method that has draws returns them in a sample: a bootstrap, a sequential Monte Carlo run, a Markov chain. Plots, reports, the predictive and the scores read a sample, never the result of one method. Every statistic is weighted and computed in Rust; with equal weights it is the unweighted statistic. A sample has no single set of parameters: to run one draw, take it from `draws`. Attributes: | Name | Type | Description | | ------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `names` | `list[str]` | One name per column, in the order of the problem's parameters. | | `bounds` | `list[tuple[float, float]]` | The bounds of each parameter; every value lies inside them. | | `values` | `list[list[float]]` | One row per draw, one column per name. | | `log_weights` | `list[float]` | The natural logarithm of each draw's weight, normalized so that the weights sum to 1. A draw of weight zero has -inf. | | `groups` | \`list[int] | None\` | | `method` | `SampleMethod` | What made the sample. | Raises: | Type | Description | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If a value is not finite or outside its bounds, a log weight is NaN or +inf, every log weight is -inf, a name repeats, or the shapes differ. | Source code in `commol/context/inference/sample.py` ```python class ParameterSample(ResultModel): """ Draws of the parameters of a calibration problem, each with a weight. A method that has draws returns them in a sample: a bootstrap, a sequential Monte Carlo run, a Markov chain. Plots, reports, the predictive and the scores read a sample, never the result of one method. Every statistic is weighted and computed in Rust; with equal weights it is the unweighted statistic. A sample has no single set of parameters: to run one draw, take it from `draws`. Attributes ---------- names : list[str] One name per column, in the order of the problem's parameters. bounds : list[tuple[float, float]] The bounds of each parameter; every value lies inside them. values : list[list[float]] One row per draw, one column per name. log_weights : list[float] The natural logarithm of each draw's weight, normalized so that the weights sum to 1. A draw of weight zero has ``-inf``. groups : list[int] | None The group of each draw (a chain, a replicate or a wave), or None. method : SampleMethod What made the sample. Raises ------ ValueError If a value is not finite or outside its bounds, a log weight is NaN or ``+inf``, every log weight is ``-inf``, a name repeats, or the shapes differ. """ model_config = ConfigDict(extra="forbid", frozen=True, ser_json_inf_nan="constants") names: list[str] = Field(description="One name per column.") bounds: list[tuple[float, float]] = Field(description="Bounds of each parameter.") values: list[list[float]] = Field(description="One row per draw.") log_weights: list[float] = Field(description="Normalized log weight of each draw.") groups: list[int] | None = Field(default=None, description="Group of each draw.") method: SampleMethod = Field(description="What made the sample.") _rust: "ParameterSampleProtocol" = PrivateAttr() @field_validator("log_weights") @classmethod def normalize_log_weights( cls, log_weights: list[float], info: ValidationInfo ) -> list[float]: """ Check the invariants in Rust and normalize the log weights; with a field before them that is not valid, pydantic reports that field. """ data = info.data if not {"names", "bounds", "values"} <= data.keys(): return log_weights rust = commol_rs.inference.ParameterSample( data["names"], data["bounds"], data["values"], log_weights, None ) return rust.log_weights @model_validator(mode="after") def build_rust_sample(self) -> Self: """Keep the Rust sample that computes the statistics.""" self._rust = commol_rs.inference.ParameterSample( self.names, self.bounds, self.values, self.log_weights, self.groups ) return self @classmethod def equally_weighted( cls, names: list[str], bounds: list[tuple[float, float]], values: list[list[float]], method: SampleMethod, groups: list[int] | None = None, ) -> Self: """ A sample whose draws have equal weights. Parameters ---------- names : list[str] One name per column. bounds : list[tuple[float, float]] The bounds of each parameter. values : list[list[float]] One row per draw. method : SampleMethod What made the sample. groups : list[int] | None, optional The group of each draw. Returns ------- ParameterSample The sample. """ return cls( names=names, bounds=bounds, values=values, log_weights=[0.0] * len(values), groups=groups, method=method, ) @property def rust_sample(self) -> "ParameterSampleProtocol": """The sample in Rust, for the runners of methods and the predictive.""" return self._rust @property def weights(self) -> list[float]: """The weight of each draw; they sum to 1.""" return self._rust.weights def __len__(self) -> int: """The number of draws.""" return len(self.values) def draws(self) -> Iterator[dict[str, float]]: """ Every draw, as a mapping of parameter name to value. Yields ------ dict[str, float] One draw, in the order of `values`. """ for row in self.values: yield dict(zip(self.names, row, strict=True)) def mean(self) -> dict[str, float]: """ The weighted mean of each parameter. Returns ------- dict[str, float] One mean per name. """ return dict(zip(self.names, self._rust.mean(), strict=True)) def covariance(self) -> list[list[float]]: """ The weighted covariance matrix, in the order of `names`. With weights ``w`` that sum to 1 it is ``Σ wᵢ(xᵢ − x̄)(yᵢ − ȳ) / (1 − Σ wᵢ²)``: with equal weights, the sample covariance. It is NaN for a sample of one effective draw. Returns ------- list[list[float]] One row per name. """ return self._rust.covariance() def correlation(self) -> list[list[float]]: """ The weighted correlation matrix, in the order of `names`. Returns ------- list[list[float]] One row per name; NaN for a parameter with no spread. """ return self._rust.correlation() def quantiles(self, levels: list[float]) -> dict[str, list[float]]: """ The weighted quantiles of each parameter. The quantile is the inverse of the weighted empirical distribution, interpolated between the midpoints of the cumulative weights; with equal weights it is NumPy's ``method="hazen"``. Parameters ---------- levels : list[float] Quantile levels, each between 0 and 1. Returns ------- dict[str, list[float]] One quantile per level for each name. Raises ------ ValueError If a level is not between 0 and 1. """ rows = self._rust.quantiles([float(level) for level in levels]) return { name: [row[column] for row in rows] for column, name in enumerate(self.names) } def interval(self, confidence_level: float) -> dict[str, tuple[float, float]]: """ The central interval of each parameter. Parameters ---------- confidence_level : float Strictly between 0 and 1, such as 0.95. Returns ------- dict[str, tuple[float, float]] ``(lower, upper)`` for each name: the quantiles ``(1 − c)/2`` and ``(1 + c)/2``. Raises ------ ValueError If the confidence level is not strictly between 0 and 1. """ lower, upper = self._rust.interval(confidence_level) return { name: (low, high) for name, low, high in zip(self.names, lower, upper, strict=True) } @property def effective_size(self) -> float: """ Kish's effective sample size, ``1 / Σ wᵢ²``: between 1 and the number of draws; equal weights give the number of draws. """ return self._rust.effective_size() def resample(self, count: int, seed: int) -> "ParameterSample": """ Draw `count` draws with replacement, each with probability its weight. Parameters ---------- count : int Number of draws. seed : int Seed of the generator. Returns ------- ParameterSample A sample of equal weights. """ indices = self._rust.resample(count, seed) return ParameterSample.equally_weighted( self.names, self.bounds, [self.values[i] for i in indices], SampleMethod( name="resample", settings={"of": self.method.name, "count": count, "seed": seed}, ), ) def summary(self, confidence_level: float) -> dict[str, ParameterSummary]: """ Weighted statistics of each parameter, for a report. Parameters ---------- confidence_level : float Confidence level of the interval, strictly between 0 and 1. Returns ------- dict[str, ParameterSummary] One summary per name. Raises ------ ValueError If the confidence level is not strictly between 0 and 1. """ lower, upper = self._rust.interval(confidence_level) quantiles = self.quantiles([0.0, 0.5, 1.0]) mean = self.mean() covariance = self.covariance() return { name: ParameterSummary( mean=mean[name], median=quantiles[name][1], std=covariance[index][index] ** 0.5, lower=lower[index], upper=upper[index], min=quantiles[name][0], max=quantiles[name][2], ) for index, name in enumerate(self.names) } ``` ### Attributes #### rust_sample ```python rust_sample: ParameterSampleProtocol ``` The sample in Rust, for the runners of methods and the predictive. #### weights ```python weights: list[float] ``` The weight of each draw; they sum to 1. #### effective_size ```python effective_size: float ``` Kish's effective sample size, `1 / Σ wᵢ²`: between 1 and the number of draws; equal weights give the number of draws. ### Methods: #### normalize_log_weights ```python normalize_log_weights(log_weights: list[float], info: ValidationInfo) -> list[float] ``` Check the invariants in Rust and normalize the log weights; with a field before them that is not valid, pydantic reports that field. Source code in `commol/context/inference/sample.py` ```python @field_validator("log_weights") @classmethod def normalize_log_weights( cls, log_weights: list[float], info: ValidationInfo ) -> list[float]: """ Check the invariants in Rust and normalize the log weights; with a field before them that is not valid, pydantic reports that field. """ data = info.data if not {"names", "bounds", "values"} <= data.keys(): return log_weights rust = commol_rs.inference.ParameterSample( data["names"], data["bounds"], data["values"], log_weights, None ) return rust.log_weights ``` #### build_rust_sample ```python build_rust_sample() -> Self ``` Keep the Rust sample that computes the statistics. Source code in `commol/context/inference/sample.py` ```python @model_validator(mode="after") def build_rust_sample(self) -> Self: """Keep the Rust sample that computes the statistics.""" self._rust = commol_rs.inference.ParameterSample( self.names, self.bounds, self.values, self.log_weights, self.groups ) return self ``` #### equally_weighted ```python equally_weighted(names: list[str], bounds: list[tuple[float, float]], values: list[list[float]], method: SampleMethod, groups: list[int] | None = None) -> Self ``` A sample whose draws have equal weights. Parameters: | Name | Type | Description | Default | | -------- | --------------------------- | ----------------------------- | ----------------------- | | `names` | `list[str]` | One name per column. | *required* | | `bounds` | `list[tuple[float, float]]` | The bounds of each parameter. | *required* | | `values` | `list[list[float]]` | One row per draw. | *required* | | `method` | `SampleMethod` | What made the sample. | *required* | | `groups` | \`list[int] | None\` | The group of each draw. | Returns: | Type | Description | | ----------------- | ----------- | | `ParameterSample` | The sample. | Source code in `commol/context/inference/sample.py` ```python @classmethod def equally_weighted( cls, names: list[str], bounds: list[tuple[float, float]], values: list[list[float]], method: SampleMethod, groups: list[int] | None = None, ) -> Self: """ A sample whose draws have equal weights. Parameters ---------- names : list[str] One name per column. bounds : list[tuple[float, float]] The bounds of each parameter. values : list[list[float]] One row per draw. method : SampleMethod What made the sample. groups : list[int] | None, optional The group of each draw. Returns ------- ParameterSample The sample. """ return cls( names=names, bounds=bounds, values=values, log_weights=[0.0] * len(values), groups=groups, method=method, ) ``` #### __len__ ```python __len__() -> int ``` The number of draws. Source code in `commol/context/inference/sample.py` ```python def __len__(self) -> int: """The number of draws.""" return len(self.values) ``` #### draws ```python draws() -> Iterator[dict[str, float]] ``` Every draw, as a mapping of parameter name to value. Yields: | Type | Description | | ------------------ | --------------------------------- | | `dict[str, float]` | One draw, in the order of values. | Source code in `commol/context/inference/sample.py` ```python def draws(self) -> Iterator[dict[str, float]]: """ Every draw, as a mapping of parameter name to value. Yields ------ dict[str, float] One draw, in the order of `values`. """ for row in self.values: yield dict(zip(self.names, row, strict=True)) ``` #### mean ```python mean() -> dict[str, float] ``` The weighted mean of each parameter. Returns: | Type | Description | | ------------------ | ------------------ | | `dict[str, float]` | One mean per name. | Source code in `commol/context/inference/sample.py` ```python def mean(self) -> dict[str, float]: """ The weighted mean of each parameter. Returns ------- dict[str, float] One mean per name. """ return dict(zip(self.names, self._rust.mean(), strict=True)) ``` #### covariance ```python covariance() -> list[list[float]] ``` The weighted covariance matrix, in the order of `names`. With weights `w` that sum to 1 it is `Σ wᵢ(xᵢ − x̄)(yᵢ − ȳ) / (1 − Σ wᵢ²)`: with equal weights, the sample covariance. It is NaN for a sample of one effective draw. Returns: | Type | Description | | ------------------- | ----------------- | | `list[list[float]]` | One row per name. | Source code in `commol/context/inference/sample.py` ```python def covariance(self) -> list[list[float]]: """ The weighted covariance matrix, in the order of `names`. With weights ``w`` that sum to 1 it is ``Σ wᵢ(xᵢ − x̄)(yᵢ − ȳ) / (1 − Σ wᵢ²)``: with equal weights, the sample covariance. It is NaN for a sample of one effective draw. Returns ------- list[list[float]] One row per name. """ return self._rust.covariance() ``` #### correlation ```python correlation() -> list[list[float]] ``` The weighted correlation matrix, in the order of `names`. Returns: | Type | Description | | ------------------- | ----------------------------------------------------- | | `list[list[float]]` | One row per name; NaN for a parameter with no spread. | Source code in `commol/context/inference/sample.py` ```python def correlation(self) -> list[list[float]]: """ The weighted correlation matrix, in the order of `names`. Returns ------- list[list[float]] One row per name; NaN for a parameter with no spread. """ return self._rust.correlation() ``` #### quantiles ```python quantiles(levels: list[float]) -> dict[str, list[float]] ``` The weighted quantiles of each parameter. The quantile is the inverse of the weighted empirical distribution, interpolated between the midpoints of the cumulative weights; with equal weights it is NumPy's `method="hazen"`. Parameters: | Name | Type | Description | Default | | -------- | ------------- | -------------------------------------- | ---------- | | `levels` | `list[float]` | Quantile levels, each between 0 and 1. | *required* | Returns: | Type | Description | | ------------------------ | ------------------------------------- | | `dict[str, list[float]]` | One quantile per level for each name. | Raises: | Type | Description | | ------------ | ---------------------------------- | | `ValueError` | If a level is not between 0 and 1. | Source code in `commol/context/inference/sample.py` ```python def quantiles(self, levels: list[float]) -> dict[str, list[float]]: """ The weighted quantiles of each parameter. The quantile is the inverse of the weighted empirical distribution, interpolated between the midpoints of the cumulative weights; with equal weights it is NumPy's ``method="hazen"``. Parameters ---------- levels : list[float] Quantile levels, each between 0 and 1. Returns ------- dict[str, list[float]] One quantile per level for each name. Raises ------ ValueError If a level is not between 0 and 1. """ rows = self._rust.quantiles([float(level) for level in levels]) return { name: [row[column] for row in rows] for column, name in enumerate(self.names) } ``` #### interval ```python interval(confidence_level: float) -> dict[str, tuple[float, float]] ``` The central interval of each parameter. Parameters: | Name | Type | Description | Default | | ------------------ | ------- | --------------------------------------- | ---------- | | `confidence_level` | `float` | Strictly between 0 and 1, such as 0.95. | *required* | Returns: | Type | Description | | -------------------------------- | -------------------------------------------------------------------- | | `dict[str, tuple[float, float]]` | (lower, upper) for each name: the quantiles (1 − c)/2 and (1 + c)/2. | Raises: | Type | Description | | ------------ | -------------------------------------------------------- | | `ValueError` | If the confidence level is not strictly between 0 and 1. | Source code in `commol/context/inference/sample.py` ```python def interval(self, confidence_level: float) -> dict[str, tuple[float, float]]: """ The central interval of each parameter. Parameters ---------- confidence_level : float Strictly between 0 and 1, such as 0.95. Returns ------- dict[str, tuple[float, float]] ``(lower, upper)`` for each name: the quantiles ``(1 − c)/2`` and ``(1 + c)/2``. Raises ------ ValueError If the confidence level is not strictly between 0 and 1. """ lower, upper = self._rust.interval(confidence_level) return { name: (low, high) for name, low, high in zip(self.names, lower, upper, strict=True) } ``` #### resample ```python resample(count: int, seed: int) -> ParameterSample ``` Draw `count` draws with replacement, each with probability its weight. Parameters: | Name | Type | Description | Default | | ------- | ----- | ---------------------- | ---------- | | `count` | `int` | Number of draws. | *required* | | `seed` | `int` | Seed of the generator. | *required* | Returns: | Type | Description | | ----------------- | -------------------------- | | `ParameterSample` | A sample of equal weights. | Source code in `commol/context/inference/sample.py` ```python def resample(self, count: int, seed: int) -> "ParameterSample": """ Draw `count` draws with replacement, each with probability its weight. Parameters ---------- count : int Number of draws. seed : int Seed of the generator. Returns ------- ParameterSample A sample of equal weights. """ indices = self._rust.resample(count, seed) return ParameterSample.equally_weighted( self.names, self.bounds, [self.values[i] for i in indices], SampleMethod( name="resample", settings={"of": self.method.name, "count": count, "seed": seed}, ), ) ``` #### summary ```python summary(confidence_level: float) -> dict[str, ParameterSummary] ``` Weighted statistics of each parameter, for a report. Parameters: | Name | Type | Description | Default | | ------------------ | ------- | ----------------------------------------------------------- | ---------- | | `confidence_level` | `float` | Confidence level of the interval, strictly between 0 and 1. | *required* | Returns: | Type | Description | | ----------------------------- | --------------------- | | `dict[str, ParameterSummary]` | One summary per name. | Raises: | Type | Description | | ------------ | -------------------------------------------------------- | | `ValueError` | If the confidence level is not strictly between 0 and 1. | Source code in `commol/context/inference/sample.py` ```python def summary(self, confidence_level: float) -> dict[str, ParameterSummary]: """ Weighted statistics of each parameter, for a report. Parameters ---------- confidence_level : float Confidence level of the interval, strictly between 0 and 1. Returns ------- dict[str, ParameterSummary] One summary per name. Raises ------ ValueError If the confidence level is not strictly between 0 and 1. """ lower, upper = self._rust.interval(confidence_level) quantiles = self.quantiles([0.0, 0.5, 1.0]) mean = self.mean() covariance = self.covariance() return { name: ParameterSummary( mean=mean[name], median=quantiles[name][1], std=covariance[index][index] ** 0.5, lower=lower[index], upper=upper[index], min=quantiles[name][0], max=quantiles[name][2], ) for index, name in enumerate(self.names) } ``` ## SampleMethod Bases: `ResultModel` What made a sample: the name of the method and its settings. Attributes: | Name | Type | Description | | ---------- | ---------------------- | ----------------------------------------------------- | | `name` | `str` | The name of the method, such as "bayesian_bootstrap". | | `settings` | `dict[str, JsonValue]` | The settings the method ran with. | Source code in `commol/context/inference/sample.py` ```python class SampleMethod(ResultModel): """ What made a sample: the name of the method and its settings. Attributes ---------- name : str The name of the method, such as ``"bayesian_bootstrap"``. settings : dict[str, JsonValue] The settings the method ran with. """ model_config = ConfigDict(extra="forbid", frozen=True) name: str = Field(description="The name of the method.") settings: dict[str, JsonValue] = Field( default_factory=dict, description="The settings the method ran with." ) ``` ## ParameterSummary Bases: `ResultModel` Weighted statistics of one parameter of a sample. Attributes: | Name | Type | Description | | -------- | ------- | -------------------------------------------------------------- | | `mean` | `float` | The weighted mean. | | `median` | `float` | The weighted median. | | `std` | `float` | The weighted standard deviation (ParameterSample.covariance). | | `lower` | `float` | The lower end of the central interval of the confidence level. | | `upper` | `float` | The upper end of the central interval of the confidence level. | | `min` | `float` | The smallest value of positive weight. | | `max` | `float` | The largest value of positive weight. | Source code in `commol/context/inference/sample.py` ```python class ParameterSummary(ResultModel): """ Weighted statistics of one parameter of a sample. Attributes ---------- mean : float The weighted mean. median : float The weighted median. std : float The weighted standard deviation (`ParameterSample.covariance`). lower : float The lower end of the central interval of the confidence level. upper : float The upper end of the central interval of the confidence level. min : float The smallest value of positive weight. max : float The largest value of positive weight. """ model_config = ConfigDict(extra="forbid", frozen=True) mean: float median: float std: float lower: float upper: float min: float max: float ``` ## Predictive The run of every draw of a parameter sample. The draws run in parallel in Rust, through the forward model of a calibration problem or of a sensitivity analysis: with a stochastic engine every replicate of a draw is a draw of the predictive, with an equal share of its weight. A band reduces the draws position by position with their weights; without observation noise it is a band of the model, not a prediction interval. Source code in `commol/api/inference/predictive.py` ```python class Predictive: """ The run of every draw of a parameter sample. The draws run in parallel in Rust, through the forward model of a calibration problem or of a sensitivity analysis: with a stochastic engine every replicate of a draw is a draw of the predictive, with an equal share of its weight. A band reduces the draws position by position with their weights; without observation noise it is a band of the model, not a prediction interval. """ def __init__( self, source: PredictiveSource, sample: ParameterSample, positions: Sequence[float], ) -> None: """ Run every draw of `sample` at `positions`. Parameters ---------- source : PredictiveSource The calibration problem the sample is a sample of (`Calibrator`), or the sensitivity analysis of its inputs (`SensitivityAnalysis`). sample : ParameterSample Its parameters, in the order of the problem or the analysis. positions : Sequence[float] Positions to report at, sorted and unique. Raises ------ ValueError Before any draw runs: if the sample's parameters are not the source's, or the positions are not sorted and unique or the engine does not report at one of them (a difference engine reports only at integer steps). InfeasibleStateError If a draw leaves the values its model allows. The message names the parameter set, then says what the run says (the compartment, the step and the flows), or what the initial amounts exceed. """ self.source: PredictiveSource = source self.sample: ParameterSample = sample self.positions: list[float] = [float(position) for position in positions] self.output_names: list[str] = list(source.simulation.engine.output_names) self._rust = source.rust_predictive(sample, self.positions) @property def weights(self) -> list[float]: """The weight of each draw of the predictive; they sum to 1.""" return self._rust.weights @property def draw_seeds(self) -> list[int]: """ The seed of each draw of `sample`, in its order. With a stochastic engine, replicate ``i`` of draw ``j`` runs with the seed replicate ``i`` of ``Simulation.replicates(count, seed=draw_seeds[j])`` takes, so those replicates of the model at that draw give its runs. """ return self._rust.draw_seeds @property def plot(self) -> PredictivePlot: """ The figures of the draws: ``plot.series()`` and ``plot.cumulative()``, the weighted median and the band of each series. """ return PredictivePlot(self) def runs(self) -> list[SimulationResults]: """ The trajectory of every draw, in the order of `weights`. Returns ------- list[SimulationResults] One run per draw, at `positions`, with the model of the source. """ model = self.source.simulation.model_definition return [ SimulationResults.from_rows( self.positions, self.output_names, rows, model=model ) for rows in self._rust.trajectories ] def quantities( self, quantities: "Sequence[QuantityBase]" ) -> list[dict[str, float]]: """ The quantities of interest of every draw, in the order of `weights`. Each run is reduced on its own, as `SimulationResults.quantities` reduces one, with the Rust code of `Propagation`. The predictive runs at `positions`, so they must hold ``quantity_schedule(quantities)``. Parameters ---------- quantities : Sequence[Quantity] The quantities; a `Trajectory` gives one scalar per position. Returns ------- list[dict[str, float]] One dict per draw of the predictive, with one value per label of the quantities. Raises ------ ValueError If there is no quantity, two scalars have one label, a quantity names an output the model does not have, or `positions` lack a position a quantity reads; the message lists every such position with its quantity. """ return quantities_of_runs(self.runs(), self.positions, quantities) def report( self, confidence_level: float, positions: Sequence[float], window: float, windows: int, observed_data: Iterable[ObservedDataPoint] = (), accumulators: Iterable[str] = (), prediction_intervals: "PredictionIntervals | None" = None, ) -> "SimulationReport": """ Report the runs of the draws as `SimulationResults.report` reports one. Every number becomes a ``{mean, median, ci_lower, ci_upper, min, max}`` block, reduced draw by draw with the weights of the draws; the layout is the one of the report of a run. Each leaf comes from the draws only, so the report takes no point estimate. The outputs of an accumulator come from the model of the source. Parameters ---------- confidence_level : float Confidence level of the reported intervals, strictly between 0 and 1. positions : Sequence[float] Positions the series and cumulative accumulators are sampled at; each must be one of `positions` of the predictive. window : float Length of one window on the axis. windows : int Number of windows on the observed and windowed axes. observed_data : Iterable[ObservedDataPoint], optional The calibration targets to report alongside the draws. accumulators : Iterable[str], optional Accumulator ids to report. prediction_intervals : PredictionIntervals | None, optional Prediction intervals of a conformal method (``ConformalResult.intervals``), reported as they are. Returns ------- SimulationReport Keys ``observed``, ``series`` and ``accumulators``, then ``prediction_intervals`` when they are given. Raises ------ ValueError If the confidence level is out of range, or the predictive has no value at a position the report reads. KeyError If an accumulator is not a bin or accumulator of the model. """ from commol.api.reporting import _ReportRuns, _simulation_report return _simulation_report( _ReportRuns( model=self.source.simulation.model_definition, runs=self.runs(), confidence_level=confidence_level, weights=self.weights, ), positions, window, windows, observed_data, accumulators, prediction_intervals, ) def band(self, confidence_level: float) -> PredictiveBand: """ The band of every output. Parameters ---------- confidence_level : float Strictly between 0 and 1. Returns ------- PredictiveBand The weighted mean, median and central interval of each output at each position. Raises ------ ValueError If the confidence level is not strictly between 0 and 1. """ mean, median, lower, upper = self._rust.band(confidence_level) names = self.output_names return PredictiveBand( confidence_level=confidence_level, positions=self.positions, mean=by_output(names, mean), median=by_output(names, median), lower=by_output(names, lower), upper=by_output(names, upper), ) def series_band( self, outputs: Sequence[str], confidence_level: float, cumulative: bool = False, ) -> SeriesBand: """ The band of the sum of some outputs, or of its running total. Each draw is reduced to the series first, then the series are reduced across the draws. Parameters ---------- outputs : Sequence[str] The outputs summed. confidence_level : float Strictly between 0 and 1. cumulative : bool, optional Whether the series is the running total along the positions. Returns ------- SeriesBand One value per position. Raises ------ ValueError If an output is unknown or the confidence level is out of range. """ unknown = [name for name in outputs if name not in self.output_names] if unknown: raise ValueError( f"Unknown outputs {unknown}. Outputs: {self.output_names}." ) indices = [self.output_names.index(name) for name in outputs] mean, median, lower, upper = self._rust.series_band( indices, cumulative, confidence_level ) return SeriesBand( confidence_level=confidence_level, positions=self.positions, mean=mean, median=median, lower=lower, upper=upper, ) def at_observations( self, observed_data: Sequence[ObservedDataPoint], noise: ObservationNoise, ) -> ObservationDraws: """ Every draw's value of each observation, with observation noise. The observations need not be the ones the problem was fitted to: held out data are read the same way, through the observation operator, and so are the observations of the model of an analysis. The noise is drawn from the calibrator's seed. Parameters ---------- observed_data : Sequence[ObservedDataPoint] The observations. noise : NoNoise | ResidualNoise | DistributionNoise What an observation shows around a draw's prediction, with no default: ``NoNoise()`` gives a band of the model, ``ResidualNoise(...)`` and ``DistributionNoise()`` a prediction interval. Both need a calibrator: residuals come from a fit, and observation models from a problem. Returns ------- ObservationDraws Each draw's value of each observation. Raises ------ ValueError If an observation names an unknown output or factor, a series the residual noise has no residuals of, or a series with no observation model; `DistributionNoise` comes with a problem that has a loss; or the noise is not `NoNoise()` and the source is an analysis. InfeasibleStateError If a draw leaves the values its model allows. The message names the parameter set, then says what the run says (the compartment, the step and the flows), or what the initial amounts exceed. """ draws = self.source.rust_observation_draws( self.sample, list(observed_data), noise ) return ObservationDraws(observed_data, draws) ``` ### Attributes #### weights ```python weights: list[float] ``` The weight of each draw of the predictive; they sum to 1. #### draw_seeds ```python draw_seeds: list[int] ``` The seed of each draw of `sample`, in its order. With a stochastic engine, replicate `i` of draw `j` runs with the seed replicate `i` of `Simulation.replicates(count, seed=draw_seeds[j])` takes, so those replicates of the model at that draw give its runs. #### plot ```python plot: PredictivePlot ``` The figures of the draws: `plot.series()` and `plot.cumulative()`, the weighted median and the band of each series. ### Methods: #### __init__ ```python __init__(source: PredictiveSource, sample: ParameterSample, positions: Sequence[float]) -> None ``` Run every draw of `sample` at `positions`. Parameters: | Name | Type | Description | Default | | ----------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `source` | `PredictiveSource` | The calibration problem the sample is a sample of (Calibrator), or the sensitivity analysis of its inputs (SensitivityAnalysis). | *required* | | `sample` | `ParameterSample` | Its parameters, in the order of the problem or the analysis. | *required* | | `positions` | `Sequence[float]` | Positions to report at, sorted and unique. | *required* | Raises: | Type | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | Before any draw runs: if the sample's parameters are not the source's, or the positions are not sorted and unique or the engine does not report at one of them (a difference engine reports only at integer steps). | | `InfeasibleStateError` | If a draw leaves the values its model allows. The message names the parameter set, then says what the run says (the compartment, the step and the flows), or what the initial amounts exceed. | Source code in `commol/api/inference/predictive.py` ```python def __init__( self, source: PredictiveSource, sample: ParameterSample, positions: Sequence[float], ) -> None: """ Run every draw of `sample` at `positions`. Parameters ---------- source : PredictiveSource The calibration problem the sample is a sample of (`Calibrator`), or the sensitivity analysis of its inputs (`SensitivityAnalysis`). sample : ParameterSample Its parameters, in the order of the problem or the analysis. positions : Sequence[float] Positions to report at, sorted and unique. Raises ------ ValueError Before any draw runs: if the sample's parameters are not the source's, or the positions are not sorted and unique or the engine does not report at one of them (a difference engine reports only at integer steps). InfeasibleStateError If a draw leaves the values its model allows. The message names the parameter set, then says what the run says (the compartment, the step and the flows), or what the initial amounts exceed. """ self.source: PredictiveSource = source self.sample: ParameterSample = sample self.positions: list[float] = [float(position) for position in positions] self.output_names: list[str] = list(source.simulation.engine.output_names) self._rust = source.rust_predictive(sample, self.positions) ``` #### runs ```python runs() -> list[SimulationResults] ``` The trajectory of every draw, in the order of `weights`. Returns: | Type | Description | | ------------------------- | ------------------------------------------------------------- | | `list[SimulationResults]` | One run per draw, at positions, with the model of the source. | Source code in `commol/api/inference/predictive.py` ```python def runs(self) -> list[SimulationResults]: """ The trajectory of every draw, in the order of `weights`. Returns ------- list[SimulationResults] One run per draw, at `positions`, with the model of the source. """ model = self.source.simulation.model_definition return [ SimulationResults.from_rows( self.positions, self.output_names, rows, model=model ) for rows in self._rust.trajectories ] ``` #### quantities ```python quantities(quantities: Sequence[QuantityBase]) -> list[dict[str, float]] ``` The quantities of interest of every draw, in the order of `weights`. Each run is reduced on its own, as `SimulationResults.quantities` reduces one, with the Rust code of `Propagation`. The predictive runs at `positions`, so they must hold `quantity_schedule(quantities)`. Parameters: | Name | Type | Description | Default | | ------------ | -------------------- | ----------------------------------------------------------- | ---------- | | `quantities` | `Sequence[Quantity]` | The quantities; a Trajectory gives one scalar per position. | *required* | Returns: | Type | Description | | ------------------------ | -------------------------------------------------------------------------------- | | `list[dict[str, float]]` | One dict per draw of the predictive, with one value per label of the quantities. | Raises: | Type | Description | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If there is no quantity, two scalars have one label, a quantity names an output the model does not have, or positions lack a position a quantity reads; the message lists every such position with its quantity. | Source code in `commol/api/inference/predictive.py` ```python def quantities( self, quantities: "Sequence[QuantityBase]" ) -> list[dict[str, float]]: """ The quantities of interest of every draw, in the order of `weights`. Each run is reduced on its own, as `SimulationResults.quantities` reduces one, with the Rust code of `Propagation`. The predictive runs at `positions`, so they must hold ``quantity_schedule(quantities)``. Parameters ---------- quantities : Sequence[Quantity] The quantities; a `Trajectory` gives one scalar per position. Returns ------- list[dict[str, float]] One dict per draw of the predictive, with one value per label of the quantities. Raises ------ ValueError If there is no quantity, two scalars have one label, a quantity names an output the model does not have, or `positions` lack a position a quantity reads; the message lists every such position with its quantity. """ return quantities_of_runs(self.runs(), self.positions, quantities) ``` #### report ```python report(confidence_level: float, positions: Sequence[float], window: float, windows: int, observed_data: Iterable[ObservedDataPoint] = (), accumulators: Iterable[str] = (), prediction_intervals: PredictionIntervals | None = None) -> SimulationReport ``` Report the runs of the draws as `SimulationResults.report` reports one. Every number becomes a `{mean, median, ci_lower, ci_upper, min, max}` block, reduced draw by draw with the weights of the draws; the layout is the one of the report of a run. Each leaf comes from the draws only, so the report takes no point estimate. The outputs of an accumulator come from the model of the source. Parameters: | Name | Type | Description | Default | | ---------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `confidence_level` | `float` | Confidence level of the reported intervals, strictly between 0 and 1. | *required* | | `positions` | `Sequence[float]` | Positions the series and cumulative accumulators are sampled at; each must be one of positions of the predictive. | *required* | | `window` | `float` | Length of one window on the axis. | *required* | | `windows` | `int` | Number of windows on the observed and windowed axes. | *required* | | `observed_data` | `Iterable[ObservedDataPoint]` | The calibration targets to report alongside the draws. | `()` | | `accumulators` | `Iterable[str]` | Accumulator ids to report. | `()` | | `prediction_intervals` | \`PredictionIntervals | None\` | Prediction intervals of a conformal method (ConformalResult.intervals), reported as they are. | Returns: | Type | Description | | ------------------ | -------------------------------------------------------------------------------------- | | `SimulationReport` | Keys observed, series and accumulators, then prediction_intervals when they are given. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------------------- | | `ValueError` | If the confidence level is out of range, or the predictive has no value at a position the report reads. | | `KeyError` | If an accumulator is not a bin or accumulator of the model. | Source code in `commol/api/inference/predictive.py` ```python def report( self, confidence_level: float, positions: Sequence[float], window: float, windows: int, observed_data: Iterable[ObservedDataPoint] = (), accumulators: Iterable[str] = (), prediction_intervals: "PredictionIntervals | None" = None, ) -> "SimulationReport": """ Report the runs of the draws as `SimulationResults.report` reports one. Every number becomes a ``{mean, median, ci_lower, ci_upper, min, max}`` block, reduced draw by draw with the weights of the draws; the layout is the one of the report of a run. Each leaf comes from the draws only, so the report takes no point estimate. The outputs of an accumulator come from the model of the source. Parameters ---------- confidence_level : float Confidence level of the reported intervals, strictly between 0 and 1. positions : Sequence[float] Positions the series and cumulative accumulators are sampled at; each must be one of `positions` of the predictive. window : float Length of one window on the axis. windows : int Number of windows on the observed and windowed axes. observed_data : Iterable[ObservedDataPoint], optional The calibration targets to report alongside the draws. accumulators : Iterable[str], optional Accumulator ids to report. prediction_intervals : PredictionIntervals | None, optional Prediction intervals of a conformal method (``ConformalResult.intervals``), reported as they are. Returns ------- SimulationReport Keys ``observed``, ``series`` and ``accumulators``, then ``prediction_intervals`` when they are given. Raises ------ ValueError If the confidence level is out of range, or the predictive has no value at a position the report reads. KeyError If an accumulator is not a bin or accumulator of the model. """ from commol.api.reporting import _ReportRuns, _simulation_report return _simulation_report( _ReportRuns( model=self.source.simulation.model_definition, runs=self.runs(), confidence_level=confidence_level, weights=self.weights, ), positions, window, windows, observed_data, accumulators, prediction_intervals, ) ``` #### band ```python band(confidence_level: float) -> PredictiveBand ``` The band of every output. Parameters: | Name | Type | Description | Default | | ------------------ | ------- | ------------------------- | ---------- | | `confidence_level` | `float` | Strictly between 0 and 1. | *required* | Returns: | Type | Description | | ---------------- | ------------------------------------------------------------------------------- | | `PredictiveBand` | The weighted mean, median and central interval of each output at each position. | Raises: | Type | Description | | ------------ | -------------------------------------------------------- | | `ValueError` | If the confidence level is not strictly between 0 and 1. | Source code in `commol/api/inference/predictive.py` ```python def band(self, confidence_level: float) -> PredictiveBand: """ The band of every output. Parameters ---------- confidence_level : float Strictly between 0 and 1. Returns ------- PredictiveBand The weighted mean, median and central interval of each output at each position. Raises ------ ValueError If the confidence level is not strictly between 0 and 1. """ mean, median, lower, upper = self._rust.band(confidence_level) names = self.output_names return PredictiveBand( confidence_level=confidence_level, positions=self.positions, mean=by_output(names, mean), median=by_output(names, median), lower=by_output(names, lower), upper=by_output(names, upper), ) ``` #### series_band ```python series_band(outputs: Sequence[str], confidence_level: float, cumulative: bool = False) -> SeriesBand ``` The band of the sum of some outputs, or of its running total. Each draw is reduced to the series first, then the series are reduced across the draws. Parameters: | Name | Type | Description | Default | | ------------------ | --------------- | ------------------------------------------------------------ | ---------- | | `outputs` | `Sequence[str]` | The outputs summed. | *required* | | `confidence_level` | `float` | Strictly between 0 and 1. | *required* | | `cumulative` | `bool` | Whether the series is the running total along the positions. | `False` | Returns: | Type | Description | | ------------ | ----------------------- | | `SeriesBand` | One value per position. | Raises: | Type | Description | | ------------ | ---------------------------------------------------------------- | | `ValueError` | If an output is unknown or the confidence level is out of range. | Source code in `commol/api/inference/predictive.py` ```python def series_band( self, outputs: Sequence[str], confidence_level: float, cumulative: bool = False, ) -> SeriesBand: """ The band of the sum of some outputs, or of its running total. Each draw is reduced to the series first, then the series are reduced across the draws. Parameters ---------- outputs : Sequence[str] The outputs summed. confidence_level : float Strictly between 0 and 1. cumulative : bool, optional Whether the series is the running total along the positions. Returns ------- SeriesBand One value per position. Raises ------ ValueError If an output is unknown or the confidence level is out of range. """ unknown = [name for name in outputs if name not in self.output_names] if unknown: raise ValueError( f"Unknown outputs {unknown}. Outputs: {self.output_names}." ) indices = [self.output_names.index(name) for name in outputs] mean, median, lower, upper = self._rust.series_band( indices, cumulative, confidence_level ) return SeriesBand( confidence_level=confidence_level, positions=self.positions, mean=mean, median=median, lower=lower, upper=upper, ) ``` #### at_observations ```python at_observations(observed_data: Sequence[ObservedDataPoint], noise: ObservationNoise) -> ObservationDraws ``` Every draw's value of each observation, with observation noise. The observations need not be the ones the problem was fitted to: held out data are read the same way, through the observation operator, and so are the observations of the model of an analysis. The noise is drawn from the calibrator's seed. Parameters: | Name | Type | Description | Default | | --------------- | ----------------------------- | ----------------- | ------------------- | | `observed_data` | `Sequence[ObservedDataPoint]` | The observations. | *required* | | `noise` | \`NoNoise | ResidualNoise | DistributionNoise\` | Returns: | Type | Description | | ------------------ | -------------------------------------- | | `ObservationDraws` | Each draw's value of each observation. | Raises: | Type | Description | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If an observation names an unknown output or factor, a series the residual noise has no residuals of, or a series with no observation model; DistributionNoise comes with a problem that has a loss; or the noise is not NoNoise() and the source is an analysis. | | `InfeasibleStateError` | If a draw leaves the values its model allows. The message names the parameter set, then says what the run says (the compartment, the step and the flows), or what the initial amounts exceed. | Source code in `commol/api/inference/predictive.py` ```python def at_observations( self, observed_data: Sequence[ObservedDataPoint], noise: ObservationNoise, ) -> ObservationDraws: """ Every draw's value of each observation, with observation noise. The observations need not be the ones the problem was fitted to: held out data are read the same way, through the observation operator, and so are the observations of the model of an analysis. The noise is drawn from the calibrator's seed. Parameters ---------- observed_data : Sequence[ObservedDataPoint] The observations. noise : NoNoise | ResidualNoise | DistributionNoise What an observation shows around a draw's prediction, with no default: ``NoNoise()`` gives a band of the model, ``ResidualNoise(...)`` and ``DistributionNoise()`` a prediction interval. Both need a calibrator: residuals come from a fit, and observation models from a problem. Returns ------- ObservationDraws Each draw's value of each observation. Raises ------ ValueError If an observation names an unknown output or factor, a series the residual noise has no residuals of, or a series with no observation model; `DistributionNoise` comes with a problem that has a loss; or the noise is not `NoNoise()` and the source is an analysis. InfeasibleStateError If a draw leaves the values its model allows. The message names the parameter set, then says what the run says (the compartment, the step and the flows), or what the initial amounts exceed. """ draws = self.source.rust_observation_draws( self.sample, list(observed_data), noise ) return ObservationDraws(observed_data, draws) ``` ## PredictiveBand Bases: `ResultModel` The band of every output of a predictive. Attributes: | Name | Type | Description | | ---------------------------- | ------------------------ | ------------------------------------------------------------------------------------- | | `confidence_level` | `float` | The level of the central interval. | | `positions` | `list[float]` | Where the values are. | | `mean, median, lower, upper` | `dict[str, list[float]]` | For each output, one value per position, reduced across the draws with their weights. | Source code in `commol/api/inference/predictive.py` ```python class PredictiveBand(ResultModel): """ The band of every output of a predictive. Attributes ---------- confidence_level : float The level of the central interval. positions : list[float] Where the values are. mean, median, lower, upper : dict[str, list[float]] For each output, one value per position, reduced across the draws with their weights. """ model_config = ConfigDict(extra="forbid", frozen=True) confidence_level: float positions: list[float] mean: dict[str, list[float]] = Field(description="Weighted mean") median: dict[str, list[float]] = Field(description="Weighted median") lower: dict[str, list[float]] = Field(description="Lower quantile") upper: dict[str, list[float]] = Field(description="Upper quantile") ``` ## SeriesBand Bases: `ResultModel` The band of one series: four values at each of its positions. Attributes: | Name | Type | Description | | ------------------ | ------------- | ----------------------------------- | | `confidence_level` | `float` | The level of the central interval. | | `positions` | `list[float]` | Where the values are. | | `mean` | `list[float]` | The weighted mean across the draws. | | `median` | `list[float]` | The weighted median. | | `lower` | `list[float]` | The quantile (1 − c)/2. | | `upper` | `list[float]` | The quantile (1 + c)/2. | Source code in `commol/api/inference/predictive.py` ```python class SeriesBand(ResultModel): """ The band of one series: four values at each of its positions. Attributes ---------- confidence_level : float The level of the central interval. positions : list[float] Where the values are. mean : list[float] The weighted mean across the draws. median : list[float] The weighted median. lower : list[float] The quantile ``(1 − c)/2``. upper : list[float] The quantile ``(1 + c)/2``. """ model_config = ConfigDict(extra="forbid", frozen=True) confidence_level: float positions: list[float] mean: list[float] median: list[float] lower: list[float] upper: list[float] ``` ## ObservationDraws Every draw's value of a set of observations. The values are read through the observation operator of the calibration problem (sums of outputs, windows, factors), the one the loss uses. Without observation noise their interval is a band of the model, not a prediction interval. Source code in `commol/api/inference/predictive.py` ```python class ObservationDraws: """ Every draw's value of a set of observations. The values are read through the observation operator of the calibration problem (sums of outputs, windows, factors), the one the loss uses. Without observation noise their interval is a band of the model, not a prediction interval. """ def __init__( self, observations: Sequence[ObservedDataPoint], draws: "ObservationDrawsProtocol", ) -> None: """ Wrap the draws of `observations`. Parameters ---------- observations : Sequence[ObservedDataPoint] The observations, in the order of each draw's values. draws : ObservationDrawsProtocol The draws, computed in Rust. """ self.observations: list[ObservedDataPoint] = list(observations) self.rust_draws: "ObservationDrawsProtocol" = draws @property def values(self) -> list[list[float]]: """Each draw's value of each observation, ``[draw][observation]``.""" return self.rust_draws.values @property def weights(self) -> list[float]: """The weight of each draw; they sum to 1.""" return self.rust_draws.weights @property def observed(self) -> list[float]: """The observed value of each observation.""" return [point.value for point in self.observations] def band(self, confidence_level: float) -> SeriesBand: """ The band of each observation across the draws. Parameters ---------- confidence_level : float Strictly between 0 and 1. Returns ------- SeriesBand One value per observation, at its position. Raises ------ ValueError If the confidence level is not strictly between 0 and 1. """ mean, median, lower, upper = self.rust_draws.band(confidence_level) return SeriesBand( confidence_level=confidence_level, positions=[point.at for point in self.observations], mean=mean, median=median, lower=lower, upper=upper, ) ``` ### Attributes #### values ```python values: list[list[float]] ``` Each draw's value of each observation, `[draw][observation]`. #### weights ```python weights: list[float] ``` The weight of each draw; they sum to 1. #### observed ```python observed: list[float] ``` The observed value of each observation. ### Methods: #### __init__ ```python __init__(observations: Sequence[ObservedDataPoint], draws: ObservationDrawsProtocol) -> None ``` Wrap the draws of `observations`. Parameters: | Name | Type | Description | Default | | -------------- | ----------------------------- | ----------------------------------------------------- | ---------- | | `observations` | `Sequence[ObservedDataPoint]` | The observations, in the order of each draw's values. | *required* | | `draws` | `ObservationDrawsProtocol` | The draws, computed in Rust. | *required* | Source code in `commol/api/inference/predictive.py` ```python def __init__( self, observations: Sequence[ObservedDataPoint], draws: "ObservationDrawsProtocol", ) -> None: """ Wrap the draws of `observations`. Parameters ---------- observations : Sequence[ObservedDataPoint] The observations, in the order of each draw's values. draws : ObservationDrawsProtocol The draws, computed in Rust. """ self.observations: list[ObservedDataPoint] = list(observations) self.rust_draws: "ObservationDrawsProtocol" = draws ``` #### band ```python band(confidence_level: float) -> SeriesBand ``` The band of each observation across the draws. Parameters: | Name | Type | Description | Default | | ------------------ | ------- | ------------------------- | ---------- | | `confidence_level` | `float` | Strictly between 0 and 1. | *required* | Returns: | Type | Description | | ------------ | ------------------------------------------- | | `SeriesBand` | One value per observation, at its position. | Raises: | Type | Description | | ------------ | -------------------------------------------------------- | | `ValueError` | If the confidence level is not strictly between 0 and 1. | Source code in `commol/api/inference/predictive.py` ```python def band(self, confidence_level: float) -> SeriesBand: """ The band of each observation across the draws. Parameters ---------- confidence_level : float Strictly between 0 and 1. Returns ------- SeriesBand One value per observation, at its position. Raises ------ ValueError If the confidence level is not strictly between 0 and 1. """ mean, median, lower, upper = self.rust_draws.band(confidence_level) return SeriesBand( confidence_level=confidence_level, positions=[point.at for point in self.observations], mean=mean, median=median, lower=lower, upper=upper, ) ``` ## Scenarios Bases: `Mapping[str, Predictive]` Every draw of a sample run under each scenario, with the draws paired. A scenario is a set of parameter values that override those of the model of the source; the source and its model stay unchanged. Each scenario holds the `Predictive` of the sample on a copy of the model with its values, built from the source with the seed of the source (`with_model`). So run `k` of every scenario is the same draw, and with a stochastic engine it runs with the same seed: common random numbers, whose noise cancels in a difference. `Scenarios` is a mapping from the name of a scenario to its `Predictive`, so `plot_compared(scenarios)` draws them on the same panels. Source code in `commol/api/inference/scenarios.py` ```python class Scenarios(Mapping[str, Predictive]): """ Every draw of a sample run under each scenario, with the draws paired. A scenario is a set of parameter values that override those of the model of the source; the source and its model stay unchanged. Each scenario holds the `Predictive` of the sample on a copy of the model with its values, built from the source with the seed of the source (`with_model`). So run ``k`` of every scenario is the same draw, and with a stochastic engine it runs with the same seed: common random numbers, whose noise cancels in a difference. `Scenarios` is a mapping from the name of a scenario to its `Predictive`, so ``plot_compared(scenarios)`` draws them on the same panels. """ def __init__( self, source: PredictiveSource, sample: ParameterSample, scenarios: Mapping[str, Mapping[str, "ParameterUpdate"]], positions: Sequence[float], ) -> None: """ Run every draw of `sample` under each scenario, at `positions`. Parameters ---------- source : PredictiveSource The calibration problem the sample is a sample of (`Calibrator`), or the sensitivity analysis of its inputs (`SensitivityAnalysis`). sample : ParameterSample Its parameters, in the order of the problem or the analysis. scenarios : Mapping[str, Mapping[str, ParameterUpdate]] Each scenario by name: the value of each parameter it overrides, any value `Model.update_parameters` takes, such as a number, a formula or the points of a series. An empty mapping keeps the model as it is. positions : Sequence[float] Positions to report at, sorted and unique. A comparison reads the runs here, so they hold ``quantity_schedule(quantities)``. Raises ------ ValueError Before any draw runs: if there is no scenario, a scenario sets a parameter of the sample or one the model does not have (the message names the scenario and the parameter), or a value does not fit the model. Then as `Predictive` raises. InfeasibleStateError If a draw leaves the values its model allows, as `Predictive` raises. """ if not scenarios: raise ValueError("Give at least one scenario.") model = source.simulation.model_definition models = {} for name, overrides in scenarios.items(): held = [parameter for parameter in overrides if parameter in sample.names] if held: raise ValueError( f"Scenario '{name}' sets {held}, which the sample holds: each " "draw sets them. A scenario overrides only parameters the " "sample does not hold." ) scenario_model = model.model_copy(deep=True) try: scenario_model.update_parameters(overrides) except ValueError as error: raise ValueError(f"Scenario '{name}': {error}") from error models[name] = scenario_model self.sample: ParameterSample = sample self.positions: list[float] = [float(position) for position in positions] self._predictives: dict[str, Predictive] = { name: Predictive(source.with_model(scenario_model), sample, positions) for name, scenario_model in models.items() } def __getitem__(self, name: str) -> Predictive: """ The predictive of one scenario. Parameters ---------- name : str The name of the scenario. Returns ------- Predictive The runs of every draw under the scenario. Raises ------ KeyError If there is no scenario of that name; the message lists the names. """ if name not in self._predictives: raise KeyError( f"No scenario '{name}'. Scenarios: {list(self._predictives)}." ) return self._predictives[name] def __iter__(self) -> Iterator[str]: """The names of the scenarios, in the order they were given.""" return iter(self._predictives) def __len__(self) -> int: """The number of scenarios.""" return len(self._predictives) @property def weights(self) -> list[float]: """The weight of each run, the same in every scenario; they sum to 1.""" return next(iter(self._predictives.values())).weights def summary( self, name: str, quantities: "Sequence[QuantityBase]", confidence_level: float, ) -> list[ScenarioSummary]: """ The weighted distribution of each quantity across the runs of one scenario. Parameters ---------- name : str The scenario. quantities : Sequence[Quantity] The quantities; a `Trajectory` gives one scalar per position. confidence_level : float Strictly between 0 and 1. Returns ------- list[ScenarioSummary] One summary per label of the quantities, in their order. With one run, the value of that run and no interval. Raises ------ KeyError If there is no scenario of that name. ValueError If the confidence level is not strictly between 0 and 1, a quantity does not fit the runs (as `Predictive.quantities` raises), or it is NaN in a run. """ return _summaries( self[name].quantities(quantities), self.weights, confidence_level ) def compare( self, name: str, baseline: str, quantities: "Sequence[QuantityBase]", confidence_level: float, ) -> list[ScenarioSummary]: """ The weighted distribution of the difference of each quantity between two scenarios, taken run by run. The difference ``name − baseline`` is taken within each run, where both scenarios have the same draw and seed, and then reduced across the runs with their weights. So its interval is the interval of the paired differences, not the difference of two intervals. Parameters ---------- name : str The scenario compared. baseline : str The scenario subtracted. quantities : Sequence[Quantity] The quantities; a `Trajectory` gives one scalar per position, each compared at its position. confidence_level : float Strictly between 0 and 1. Returns ------- list[ScenarioSummary] One summary per label of the quantities, in their order. With one run, the difference of that run and no interval. Raises ------ KeyError If there is no scenario of either name. ValueError If the confidence level is not strictly between 0 and 1, a quantity does not fit the runs (as `Predictive.quantities` raises), or it is NaN in a run of either scenario. """ compared = self[name].quantities(quantities) subtracted = self[baseline].quantities(quantities) differences = [ {label: value - other[label] for label, value in run.items()} for run, other in zip(compared, subtracted, strict=True) ] return _summaries(differences, self.weights, confidence_level) ``` ### Attributes #### weights ```python weights: list[float] ``` The weight of each run, the same in every scenario; they sum to 1. ### Methods: #### __init__ ```python __init__(source: PredictiveSource, sample: ParameterSample, scenarios: Mapping[str, Mapping[str, ParameterUpdate]], positions: Sequence[float]) -> None ``` Run every draw of `sample` under each scenario, at `positions`. Parameters: | Name | Type | Description | Default | | ----------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `source` | `PredictiveSource` | The calibration problem the sample is a sample of (Calibrator), or the sensitivity analysis of its inputs (SensitivityAnalysis). | *required* | | `sample` | `ParameterSample` | Its parameters, in the order of the problem or the analysis. | *required* | | `scenarios` | `Mapping[str, Mapping[str, ParameterUpdate]]` | Each scenario by name: the value of each parameter it overrides, any value Model.update_parameters takes, such as a number, a formula or the points of a series. An empty mapping keeps the model as it is. | *required* | | `positions` | `Sequence[float]` | Positions to report at, sorted and unique. A comparison reads the runs here, so they hold quantity_schedule(quantities). | *required* | Raises: | Type | Description | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | Before any draw runs: if there is no scenario, a scenario sets a parameter of the sample or one the model does not have (the message names the scenario and the parameter), or a value does not fit the model. Then as Predictive raises. | | `InfeasibleStateError` | If a draw leaves the values its model allows, as Predictive raises. | Source code in `commol/api/inference/scenarios.py` ```python def __init__( self, source: PredictiveSource, sample: ParameterSample, scenarios: Mapping[str, Mapping[str, "ParameterUpdate"]], positions: Sequence[float], ) -> None: """ Run every draw of `sample` under each scenario, at `positions`. Parameters ---------- source : PredictiveSource The calibration problem the sample is a sample of (`Calibrator`), or the sensitivity analysis of its inputs (`SensitivityAnalysis`). sample : ParameterSample Its parameters, in the order of the problem or the analysis. scenarios : Mapping[str, Mapping[str, ParameterUpdate]] Each scenario by name: the value of each parameter it overrides, any value `Model.update_parameters` takes, such as a number, a formula or the points of a series. An empty mapping keeps the model as it is. positions : Sequence[float] Positions to report at, sorted and unique. A comparison reads the runs here, so they hold ``quantity_schedule(quantities)``. Raises ------ ValueError Before any draw runs: if there is no scenario, a scenario sets a parameter of the sample or one the model does not have (the message names the scenario and the parameter), or a value does not fit the model. Then as `Predictive` raises. InfeasibleStateError If a draw leaves the values its model allows, as `Predictive` raises. """ if not scenarios: raise ValueError("Give at least one scenario.") model = source.simulation.model_definition models = {} for name, overrides in scenarios.items(): held = [parameter for parameter in overrides if parameter in sample.names] if held: raise ValueError( f"Scenario '{name}' sets {held}, which the sample holds: each " "draw sets them. A scenario overrides only parameters the " "sample does not hold." ) scenario_model = model.model_copy(deep=True) try: scenario_model.update_parameters(overrides) except ValueError as error: raise ValueError(f"Scenario '{name}': {error}") from error models[name] = scenario_model self.sample: ParameterSample = sample self.positions: list[float] = [float(position) for position in positions] self._predictives: dict[str, Predictive] = { name: Predictive(source.with_model(scenario_model), sample, positions) for name, scenario_model in models.items() } ``` #### __getitem__ ```python __getitem__(name: str) -> Predictive ``` The predictive of one scenario. Parameters: | Name | Type | Description | Default | | ------ | ----- | ------------------------- | ---------- | | `name` | `str` | The name of the scenario. | *required* | Returns: | Type | Description | | ------------ | ------------------------------------------ | | `Predictive` | The runs of every draw under the scenario. | Raises: | Type | Description | | ---------- | ------------------------------------------------------------------ | | `KeyError` | If there is no scenario of that name; the message lists the names. | Source code in `commol/api/inference/scenarios.py` ```python def __getitem__(self, name: str) -> Predictive: """ The predictive of one scenario. Parameters ---------- name : str The name of the scenario. Returns ------- Predictive The runs of every draw under the scenario. Raises ------ KeyError If there is no scenario of that name; the message lists the names. """ if name not in self._predictives: raise KeyError( f"No scenario '{name}'. Scenarios: {list(self._predictives)}." ) return self._predictives[name] ``` #### __iter__ ```python __iter__() -> Iterator[str] ``` The names of the scenarios, in the order they were given. Source code in `commol/api/inference/scenarios.py` ```python def __iter__(self) -> Iterator[str]: """The names of the scenarios, in the order they were given.""" return iter(self._predictives) ``` #### __len__ ```python __len__() -> int ``` The number of scenarios. Source code in `commol/api/inference/scenarios.py` ```python def __len__(self) -> int: """The number of scenarios.""" return len(self._predictives) ``` #### summary ```python summary(name: str, quantities: Sequence[QuantityBase], confidence_level: float) -> list[ScenarioSummary] ``` The weighted distribution of each quantity across the runs of one scenario. Parameters: | Name | Type | Description | Default | | ------------------ | -------------------- | ----------------------------------------------------------- | ---------- | | `name` | `str` | The scenario. | *required* | | `quantities` | `Sequence[Quantity]` | The quantities; a Trajectory gives one scalar per position. | *required* | | `confidence_level` | `float` | Strictly between 0 and 1. | *required* | Returns: | Type | Description | | ----------------------- | ------------------------------------------------------------------------------------------------------------- | | `list[ScenarioSummary]` | One summary per label of the quantities, in their order. With one run, the value of that run and no interval. | Raises: | Type | Description | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `KeyError` | If there is no scenario of that name. | | `ValueError` | If the confidence level is not strictly between 0 and 1, a quantity does not fit the runs (as Predictive.quantities raises), or it is NaN in a run. | Source code in `commol/api/inference/scenarios.py` ```python def summary( self, name: str, quantities: "Sequence[QuantityBase]", confidence_level: float, ) -> list[ScenarioSummary]: """ The weighted distribution of each quantity across the runs of one scenario. Parameters ---------- name : str The scenario. quantities : Sequence[Quantity] The quantities; a `Trajectory` gives one scalar per position. confidence_level : float Strictly between 0 and 1. Returns ------- list[ScenarioSummary] One summary per label of the quantities, in their order. With one run, the value of that run and no interval. Raises ------ KeyError If there is no scenario of that name. ValueError If the confidence level is not strictly between 0 and 1, a quantity does not fit the runs (as `Predictive.quantities` raises), or it is NaN in a run. """ return _summaries( self[name].quantities(quantities), self.weights, confidence_level ) ``` #### compare ```python compare(name: str, baseline: str, quantities: Sequence[QuantityBase], confidence_level: float) -> list[ScenarioSummary] ``` The weighted distribution of the difference of each quantity between two scenarios, taken run by run. The difference `name − baseline` is taken within each run, where both scenarios have the same draw and seed, and then reduced across the runs with their weights. So its interval is the interval of the paired differences, not the difference of two intervals. Parameters: | Name | Type | Description | Default | | ------------------ | -------------------- | ------------------------------------------------------------------------------------------ | ---------- | | `name` | `str` | The scenario compared. | *required* | | `baseline` | `str` | The scenario subtracted. | *required* | | `quantities` | `Sequence[Quantity]` | The quantities; a Trajectory gives one scalar per position, each compared at its position. | *required* | | `confidence_level` | `float` | Strictly between 0 and 1. | *required* | Returns: | Type | Description | | ----------------------- | ------------------------------------------------------------------------------------------------------------------ | | `list[ScenarioSummary]` | One summary per label of the quantities, in their order. With one run, the difference of that run and no interval. | Raises: | Type | Description | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `KeyError` | If there is no scenario of either name. | | `ValueError` | If the confidence level is not strictly between 0 and 1, a quantity does not fit the runs (as Predictive.quantities raises), or it is NaN in a run of either scenario. | Source code in `commol/api/inference/scenarios.py` ```python def compare( self, name: str, baseline: str, quantities: "Sequence[QuantityBase]", confidence_level: float, ) -> list[ScenarioSummary]: """ The weighted distribution of the difference of each quantity between two scenarios, taken run by run. The difference ``name − baseline`` is taken within each run, where both scenarios have the same draw and seed, and then reduced across the runs with their weights. So its interval is the interval of the paired differences, not the difference of two intervals. Parameters ---------- name : str The scenario compared. baseline : str The scenario subtracted. quantities : Sequence[Quantity] The quantities; a `Trajectory` gives one scalar per position, each compared at its position. confidence_level : float Strictly between 0 and 1. Returns ------- list[ScenarioSummary] One summary per label of the quantities, in their order. With one run, the difference of that run and no interval. Raises ------ KeyError If there is no scenario of either name. ValueError If the confidence level is not strictly between 0 and 1, a quantity does not fit the runs (as `Predictive.quantities` raises), or it is NaN in a run of either scenario. """ compared = self[name].quantities(quantities) subtracted = self[baseline].quantities(quantities) differences = [ {label: value - other[label] for label, value in run.items()} for run, other in zip(compared, subtracted, strict=True) ] return _summaries(differences, self.weights, confidence_level) ``` ## ScenarioSummary Bases: `ResultModel` The weighted distribution of one quantity across the runs of a scenario, or of its difference between two scenarios. Attributes: | Name | Type | Description | | ------------------ | ------- | --------------------------------------------- | | `label` | `str` | The quantity, as expand_quantities labels it. | | `confidence_level` | `float` | The level of the central interval. | | `mean` | `float` | The weighted mean. | | `median` | `float` | The weighted median. | | `lower` | \`float | None\` | | `upper` | \`float | None\` | Source code in `commol/api/inference/scenarios.py` ```python class ScenarioSummary(ResultModel): """ The weighted distribution of one quantity across the runs of a scenario, or of its difference between two scenarios. Attributes ---------- label : str The quantity, as `expand_quantities` labels it. confidence_level : float The level of the central interval. mean : float The weighted mean. median : float The weighted median. lower : float | None The quantile ``(1 − c)/2``; None when there is one run. upper : float | None The quantile ``(1 + c)/2``; None when there is one run. """ model_config = ConfigDict(extra="forbid", frozen=True) label: str confidence_level: float mean: float median: float lower: float | None upper: float | None ``` ## NoNoise Bases: `BaseModel` No observation noise: an observation shows the prediction of each draw. The band of a predictive with `NoNoise` is a band of the model. It says where the model curve lies, not where the next observation falls, so its coverage of data is below its level. Source code in `commol/context/inference/noise.py` ```python class NoNoise(BaseModel): """ No observation noise: an observation shows the prediction of each draw. The band of a predictive with `NoNoise` is a band of the model. It says where the model curve lies, not where the next observation falls, so its coverage of data is below its level. """ model_config = ConfigDict(extra="forbid", frozen=True) ``` ## ResidualNoise Bases: `RangeChecked` Observation noise made of the residuals of a fit, with no noise model. Each draw adds moving blocks of `block_length` positions of residuals of the fit, drawn with replacement across every series at once, to its predictions, on `residual_scale`. The band is then a prediction interval. It serves the series the fit has residuals of, at any position: the residual of a series at a drawn position is the one at the nearest position where the series was observed. Attributes: | Name | Type | Description | | ---------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fit` | `CalibrationResult` | The fit whose residuals are added. | | `block_length` | `int` | Positions per block, at least 1 and at most the number of distinct positions of the problem's observations. A bootstrap reports the length it used in its diagnostics. | | `residual_scale` | `{additive, multiplicative}` | "additive" adds r = y − ŷ; "multiplicative" multiplies by exp(r) with r = log(y / ŷ) and needs positive observed and fitted values. | Source code in `commol/context/inference/noise.py` ```python class ResidualNoise(RangeChecked): """ Observation noise made of the residuals of a fit, with no noise model. Each draw adds moving blocks of `block_length` positions of residuals of the fit, drawn with replacement across every series at once, to its predictions, on `residual_scale`. The band is then a prediction interval. It serves the series the fit has residuals of, at any position: the residual of a series at a drawn position is the one at the nearest position where the series was observed. Attributes ---------- fit : CalibrationResult The fit whose residuals are added. block_length : int Positions per block, at least 1 and at most the number of distinct positions of the problem's observations. A bootstrap reports the length it used in its diagnostics. residual_scale : {"additive", "multiplicative"} ``"additive"`` adds ``r = y − ŷ``; ``"multiplicative"`` multiplies by ``exp(r)`` with ``r = log(y / ŷ)`` and needs positive observed and fitted values. """ model_config = ConfigDict(extra="forbid", frozen=True) fit: CalibrationResult = Field(description="The fit whose residuals are added.") block_length: int = Field(description="Positions per block.") residual_scale: Literal["additive", "multiplicative"] = Field( description="The scale of the residuals." ) ``` ## DistributionNoise Bases: `BaseModel` Observation noise of the problem's observation models. Each draw draws every observation from the model of its series around the draw's prediction, with the draw's noise parameters, so the band is a prediction interval of the posterior or bootstrap predictive. It needs a problem with `observation_models`, and serves the series they name. A prediction outside the means a model allows, such as a negative count, draws at the nearest allowed one. Source code in `commol/context/inference/noise.py` ```python class DistributionNoise(BaseModel): """ Observation noise of the problem's observation models. Each draw draws every observation from the model of its series around the draw's prediction, with the draw's noise parameters, so the band is a prediction interval of the posterior or bootstrap predictive. It needs a problem with ``observation_models``, and serves the series they name. A prediction outside the means a model allows, such as a negative count, draws at the nearest allowed one. """ model_config = ConfigDict(extra="forbid", frozen=True) ``` ## Resampling Guide: [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/index.md). ## BayesianBootstrap Bases: `UncertaintyMethod[BootstrapResult]`, `RangeChecked` The Bayesian (weighted) bootstrap by blocks. Each replicate draws one Dirichlet(1, …, 1) weight per block, scaled to mean 1, multiplies the weight of every observation of the block by it, and fits. With a loss it is the loss-likelihood bootstrap of Lyddon, Holmes and Walker (2019); with one position per block, the Bayesian bootstrap of Rubin (1981) and the weighted likelihood bootstrap of Newton and Raftery (1994). The observed values never change. Attributes: | Name | Type | Description | | -------------- | ----- | ------------------------------------------------------------------------------------------------------------- | | `replicates` | `int` | Number of replicates, at least 1. | | `block_length` | \`int | auto\` | | `starts` | `int` | Starts of the fit and of each replicate (default 1): the fit, then rows of a Latin hypercube over the bounds. | Source code in `commol/context/inference/resampling.py` ```python class BayesianBootstrap(UncertaintyMethod[BootstrapResult], RangeChecked): """ The Bayesian (weighted) bootstrap by blocks. Each replicate draws one Dirichlet(1, …, 1) weight per block, scaled to mean 1, multiplies the weight of every observation of the block by it, and fits. With a loss it is the loss-likelihood bootstrap of Lyddon, Holmes and Walker (2019); with one position per block, the Bayesian bootstrap of Rubin (1981) and the weighted likelihood bootstrap of Newton and Raftery (1994). The observed values never change. Attributes ---------- replicates : int Number of replicates, at least 1. block_length : int | "auto" Positions per block. starts : int Starts of the fit and of each replicate (default 1): the fit, then rows of a Latin hypercube over the bounds. """ replicates: int = Field(description="Number of replicates.") block_length: BlockLength = Field(description="Positions per block.") starts: int = Field(default=1, description="Starts per fit.") ``` ## ResidualBootstrap Bases: `UncertaintyMethod[BootstrapResult]`, `RangeChecked` The residual block bootstrap. Each replicate joins blocks of residuals of the fit, drawn with replacement across every series at once, adds them to the fitted values on `residual_scale`, and fits the synthetic data. Attributes: | Name | Type | Description | | ---------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `replicates` | `int` | Number of replicates, at least 1. | | `block_length` | \`int | auto\` | | `residual_scale` | `{additive, multiplicative}` | "additive": r = y − ŷ and ŷ + r\*. "multiplicative": r = log(y / ŷ) and ŷ · exp(r\*); it needs positive observed and fitted values and keeps synthetic values positive. | | `scheme` | `{moving, stationary}` | "moving": blocks of block_length consecutive positions (Künsch 1989). "stationary": blocks of geometric length with mean block_length, wrapped around the end (Politis and Romano 1994). | | `starts` | `int` | Starts of the fit and of each replicate (default 1). | Source code in `commol/context/inference/resampling.py` ```python class ResidualBootstrap(UncertaintyMethod[BootstrapResult], RangeChecked): """ The residual block bootstrap. Each replicate joins blocks of residuals of the fit, drawn with replacement across every series at once, adds them to the fitted values on `residual_scale`, and fits the synthetic data. Attributes ---------- replicates : int Number of replicates, at least 1. block_length : int | "auto" Positions per block; with ``scheme="stationary"`` the mean length. residual_scale : {"additive", "multiplicative"} ``"additive"``: ``r = y − ŷ`` and ``ŷ + r*``. ``"multiplicative"``: ``r = log(y / ŷ)`` and ``ŷ · exp(r*)``; it needs positive observed and fitted values and keeps synthetic values positive. scheme : {"moving", "stationary"} ``"moving"``: blocks of `block_length` consecutive positions (Künsch 1989). ``"stationary"``: blocks of geometric length with mean `block_length`, wrapped around the end (Politis and Romano 1994). starts : int Starts of the fit and of each replicate (default 1). """ replicates: int = Field(description="Number of replicates.") block_length: BlockLength = Field(description="Positions per block.") residual_scale: Literal["additive", "multiplicative"] = Field( description="The scale of the residuals." ) scheme: Literal["moving", "stationary"] = Field( description="How blocks of residuals are drawn." ) starts: int = Field(default=1, description="Starts per fit.") ``` ## WildBootstrap Bases: `UncertaintyMethod[BootstrapResult]`, `RangeChecked` The wild block bootstrap. Each replicate multiplies the additive residuals of each block by one draw of `multipliers` and fits `ŷ + r · v`. Every residual stays at its own position, so it holds under noise whose size changes along the series. Attributes: | Name | Type | Description | | -------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `replicates` | `int` | Number of replicates, at least 1. | | `block_length` | \`int | auto\` | | `multipliers` | `{rademacher, mammen}` | "rademacher": ±1 with probability 1/2. "mammen": two values with mean 0, variance 1 and third moment 1 (Mammen 1993), which keeps the skewness of the residuals. | | `starts` | `int` | Starts of the fit and of each replicate (default 1). | Source code in `commol/context/inference/resampling.py` ```python class WildBootstrap(UncertaintyMethod[BootstrapResult], RangeChecked): """ The wild block bootstrap. Each replicate multiplies the additive residuals of each block by one draw of `multipliers` and fits ``ŷ + r · v``. Every residual stays at its own position, so it holds under noise whose size changes along the series. Attributes ---------- replicates : int Number of replicates, at least 1. block_length : int | "auto" Positions per block. multipliers : {"rademacher", "mammen"} ``"rademacher"``: ±1 with probability 1/2. ``"mammen"``: two values with mean 0, variance 1 and third moment 1 (Mammen 1993), which keeps the skewness of the residuals. starts : int Starts of the fit and of each replicate (default 1). """ replicates: int = Field(description="Number of replicates.") block_length: BlockLength = Field(description="Positions per block.") multipliers: Literal["rademacher", "mammen"] = Field( description="The distribution of the multipliers." ) starts: int = Field(default=1, description="Starts per fit.") ``` ## BlockJackknife Bases: `UncertaintyMethod[JackknifeResult]`, `RangeChecked` The block jackknife (Künsch 1989): one fit per block left out. It gives a standard error and a bias of each parameter, and the influence of each block, with as many fits as blocks. A block is left out by giving its observations weight 0. Attributes: | Name | Type | Description | | -------------- | ----- | -------------------------------------------------------- | | `block_length` | \`int | auto\` | | `starts` | `int` | Starts of the fit and of each leave-out fit (default 1). | Source code in `commol/context/inference/resampling.py` ```python class BlockJackknife(UncertaintyMethod[JackknifeResult], RangeChecked): """ The block jackknife (Künsch 1989): one fit per block left out. It gives a standard error and a bias of each parameter, and the influence of each block, with as many fits as blocks. A block is left out by giving its observations weight 0. Attributes ---------- block_length : int | "auto" Positions per block; it must leave at least two blocks. starts : int Starts of the fit and of each leave-out fit (default 1). """ block_length: BlockLength = Field(description="Positions per block.") starts: int = Field(default=1, description="Starts per fit.") ``` ## ParametricBootstrap Bases: `UncertaintyMethod[BootstrapResult]`, `RangeChecked` The parametric bootstrap of the problem's observation models. Each replicate draws every observed value from the model of its series at the fit, around the fitted prediction and with the fitted noise parameters, and fits the new data. It needs a problem with `observation_models`; the observations of a replicate are independent given the fit, so it assumes the models hold. Attributes: | Name | Type | Description | | ------------ | ----- | ------------------------------------------------------------------------------------------------------------- | | `replicates` | `int` | Number of replicates, at least 1. | | `starts` | `int` | Starts of the fit and of each replicate (default 1): the fit, then rows of a Latin hypercube over the bounds. | Source code in `commol/context/inference/resampling.py` ```python class ParametricBootstrap(UncertaintyMethod[BootstrapResult], RangeChecked): """ The parametric bootstrap of the problem's observation models. Each replicate draws every observed value from the model of its series at the fit, around the fitted prediction and with the fitted noise parameters, and fits the new data. It needs a problem with ``observation_models``; the observations of a replicate are independent given the fit, so it assumes the models hold. Attributes ---------- replicates : int Number of replicates, at least 1. starts : int Starts of the fit and of each replicate (default 1): the fit, then rows of a Latin hypercube over the bounds. """ replicates: int = Field(description="Number of replicates.") starts: int = Field(default=1, description="Starts per fit.") ``` ## BootstrapResult Bases: `ResultModel` The result of a bootstrap. Attributes: | Name | Type | Description | | --------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `fit` | `CalibrationResult` | The fit every replicate starts from: Calibrator.run(starts). | | `sample` | `ParameterSample` | One draw per replicate that did not fail, with equal weights; the group of a draw is its replicate. | | `failed` | `list[str]` | Each replicate that failed: "Replicate failed: ", also logged as a warning. A failed replicate is not in the sample. | | `not_converged` | `int` | Replicates kept although their optimizer did not converge. | | `diagnostics` | `ResamplingDiagnostics` | The block length, and the synthetic values below zero. | Source code in `commol/context/inference/resampling.py` ```python class BootstrapResult(ResultModel): """ The result of a bootstrap. Attributes ---------- fit : CalibrationResult The fit every replicate starts from: ``Calibrator.run(starts)``. sample : ParameterSample One draw per replicate that did not fail, with equal weights; the group of a draw is its replicate. failed : list[str] Each replicate that failed: ``"Replicate failed: "``, also logged as a warning. A failed replicate is not in the sample. not_converged : int Replicates kept although their optimizer did not converge. diagnostics : ResamplingDiagnostics The block length, and the synthetic values below zero. """ model_config = ConfigDict(extra="forbid", frozen=True) fit: CalibrationResult sample: ParameterSample failed: list[str] = Field(description="Each replicate that failed.") not_converged: int = Field(description="Replicates that did not converge.") diagnostics: ResamplingDiagnostics def interval_error(self, confidence_level: float) -> dict[str, BoundError]: """ The Monte Carlo error of the central interval of each parameter. More replicates make it smaller; when it is wide against the interval, run more. Parameters ---------- confidence_level : float Strictly between 0 and 1. Returns ------- dict[str, BoundError] The error of each bound, by parameter. Raises ------ ValueError If the confidence level is not strictly between 0 and 1. """ low, high = commol_rs.inference.interval_levels(confidence_level) errors: dict[str, BoundError] = {} for index, name in enumerate(self.sample.names): column = [row[index] for row in self.sample.values] errors[name] = BoundError( lower=commol_rs.inference.quantile_error(column, low), upper=commol_rs.inference.quantile_error(column, high), ) return errors ``` ### Methods: #### interval_error ```python interval_error(confidence_level: float) -> dict[str, BoundError] ``` The Monte Carlo error of the central interval of each parameter. More replicates make it smaller; when it is wide against the interval, run more. Parameters: | Name | Type | Description | Default | | ------------------ | ------- | ------------------------- | ---------- | | `confidence_level` | `float` | Strictly between 0 and 1. | *required* | Returns: | Type | Description | | ----------------------- | -------------------------------------- | | `dict[str, BoundError]` | The error of each bound, by parameter. | Raises: | Type | Description | | ------------ | -------------------------------------------------------- | | `ValueError` | If the confidence level is not strictly between 0 and 1. | Source code in `commol/context/inference/resampling.py` ```python def interval_error(self, confidence_level: float) -> dict[str, BoundError]: """ The Monte Carlo error of the central interval of each parameter. More replicates make it smaller; when it is wide against the interval, run more. Parameters ---------- confidence_level : float Strictly between 0 and 1. Returns ------- dict[str, BoundError] The error of each bound, by parameter. Raises ------ ValueError If the confidence level is not strictly between 0 and 1. """ low, high = commol_rs.inference.interval_levels(confidence_level) errors: dict[str, BoundError] = {} for index, name in enumerate(self.sample.names): column = [row[index] for row in self.sample.values] errors[name] = BoundError( lower=commol_rs.inference.quantile_error(column, low), upper=commol_rs.inference.quantile_error(column, high), ) return errors ``` ## JackknifeResult Bases: `ResultModel` The result of a block jackknife. With `B` fits `θ̂₍₋ᵦ₎` that leave one block out, around the fit `θ̂`, and their mean `θ̄`: the bias is `(B − 1)(θ̄ − θ̂)`, the standard error `√((B − 1)/B · Σ (θ̂₍₋ᵦ₎ − θ̄)²)`, and the pseudo-value of block `b` is `B θ̂ − (B − 1) θ̂₍₋ᵦ₎`. The pseudo-values are not draws: their percentiles are not an interval. Attributes: | Name | Type | Description | | ---------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fit` | `CalibrationResult` | The fit with every observation. | | `estimate` | `dict[str, float]` | The bias-corrected estimate of each parameter. | | `bias` | `dict[str, float]` | The jackknife bias of each parameter. | | `standard_error` | `dict[str, float]` | The jackknife standard error of each parameter. | | `blocks` | `list[tuple[float, float]]` | The first and last position of each block that was left out and fitted, in order. | | `pseudo_values` | `dict[str, list[float]]` | The pseudo-values of each parameter, one per block. | | `influence` | `dict[str, list[float]]` | How far leaving each block out moves each parameter, in standard errors: (θ̂₍₋ᵦ₎ − θ̂) / se, one per block. NaN or infinite when a standard error is 0. | | `failed` | `list[str]` | Each block whose fit failed: "Block failed: ", with blocks numbered from 0 in order of position; also logged as a warning. A block that failed is not in blocks. | | `not_converged` | `int` | Fits kept although their optimizer did not converge. | | `diagnostics` | `ResamplingDiagnostics` | The block length. | Source code in `commol/context/inference/resampling.py` ```python class JackknifeResult(ResultModel): """ The result of a block jackknife. With ``B`` fits ``θ̂₍₋ᵦ₎`` that leave one block out, around the fit ``θ̂``, and their mean ``θ̄``: the bias is ``(B − 1)(θ̄ − θ̂)``, the standard error ``√((B − 1)/B · Σ (θ̂₍₋ᵦ₎ − θ̄)²)``, and the pseudo-value of block ``b`` is ``B θ̂ − (B − 1) θ̂₍₋ᵦ₎``. The pseudo-values are not draws: their percentiles are not an interval. Attributes ---------- fit : CalibrationResult The fit with every observation. estimate : dict[str, float] The bias-corrected estimate of each parameter. bias : dict[str, float] The jackknife bias of each parameter. standard_error : dict[str, float] The jackknife standard error of each parameter. blocks : list[tuple[float, float]] The first and last position of each block that was left out and fitted, in order. pseudo_values : dict[str, list[float]] The pseudo-values of each parameter, one per block. influence : dict[str, list[float]] How far leaving each block out moves each parameter, in standard errors: ``(θ̂₍₋ᵦ₎ − θ̂) / se``, one per block. NaN or infinite when a standard error is 0. failed : list[str] Each block whose fit failed: ``"Block failed: "``, with blocks numbered from 0 in order of position; also logged as a warning. A block that failed is not in `blocks`. not_converged : int Fits kept although their optimizer did not converge. diagnostics : ResamplingDiagnostics The block length. """ model_config = ConfigDict(extra="forbid", frozen=True, ser_json_inf_nan="constants") fit: CalibrationResult estimate: dict[str, float] bias: dict[str, float] standard_error: dict[str, float] blocks: list[tuple[float, float]] pseudo_values: dict[str, list[float]] influence: dict[str, list[float]] failed: list[str] = Field(description="Each block whose fit failed.") not_converged: int = Field(description="Fits that did not converge.") diagnostics: ResamplingDiagnostics ``` ## ResamplingDiagnostics Bases: `ResultModel` The blocks of a resampling method, and the synthetic values below zero. Attributes: | Name | Type | Description | | ------------------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------- | | `block_length` | \`int | None\` | | `block_length_estimates` | \`dict[str, float] | None\` | | `negative_values` | `int` | Synthetic observed values below zero, over every replicate. They are kept; many of them point to residual_scale="multiplicative". | Source code in `commol/context/inference/resampling.py` ```python class ResamplingDiagnostics(ResultModel): """ The blocks of a resampling method, and the synthetic values below zero. Attributes ---------- block_length : int | None The block length the method ran with; None for the parametric bootstrap, which draws no blocks. block_length_estimates : dict[str, float] | None With ``block_length="auto"``, the estimate of each series, by label; otherwise None. negative_values : int Synthetic observed values below zero, over every replicate. They are kept; many of them point to ``residual_scale="multiplicative"``. """ model_config = ConfigDict(extra="forbid", frozen=True) block_length: int | None = Field(description="The block length used.") block_length_estimates: dict[str, float] | None = Field( description="The estimate of each series with the automatic length." ) negative_values: int = Field(description="Synthetic values below zero.") ``` ## BoundError Bases: `ResultModel` The Monte Carlo error of the bounds of an interval: for each bound, an order-statistic interval that holds the bound of infinitely many replicates with about 95 % probability. Attributes: | Name | Type | Description | | ------- | --------------------- | -------------------------------------- | | `lower` | `tuple[float, float]` | The error interval of the lower bound. | | `upper` | `tuple[float, float]` | The error interval of the upper bound. | Source code in `commol/context/inference/resampling.py` ```python class BoundError(ResultModel): """ The Monte Carlo error of the bounds of an interval: for each bound, an order-statistic interval that holds the bound of infinitely many replicates with about 95 % probability. Attributes ---------- lower : tuple[float, float] The error interval of the lower bound. upper : tuple[float, float] The error interval of the upper bound. """ model_config = ConfigDict(extra="forbid", frozen=True) lower: tuple[float, float] upper: tuple[float, float] ``` ## Profile likelihood Guide: [Profile Likelihood](https://munqu.github.io/commol/latest/guide/uncertainty/profiles/index.md). ## ProfileLikelihood Bases: `UncertaintyMethod['ProfileResult']`, `RangeChecked` The profile likelihood of parameters (Raue et al. 2009). Each parameter is held at values that step away from the fit, in both directions, and the others are fitted again with the problem's own optimizer. The step grows while the profile rises slowly and shrinks when it jumps, and each point starts from a line through the two points before it. A side ends past the threshold, at a bound, or after `max_steps` points. Parameters run in parallel. Attributes: | Name | Type | Description | | ------------ | ----------- | --------------------------------------------------------------------------------------- | | `parameters` | `list[str]` | The parameters to profile, at least one. | | `threshold` | `Threshold` | The threshold of the statistic. | | `max_steps` | `int` | The most points on each side of a profile (default 100). | | `starts` | `int` | Starts of the fit (default 1): the fit, then rows of a Latin hypercube over the bounds. | Source code in `commol/context/inference/profiles.py` ```python class ProfileLikelihood(UncertaintyMethod["ProfileResult"], RangeChecked): """ The profile likelihood of parameters (Raue et al. 2009). Each parameter is held at values that step away from the fit, in both directions, and the others are fitted again with the problem's own optimizer. The step grows while the profile rises slowly and shrinks when it jumps, and each point starts from a line through the two points before it. A side ends past the threshold, at a bound, or after `max_steps` points. Parameters run in parallel. Attributes ---------- parameters : list[str] The parameters to profile, at least one. threshold : Threshold The threshold of the statistic. max_steps : int The most points on each side of a profile (default 100). starts : int Starts of the fit (default 1): the fit, then rows of a Latin hypercube over the bounds. """ parameters: list[str] = Field(min_length=1) threshold: Threshold max_steps: int = Field( default=100, ) starts: int = Field( default=1, ) @model_validator(mode="after") def check_unique(self) -> Self: """Each parameter is profiled once.""" if len(set(self.parameters)) != len(self.parameters): raise ValueError(f"A parameter is listed twice: {self.parameters}.") return self ``` ### Methods: #### check_unique ```python check_unique() -> Self ``` Each parameter is profiled once. Source code in `commol/context/inference/profiles.py` ```python @model_validator(mode="after") def check_unique(self) -> Self: """Each parameter is profiled once.""" if len(set(self.parameters)) != len(self.parameters): raise ValueError(f"A parameter is listed twice: {self.parameters}.") return self ``` ## PredictionProfile Bases: `UncertaintyMethod['ProfileResult']`, `RangeChecked` The profile likelihood of predictions (Kreutz et al. 2012). Each prediction is held at values that step away from its value at the fit, as an equality constraint met by an augmented Lagrangian whose inner solver is the problem's own optimizer, and the parameters are fitted again. The interval of a prediction is the set of values the data do not reject; several specs at several positions give a band. Attributes: | Name | Type | Description | | ----------- | ---------------------- | -------------------------------------------------------- | | `specs` | `list[PredictionSpec]` | The predictions, at least one, with distinct labels. | | `threshold` | `Threshold` | The threshold of the statistic. | | `max_steps` | `int` | The most points on each side of a profile (default 100). | | `starts` | `int` | Starts of the fit (default 1). | Source code in `commol/context/inference/profiles.py` ```python class PredictionProfile(UncertaintyMethod["ProfileResult"], RangeChecked): """ The profile likelihood of predictions (Kreutz et al. 2012). Each prediction is held at values that step away from its value at the fit, as an equality constraint met by an augmented Lagrangian whose inner solver is the problem's own optimizer, and the parameters are fitted again. The interval of a prediction is the set of values the data do not reject; several specs at several positions give a band. Attributes ---------- specs : list[PredictionSpec] The predictions, at least one, with distinct labels. threshold : Threshold The threshold of the statistic. max_steps : int The most points on each side of a profile (default 100). starts : int Starts of the fit (default 1). """ specs: list[PredictionSpec] = Field(min_length=1) threshold: Threshold max_steps: int = Field( default=100, ) starts: int = Field( default=1, ) @model_validator(mode="after") def check_labels(self) -> Self: """Each prediction has a label of its own.""" labels = [spec.as_observation().label for spec in self.specs] if len(set(labels)) != len(labels): raise ValueError( f"Each prediction needs a label of its own, got {labels}; set label." ) return self ``` ### Methods: #### check_labels ```python check_labels() -> Self ``` Each prediction has a label of its own. Source code in `commol/context/inference/profiles.py` ```python @model_validator(mode="after") def check_labels(self) -> Self: """Each prediction has a label of its own.""" labels = [spec.as_observation().label for spec in self.specs] if len(set(labels)) != len(labels): raise ValueError( f"Each prediction needs a label of its own, got {labels}; set label." ) return self ``` ## PredictionSpec Bases: `RangeChecked` A prediction to profile: an output, or a sum of outputs, at a position, read as an observation is. Attributes: | Name | Type | Description | | ----------- | ----------- | ------------------------------------------------------------- | | `at` | `float` | The position. | | `output` | \`str | None\` | | `outputs` | \`list[str] | None\` | | `label` | `str` | The name of the prediction in the result; defaults to output. | | `window` | \`float | None\` | | `factor_id` | \`str | None\` | Source code in `commol/context/inference/profiles.py` ```python class PredictionSpec(RangeChecked): """ A prediction to profile: an output, or a sum of outputs, at a position, read as an observation is. Attributes ---------- at : float The position. output : str | None The output. Give either `output` or `outputs`. outputs : list[str] | None Outputs summed. Needs a `label`. label : str The name of the prediction in the result; defaults to `output`. window : float | None A window length: the prediction is the output at `at` less the output at ``at - window``. factor_id : str | None A factor parameter that multiplies the prediction. """ model_config = ConfigDict(extra="forbid", frozen=True) at: float = Field() output: str | None = Field(default=None, min_length=1) outputs: list[str] | None = None label: str = "" window: float | None = Field( default=None, ) factor_id: str | None = None def as_observation(self) -> ObservedDataPoint: """ The observation that reads this prediction; its value is not read. Returns ------- ObservedDataPoint The observation. Raises ------ ValueError If the outputs or the label are not valid. """ return ObservedDataPoint( at=self.at, output=self.output, outputs=self.outputs, label=self.label, value=0.0, window=self.window, factor_id=self.factor_id, ) ``` ### Methods: #### as_observation ```python as_observation() -> ObservedDataPoint ``` The observation that reads this prediction; its value is not read. Returns: | Type | Description | | ------------------- | ---------------- | | `ObservedDataPoint` | The observation. | Raises: | Type | Description | | ------------ | ------------------------------------------ | | `ValueError` | If the outputs or the label are not valid. | Source code in `commol/context/inference/profiles.py` ```python def as_observation(self) -> ObservedDataPoint: """ The observation that reads this prediction; its value is not read. Returns ------- ObservedDataPoint The observation. Raises ------ ValueError If the outputs or the label are not valid. """ return ObservedDataPoint( at=self.at, output=self.output, outputs=self.outputs, label=self.label, value=0.0, window=self.window, factor_id=self.factor_id, ) ``` ## Threshold Bases: `RangeChecked` How far the statistic of a profile may rise above the fit inside an interval of `confidence_level`. Build one with `chi_square`, `beale` or `bootstrap_calibrated`. Attributes: | Name | Type | Description | | ------------------ | ------------------------------------------- | --------------------------------------------------------------- | | `kind` | `{chi_square, beale, bootstrap_calibrated}` | The rule. | | `confidence_level` | `float` | The level of each pointwise interval, strictly between 0 and 1. | | `bootstrap` | \`BayesianBootstrap | ParametricBootstrap | Source code in `commol/context/inference/profiles.py` ```python class Threshold(RangeChecked): """ How far the statistic of a profile may rise above the fit inside an interval of `confidence_level`. Build one with `chi_square`, `beale` or `bootstrap_calibrated`. Attributes ---------- kind : {"chi_square", "beale", "bootstrap_calibrated"} The rule. confidence_level : float The level of each pointwise interval, strictly between 0 and 1. bootstrap : BayesianBootstrap | ParametricBootstrap | ResidualBootstrap | WildBootstrap | None The bootstrap of ``"bootstrap_calibrated"``; None otherwise. """ # noqa: E501 model_config = ConfigDict(extra="forbid", frozen=True) kind: Literal["chi_square", "beale", "bootstrap_calibrated"] confidence_level: float bootstrap: ProfileBootstrap | None = None @model_validator(mode="after") def check_bootstrap(self) -> Self: """A bootstrap goes with ``"bootstrap_calibrated"``, and only with it.""" if (self.kind == "bootstrap_calibrated") != (self.bootstrap is not None): raise ValueError( "A bootstrap calibrates the threshold 'bootstrap_calibrated' and no " "other; build thresholds with Threshold.chi_square, Threshold.beale " "or Threshold.bootstrap_calibrated." ) return self @classmethod def chi_square(cls, confidence_level: float) -> Self: """ ``½ χ²₁(confidence_level)`` on ``−log L`` (Wilks): the threshold of a problem with observation models. It assumes that the models hold and that the statistic is near its large-sample distribution. Parameters ---------- confidence_level : float The level of each interval, strictly between 0 and 1. Returns ------- Threshold The threshold. Raises ------ ValueError If ``confidence_level`` is not strictly between 0 and 1. """ return cls(kind="chi_square", confidence_level=confidence_level) @classmethod def beale(cls, confidence_level: float) -> Self: """ Beale's threshold of a sum of squares: ``S(θ̂) · F(1, n − p; confidence_level) / (n − p)``, with ``n`` observations of positive weight and ``p`` parameters. It assumes independent Gaussian errors of one variance, and is exact for a model linear in its parameters. It needs the loss ``"sse"``. Parameters ---------- confidence_level : float The level of each interval, strictly between 0 and 1. Returns ------- Threshold The threshold. Raises ------ ValueError If ``confidence_level`` is not strictly between 0 and 1. """ return cls(kind="beale", confidence_level=confidence_level) @classmethod def bootstrap_calibrated( cls, bootstrap: ProfileBootstrap, confidence_level: float ) -> Self: """ The threshold of each quantity calibrated by `bootstrap` (prepivoting, Beran 1988), for any loss. Each replicate of the bootstrap, rebuilt from the problem's seed as ``Calibrator.uncertainty(bootstrap)`` builds it, is fitted (``θ̂_b``) and then fitted again with the quantity held at its value at the fit of the original data: the rise of the replicate is ``PL_b(θ̂ⱼ) − L_b(θ̂_b)``. The threshold is the quantile `confidence_level` of the rises. It costs one fit and one refit per quantity for each replicate. Parameters ---------- bootstrap : BayesianBootstrap | ParametricBootstrap | ResidualBootstrap | WildBootstrap The bootstrap, with its replicates and starts. confidence_level : float The level of each interval, strictly between 0 and 1. Returns ------- Threshold The threshold. Raises ------ ValueError If ``confidence_level`` is not strictly between 0 and 1. """ # noqa: E501 return cls( kind="bootstrap_calibrated", confidence_level=confidence_level, bootstrap=bootstrap, ) ``` ### Methods: #### check_bootstrap ```python check_bootstrap() -> Self ``` A bootstrap goes with `"bootstrap_calibrated"`, and only with it. Source code in `commol/context/inference/profiles.py` ```python @model_validator(mode="after") def check_bootstrap(self) -> Self: """A bootstrap goes with ``"bootstrap_calibrated"``, and only with it.""" if (self.kind == "bootstrap_calibrated") != (self.bootstrap is not None): raise ValueError( "A bootstrap calibrates the threshold 'bootstrap_calibrated' and no " "other; build thresholds with Threshold.chi_square, Threshold.beale " "or Threshold.bootstrap_calibrated." ) return self ``` #### chi_square ```python chi_square(confidence_level: float) -> Self ``` `½ χ²₁(confidence_level)` on `−log L` (Wilks): the threshold of a problem with observation models. It assumes that the models hold and that the statistic is near its large-sample distribution. Parameters: | Name | Type | Description | Default | | ------------------ | ------- | ----------------------------------------------------- | ---------- | | `confidence_level` | `float` | The level of each interval, strictly between 0 and 1. | *required* | Returns: | Type | Description | | ----------- | -------------- | | `Threshold` | The threshold. | Raises: | Type | Description | | ------------ | ---------------------------------------------------- | | `ValueError` | If confidence_level is not strictly between 0 and 1. | Source code in `commol/context/inference/profiles.py` ```python @classmethod def chi_square(cls, confidence_level: float) -> Self: """ ``½ χ²₁(confidence_level)`` on ``−log L`` (Wilks): the threshold of a problem with observation models. It assumes that the models hold and that the statistic is near its large-sample distribution. Parameters ---------- confidence_level : float The level of each interval, strictly between 0 and 1. Returns ------- Threshold The threshold. Raises ------ ValueError If ``confidence_level`` is not strictly between 0 and 1. """ return cls(kind="chi_square", confidence_level=confidence_level) ``` #### beale ```python beale(confidence_level: float) -> Self ``` Beale's threshold of a sum of squares: `S(θ̂) · F(1, n − p; confidence_level) / (n − p)`, with `n` observations of positive weight and `p` parameters. It assumes independent Gaussian errors of one variance, and is exact for a model linear in its parameters. It needs the loss `"sse"`. Parameters: | Name | Type | Description | Default | | ------------------ | ------- | ----------------------------------------------------- | ---------- | | `confidence_level` | `float` | The level of each interval, strictly between 0 and 1. | *required* | Returns: | Type | Description | | ----------- | -------------- | | `Threshold` | The threshold. | Raises: | Type | Description | | ------------ | ---------------------------------------------------- | | `ValueError` | If confidence_level is not strictly between 0 and 1. | Source code in `commol/context/inference/profiles.py` ```python @classmethod def beale(cls, confidence_level: float) -> Self: """ Beale's threshold of a sum of squares: ``S(θ̂) · F(1, n − p; confidence_level) / (n − p)``, with ``n`` observations of positive weight and ``p`` parameters. It assumes independent Gaussian errors of one variance, and is exact for a model linear in its parameters. It needs the loss ``"sse"``. Parameters ---------- confidence_level : float The level of each interval, strictly between 0 and 1. Returns ------- Threshold The threshold. Raises ------ ValueError If ``confidence_level`` is not strictly between 0 and 1. """ return cls(kind="beale", confidence_level=confidence_level) ``` #### bootstrap_calibrated ```python bootstrap_calibrated(bootstrap: ProfileBootstrap, confidence_level: float) -> Self ``` The threshold of each quantity calibrated by `bootstrap` (prepivoting, Beran 1988), for any loss. Each replicate of the bootstrap, rebuilt from the problem's seed as `Calibrator.uncertainty(bootstrap)` builds it, is fitted (`θ̂_b`) and then fitted again with the quantity held at its value at the fit of the original data: the rise of the replicate is `PL_b(θ̂ⱼ) − L_b(θ̂_b)`. The threshold is the quantile `confidence_level` of the rises. It costs one fit and one refit per quantity for each replicate. Parameters: | Name | Type | Description | Default | | ------------------ | ------------------- | ----------------------------------------------------- | ----------------- | | `bootstrap` | \`BayesianBootstrap | ParametricBootstrap | ResidualBootstrap | | `confidence_level` | `float` | The level of each interval, strictly between 0 and 1. | *required* | Returns: | Type | Description | | ----------- | -------------- | | `Threshold` | The threshold. | Raises: | Type | Description | | ------------ | ---------------------------------------------------- | | `ValueError` | If confidence_level is not strictly between 0 and 1. | Source code in `commol/context/inference/profiles.py` ```python @classmethod def bootstrap_calibrated( cls, bootstrap: ProfileBootstrap, confidence_level: float ) -> Self: """ The threshold of each quantity calibrated by `bootstrap` (prepivoting, Beran 1988), for any loss. Each replicate of the bootstrap, rebuilt from the problem's seed as ``Calibrator.uncertainty(bootstrap)`` builds it, is fitted (``θ̂_b``) and then fitted again with the quantity held at its value at the fit of the original data: the rise of the replicate is ``PL_b(θ̂ⱼ) − L_b(θ̂_b)``. The threshold is the quantile `confidence_level` of the rises. It costs one fit and one refit per quantity for each replicate. Parameters ---------- bootstrap : BayesianBootstrap | ParametricBootstrap | ResidualBootstrap | WildBootstrap The bootstrap, with its replicates and starts. confidence_level : float The level of each interval, strictly between 0 and 1. Returns ------- Threshold The threshold. Raises ------ ValueError If ``confidence_level`` is not strictly between 0 and 1. """ # noqa: E501 return cls( kind="bootstrap_calibrated", confidence_level=confidence_level, bootstrap=bootstrap, ) ``` ## ProfileResult Bases: `_FrozenResult` The result of a profile likelihood. Attributes: | Name | Type | Description | | ------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fit` | `CalibrationResult` | The fit every profile starts from. | | `quantity` | `{parameter, prediction}` | What the profiles hold fixed: parameters (ProfileLikelihood) or predictions (PredictionProfile). | | `threshold` | `Threshold` | The threshold the profiles ran with. | | `profiles` | `dict[str, Profile]` | The profile of each parameter or prediction, by name. | | `intervals` | `dict[str, ProfileInterval]` | The interval of each. | | `verdicts` | \`dict\[str, ProfileVerdictName | None\]\` | | `structure` | `dict[str, ProfileStructure]` | For each profile of a parameter that does not cross on both sides, what resetting the other parameters says. | | `tolerance` | `float` | The numerical tolerance of the statistic: max(τ, ν), the tolerance of the optimizer and the noise floor of the objective at the fit. | | `evaluation_floor` | \`float | None\` | | `notes` | `list[str]` | What a reader should know, such as a threshold below the tolerance or a refit below the fit. | | `failed` | `list[str]` | With a bootstrap threshold, each replicate that failed: "Replicate failed: ", also logged as a warning. Empty with any other threshold. | | `not_converged` | `int` | With a bootstrap threshold, the replicates kept although their optimizer did not converge; 0 with any other threshold. | Source code in `commol/context/inference/profiles.py` ```python class ProfileResult(_FrozenResult): """ The result of a profile likelihood. Attributes ---------- fit : CalibrationResult The fit every profile starts from. quantity : {"parameter", "prediction"} What the profiles hold fixed: parameters (`ProfileLikelihood`) or predictions (`PredictionProfile`). threshold : Threshold The threshold the profiles ran with. profiles : dict[str, Profile] The profile of each parameter or prediction, by name. intervals : dict[str, ProfileInterval] The interval of each. verdicts : dict[str, ProfileVerdictName | None] What the shape of each profile says: ``identifiable`` (it crosses the threshold on both sides), ``practically_non_identifiable`` (it stays below on a side, up to a bound or `max_steps`) or ``structurally_non_identifiable`` (flat within `tolerance` over the whole range). None when the threshold is below `tolerance`: the profile cannot tell a rise from numerical error. structure : dict[str, ProfileStructure] For each profile of a parameter that does not cross on both sides, what resetting the other parameters says. tolerance : float The numerical tolerance of the statistic: ``max(τ, ν)``, the tolerance of the optimizer and the noise floor of the objective at the fit. evaluation_floor : float | None The floor of a comparison of two evaluations of the objective; None when it cannot be computed. notes : list[str] What a reader should know, such as a threshold below the tolerance or a refit below the fit. failed : list[str] With a bootstrap threshold, each replicate that failed: ``"Replicate failed: "``, also logged as a warning. Empty with any other threshold. not_converged : int With a bootstrap threshold, the replicates kept although their optimizer did not converge; 0 with any other threshold. """ fit: CalibrationResult quantity: Literal["parameter", "prediction"] threshold: Threshold profiles: dict[str, Profile] intervals: dict[str, ProfileInterval] verdicts: dict[str, ProfileVerdictName | None] tolerance: float evaluation_floor: float | None = None structure: dict[str, ProfileStructure] = Field(default_factory=dict) notes: list[str] = Field(default_factory=list) failed: list[str] = Field(default_factory=list) not_converged: int = 0 def plot( self, output_file: str | None = None, config: "PlotConfig | None" = None, axes: "Figure | None" = None, ) -> "Figure": """ Draw each profile: one panel per parameter or prediction, with the statistic of each point, the threshold, and the ends of the interval. A profile that stays under the threshold up to a bound is open on that side; a flat one stays near 0 over its whole range. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. axes : Figure | None A figure of commol to draw on: each panel is the `Axes` whose label is the name of its profile. If None, a new figure. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a profile. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _profiles return _profiles(self, output_file=output_file, config=config, axes=axes) def identifiability(self) -> IdentifiabilityReport: """ The verdicts of the profiled parameters as an identifiability report, with a ``profile`` section; its ``report()`` gives them as data. ``identifiable`` gives the practical verdict ``determined`` and ``practically_non_identifiable`` the practical verdict ``poorly_determined``. ``structurally_non_identifiable`` gives the structural verdict of its `structure`, ``in_combination`` or ``non_identifiable``, with no combination: the reason names the compensating parameters. A flat profile with no structural verdict gives the practical verdict ``poorly_determined``. Every reason says that the verdict holds with the other parameters at their fitted values. A parameter with no profile, or no verdict, gets neither. `IdentifiabilityReport.merge` joins it with the reports of other methods. Returns ------- IdentifiabilityReport One entry per parameter of the problem, in problem order. Raises ------ ValueError If the result holds prediction profiles, which have no verdict on a parameter. """ if self.quantity != "parameter": raise ValueError( "This result holds the profiles of predictions, and predictions " "have no verdict on a parameter; profile the parameters " "(ProfileLikelihood) for their identifiability." ) parameters = list(self.fit.best_parameters) verdicts: dict[str, ParameterVerdicts] = {} for name in parameters: verdict = self.verdicts.get(name) reason = f"profile likelihood: {verdict}. {_RESET}" if verdict == "structurally_non_identifiable": structure = self.structure[name] reason = structure.reason( name, self.profiles[name].threshold, self.evaluation_floor ) if structure.verdict is None: verdicts[name] = ParameterVerdicts( practical=PracticalVerdict( verdict="poorly_determined", reasons=[reason] ) ) else: verdicts[name] = ParameterVerdicts( structural=StructuralVerdict( verdict=structure.verdict, reasons=[reason] ) ) elif verdict is not None: verdicts[name] = ParameterVerdicts( practical=PracticalVerdict( verdict=( "determined" if verdict == "identifiable" else "poorly_determined" ), reasons=[reason], ) ) else: verdicts[name] = ParameterVerdicts() return IdentifiabilityReport( parameters=parameters, verdicts=verdicts, profile=[ ProfileSection( threshold=self.threshold.kind, confidence_level=self.threshold.confidence_level, tolerance=self.tolerance, verdicts=self.verdicts, intervals={ name: (interval.lower, interval.upper) for name, interval in self.intervals.items() }, ) ], ) ``` ### Methods: #### plot ```python plot(output_file: str | None = None, config: PlotConfig | None = None, axes: Figure | None = None) -> Figure ``` Draw each profile: one panel per parameter or prediction, with the statistic of each point, the threshold, and the ends of the interval. A profile that stays under the threshold up to a bound is open on that side; a flat one stays near 0 over its whole range. Parameters: | Name | Type | Description | Default | | ------------- | ------------ | ----------- | -------------------------------------------------------------------------------------------------------------------- | | `output_file` | \`str | None\` | Path to save the figure to. If None, the figure is only returned. | | `config` | \`PlotConfig | None\` | Layout and style. If None, the defaults. | | `axes` | \`Figure | None\` | A figure of commol to draw on: each panel is the Axes whose label is the name of its profile. If None, a new figure. | Returns: | Type | Description | | -------- | ---------------------- | | `Figure` | The matplotlib figure. | Raises: | Type | Description | | ------------- | ------------------------------------------------------------ | | `ValueError` | If axes has no panel of a profile. | | `ImportError` | If matplotlib is not installed (pip install 'commol[plot]'). | Source code in `commol/context/inference/profiles.py` ```python def plot( self, output_file: str | None = None, config: "PlotConfig | None" = None, axes: "Figure | None" = None, ) -> "Figure": """ Draw each profile: one panel per parameter or prediction, with the statistic of each point, the threshold, and the ends of the interval. A profile that stays under the threshold up to a bound is open on that side; a flat one stays near 0 over its whole range. Parameters ---------- output_file : str | None Path to save the figure to. If None, the figure is only returned. config : PlotConfig | None Layout and style. If None, the defaults. axes : Figure | None A figure of commol to draw on: each panel is the `Axes` whose label is the name of its profile. If None, a new figure. Returns ------- Figure The matplotlib figure. Raises ------ ValueError If ``axes`` has no panel of a profile. ImportError If matplotlib is not installed (``pip install 'commol[plot]'``). """ from commol.api.plotting.figures import _profiles return _profiles(self, output_file=output_file, config=config, axes=axes) ``` #### identifiability ```python identifiability() -> IdentifiabilityReport ``` The verdicts of the profiled parameters as an identifiability report, with a `profile` section; its `report()` gives them as data. `identifiable` gives the practical verdict `determined` and `practically_non_identifiable` the practical verdict `poorly_determined`. `structurally_non_identifiable` gives the structural verdict of its `structure`, `in_combination` or `non_identifiable`, with no combination: the reason names the compensating parameters. A flat profile with no structural verdict gives the practical verdict `poorly_determined`. Every reason says that the verdict holds with the other parameters at their fitted values. A parameter with no profile, or no verdict, gets neither. `IdentifiabilityReport.merge` joins it with the reports of other methods. Returns: | Type | Description | | ----------------------- | --------------------------------------------------------- | | `IdentifiabilityReport` | One entry per parameter of the problem, in problem order. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------ | | `ValueError` | If the result holds prediction profiles, which have no verdict on a parameter. | Source code in `commol/context/inference/profiles.py` ```python def identifiability(self) -> IdentifiabilityReport: """ The verdicts of the profiled parameters as an identifiability report, with a ``profile`` section; its ``report()`` gives them as data. ``identifiable`` gives the practical verdict ``determined`` and ``practically_non_identifiable`` the practical verdict ``poorly_determined``. ``structurally_non_identifiable`` gives the structural verdict of its `structure`, ``in_combination`` or ``non_identifiable``, with no combination: the reason names the compensating parameters. A flat profile with no structural verdict gives the practical verdict ``poorly_determined``. Every reason says that the verdict holds with the other parameters at their fitted values. A parameter with no profile, or no verdict, gets neither. `IdentifiabilityReport.merge` joins it with the reports of other methods. Returns ------- IdentifiabilityReport One entry per parameter of the problem, in problem order. Raises ------ ValueError If the result holds prediction profiles, which have no verdict on a parameter. """ if self.quantity != "parameter": raise ValueError( "This result holds the profiles of predictions, and predictions " "have no verdict on a parameter; profile the parameters " "(ProfileLikelihood) for their identifiability." ) parameters = list(self.fit.best_parameters) verdicts: dict[str, ParameterVerdicts] = {} for name in parameters: verdict = self.verdicts.get(name) reason = f"profile likelihood: {verdict}. {_RESET}" if verdict == "structurally_non_identifiable": structure = self.structure[name] reason = structure.reason( name, self.profiles[name].threshold, self.evaluation_floor ) if structure.verdict is None: verdicts[name] = ParameterVerdicts( practical=PracticalVerdict( verdict="poorly_determined", reasons=[reason] ) ) else: verdicts[name] = ParameterVerdicts( structural=StructuralVerdict( verdict=structure.verdict, reasons=[reason] ) ) elif verdict is not None: verdicts[name] = ParameterVerdicts( practical=PracticalVerdict( verdict=( "determined" if verdict == "identifiable" else "poorly_determined" ), reasons=[reason], ) ) else: verdicts[name] = ParameterVerdicts() return IdentifiabilityReport( parameters=parameters, verdicts=verdicts, profile=[ ProfileSection( threshold=self.threshold.kind, confidence_level=self.threshold.confidence_level, tolerance=self.tolerance, verdicts=self.verdicts, intervals={ name: (interval.lower, interval.upper) for name, interval in self.intervals.items() }, ) ], ) ``` ## Profile Bases: `_FrozenResult` The profile of one parameter or prediction. Attributes: | Name | Type | Description | | ----------------- | --------------------------------------- | ----------------------------------------------------------------------------- | | `center` | `float` | The value at the fit. | | `threshold` | `float` | The threshold of the statistic. | | `values` | `list[float]` | The value of each point, increasing; the fit is one of them. | | `statistics` | `list[float]` | The statistic of each point: the objective of its refit less that of the fit. | | `parameters` | `list[dict[str, float]]` | Every parameter of each refit. | | `multipliers` | \`list\[float | None\]\` | | `converged` | `list[bool]` | Whether each refit converged. | | `ends` | `tuple[ProfileEndName, ProfileEndName]` | Why the side below and the side above the fit end. | | `evaluations` | `int` | Evaluations of the objective. | | `replicate_rises` | \`list[float] | None\` | | `box_crossings` | \`tuple\[str | None, str | | `refit_error` | \`float | None\` | Source code in `commol/context/inference/profiles.py` ```python class Profile(_FrozenResult): """ The profile of one parameter or prediction. Attributes ---------- center : float The value at the fit. threshold : float The threshold of the statistic. values : list[float] The value of each point, increasing; the fit is one of them. statistics : list[float] The statistic of each point: the objective of its refit less that of the fit. parameters : list[dict[str, float]] Every parameter of each refit. multipliers : list[float | None] For a prediction, the multiplier of each point: minus the slope of the profile there. converged : list[bool] Whether each refit converged. ends : tuple[ProfileEndName, ProfileEndName] Why the side below and the side above the fit end. evaluations : int Evaluations of the objective. replicate_rises : list[float] | None With a bootstrap threshold, the rise of each replicate kept. box_crossings : tuple[str | None, str | None] For the side below and the side above, the free parameter whose bound makes the profile cross the threshold: it is on a bound at the crossing point that it is not on over the whole side from the fit. Such a side counts as open; its interval end stays, flagged in `ProfileResult.notes`. A parameter that stays on its bound from the fit to the crossing is not seen. refit_error : float | None For a parameter, ``|δ|``: the largest distance from the objective at the fit of a refit with the parameter at its fitted value, from each neighbour of the fit. A diagnostic of the optimizer. """ center: float threshold: float values: list[float] statistics: list[float] parameters: list[dict[str, float]] multipliers: list[float | None] converged: list[bool] ends: tuple[ProfileEndName, ProfileEndName] evaluations: int replicate_rises: list[float] | None = None box_crossings: tuple[str | None, str | None] = (None, None) refit_error: float | None = None ``` ## ProfileInterval Bases: `_FrozenResult` The pointwise interval of a quantity: where its profile crosses the threshold. Attributes: | Name | Type | Description | | -------------- | ------- | ----------- | | `lower, upper` | \`float | None\` | Source code in `commol/context/inference/profiles.py` ```python class ProfileInterval(_FrozenResult): """ The pointwise interval of a quantity: where its profile crosses the threshold. Attributes ---------- lower, upper : float | None The ends; None on a side that does not cross. """ lower: float | None upper: float | None ``` ## ProfileStructure Bases: `_FrozenResult` What resetting the other parameters says about a profile. A profile is flat because other parameters compensate the parameter along it, or because nothing the data read depends on it. At each point of the profile inside the box, and at both ends of the box of the parameter, the other parameters are reset to their fitted values and the objective is evaluated once, with no optimizer; each reset is compared with the objective at the fit. Attributes: | Name | Type | Description | | ---------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `verdict` | \`{in_combination, non_identifiable} | None\` | | `largest_rise` | \`float | None\` | | `compensated_by` | `list[str]` | The parameters whose reset alone, where each moves most along the profile, raises it above the threshold. Noise parameters of observation models are never named. | | `joint_rise` | \`float | None\` | | `undetermined` | \`UndeterminedName | None\` | | `on_bound` | \`str | None\` | Source code in `commol/context/inference/profiles.py` ```python class ProfileStructure(_FrozenResult): """ What resetting the other parameters says about a profile. A profile is flat because other parameters compensate the parameter along it, or because nothing the data read depends on it. At each point of the profile inside the box, and at both ends of the box of the parameter, the other parameters are reset to their fitted values and the objective is evaluated once, with no optimizer; each reset is compared with the objective at the fit. Attributes ---------- verdict : {"in_combination", "non_identifiable"} | None ``non_identifiable``: every reset is feasible and within the evaluation floor of the fit. ``in_combination``: the profile is flat and a feasible reset inside the box rises above the threshold. None otherwise, and `undetermined` says why. largest_rise : float | None The largest rise of a feasible reset over the objective at the fit; None when none was feasible. compensated_by : list[str] The parameters whose reset alone, where each moves most along the profile, raises it above the threshold. Noise parameters of observation models are never named. joint_rise : float | None The largest rise of resetting the parameters not named together, at the outermost point of each side, over the objective of that point, when it is above the evaluation floor: `compensated_by` may be incomplete. Infinite when such a reset is not feasible. undetermined : UndeterminedName | None Why there is no verdict. on_bound : str | None With ``undetermined="on_bound"``, the parameter on its bound at the fit. """ verdict: Literal["in_combination", "non_identifiable"] | None largest_rise: float | None compensated_by: list[str] joint_rise: float | None undetermined: UndeterminedName | None = None on_bound: str | None = None def reason(self, name: str, threshold: float, floor: float | None) -> str: """ The reason of the verdict of parameter `name` in a report. Parameters ---------- name : str The parameter. threshold : float The threshold of its profile. floor : float | None The evaluation floor of the result. Returns ------- str The reason, which says that the verdict holds with the other parameters at their fitted values. """ rise = ( "no feasible reset" if self.largest_rise is None else (f"a largest rise of {self.largest_rise:.3g}") ) if self.verdict == "in_combination": text = ( f"profile likelihood: flat, and resetting the other parameters " f"gives {rise}, above the threshold {threshold:.3g}" ) if self.compensated_by: text += ( f"; {name}, compensated by {', '.join(self.compensated_by)} " "along its profile" ) if self.joint_rise is not None: text += ( f"; the list may be incomplete (largest rise {self.joint_rise:.3g})" ) elif self.verdict == "non_identifiable": text = ( "profile likelihood: resetting the other parameters leaves the " f"objective within the evaluation floor {floor:.3g} of the fit " f"along the profile and at both ends of the box ({rise}): nothing " f"the data read depends on {name}" ) else: text = ( f"profile likelihood: flat, with no structural verdict " f"({self.undetermined}; {rise}, threshold {threshold:.3g})" ) return f"{text}. {_RESET}" ``` ### Methods: #### reason ```python reason(name: str, threshold: float, floor: float | None) -> str ``` The reason of the verdict of parameter `name` in a report. Parameters: | Name | Type | Description | Default | | ----------- | ------- | ----------------------------- | ----------------------------------- | | `name` | `str` | The parameter. | *required* | | `threshold` | `float` | The threshold of its profile. | *required* | | `floor` | \`float | None\` | The evaluation floor of the result. | Returns: | Type | Description | | ----- | ----------------------------------------------------------------------------------------------- | | `str` | The reason, which says that the verdict holds with the other parameters at their fitted values. | Source code in `commol/context/inference/profiles.py` ```python def reason(self, name: str, threshold: float, floor: float | None) -> str: """ The reason of the verdict of parameter `name` in a report. Parameters ---------- name : str The parameter. threshold : float The threshold of its profile. floor : float | None The evaluation floor of the result. Returns ------- str The reason, which says that the verdict holds with the other parameters at their fitted values. """ rise = ( "no feasible reset" if self.largest_rise is None else (f"a largest rise of {self.largest_rise:.3g}") ) if self.verdict == "in_combination": text = ( f"profile likelihood: flat, and resetting the other parameters " f"gives {rise}, above the threshold {threshold:.3g}" ) if self.compensated_by: text += ( f"; {name}, compensated by {', '.join(self.compensated_by)} " "along its profile" ) if self.joint_rise is not None: text += ( f"; the list may be incomplete (largest rise {self.joint_rise:.3g})" ) elif self.verdict == "non_identifiable": text = ( "profile likelihood: resetting the other parameters leaves the " f"objective within the evaluation floor {floor:.3g} of the fit " f"along the profile and at both ends of the box ({rise}): nothing " f"the data read depends on {name}" ) else: text = ( f"profile likelihood: flat, with no structural verdict " f"({self.undetermined}; {rise}, threshold {threshold:.3g})" ) return f"{text}. {_RESET}" ``` ## Sequential Monte Carlo Guide: [Sequential Monte Carlo](https://munqu.github.io/commol/latest/guide/uncertainty/smc/index.md). ## Priors Bases: `BaseModel` One prior per calibrated parameter. A sampling method takes its priors with no default: what is known about each parameter before the data is a modelling choice. Give a mapping of parameter to distribution, or `uniform_on_bounds` for `Uniform()` on every parameter. Attributes: | Name | Type | Description | | --------------- | ------------------------- | ----------- | | `distributions` | \`dict[str, Distribution] | None\` | Source code in `commol/context/inference/distributions.py` ```python class Priors(BaseModel): """ One prior per calibrated parameter. A sampling method takes its priors with no default: what is known about each parameter before the data is a modelling choice. Give a mapping of parameter to distribution, or `uniform_on_bounds` for ``Uniform()`` on every parameter. Attributes ---------- distributions : dict[str, Distribution] | None The prior of each parameter; None gives ``Uniform()`` to every parameter (`uniform_on_bounds`). """ model_config = ConfigDict(extra="forbid", frozen=True) distributions: dict[str, Distribution] | None = Field( description="The prior of each parameter, or None for uniform on the bounds." ) @classmethod def uniform_on_bounds(cls) -> Self: """ ``Uniform()`` on the bounds of every parameter. Returns ------- Priors The priors; every parameter needs finite bounds. """ return cls(distributions=None) def rust_priors(self) -> list[tuple[str, str, list[float]]] | None: """The priors as the Rust runners read them; None is uniform.""" if self.distributions is None: return None return [ distribution.rust_prior(name) for name, distribution in self.distributions.items() ] ``` ### Methods: #### uniform_on_bounds ```python uniform_on_bounds() -> Self ``` `Uniform()` on the bounds of every parameter. Returns: | Type | Description | | -------- | ------------------------------------------------ | | `Priors` | The priors; every parameter needs finite bounds. | Source code in `commol/context/inference/distributions.py` ```python @classmethod def uniform_on_bounds(cls) -> Self: """ ``Uniform()`` on the bounds of every parameter. Returns ------- Priors The priors; every parameter needs finite bounds. """ return cls(distributions=None) ``` #### rust_priors ```python rust_priors() -> list[tuple[str, str, list[float]]] | None ``` The priors as the Rust runners read them; None is uniform. Source code in `commol/context/inference/distributions.py` ```python def rust_priors(self) -> list[tuple[str, str, list[float]]] | None: """The priors as the Rust runners read them; None is uniform.""" if self.distributions is None: return None return [ distribution.rust_prior(name) for name, distribution in self.distributions.items() ] ``` ## Uniform Bases: `DistributionBase` The uniform distribution on the bounds; they must be finite. Source code in `commol/context/inference/distributions.py` ```python class Uniform(DistributionBase): """The uniform distribution on the bounds; they must be finite.""" rust_kind: ClassVar[str] = "uniform" kind: Literal["uniform"] = "uniform" ``` ## LogUniform Bases: `DistributionBase` The log-uniform distribution on the bounds: `log x` is uniform, the density is proportional to `1/x`. The bounds must be finite and the lower one above 0. Source code in `commol/context/inference/distributions.py` ```python class LogUniform(DistributionBase): """ The log-uniform distribution on the bounds: ``log x`` is uniform, the density is proportional to ``1/x``. The bounds must be finite and the lower one above 0. """ rust_kind: ClassVar[str] = "log_uniform" kind: Literal["log_uniform"] = "log_uniform" ``` ## Beta Bases: `DistributionBase`, `RangeChecked` The beta distribution of shapes `a` and `b`, scaled to the bounds; they must be finite. Attributes: | Name | Type | Description | | ---- | ------- | -------------------------- | | `a` | `float` | The first shape, above 0. | | `b` | `float` | The second shape, above 0. | Source code in `commol/context/inference/distributions.py` ```python class Beta(DistributionBase, RangeChecked): """ The beta distribution of shapes `a` and `b`, scaled to the bounds; they must be finite. Attributes ---------- a : float The first shape, above 0. b : float The second shape, above 0. """ rust_kind: ClassVar[str] = "beta" kind: Literal["beta"] = "beta" a: float = Field(description="The first shape.") b: float = Field(description="The second shape.") def rust_parameters(self) -> list[float]: """The shapes `a` and `b`.""" return [self.a, self.b] ``` ### Methods: #### rust_parameters ```python rust_parameters() -> list[float] ``` The shapes `a` and `b`. Source code in `commol/context/inference/distributions.py` ```python def rust_parameters(self) -> list[float]: """The shapes `a` and `b`.""" return [self.a, self.b] ``` ## Normal Bases: `DistributionBase`, `RangeChecked` The normal distribution, truncated to the bounds. Attributes: | Name | Type | Description | | ------ | ------- | -------------------------------------------------- | | `mean` | `float` | The mean before truncation. | | `sd` | `float` | The standard deviation before truncation, above 0. | Source code in `commol/context/inference/distributions.py` ```python class Normal(DistributionBase, RangeChecked): """ The normal distribution, truncated to the bounds. Attributes ---------- mean : float The mean before truncation. sd : float The standard deviation before truncation, above 0. """ rust_kind: ClassVar[str] = "normal" kind: Literal["normal"] = "normal" mean: float = Field(description="The mean.") sd: float = Field(description="The standard deviation.") def rust_parameters(self) -> list[float]: """The mean and the standard deviation.""" return [self.mean, self.sd] ``` ### Methods: #### rust_parameters ```python rust_parameters() -> list[float] ``` The mean and the standard deviation. Source code in `commol/context/inference/distributions.py` ```python def rust_parameters(self) -> list[float]: """The mean and the standard deviation.""" return [self.mean, self.sd] ``` ## LogNormal Bases: `DistributionBase`, `RangeChecked` The log-normal distribution, truncated to the bounds and to `(0, ∞)`: `log x` is normal. The upper bound must be above 0. Attributes: | Name | Type | Description | | ------- | ------- | ----------------------------------------- | | `mu` | `float` | The mean of log x. | | `sigma` | `float` | The standard deviation of log x, above 0. | Source code in `commol/context/inference/distributions.py` ```python class LogNormal(DistributionBase, RangeChecked): """ The log-normal distribution, truncated to the bounds and to ``(0, ∞)``: ``log x`` is normal. The upper bound must be above 0. Attributes ---------- mu : float The mean of ``log x``. sigma : float The standard deviation of ``log x``, above 0. """ rust_kind: ClassVar[str] = "log_normal" kind: Literal["log_normal"] = "log_normal" mu: float = Field(description="The mean of log x.") sigma: float = Field(description="The standard deviation of log x.") def rust_parameters(self) -> list[float]: """`mu` and `sigma`.""" return [self.mu, self.sigma] ``` ### Methods: #### rust_parameters ```python rust_parameters() -> list[float] ``` `mu` and `sigma`. Source code in `commol/context/inference/distributions.py` ```python def rust_parameters(self) -> list[float]: """`mu` and `sigma`.""" return [self.mu, self.sigma] ``` ## Gamma Bases: `DistributionBase`, `RangeChecked` The gamma distribution of shape `shape` and rate `rate` (mean `shape / rate`), truncated to the bounds and to `(0, ∞)`. The upper bound must be above 0. Attributes: | Name | Type | Description | | ------- | ------- | ------------------- | | `shape` | `float` | The shape, above 0. | | `rate` | `float` | The rate, above 0. | Source code in `commol/context/inference/distributions.py` ```python class Gamma(DistributionBase, RangeChecked): """ The gamma distribution of shape `shape` and rate `rate` (mean ``shape / rate``), truncated to the bounds and to ``(0, ∞)``. The upper bound must be above 0. Attributes ---------- shape : float The shape, above 0. rate : float The rate, above 0. """ rust_kind: ClassVar[str] = "gamma" kind: Literal["gamma"] = "gamma" shape: float = Field(description="The shape.") rate: float = Field(description="The rate.") def rust_parameters(self) -> list[float]: """The shape and the rate.""" return [self.shape, self.rate] ``` ### Methods: #### rust_parameters ```python rust_parameters() -> list[float] ``` The shape and the rate. Source code in `commol/context/inference/distributions.py` ```python def rust_parameters(self) -> list[float]: """The shape and the rate.""" return [self.shape, self.rate] ``` ## GibbsPosterior Bases: `WithPriors`, `UncertaintyMethod[GibbsResult]`, `RangeChecked` The Gibbs posterior `π(θ) ∝ prior(θ) · exp(−η · loss(θ))` of the problem's loss, with its weights and normalization, sampled by sequential Monte Carlo from the priors (temperature 0) to the posterior (temperature 1). `η` sets how much the data weigh against the priors; the loss has no scale of its own, so `η` has no default. Attributes: | Name | Type | Description | | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `priors` | \`Priors | dict[str, Distribution]\` | | `eta` | \`float | InformationMatchingEta | | `particles` | `int` | Number of particles, at least 2. | | `target_cess` | `float` | The conditional effective sample size of each step, as a share of the particles, strictly between 0 and 1: the next temperature is the highest that keeps it. | | `resample_threshold` | `float` | Resample when the effective size falls below this share of the particles, in (0, 1\]. | | `stay_probability` | `float` | The probability that a particle does not move in a step, strictly between 0 and 1: each step moves every particle ⌈log c / log(1 − a)⌉ times. | Source code in `commol/context/inference/smc.py` ```python class GibbsPosterior(WithPriors, UncertaintyMethod[GibbsResult], RangeChecked): """ The Gibbs posterior ``π(θ) ∝ prior(θ) · exp(−η · loss(θ))`` of the problem's loss, with its weights and normalization, sampled by sequential Monte Carlo from the priors (temperature 0) to the posterior (temperature 1). `η` sets how much the data weigh against the priors; the loss has no scale of its own, so `η` has no default. Attributes ---------- priors : Priors | dict[str, Distribution] The prior of each calibrated parameter, or ``Priors.uniform_on_bounds()``. eta : float | InformationMatchingEta | BootstrapCoverageEta A number above 0, ``InformationMatchingEta(block_length=...)`` or ``BootstrapCoverageEta(...)``. particles : int Number of particles, at least 2. target_cess : float The conditional effective sample size of each step, as a share of the particles, strictly between 0 and 1: the next temperature is the highest that keeps it. resample_threshold : float Resample when the effective size falls below this share of the particles, in ``(0, 1]``. stay_probability : float The probability that a particle does not move in a step, strictly between 0 and 1: each step moves every particle ``⌈log c / log(1 − a)⌉`` times. """ eta: Eta = Field(description="The scale of the loss, or its calibration.") particles: int = Field(description="Number of particles.") target_cess: float = Field(description="Share kept per step.") resample_threshold: float = Field(description="Resample below this share.") stay_probability: float = Field(description="Probability a particle does not move.") @model_validator(mode="before") @classmethod def require_eta(cls, data: JsonValue) -> JsonValue: """Name the choices of `eta` when it is missing.""" _require(data, "eta", f"give {ETA_CHOICES}") return data ``` ### Methods: #### require_eta ```python require_eta(data: JsonValue) -> JsonValue ``` Name the choices of `eta` when it is missing. Source code in `commol/context/inference/smc.py` ```python @model_validator(mode="before") @classmethod def require_eta(cls, data: JsonValue) -> JsonValue: """Name the choices of `eta` when it is missing.""" _require(data, "eta", f"give {ETA_CHOICES}") return data ``` ## BayesianPosterior Bases: `WithPriors`, `UncertaintyMethod[SMCResult]`, `RangeChecked` The posterior `π(θ) ∝ prior(θ) · L(θ)` of the problem's observation models, sampled by sequential Monte Carlo from the priors (temperature 0) to the posterior (temperature 1), with its log evidence. It needs a problem with `observation_models`: their likelihood sets the weight of the data, so it has no `η`. The noise parameters are sampled with the others, so each needs a prior. Attributes: | Name | Type | Description | | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `priors` | \`Priors | dict[str, Distribution]\` | | `particles` | `int` | Number of particles, at least 2. | | `target_cess` | `float` | The conditional effective sample size of each step, as a share of the particles, strictly between 0 and 1: the next temperature is the highest that keeps it. | | `resample_threshold` | `float` | Resample when the effective size falls below this share of the particles, in (0, 1\]. | | `stay_probability` | `float` | The probability that a particle does not move in a step, strictly between 0 and 1: each step moves every particle ⌈log c / log(1 − a)⌉ times. | Source code in `commol/context/inference/smc.py` ```python class BayesianPosterior(WithPriors, UncertaintyMethod[SMCResult], RangeChecked): """ The posterior ``π(θ) ∝ prior(θ) · L(θ)`` of the problem's observation models, sampled by sequential Monte Carlo from the priors (temperature 0) to the posterior (temperature 1), with its log evidence. It needs a problem with ``observation_models``: their likelihood sets the weight of the data, so it has no `η`. The noise parameters are sampled with the others, so each needs a prior. Attributes ---------- priors : Priors | dict[str, Distribution] The prior of each calibrated parameter, or ``Priors.uniform_on_bounds()``. particles : int Number of particles, at least 2. target_cess : float The conditional effective sample size of each step, as a share of the particles, strictly between 0 and 1: the next temperature is the highest that keeps it. resample_threshold : float Resample when the effective size falls below this share of the particles, in ``(0, 1]``. stay_probability : float The probability that a particle does not move in a step, strictly between 0 and 1: each step moves every particle ``⌈log c / log(1 − a)⌉`` times. """ particles: int = Field(description="Number of particles.") target_cess: float = Field(description="Share kept per step.") resample_threshold: float = Field(description="Resample below this share.") stay_probability: float = Field(description="Probability a particle does not move.") ``` ## InformationMatchingEta Bases: `RangeChecked` Calibrate `η` by information matching: `η = tr(J I⁻¹ J) / tr(J)` at the fit, with `J` the Gauss–Newton curvature of the objective and `I` the variance of the scores of its terms, summed over blocks of positions as the meat of `Sandwich` (Holmes and Walker 2017; Lyddon, Holmes and Walker 2019). For the sum of squares of a model with Gaussian noise of variance `σ²` it estimates `1/(2σ²)`; for a correct observation model, 1. It needs `loss_function="sse"` or observation models, and a deterministic engine, and is cheap and local. The variance sums the scores over blocks of positions, so it holds under dependence inside a block. Attributes: | Name | Type | Description | | -------------- | ----- | ------------------------------------------------------------------------------------------------- | | `block_length` | \`int | auto\` | | `starts` | `int` | Starts of the fit (default 1): the initial guess, then rows of a Latin hypercube over the bounds. | Source code in `commol/context/inference/smc.py` ```python class InformationMatchingEta(RangeChecked): """ Calibrate `η` by information matching: `η = tr(J I⁻¹ J) / tr(J)` at the fit, with ``J`` the Gauss–Newton curvature of the objective and ``I`` the variance of the scores of its terms, summed over blocks of positions as the meat of `Sandwich` (Holmes and Walker 2017; Lyddon, Holmes and Walker 2019). For the sum of squares of a model with Gaussian noise of variance ``σ²`` it estimates ``1/(2σ²)``; for a correct observation model, 1. It needs ``loss_function="sse"`` or observation models, and a deterministic engine, and is cheap and local. The variance sums the scores over blocks of positions, so it holds under dependence inside a block. Attributes ---------- block_length : int | "auto" Positions per block of the scores; there must be more blocks than parameters. starts : int Starts of the fit (default 1): the initial guess, then rows of a Latin hypercube over the bounds. """ model_config = ConfigDict(extra="forbid", frozen=True) kind: Literal["information_matching"] = "information_matching" block_length: BlockLength = Field(description="Positions per block.") starts: int = Field(default=1, description="Starts of the fit.") ``` ## BootstrapCoverageEta Bases: `RangeChecked` Calibrate `η` by bootstrap coverage: the `η` at which the central intervals of `confidence_level` of the Gibbs posteriors of Bayesian-bootstrap data sets hold the fit at that rate, over the replicates and the parameters (Syring and Martin 2019). It works with any loss and engine, and costs one sampler per replicate. Attributes: | Name | Type | Description | | ------------------ | ------- | ------------------------------------------------------------------------------------------------- | | `replicates` | `int` | Number of Bayesian-bootstrap data sets, at least 1. | | `block_length` | \`int | auto\` | | `confidence_level` | `float` | The level of the intervals, strictly between 0 and 1. | | `starts` | `int` | Starts of the fit (default 1): the initial guess, then rows of a Latin hypercube over the bounds. | Source code in `commol/context/inference/smc.py` ```python class BootstrapCoverageEta(RangeChecked): """ Calibrate `η` by bootstrap coverage: the `η` at which the central intervals of `confidence_level` of the Gibbs posteriors of Bayesian-bootstrap data sets hold the fit at that rate, over the replicates and the parameters (Syring and Martin 2019). It works with any loss and engine, and costs one sampler per replicate. Attributes ---------- replicates : int Number of Bayesian-bootstrap data sets, at least 1. block_length : int | "auto" Positions per block of the bootstrap. confidence_level : float The level of the intervals, strictly between 0 and 1. starts : int Starts of the fit (default 1): the initial guess, then rows of a Latin hypercube over the bounds. """ model_config = ConfigDict(extra="forbid", frozen=True) kind: Literal["bootstrap_coverage"] = "bootstrap_coverage" replicates: int = Field(description="Number of bootstrap data sets.") block_length: BlockLength = Field(description="Positions per block.") confidence_level: float = Field(description="The level of the intervals.") starts: int = Field(default=1, description="Starts of the fit.") ``` ## ABCSMC Bases: `WithPriors`, `UncertaintyMethod[SMCResult]`, `RangeChecked` Approximate Bayesian computation by sequential Monte Carlo (Del Moral, Doucet and Jasra 2012): the tolerance `ε` falls step by step, and a particle is inside when the distance of its run is at most `ε`. Each next tolerance keeps the weighted share `target_cess` of the particles: with weights of 0 or 1, the weighted quantile of their distances. With a stochastic engine each distance reads one fresh replicate, which the particle keeps until a move replaces it. The run stops when the first move of a step accepts less than `min_acceptance`, and keeps the sample of the tolerance before. Attributes: | Name | Type | Description | | ------------------ | -------------- | ----------------------------------------------------------------------------------- | | `priors` | \`Priors | dict[str, Distribution]\` | | `distance` | \`LossDistance | SeriesDistance\` | | `particles` | `int` | Number of particles, at least 2. | | `target_cess` | `float` | The weighted share of the particles each tolerance keeps, strictly between 0 and 1. | | `min_acceptance` | `float` | The acceptance rate below which the run stops, strictly between 0 and 1. | | `stay_probability` | `float` | The probability that a particle does not move in a step, strictly between 0 and 1. | Source code in `commol/context/inference/smc.py` ```python class ABCSMC(WithPriors, UncertaintyMethod[SMCResult], RangeChecked): """ Approximate Bayesian computation by sequential Monte Carlo (Del Moral, Doucet and Jasra 2012): the tolerance ``ε`` falls step by step, and a particle is inside when the distance of its run is at most ``ε``. Each next tolerance keeps the weighted share `target_cess` of the particles: with weights of 0 or 1, the weighted quantile of their distances. With a stochastic engine each distance reads one fresh replicate, which the particle keeps until a move replaces it. The run stops when the first move of a step accepts less than `min_acceptance`, and keeps the sample of the tolerance before. Attributes ---------- priors : Priors | dict[str, Distribution] The prior of each calibrated parameter, or ``Priors.uniform_on_bounds()``. distance : LossDistance | SeriesDistance How far a run is from the observations. particles : int Number of particles, at least 2. target_cess : float The weighted share of the particles each tolerance keeps, strictly between 0 and 1. min_acceptance : float The acceptance rate below which the run stops, strictly between 0 and 1. stay_probability : float The probability that a particle does not move in a step, strictly between 0 and 1. """ distance: ABCDistance = Field(description="The distance.") particles: int = Field(description="Number of particles.") target_cess: float = Field(description="Share kept per step.") min_acceptance: float = Field( description="Acceptance rate below which the run stops." ) stay_probability: float = Field(description="Probability a particle does not move.") ``` ## ABCRejection Bases: `WithPriors`, `UncertaintyMethod[SMCResult]`, `RangeChecked` ABC rejection: `draws` draws of the priors, of which the closest share `quantile` is kept, with equal weights. It is one step of `ABCSMC` with no move. With a stochastic engine each distance reads one fresh replicate. Attributes: | Name | Type | Description | | ---------- | -------------- | ------------------------------------------------------ | | `priors` | \`Priors | dict[str, Distribution]\` | | `distance` | \`LossDistance | SeriesDistance\` | | `draws` | `int` | Number of draws of the priors, at least 2. | | `quantile` | `float` | The share of the draws kept, strictly between 0 and 1. | Source code in `commol/context/inference/smc.py` ```python class ABCRejection(WithPriors, UncertaintyMethod[SMCResult], RangeChecked): """ ABC rejection: `draws` draws of the priors, of which the closest share `quantile` is kept, with equal weights. It is one step of `ABCSMC` with no move. With a stochastic engine each distance reads one fresh replicate. Attributes ---------- priors : Priors | dict[str, Distribution] The prior of each calibrated parameter, or ``Priors.uniform_on_bounds()``. distance : LossDistance | SeriesDistance How far a run is from the observations. draws : int Number of draws of the priors, at least 2. quantile : float The share of the draws kept, strictly between 0 and 1. """ distance: ABCDistance = Field(description="The distance.") draws: int = Field(description="Number of draws.") quantile: float = Field(description="The share of draws kept.") ``` ## LossDistance Bases: `BaseModel` The problem's loss as the distance of ABC: `loss_function` with the observation weights and `normalize_observations`. With a stochastic engine it reads one fresh replicate per evaluation, so the problem's `replicates` and `replicate_summary` do not apply. Source code in `commol/context/inference/smc.py` ```python class LossDistance(BaseModel): """ The problem's loss as the distance of ABC: ``loss_function`` with the observation weights and ``normalize_observations``. With a stochastic engine it reads one fresh replicate per evaluation, so the problem's ``replicates`` and ``replicate_summary`` do not apply. """ model_config = ConfigDict(extra="forbid", frozen=True) kind: Literal["loss"] = "loss" ``` ## SeriesDistance Bases: `BaseModel` The largest distance over the series: `metric` of each series on its residuals divided by the root mean square of its observed values, so series in different units compare on one scale. Attributes: | Name | Type | Description | | -------- | ------------- | ----------------------------------------------------------------------------------------- | | `metric` | `{rmse, mae}` | The weighted root mean square or mean absolute value of the scaled residuals of a series. | Source code in `commol/context/inference/smc.py` ```python class SeriesDistance(BaseModel): """ The largest distance over the series: `metric` of each series on its residuals divided by the root mean square of its observed values, so series in different units compare on one scale. Attributes ---------- metric : {"rmse", "mae"} The weighted root mean square or mean absolute value of the scaled residuals of a series. """ model_config = ConfigDict(extra="forbid", frozen=True) kind: Literal["series"] = "series" metric: Literal["rmse", "mae"] = Field(description="The metric of a series.") ``` ## SMCResult Bases: `ResultModel` The result of a sequential Monte Carlo method. Attributes: | Name | Type | Description | | -------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sample` | `ParameterSample` | The weighted particles at the last level. | | `levels` | `list[float]` | The level of each step: the temperature of a Gibbs posterior, from above 0 to 1, or the tolerance of ABC, falling. | | `kept` | \`list\[float | None\]\` | | `log_evidence` | `float` | The log of the product of the mean incremental weights: for a Gibbs posterior, the log of ∫ prior(θ) exp(−η loss(θ)) dθ; for a Bayesian posterior, the log marginal likelihood log ∫ prior(θ) L(θ) dθ; for ABC, the log of the prior probability of the last tolerance. | | `diagnostics` | `SMCDiagnostics` | The effective sizes, acceptance rates and move counts of each step. | Source code in `commol/context/inference/smc.py` ```python class SMCResult(ResultModel): """ The result of a sequential Monte Carlo method. Attributes ---------- sample : ParameterSample The weighted particles at the last level. levels : list[float] The level of each step: the temperature of a Gibbs posterior, from above 0 to 1, or the tolerance of ABC, falling. kept : list[float | None] The value each particle of the sample keeps, in its order: the loss of a Gibbs posterior, the log-likelihood of a Bayesian posterior, or the distance of ABC; None for a particle outside the support. log_evidence : float The log of the product of the mean incremental weights: for a Gibbs posterior, the log of ``∫ prior(θ) exp(−η loss(θ)) dθ``; for a Bayesian posterior, the log marginal likelihood ``log ∫ prior(θ) L(θ) dθ``; for ABC, the log of the prior probability of the last tolerance. diagnostics : SMCDiagnostics The effective sizes, acceptance rates and move counts of each step. """ model_config = ConfigDict(extra="forbid", frozen=True, ser_json_inf_nan="constants") sample: ParameterSample levels: list[float] kept: list[float | None] log_evidence: float diagnostics: SMCDiagnostics ``` ## GibbsResult Bases: `SMCResult` The result of a Gibbs posterior: the attributes of `SMCResult`, and these. Attributes: | Name | Type | Description | | ---------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `eta` | `float` | The η the posterior ran with. | | `fit` | \`CalibrationResult | None\` | | `coverage` | \`float | None\` | | `failed` | `list[str]` | With BootstrapCoverageEta, each replicate whose sampler failed: "Replicate failed: ", also logged as a warning. Empty otherwise. | Source code in `commol/context/inference/smc.py` ```python class GibbsResult(SMCResult): """ The result of a Gibbs posterior: the attributes of `SMCResult`, and these. Attributes ---------- eta : float The `η` the posterior ran with. fit : CalibrationResult | None The fit a calibration of `η` read, ``Calibrator.run(starts)``; None when `η` is a number. coverage : float | None With `BootstrapCoverageEta`, the coverage of the fit by the intervals of the bootstrap posteriors at `η`; otherwise None. failed : list[str] With `BootstrapCoverageEta`, each replicate whose sampler failed: ``"Replicate failed: "``, also logged as a warning. Empty otherwise. """ eta: float fit: CalibrationResult | None coverage: float | None failed: list[str] = Field(default_factory=list) ``` ## SMCDiagnostics Bases: `ResultModel` What happened at each step of a sequential Monte Carlo run. Attributes: | Name | Type | Description | | ------------------ | ------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `effective_sizes` | `list[float]` | Kish's effective size of the weights after the reweighting of each step. A step that keeps few particles has a small one. | | `resampled` | `list[bool]` | Whether each step resampled. | | `acceptance_rates` | `list[float]` | The share of the proposals of the first move of each step that were accepted; for ABC rejection, the share of draws kept. | | `move_counts` | `list[int]` | How many times each particle moved in each step: ⌈log c / log(1 − a)⌉ with c the stay probability and a the acceptance rate. | | `evaluations` | `int` | The number of model runs. | | `stop_reason` | \`str | None\` | | `notes` | `list[str]` | What the reader of the sample should know, such as settings of the problem that do not apply. | Source code in `commol/context/inference/smc.py` ```python class SMCDiagnostics(ResultModel): """ What happened at each step of a sequential Monte Carlo run. Attributes ---------- effective_sizes : list[float] Kish's effective size of the weights after the reweighting of each step. A step that keeps few particles has a small one. resampled : list[bool] Whether each step resampled. acceptance_rates : list[float] The share of the proposals of the first move of each step that were accepted; for ABC rejection, the share of draws kept. move_counts : list[int] How many times each particle moved in each step: ``⌈log c / log(1 − a)⌉`` with ``c`` the stay probability and ``a`` the acceptance rate. evaluations : int The number of model runs. stop_reason : str | None Why the run stopped before its last level, or None. notes : list[str] What the reader of the sample should know, such as settings of the problem that do not apply. """ model_config = ConfigDict(extra="forbid", frozen=True) effective_sizes: list[float] resampled: list[bool] acceptance_rates: list[float] move_counts: list[int] evaluations: int stop_reason: str | None notes: list[str] ``` ## Local approximations Guide: [Local Approximations](https://munqu.github.io/commol/latest/guide/uncertainty/local/index.md). ## Laplace Bases: `UncertaintyMethod[LaplaceResult]`, `RangeChecked` The Laplace approximation of the problem's observation models: the normal `N(θ̂, H⁻¹)` on the unbounded scale of the parameters, centred on the fit, with `H` the curvature of `−log L` there. It needs `observation_models`: a loss has no noise scale, so its curvature is no inverse variance (use `Sandwich`). A fit on a bound of its parameter is refused: on the unbounded scale its variance is infinite there. Attributes: | Name | Type | Description | | ----------- | ------------------------- | ------------------------------------------------------------------------------------------------- | | `draws` | `int` | Number of draws of the normal, at least 1. | | `curvature` | `{gauss_newton, hessian}` | The curvature (default "gauss_newton"). | | `starts` | `int` | Starts of the fit (default 1): the initial guess, then rows of a Latin hypercube over the bounds. | Source code in `commol/context/inference/local.py` ```python class Laplace(UncertaintyMethod[LaplaceResult], RangeChecked): """ The Laplace approximation of the problem's observation models: the normal ``N(θ̂, H⁻¹)`` on the unbounded scale of the parameters, centred on the fit, with ``H`` the curvature of ``−log L`` there. It needs ``observation_models``: a loss has no noise scale, so its curvature is no inverse variance (use `Sandwich`). A fit on a bound of its parameter is refused: on the unbounded scale its variance is infinite there. Attributes ---------- draws : int Number of draws of the normal, at least 1. curvature : {"gauss_newton", "hessian"} The curvature (default ``"gauss_newton"``). starts : int Starts of the fit (default 1): the initial guess, then rows of a Latin hypercube over the bounds. """ draws: int = Field(description="Number of draws.") curvature: CurvatureName = Field( default="gauss_newton", description="The curvature." ) starts: int = Field(default=1, description="Starts of the fit.") ``` ## Sandwich Bases: `UncertaintyMethod[SandwichResult]`, `RangeChecked` The sandwich covariance `H⁻¹ B H⁻¹` at the fit, with the meat `B` summed over blocks of positions (the clustered estimator CR1): `B = G/(G − 1) · Σ_b s_b s_bᵀ`, with `s_b` the sum of the gradients of the terms of the observations of block `b`. It needs no noise model: with a loss, `H` is the curvature of the loss; with observation models, of `−log L`. It holds when the noise depends on its neighbours inside a block, when its size changes, and when the noise model is wrong but the mean is right. Intervals and draws read a Student t with `G − 1` degrees of freedom; with `G` blocks not above the parameters it is refused, since `B` would have rank below them. `loss_function="mae"` is refused: its sandwich needs a density of the residuals at 0 and a bandwidth (use a bootstrap). Attributes: | Name | Type | Description | | -------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------- | | `block_length` | \`int | auto\` | | `draws` | `int` | Number of draws of the Student t, at least 1. | | `curvature` | `{gauss_newton, hessian}` | The curvature H (default "gauss_newton"). With the Hessian, the sandwich also holds when the mean is wrong. | | `starts` | `int` | Starts of the fit (default 1): the initial guess, then rows of a Latin hypercube over the bounds. | Source code in `commol/context/inference/local.py` ```python class Sandwich(UncertaintyMethod[SandwichResult], RangeChecked): """ The sandwich covariance ``H⁻¹ B H⁻¹`` at the fit, with the meat ``B`` summed over blocks of positions (the clustered estimator CR1): ``B = G/(G − 1) · Σ_b s_b s_bᵀ``, with ``s_b`` the sum of the gradients of the terms of the observations of block ``b``. It needs no noise model: with a loss, ``H`` is the curvature of the loss; with observation models, of ``−log L``. It holds when the noise depends on its neighbours inside a block, when its size changes, and when the noise model is wrong but the mean is right. Intervals and draws read a Student t with ``G − 1`` degrees of freedom; with ``G`` blocks not above the parameters it is refused, since ``B`` would have rank below them. ``loss_function="mae"`` is refused: its sandwich needs a density of the residuals at 0 and a bandwidth (use a bootstrap). Attributes ---------- block_length : int | "auto" Positions per block; ``"auto"`` is the rule of Politis and White (2004) for non-overlapping blocks on the residuals of the fit. draws : int Number of draws of the Student t, at least 1. curvature : {"gauss_newton", "hessian"} The curvature ``H`` (default ``"gauss_newton"``). With the Hessian, the sandwich also holds when the mean is wrong. starts : int Starts of the fit (default 1): the initial guess, then rows of a Latin hypercube over the bounds. """ block_length: BlockLength = Field(description="Positions per block.") draws: int = Field(description="Number of draws.") curvature: CurvatureName = Field( default="gauss_newton", description="The curvature." ) starts: int = Field(default=1, description="Starts of the fit.") ``` ## LaplaceMixture Bases: `UncertaintyMethod[ImportanceResult]`, `RangeChecked` The Laplace mixture: a Laplace approximation at each distinct optimum of a multi-start fit, weighted by its Laplace evidence, as the proposal of importance sampling of a posterior, with the largest weights smoothed (Pareto-smoothed importance sampling, Vehtari et al. 2024). The ends of the starts merge into a component when they are closer than one Mahalanobis unit to it. Every draw is weighted by the target over the mixture, on the unbounded scale. `k_hat` says whether the mixture covers the target: above 0.7 the sample is marked unreliable. Attributes: | Name | Type | Description | | ----------- | ------------------------- | ------------------------------------------------------------------------------------------------- | | `starts` | `int` | Starts of the fit (default 1): the initial guess, then rows of a Latin hypercube over the bounds. | | `draws` | `int` | Number of draws of the mixture, at least 25, so that the tail of the weights has 5. | | `target` | \`LikelihoodTarget | GibbsTarget\` | | `curvature` | `{gauss_newton, hessian}` | The curvature of each component (default "gauss_newton"). | Source code in `commol/context/inference/local.py` ```python class LaplaceMixture(UncertaintyMethod[ImportanceResult], RangeChecked): """ The Laplace mixture: a Laplace approximation at each distinct optimum of a multi-start fit, weighted by its Laplace evidence, as the proposal of importance sampling of a posterior, with the largest weights smoothed (Pareto-smoothed importance sampling, Vehtari et al. 2024). The ends of the starts merge into a component when they are closer than one Mahalanobis unit to it. Every draw is weighted by the target over the mixture, on the unbounded scale. ``k_hat`` says whether the mixture covers the target: above 0.7 the sample is marked unreliable. Attributes ---------- starts : int Starts of the fit (default 1): the initial guess, then rows of a Latin hypercube over the bounds. draws : int Number of draws of the mixture, at least 25, so that the tail of the weights has 5. target : LikelihoodTarget | GibbsTarget The posterior of the observation models with priors, or the Gibbs posterior of the loss with priors and ``eta``. curvature : {"gauss_newton", "hessian"} The curvature of each component (default ``"gauss_newton"``). """ starts: int = Field(default=1, description="Starts of the fit.") draws: int = Field(description="Number of draws.") target: MixtureTarget = Field(description="The posterior to sample.") curvature: CurvatureName = Field( default="gauss_newton", description="The curvature." ) ``` ## LikelihoodTarget Bases: `WithPriors` The posterior of the problem's observation models, `π(θ) ∝ prior(θ) · L(θ)`, as the target of a `LaplaceMixture`. Attributes: | Name | Type | Description | | -------- | -------- | ------------------------- | | `priors` | \`Priors | dict[str, Distribution]\` | Source code in `commol/context/inference/local.py` ```python class LikelihoodTarget(WithPriors): """ The posterior of the problem's observation models, ``π(θ) ∝ prior(θ) · L(θ)``, as the target of a `LaplaceMixture`. Attributes ---------- priors : Priors | dict[str, Distribution] The prior of each calibrated parameter, or ``Priors.uniform_on_bounds()``. """ kind: Literal["likelihood"] = "likelihood" ``` ## GibbsTarget Bases: `WithPriors`, `RangeChecked` The Gibbs posterior of the problem's loss, `π(θ) ∝ prior(θ) · exp(−η · loss(θ))`, as the target of a `LaplaceMixture`. Attributes: | Name | Type | Description | | -------- | -------- | ------------------------------- | | `priors` | \`Priors | dict[str, Distribution]\` | | `eta` | `float` | The scale of the loss, above 0. | Source code in `commol/context/inference/local.py` ```python class GibbsTarget(WithPriors, RangeChecked): """ The Gibbs posterior of the problem's loss, ``π(θ) ∝ prior(θ) · exp(−η · loss(θ))``, as the target of a `LaplaceMixture`. Attributes ---------- priors : Priors | dict[str, Distribution] The prior of each calibrated parameter, or ``Priors.uniform_on_bounds()``. eta : float The scale of the loss, above 0. """ kind: Literal["gibbs"] = "gibbs" eta: float = Field(description="Scale of the loss.") ``` ## LocalResult Bases: `ResultModel` What every local approximation returns: a distribution on the unbounded scale of the parameters, centred on the fit. Attributes: | Name | Type | Description | | ------------ | ------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `fit` | `CalibrationResult` | The fit the approximation is centred on: Calibrator.run(starts). | | `covariance` | `list[list[float]]` | The covariance of the distribution on the unbounded scale of the parameters, in the order of the problem's parameters. | | `sample` | `ParameterSample` | Draws of the distribution, mapped back to the parameters, with equal weights. | | `notes` | `list[str]` | The entries of the curvature whose estimated error is not small against their scale; each is also logged as a warning. | Source code in `commol/context/inference/local.py` ```python class LocalResult(ResultModel): """ What every local approximation returns: a distribution on the unbounded scale of the parameters, centred on the fit. Attributes ---------- fit : CalibrationResult The fit the approximation is centred on: ``Calibrator.run(starts)``. covariance : list[list[float]] The covariance of the distribution on the unbounded scale of the parameters, in the order of the problem's parameters. sample : ParameterSample Draws of the distribution, mapped back to the parameters, with equal weights. notes : list[str] The entries of the curvature whose estimated error is not small against their scale; each is also logged as a warning. """ model_config = ConfigDict(extra="forbid", frozen=True) fit: CalibrationResult covariance: list[list[float]] sample: ParameterSample notes: list[str] def degrees_of_freedom(self) -> float | None: """ The degrees of freedom of the distribution. Returns ------- float | None Those of a Student t, or None for a normal. """ return None ``` ### Methods: #### degrees_of_freedom ```python degrees_of_freedom() -> float | None ``` The degrees of freedom of the distribution. Returns: | Type | Description | | ------- | ----------- | | \`float | None\` | Source code in `commol/context/inference/local.py` ```python def degrees_of_freedom(self) -> float | None: """ The degrees of freedom of the distribution. Returns ------- float | None Those of a Student t, or None for a normal. """ return None ``` ## LaplaceResult Bases: `LocalResult` The result of a Laplace approximation: the normal `N(θ̂, Σ)` on the unbounded scale, `Σ = H⁻¹`. Attributes: | Name | Type | Description | | ------------ | ------------------- | ------------------------------------------------------------------------------------ | | `fit` | `CalibrationResult` | The fit the approximation is centred on: Calibrator.run(starts). | | `covariance` | `list[list[float]]` | Σ = H⁻¹ on the unbounded scale of the parameters. | | `sample` | `ParameterSample` | Draws of the normal, mapped back to the parameters, with equal weights. | | `notes` | `list[str]` | The entries of the curvature whose estimated error is not small against their scale. | Source code in `commol/context/inference/local.py` ```python class LaplaceResult(LocalResult): """ The result of a Laplace approximation: the normal ``N(θ̂, Σ)`` on the unbounded scale, ``Σ = H⁻¹``. Attributes ---------- fit : CalibrationResult The fit the approximation is centred on: ``Calibrator.run(starts)``. covariance : list[list[float]] ``Σ = H⁻¹`` on the unbounded scale of the parameters. sample : ParameterSample Draws of the normal, mapped back to the parameters, with equal weights. notes : list[str] The entries of the curvature whose estimated error is not small against their scale. """ ``` ## SandwichResult Bases: `LocalResult` The result of a sandwich: a Student t on the unbounded scale of the parameters, centred on the fit, of covariance `H⁻¹ B H⁻¹` and `blocks − 1` degrees of freedom. Attributes: | Name | Type | Description | | ------------------------ | ------------------- | ------------------------------------------------------------------------------------ | | `fit` | `CalibrationResult` | The fit the sandwich is centred on: Calibrator.run(starts). | | `covariance` | `list[list[float]]` | H⁻¹ B H⁻¹ on the unbounded scale of the parameters. | | `sample` | `ParameterSample` | Draws of the Student t, mapped back to the parameters, with equal weights. | | `notes` | `list[str]` | The entries of the curvature whose estimated error is not small against their scale. | | `blocks` | `int` | The number of blocks G; intervals read a Student t with G − 1 degrees of freedom. | | `block_length` | `int` | The positions per block. | | `block_length_estimates` | \`dict[str, float] | None\` | Source code in `commol/context/inference/local.py` ```python class SandwichResult(LocalResult): """ The result of a sandwich: a Student t on the unbounded scale of the parameters, centred on the fit, of covariance ``H⁻¹ B H⁻¹`` and ``blocks − 1`` degrees of freedom. Attributes ---------- fit : CalibrationResult The fit the sandwich is centred on: ``Calibrator.run(starts)``. covariance : list[list[float]] ``H⁻¹ B H⁻¹`` on the unbounded scale of the parameters. sample : ParameterSample Draws of the Student t, mapped back to the parameters, with equal weights. notes : list[str] The entries of the curvature whose estimated error is not small against their scale. blocks : int The number of blocks ``G``; intervals read a Student t with ``G − 1`` degrees of freedom. block_length : int The positions per block. block_length_estimates : dict[str, float] | None With ``block_length="auto"``, the estimate of each series, by label; otherwise None. """ blocks: int block_length: int block_length_estimates: dict[str, float] | None def degrees_of_freedom(self) -> float | None: """ The degrees of freedom of the Student t. Returns ------- float | None ``blocks − 1``. """ return float(self.blocks - 1) ``` ### Methods: #### degrees_of_freedom ```python degrees_of_freedom() -> float | None ``` The degrees of freedom of the Student t. Returns: | Type | Description | | ------- | ----------- | | \`float | None\` | Source code in `commol/context/inference/local.py` ```python def degrees_of_freedom(self) -> float | None: """ The degrees of freedom of the Student t. Returns ------- float | None ``blocks − 1``. """ return float(self.blocks - 1) ``` ## DeltaBand Bases: `ResultModel` The band of every output by the delta method (`Calibrator.delta_band`): the prediction at the fit plus and minus a quantile times the standard deviation of the linearized model, `J Σ Jᵀ`, with no run of the draws. Attributes: | Name | Type | Description | | ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------ | | `confidence_level` | `float` | The level of the central interval. | | `positions` | `list[float]` | Where the values are. | | `center, lower, upper` | `dict[str, list[float]]` | For each output, one value per position: the prediction at the fit and the ends of the interval. | Source code in `commol/context/inference/local.py` ```python class DeltaBand(ResultModel): """ The band of every output by the delta method (``Calibrator.delta_band``): the prediction at the fit plus and minus a quantile times the standard deviation of the linearized model, ``J Σ Jᵀ``, with no run of the draws. Attributes ---------- confidence_level : float The level of the central interval. positions : list[float] Where the values are. center, lower, upper : dict[str, list[float]] For each output, one value per position: the prediction at the fit and the ends of the interval. """ model_config = ConfigDict(extra="forbid", frozen=True) confidence_level: float positions: list[float] center: dict[str, list[float]] = Field(description="Prediction at the fit") lower: dict[str, list[float]] = Field(description="Lower end") upper: dict[str, list[float]] = Field(description="Upper end") ``` ## ImportanceResult Bases: `ResultModel` The result of a Laplace mixture: draws of the mixture weighted by the target over the mixture, smoothed by Pareto-smoothed importance sampling. Attributes: | Name | Type | Description | | ---------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | | `fit` | `CalibrationResult` | The multi-start fit whose ends made the components: Calibrator.run(starts). | | `sample` | `ParameterSample` | The draws with their smoothed weights. | | `components` | `list[MixtureComponent]` | The components, in order of their objective. | | `k_hat` | `float` | The Pareto shape of the tail of the weights. Up to 0.5 the weights have a finite variance; above 0.7 the estimate is unreliable. | | `effective_size` | `float` | Kish's effective size of the smoothed weights. | | `reliable` | `bool` | Whether k_hat is at most 0.7. An unreliable sample is also logged as a warning. | | `notes` | `list[str]` | Ends that gave no component, and an unreliable sample, in words. | Source code in `commol/context/inference/local.py` ```python class ImportanceResult(ResultModel): """ The result of a Laplace mixture: draws of the mixture weighted by the target over the mixture, smoothed by Pareto-smoothed importance sampling. Attributes ---------- fit : CalibrationResult The multi-start fit whose ends made the components: ``Calibrator.run(starts)``. sample : ParameterSample The draws with their smoothed weights. components : list[MixtureComponent] The components, in order of their objective. k_hat : float The Pareto shape of the tail of the weights. Up to 0.5 the weights have a finite variance; above 0.7 the estimate is unreliable. effective_size : float Kish's effective size of the smoothed weights. reliable : bool Whether ``k_hat`` is at most 0.7. An unreliable sample is also logged as a warning. notes : list[str] Ends that gave no component, and an unreliable sample, in words. """ model_config = ConfigDict(extra="forbid", frozen=True) fit: CalibrationResult sample: ParameterSample components: list[MixtureComponent] k_hat: float effective_size: float reliable: bool notes: list[str] ``` ## MixtureComponent Bases: `ResultModel` One component of a Laplace mixture: the Laplace approximation at one optimum of the starts. Attributes: | Name | Type | Description | | -------------- | ------------------- | ------------------------------------------------------------- | | `center` | `dict[str, float]` | The optimum, by parameter. | | `weight` | `float` | Its weight in the mixture: its Laplace evidence over the sum. | | `covariance` | `list[list[float]]` | Its covariance on the unbounded scale of the parameters. | | `log_evidence` | `float` | The log of its Laplace evidence, log p(θ̂) + log | Source code in `commol/context/inference/local.py` ```python class MixtureComponent(ResultModel): """ One component of a Laplace mixture: the Laplace approximation at one optimum of the starts. Attributes ---------- center : dict[str, float] The optimum, by parameter. weight : float Its weight in the mixture: its Laplace evidence over the sum. covariance : list[list[float]] Its covariance on the unbounded scale of the parameters. log_evidence : float The log of its Laplace evidence, ``log p(θ̂) + log |dθ/du| + (d/2) log 2π + ½ log |Σ|``. """ model_config = ConfigDict(extra="forbid", frozen=True) center: dict[str, float] weight: float covariance: list[list[float]] log_evidence: float ``` ## Conformal prediction Guide: [Conformal Prediction Intervals](https://munqu.github.io/commol/latest/guide/uncertainty/conformal/index.md). ## ConformalMethod Bases: `UncertaintyMethod[ConformalResult]` What every conformal method takes: a score, the pooling of its quantiles, and a confidence level, none of them with a default. Attributes: | Name | Type | Description | | ------------------ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `score` | `{absolute, normalized, spread}` | The nonconformity score. "normalized" needs by="all": per series its factor is one constant, so its intervals would equal those of "absolute". | | `by` | `{series, all}` | Whether each series gets its own quantile, or all series share one. | | `confidence_level` | `float` | The level of the intervals, strictly between 0 and 1. | Source code in `commol/context/inference/conformal.py` ```python class ConformalMethod(UncertaintyMethod[ConformalResult]): """ What every conformal method takes: a score, the pooling of its quantiles, and a confidence level, none of them with a default. Attributes ---------- score : {"absolute", "normalized", "spread"} The nonconformity score. ``"normalized"`` needs ``by="all"``: per series its factor is one constant, so its intervals would equal those of ``"absolute"``. by : {"series", "all"} Whether each series gets its own quantile, or all series share one. confidence_level : float The level of the intervals, strictly between 0 and 1. """ score: ConformalScore = Field(description="The nonconformity score.") by: Pooling = Field(description="Which scores each quantile reads.") confidence_level: float = Field(description="The level of the intervals.") @model_validator(mode="before") @classmethod def check_score_and_by(cls, data: JsonValue) -> JsonValue: """Check, in Rust, that `score` and `by` are given and fit together.""" if isinstance(data, Mapping): def name(key: str) -> str | None: value = data.get(key) return None if value is None else str(value) commol_rs.inference.check_conformal_settings(name("score"), name("by")) return data ``` ### Methods: #### check_score_and_by ```python check_score_and_by(data: JsonValue) -> JsonValue ``` Check, in Rust, that `score` and `by` are given and fit together. Source code in `commol/context/inference/conformal.py` ```python @model_validator(mode="before") @classmethod def check_score_and_by(cls, data: JsonValue) -> JsonValue: """Check, in Rust, that `score` and `by` are given and fit together.""" if isinstance(data, Mapping): def name(key: str) -> str | None: value = data.get(key) return None if value is None else str(value) commol_rs.inference.check_conformal_settings(name("score"), name("by")) return data ``` ## SplitConformal Bases: `ConformalMethod`, `RangeChecked` Split conformal prediction. It fits the problem's observations and scores `calibration_data`, observations held out of the fit. When the scores of the held-out and of new observations are exchangeable, each interval covers a new observation with probability at least `confidence_level`. It takes `score`, `by` and `confidence_level`, as every `ConformalMethod`. Attributes: | Name | Type | Description | | ------------------ | ------------------------- | ------------------------------------------------------------- | | `calibration_data` | `list[ObservedDataPoint]` | Held-out observations; each of their series gets intervals. | | `positions` | `list[float]` | Where the intervals are, for each series of calibration_data. | | `sample` | \`ParameterSample | None\` | | `starts` | `int` | Starts of the fit (default 1). | Source code in `commol/context/inference/conformal.py` ```python class SplitConformal(ConformalMethod, RangeChecked): """ Split conformal prediction. It fits the problem's observations and scores `calibration_data`, observations held out of the fit. When the scores of the held-out and of new observations are exchangeable, each interval covers a new observation with probability at least `confidence_level`. It takes `score`, `by` and `confidence_level`, as every `ConformalMethod`. Attributes ---------- calibration_data : list[ObservedDataPoint] Held-out observations; each of their series gets intervals. positions : list[float] Where the intervals are, for each series of `calibration_data`. sample : ParameterSample | None None: the prediction is the fit. A sample: the prediction is the weighted median of its predictive, and its weighted standard deviation is the spread of ``score="spread"``. starts : int Starts of the fit (default 1). """ calibration_data: list[ObservedDataPoint] = Field( min_length=1, description="Held-out observations." ) positions: list[float] = Field(min_length=1, description="Where the intervals are.") sample: ParameterSample | None = Field( default=None, description="The sample whose predictive predicts." ) starts: int = Field(default=1, description="Starts of the fit.") @model_validator(mode="after") def check_spread(self) -> Self: """Reject ``score="spread"`` without a sample.""" _no_spread_without_sample(self) return self ``` ### Methods: #### check_spread ```python check_spread() -> Self ``` Reject `score="spread"` without a sample. Source code in `commol/context/inference/conformal.py` ```python @model_validator(mode="after") def check_spread(self) -> Self: """Reject ``score="spread"`` without a sample.""" _no_spread_without_sample(self) return self ``` ## RollingConformal Bases: `ConformalMethod`, `RangeChecked` Rolling-origin conformal prediction: intervals ahead of the data, one quantile per horizon. Each origin fits the observations at or before it and scores the later ones. Horizon `k` is the `k`-th observation of a series after the origin: a count, not a distance on the axis. It takes `score`, `by` and `confidence_level`, as every `ConformalMethod`. Attributes: | Name | Type | Description | | ----------- | ------------- | ---------------------------------------------------------------------------- | | `origins` | `list[float]` | The positions of the origins. | | `positions` | `list[float]` | Where the intervals are, all after the data; the k-th position is horizon k. | | `starts` | `int` | Starts of each fit (default 1). | Source code in `commol/context/inference/conformal.py` ```python class RollingConformal(ConformalMethod, RangeChecked): """ Rolling-origin conformal prediction: intervals ahead of the data, one quantile per horizon. Each origin fits the observations at or before it and scores the later ones. Horizon ``k`` is the ``k``-th observation of a series after the origin: a count, not a distance on the axis. It takes `score`, `by` and `confidence_level`, as every `ConformalMethod`. Attributes ---------- origins : list[float] The positions of the origins. positions : list[float] Where the intervals are, all after the data; the ``k``-th position is horizon ``k``. starts : int Starts of each fit (default 1). """ origins: list[float] = Field(min_length=1, description="The origins.") positions: list[float] = Field(min_length=1, description="Where the intervals are.") starts: int = Field(default=1, description="Starts of each fit.") @model_validator(mode="after") def check_spread(self) -> Self: """Reject ``score="spread"``: one fit per origin has no spread.""" if self.score == "spread": raise ValueError( "RollingConformal fits once per origin, so its predictions have no " 'spread for score="spread"; use "absolute" or "normalized"' ) return self ``` ### Methods: #### check_spread ```python check_spread() -> Self ``` Reject `score="spread"`: one fit per origin has no spread. Source code in `commol/context/inference/conformal.py` ```python @model_validator(mode="after") def check_spread(self) -> Self: """Reject ``score="spread"``: one fit per origin has no spread.""" if self.score == "spread": raise ValueError( "RollingConformal fits once per origin, so its predictions have no " 'spread for score="spread"; use "absolute" or "normalized"' ) return self ``` ## JackknifePlus Bases: `ConformalMethod`, `RangeChecked` Jackknife+ (Barber et al. 2021) with blocks: one refit per block left out, and intervals from the left-out residuals and predictions. With exchangeable blocks it covers at least `1 − 2α` when each block holds one observation, and the CV+ bound with `K` blocks otherwise. It takes `score`, `by` and `confidence_level`, as every `ConformalMethod`. Attributes: | Name | Type | Description | | -------------- | ------------- | -------------------------------------------------------- | | `block_length` | \`int | auto\` | | `positions` | `list[float]` | Where the intervals are, for each series of the problem. | | `starts` | `int` | Starts of the fit and of each refit (default 1). | Source code in `commol/context/inference/conformal.py` ```python class JackknifePlus(ConformalMethod, RangeChecked): """ Jackknife+ (Barber et al. 2021) with blocks: one refit per block left out, and intervals from the left-out residuals and predictions. With exchangeable blocks it covers at least ``1 − 2α`` when each block holds one observation, and the CV+ bound with ``K`` blocks otherwise. It takes `score`, `by` and `confidence_level`, as every `ConformalMethod`. Attributes ---------- block_length : int | "auto" Positions per block; it must leave at least two blocks. positions : list[float] Where the intervals are, for each series of the problem. starts : int Starts of the fit and of each refit (default 1). """ block_length: BlockLength = Field(description="Positions per block.") positions: list[float] = Field(min_length=1, description="Where the intervals are.") starts: int = Field(default=1, description="Starts per fit.") ``` ## EnbPI Bases: `ConformalMethod`, `RangeChecked` Ensemble batch prediction intervals (Xu and Xie 2021). Each replicate draws blocks with replacement and fits. The replicates that left out the block of an observation give its leave-one-out prediction and score; their mean over every observation is the point prediction. It takes `score`, `by` and `confidence_level`, as every `ConformalMethod`. Attributes: | Name | Type | Description | | -------------- | ------------- | -------------------------------------------------------- | | `replicates` | `int` | Number of replicates, at least 2. | | `block_length` | \`int | auto\` | | `positions` | `list[float]` | Where the intervals are, for each series of the problem. | | `starts` | `int` | Starts of the fit and of each replicate (default 1). | Source code in `commol/context/inference/conformal.py` ```python class EnbPI(ConformalMethod, RangeChecked): """ Ensemble batch prediction intervals (Xu and Xie 2021). Each replicate draws blocks with replacement and fits. The replicates that left out the block of an observation give its leave-one-out prediction and score; their mean over every observation is the point prediction. It takes `score`, `by` and `confidence_level`, as every `ConformalMethod`. Attributes ---------- replicates : int Number of replicates, at least 2. block_length : int | "auto" Positions per block. positions : list[float] Where the intervals are, for each series of the problem. starts : int Starts of the fit and of each replicate (default 1). """ replicates: int = Field(description="Number of replicates.") block_length: BlockLength = Field(description="Positions per block.") positions: list[float] = Field(min_length=1, description="Where the intervals are.") starts: int = Field(default=1, description="Starts per fit.") ``` ## AdaptiveConformal Bases: `ConformalMethod`, `RangeChecked` Adaptive conformal inference (Gibbs and Candès 2021). It fits the problem's observations and walks `stream` in order of position. The interval of each point is the quantile, at the working level `1 − α_t`, of the scores of the earlier points of the stream; then the working level moves by `α_{t+1} = α_t + γ (α − err_t)`. The long-run share of misses tends to `α`, with no assumption on the data. It takes `score`, `by` and `confidence_level`, as every `ConformalMethod`. Attributes: | Name | Type | Description | | -------- | ------------------------- | -------------------------------------------- | | `step` | `float` | The step γ of the working level, positive. | | `stream` | `list[ObservedDataPoint]` | The observations that arrive after the data. | | `sample` | \`ParameterSample | None\` | | `starts` | `int` | Starts of the fit (default 1). | Source code in `commol/context/inference/conformal.py` ```python class AdaptiveConformal(ConformalMethod, RangeChecked): """ Adaptive conformal inference (Gibbs and Candès 2021). It fits the problem's observations and walks `stream` in order of position. The interval of each point is the quantile, at the working level ``1 − α_t``, of the scores of the earlier points of the stream; then the working level moves by ``α_{t+1} = α_t + γ (α − err_t)``. The long-run share of misses tends to ``α``, with no assumption on the data. It takes `score`, `by` and `confidence_level`, as every `ConformalMethod`. Attributes ---------- step : float The step ``γ`` of the working level, positive. stream : list[ObservedDataPoint] The observations that arrive after the data. sample : ParameterSample | None As in `SplitConformal`. starts : int Starts of the fit (default 1). """ step: float = Field(description="The step of the working level.") stream: list[ObservedDataPoint] = Field( min_length=1, description="The observations that arrive." ) sample: ParameterSample | None = Field( default=None, description="The sample whose predictive predicts." ) starts: int = Field(default=1, description="Starts of the fit.") @model_validator(mode="after") def check_spread(self) -> Self: """Reject ``score="spread"`` without a sample.""" _no_spread_without_sample(self) return self ``` ### Methods: #### check_spread ```python check_spread() -> Self ``` Reject `score="spread"` without a sample. Source code in `commol/context/inference/conformal.py` ```python @model_validator(mode="after") def check_spread(self) -> Self: """Reject ``score="spread"`` without a sample.""" _no_spread_without_sample(self) return self ``` ## ConformalResult Bases: `ResultModel` The result of a conformal method. Attributes: | Name | Type | Description | | --------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `intervals` | `PredictionIntervals` | The intervals at the positions the method was given. | | `scores` | `dict[str, list[float]]` | The scores of each series, by label, in the order the method took them. | | `guarantee` | `str` | The coverage the method guarantees, and what the guarantee assumes. | | `failed` | `list[str]` | Each refit that failed, which gives no score: "Origin failed: " for RollingConformal, "Block failed: " for JackknifePlus, "Replicate failed: " for EnbPI; also logged as a warning. Empty for a method that does not refit. | | `not_converged` | `int` | Refits kept although their optimizer did not converge; 0 for a method that does not refit. | Source code in `commol/context/inference/conformal.py` ```python class ConformalResult(ResultModel): """ The result of a conformal method. Attributes ---------- intervals : PredictionIntervals The intervals at the positions the method was given. scores : dict[str, list[float]] The scores of each series, by label, in the order the method took them. guarantee : str The coverage the method guarantees, and what the guarantee assumes. failed : list[str] Each refit that failed, which gives no score: ``"Origin failed: "`` for `RollingConformal`, ``"Block failed: "`` for `JackknifePlus`, ``"Replicate failed: "`` for `EnbPI`; also logged as a warning. Empty for a method that does not refit. not_converged : int Refits kept although their optimizer did not converge; 0 for a method that does not refit. """ model_config = ConfigDict(extra="forbid", frozen=True, ser_json_inf_nan="constants") intervals: PredictionIntervals scores: dict[str, list[float]] guarantee: str failed: list[str] = Field(description="Each refit that failed.") not_converged: int = Field(description="Refits that did not converge.") ``` ## PredictionIntervals Bases: `ResultModel` Prediction intervals for observations: where new observations of each series fall, at a confidence level. Attributes: | Name | Type | Description | | ------------------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `confidence_level` | `float` | The level of the intervals, 1 − α. | | `series` | `dict[str, SeriesIntervals]` | The intervals of each series, by label. | | `unbounded` | `dict[str, list[float]]` | The positions of each series whose interval is unbounded, because its quantile had fewer than 1/α − 1 scores. A series with none is not a key. | Source code in `commol/context/inference/conformal.py` ```python class PredictionIntervals(ResultModel): """ Prediction intervals for observations: where new observations of each series fall, at a confidence level. Attributes ---------- confidence_level : float The level of the intervals, ``1 − α``. series : dict[str, SeriesIntervals] The intervals of each series, by label. unbounded : dict[str, list[float]] The positions of each series whose interval is unbounded, because its quantile had fewer than ``1/α − 1`` scores. A series with none is not a key. """ model_config = ConfigDict(extra="forbid", frozen=True, ser_json_inf_nan="constants") confidence_level: float series: dict[str, SeriesIntervals] unbounded: dict[str, list[float]] ``` ## SeriesIntervals Bases: `ResultModel` The prediction intervals of one series. Attributes: | Name | Type | Description | | ------------ | ------------- | ---------------------------------------------------- | | `positions` | `list[float]` | Where the intervals are. | | `prediction` | `list[float]` | The point prediction at each position. | | `lower` | `list[float]` | The lower end of each interval; -inf when unbounded. | | `upper` | `list[float]` | The upper end of each interval; inf when unbounded. | Source code in `commol/context/inference/conformal.py` ```python class SeriesIntervals(ResultModel): """ The prediction intervals of one series. Attributes ---------- positions : list[float] Where the intervals are. prediction : list[float] The point prediction at each position. lower : list[float] The lower end of each interval; ``-inf`` when unbounded. upper : list[float] The upper end of each interval; ``inf`` when unbounded. """ model_config = ConfigDict(extra="forbid", frozen=True, ser_json_inf_nan="constants") positions: list[float] prediction: list[float] lower: list[float] upper: list[float] ``` ## Scores and intervals ## scores Scores of a predictive against observations. Every score is computed in Rust, per observation and per series: from each draw's value of the observations (`Predictive.at_observations`), or, for the log score, from the density of the observation models at each draw's prediction. Which observations to score, for example the ones held out of the calibration, and the observation noise are arguments with no default: a band of the model (`NoNoise`) compared with data covers less than its level. ### Classes #### Score Bases: `ResultModel` One score of a predictive at a set of observations. Attributes: | Name | Type | Description | | ----------------- | ------------------ | -------------------------------------------------------------------------------- | | `per_observation` | `list[float]` | The score of each observation, in the order given. | | `per_series` | `dict[str, float]` | The mean score of each series (observation label), in order of first appearance. | | `mean` | `float` | The mean over every observation. | Source code in `commol/api/inference/scores.py` ```python class Score(ResultModel): """ One score of a predictive at a set of observations. Attributes ---------- per_observation : list[float] The score of each observation, in the order given. per_series : dict[str, float] The mean score of each series (observation label), in order of first appearance. mean : float The mean over every observation. """ model_config = ConfigDict(extra="forbid", frozen=True) per_observation: list[float] = Field(description="Score of each observation") per_series: dict[str, float] = Field(description="Mean score of each series") mean: float = Field(description="Mean over every observation") ``` ### Functions: #### coverage ```python coverage(predictive: Predictive, observations: Sequence[ObservedDataPoint], noise: ObservationNoise, confidence_level: float) -> Score ``` The empirical coverage of the central interval of `confidence_level`. Parameters: | Name | Type | Description | Default | | ------------------ | ----------------------------- | -------------------------- | ------------------- | | `predictive` | `Predictive` | The predictive. | *required* | | `observations` | `Sequence[ObservedDataPoint]` | The observations to score. | *required* | | `noise` | \`NoNoise | ResidualNoise | DistributionNoise\` | | `confidence_level` | `float` | Strictly between 0 and 1. | *required* | Returns: | Type | Description | | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `Score` | 1 for each observation inside its interval, 0 outside; the means are the coverage. A calibrated interval covers about confidence_level. | Source code in `commol/api/inference/scores.py` ```python def coverage( predictive: Predictive, observations: Sequence[ObservedDataPoint], noise: ObservationNoise, confidence_level: float, ) -> Score: """ The empirical coverage of the central interval of `confidence_level`. Parameters ---------- predictive : Predictive The predictive. observations : Sequence[ObservedDataPoint] The observations to score. noise : NoNoise | ResidualNoise | DistributionNoise The observation noise of the draws (`Predictive.at_observations`). confidence_level : float Strictly between 0 and 1. Returns ------- Score 1 for each observation inside its interval, 0 outside; the means are the coverage. A calibrated interval covers about `confidence_level`. """ draws = predictive.at_observations(observations, noise) return _score(draws, draws.rust_draws.coverage(draws.observed, confidence_level)) ``` #### interval_score ```python interval_score(predictive: Predictive, observations: Sequence[ObservedDataPoint], noise: ObservationNoise, confidence_level: float) -> Score ``` The interval score: `(u − l) + (2/α)(l − y)₊ + (2/α)(y − u)₊`. Parameters: | Name | Type | Description | Default | | ------------------ | ----------------------------- | ----------------------------------------------- | ------------------- | | `predictive` | `Predictive` | The predictive. | *required* | | `observations` | `Sequence[ObservedDataPoint]` | The observations to score. | *required* | | `noise` | \`NoNoise | ResidualNoise | DistributionNoise\` | | `confidence_level` | `float` | The level c of the central interval; α = 1 − c. | *required* | Returns: | Type | Description | | ------- | ---------------- | | `Score` | Lower is better. | Source code in `commol/api/inference/scores.py` ```python def interval_score( predictive: Predictive, observations: Sequence[ObservedDataPoint], noise: ObservationNoise, confidence_level: float, ) -> Score: """ The interval score: ``(u − l) + (2/α)(l − y)₊ + (2/α)(y − u)₊``. Parameters ---------- predictive : Predictive The predictive. observations : Sequence[ObservedDataPoint] The observations to score. noise : NoNoise | ResidualNoise | DistributionNoise The observation noise of the draws (`Predictive.at_observations`). confidence_level : float The level ``c`` of the central interval; ``α = 1 − c``. Returns ------- Score Lower is better. """ draws = predictive.at_observations(observations, noise) return _score( draws, draws.rust_draws.interval_score(draws.observed, confidence_level) ) ``` #### weighted_interval_score ```python weighted_interval_score(predictive: Predictive, observations: Sequence[ObservedDataPoint], noise: ObservationNoise, confidence_levels: Sequence[float]) -> Score ``` The weighted interval score of Bracher et al. (2021). `(½|y − m| + Σₖ (αₖ/2) IS_αₖ) / (K + ½)`, with `m` the median and `αₖ = 1 − cₖ` for each confidence level `cₖ`. Parameters: | Name | Type | Description | Default | | ------------------- | ----------------------------- | ------------------------------------ | ------------------- | | `predictive` | `Predictive` | The predictive. | *required* | | `observations` | `Sequence[ObservedDataPoint]` | The observations to score. | *required* | | `noise` | \`NoNoise | ResidualNoise | DistributionNoise\` | | `confidence_levels` | `Sequence[float]` | The levels of the central intervals. | *required* | Returns: | Type | Description | | ------- | ---------------- | | `Score` | Lower is better. | References Bracher, J., Ray, E. L., Gneiting, T., & Reich, N. G. (2021). Evaluating epidemic forecasts in an interval format. PLOS Computational Biology, 17(2), e1008618. https://doi.org/10.1371/journal.pcbi.1008618 Source code in `commol/api/inference/scores.py` ```python def weighted_interval_score( predictive: Predictive, observations: Sequence[ObservedDataPoint], noise: ObservationNoise, confidence_levels: Sequence[float], ) -> Score: """ The weighted interval score of Bracher et al. (2021). ``(½|y − m| + Σₖ (αₖ/2) IS_αₖ) / (K + ½)``, with ``m`` the median and ``αₖ = 1 − cₖ`` for each confidence level ``cₖ``. Parameters ---------- predictive : Predictive The predictive. observations : Sequence[ObservedDataPoint] The observations to score. noise : NoNoise | ResidualNoise | DistributionNoise The observation noise of the draws (`Predictive.at_observations`). confidence_levels : Sequence[float] The levels of the central intervals. Returns ------- Score Lower is better. References ---------- Bracher, J., Ray, E. L., Gneiting, T., & Reich, N. G. (2021). Evaluating epidemic forecasts in an interval format. PLOS Computational Biology, 17(2), e1008618. https://doi.org/10.1371/journal.pcbi.1008618 """ draws = predictive.at_observations(observations, noise) return _score( draws, draws.rust_draws.weighted_interval_score( draws.observed, [float(c) for c in confidence_levels] ), ) ``` #### crps ```python crps(predictive: Predictive, observations: Sequence[ObservedDataPoint], noise: ObservationNoise) -> Score ``` The continuous ranked probability score of the weighted draws. `Σ wᵢ|xᵢ − y| − ½ Σᵢⱼ wᵢwⱼ|xᵢ − xⱼ|`, the function the CRPS loss of a stochastic calibration uses. Parameters: | Name | Type | Description | Default | | -------------- | ----------------------------- | -------------------------- | ------------------- | | `predictive` | `Predictive` | The predictive. | *required* | | `observations` | `Sequence[ObservedDataPoint]` | The observations to score. | *required* | | `noise` | \`NoNoise | ResidualNoise | DistributionNoise\` | Returns: | Type | Description | | ------- | ------------------------------------------------- | | `Score` | Lower is better; in the unit of the observations. | Source code in `commol/api/inference/scores.py` ```python def crps( predictive: Predictive, observations: Sequence[ObservedDataPoint], noise: ObservationNoise, ) -> Score: """ The continuous ranked probability score of the weighted draws. ``Σ wᵢ|xᵢ − y| − ½ Σᵢⱼ wᵢwⱼ|xᵢ − xⱼ|``, the function the CRPS loss of a stochastic calibration uses. Parameters ---------- predictive : Predictive The predictive. observations : Sequence[ObservedDataPoint] The observations to score. noise : NoNoise | ResidualNoise | DistributionNoise The observation noise of the draws (`Predictive.at_observations`). Returns ------- Score Lower is better; in the unit of the observations. """ draws = predictive.at_observations(observations, noise) return _score(draws, draws.rust_draws.crps(draws.observed)) ``` #### pit ```python pit(predictive: Predictive, observations: Sequence[ObservedDataPoint], noise: ObservationNoise) -> Score ``` The probability integral transform of each observation. `F(y)` of the weighted draws, randomized between `F(y⁻)` and `F(y)` where draws tie with `y`, so a discrete predictive gives uniform values too. The ties take their random numbers from the seed of the source of the predictive (`Calibrator.seed` or `SensitivityAnalysis.seed`), the seed of the draws, so the same draws give the same values. Parameters: | Name | Type | Description | Default | | -------------- | ----------------------------- | -------------------------- | ------------------- | | `predictive` | `Predictive` | The predictive. | *required* | | `observations` | `Sequence[ObservedDataPoint]` | The observations to score. | *required* | | `noise` | \`NoNoise | ResidualNoise | DistributionNoise\` | Returns: | Type | Description | | ------- | ------------------------------------------------------------- | | `Score` | Values in [0, 1]; calibrated predictions give uniform values. | Source code in `commol/api/inference/scores.py` ```python def pit( predictive: Predictive, observations: Sequence[ObservedDataPoint], noise: ObservationNoise, ) -> Score: """ The probability integral transform of each observation. ``F(y)`` of the weighted draws, randomized between ``F(y⁻)`` and ``F(y)`` where draws tie with ``y``, so a discrete predictive gives uniform values too. The ties take their random numbers from the seed of the source of the predictive (``Calibrator.seed`` or ``SensitivityAnalysis.seed``), the seed of the draws, so the same draws give the same values. Parameters ---------- predictive : Predictive The predictive. observations : Sequence[ObservedDataPoint] The observations to score. noise : NoNoise | ResidualNoise | DistributionNoise The observation noise of the draws (`Predictive.at_observations`). Returns ------- Score Values in ``[0, 1]``; calibrated predictions give uniform values. """ draws = predictive.at_observations(observations, noise) return _score(draws, draws.rust_draws.pit(draws.observed)) ``` #### log_score ```python log_score(predictive: Predictive, observations: Sequence[ObservedDataPoint], noise: DistributionNoise) -> Score ``` The log score of each observation: the log density of its value under the predictive. The predictive density is the mixture over the draws of the observation models, `log Σ_d w_d · p(y | μ_d)`, with `μ_d` the prediction of draw `d` and its noise parameters. It needs the densities of the models, so only `DistributionNoise` has one. A proper scoring rule; higher is better, and an observation the predictive gives no mass scores `-inf`. Parameters: | Name | Type | Description | Default | | -------------- | ----------------------------- | ----------------------------------------------------- | ---------- | | `predictive` | `Predictive` | The predictive, of a problem with observation_models. | *required* | | `observations` | `Sequence[ObservedDataPoint]` | The observations to score. | *required* | | `noise` | `DistributionNoise` | The observation models of the problem. | *required* | Returns: | Type | Description | | ------- | ---------------------------------- | | `Score` | The log score of each observation. | Raises: | Type | Description | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `TypeError` | If noise is not DistributionNoise. | | `ValueError` | If the problem has a loss, an observation names an unknown output or factor or a series with no observation model, or the predictive is of a sensitivity analysis, which has no observation models. | Source code in `commol/api/inference/scores.py` ```python def log_score( predictive: Predictive, observations: Sequence[ObservedDataPoint], noise: DistributionNoise, ) -> Score: """ The log score of each observation: the log density of its value under the predictive. The predictive density is the mixture over the draws of the observation models, ``log Σ_d w_d · p(y | μ_d)``, with ``μ_d`` the prediction of draw ``d`` and its noise parameters. It needs the densities of the models, so only `DistributionNoise` has one. A proper scoring rule; higher is better, and an observation the predictive gives no mass scores ``-inf``. Parameters ---------- predictive : Predictive The predictive, of a problem with ``observation_models``. observations : Sequence[ObservedDataPoint] The observations to score. noise : DistributionNoise The observation models of the problem. Returns ------- Score The log score of each observation. Raises ------ TypeError If `noise` is not `DistributionNoise`. ValueError If the problem has a loss, an observation names an unknown output or factor or a series with no observation model, or the predictive is of a sensitivity analysis, which has no observation models. """ if not isinstance(noise, DistributionNoise): raise TypeError( f"The log score needs the density of the observation models, so it " f"takes DistributionNoise(); {type(noise).__name__} has no density." ) values = predictive.source.rust_log_score(predictive.sample, list(observations)) return _score_of(observations, values) ``` ## weighted_quantiles ```python weighted_quantiles(values: Sequence[float], levels: Sequence[float], weights: Sequence[float] | None = None) -> list[float] ``` Quantiles of weighted values, by the one convention of commol. The quantile is the inverse of the weighted empirical distribution, interpolated between the midpoints of the cumulative weights. With equal weights it equals `numpy.percentile(..., method="hazen")`. A weight of zero removes a value. Parameters: | Name | Type | Description | Default | | --------- | ----------------- | -------------------------------------- | -------------------------------------------------------------------------- | | `values` | `Sequence[float]` | The values. | *required* | | `levels` | `Sequence[float]` | Quantile levels, each between 0 and 1. | *required* | | `weights` | \`Sequence[float] | None\` | One non-negative weight per value, of any scale. None gives equal weights. | Returns: | Type | Description | | ------------- | ----------------------- | | `list[float]` | One quantile per level. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------- | | `ValueError` | If the lengths differ, no weight is positive or a level is not in [0, 1]. | Source code in `commol/api/inference/intervals.py` ```python def weighted_quantiles( values: Sequence[float], levels: Sequence[float], weights: Sequence[float] | None = None, ) -> list[float]: """ Quantiles of weighted values, by the one convention of commol. The quantile is the inverse of the weighted empirical distribution, interpolated between the midpoints of the cumulative weights. With equal weights it equals ``numpy.percentile(..., method="hazen")``. A weight of zero removes a value. Parameters ---------- values : Sequence[float] The values. levels : Sequence[float] Quantile levels, each between 0 and 1. weights : Sequence[float] | None, optional One non-negative weight per value, of any scale. None gives equal weights. Returns ------- list[float] One quantile per level. Raises ------ ValueError If the lengths differ, no weight is positive or a level is not in ``[0, 1]``. """ values = [float(value) for value in values] if weights is None: weights = [1.0] * len(values) return commol_rs.inference.weighted_quantiles( values, [float(w) for w in weights], [float(level) for level in levels] ) ``` ## member_statistics ```python member_statistics(per_member: Sequence[float], confidence_level: float, weights: Sequence[float] | None = None) -> dict[str, float] ``` ```python member_statistics(per_member: Sequence[Sequence[float]], confidence_level: float, weights: Sequence[float] | None = None) -> dict[str, list[float]] ``` ```python member_statistics(per_member: NDArray[float64], confidence_level: float, weights: Sequence[float] | None = None) -> dict[str, float | list[float]] ``` ```python member_statistics(per_member: EnsembleValues, confidence_level: float, weights: Sequence[float] | None = None) -> dict[str, float] | dict[str, list[float]] | dict[str, float | list[float]] ``` Spread of one quantity across the members of an ensemble. Each member must already be reduced to the quantity of interest, so that the reported interval is a quantile of member values (:func:`weighted_quantiles`). Parameters: | Name | Type | Description | Default | | ------------------ | ----------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `per_member` | \`Sequence[float] | Sequence\[Sequence[float]\] | NDArray\` | | `confidence_level` | `float` | Confidence level of the reported interval, strictly between 0 and 1. | *required* | | `weights` | \`Sequence[float] | None\` | One weight per member, such as the weights of a :class:~commol.Predictive. None gives equal weights. | Returns: | Type | Description | | ------------------ | -------------------------- | | \`dict[str, float] | dict\[str, list[float]\]\` | Raises: | Type | Description | | ------------ | -------------------------------------------------------------------------------------------------- | | `ValueError` | If confidence_level is out of range, per_member is empty, or the weights do not match the members. | Source code in `commol/api/inference/intervals.py` ```python def member_statistics( per_member: EnsembleValues, confidence_level: float, weights: Sequence[float] | None = None, ) -> dict[str, float] | dict[str, list[float]] | dict[str, float | list[float]]: """ Spread of one quantity across the members of an ensemble. Each member must already be reduced to the quantity of interest, so that the reported interval is a quantile of member values (:func:`weighted_quantiles`). Parameters ---------- per_member : Sequence[float] | Sequence[Sequence[float]] | NDArray One entry per ensemble member. Entries may be scalars, in which case every returned statistic is a scalar, or equal-length series, in which case every returned statistic is a series reduced across members at each position. A numpy array is accepted, but its rank is not visible to a type checker; pass a list of lists for a precise return type. confidence_level : float Confidence level of the reported interval, strictly between 0 and 1. weights : Sequence[float] | None, optional One weight per member, such as the weights of a :class:`~commol.Predictive`. None gives equal weights. Returns ------- dict[str, float] | dict[str, list[float]] Keys ``mean``, ``median``, ``ci_lower``, ``ci_upper``, ``min`` and ``max``. Scalar members give scalar statistics, series members give series statistics. ``min`` and ``max`` are over the members of positive weight. Raises ------ ValueError If ``confidence_level`` is out of range, ``per_member`` is empty, or the weights do not match the members. """ # The level is checked before the values, also when there are none. commol_rs.inference.interval_levels(confidence_level) values = np.asarray(per_member, dtype=float) if values.size == 0: raise ValueError("per_member must hold at least one ensemble member.") member_weights = ( [1.0] * values.shape[0] if weights is None else [float(w) for w in weights] ) if len(member_weights) != values.shape[0]: raise ValueError( f"One weight per member: {values.shape[0]} members, " f"{len(member_weights)} weights." ) columns = values.reshape(values.shape[0], -1).T summaries = [ commol_rs.inference.weighted_summary( column.tolist(), member_weights, confidence_level ) for column in columns ] def shaped(per_column: list[float]) -> float | list[float]: return per_column[0] if values.ndim == 1 else per_column return { "mean": shaped([summary.mean for summary in summaries]), "median": shaped([summary.median for summary in summaries]), "ci_lower": shaped([summary.lower for summary in summaries]), "ci_upper": shaped([summary.upper for summary in summaries]), "min": shaped([summary.minimum for summary in summaries]), "max": shaped([summary.maximum for summary in summaries]), } ``` ## ci_percentiles ```python ci_percentiles(confidence_level: float) -> tuple[float, float] ``` Lower and upper percentile points of a two-sided confidence interval. Parameters: | Name | Type | Description | Default | | ------------------ | ------- | ----------------------------------------------------------- | ---------- | | `confidence_level` | `float` | Confidence level of the interval, strictly between 0 and 1. | *required* | Returns: | Type | Description | | --------------------- | ------------------------------------------------------ | | `tuple[float, float]` | Lower and upper percentile points, on the 0-100 scale. | Raises: | Type | Description | | ------------ | ---------------------------------------------------- | | `ValueError` | If confidence_level is not strictly between 0 and 1. | Source code in `commol/api/inference/intervals.py` ```python def ci_percentiles(confidence_level: float) -> tuple[float, float]: """ Lower and upper percentile points of a two-sided confidence interval. Parameters ---------- confidence_level : float Confidence level of the interval, strictly between 0 and 1. Returns ------- tuple[float, float] Lower and upper percentile points, on the 0-100 scale. Raises ------ ValueError If ``confidence_level`` is not strictly between 0 and 1. """ lower, upper = commol_rs.inference.interval_levels(confidence_level) return lower * 100.0, upper * 100.0 ``` # Model Structure [Back to API Reference](https://munqu.github.io/commol/latest/api/index.md) A `Model` is a tree of Pydantic models. `ModelBuilder` creates it, `Model.to_json` writes it, and the Rust engine reads the same tree from JSON (`crates/commol-core/src/types/`). Most users never build these types by hand; read this page to understand model files or to work on Commol. A model has a header, a **process** written once, a **structure** that lays the process out, and an **engine** that runs it. The process (bins, accumulators, stratifications, transitions) is the same for every kind of model; the structure changes between kinds and is tagged in JSON by `kind`, and by `variant` for a narrower form of a structure. The one kind is `aggregate`: amounts per compartment. ```text Model name, description, version, step_unit, origin ├── parameters: [Parameter] (a value may be a SeriesValue) ├── engine: ModelEngine │ ├── type: "DifferenceEquations", "DifferentialEquations", │ │ "StochasticEvents" or "StochasticSteps" │ └── settings: ODESolver | StochasticEventsSettings | StochasticStepsSettings | None │ (settings of that engine; None: its defaults) ├── process: Process │ ├── bins: [Bin] │ ├── accumulators: [Accumulator] │ ├── stratifications: [Stratification] │ │ └── conditions: [StratificationCondition] │ └── transitions: [Transition] (impulse_at, impulse_every for impulses) │ └── stratified_rates: [StratifiedRate] │ └── conditions: [StratificationCondition] └── structure: Aggregate (kind: "aggregate", variant: None) ├── bin_amounts: {bin: amount | None} (in the unit of the bin) ├── stratification_fractions: {stratification: {category: fraction}} ├── compartment_amounts: {compartment: amount} └── neighbors: Complete | Groups | None ``` In JSON the structure is an object with its `kind`, its `variant` and the fields of its class: ```json { "name": "Model", "parameters": [], "engine": {"type": "DifferenceEquations", "settings": null}, "process": {"bins": [], "accumulators": [], "stratifications": [], "transitions": []}, "structure": { "kind": "aggregate", "variant": null, "bin_amounts": {"A": 990.0, "B": 10.0}, "stratification_fractions": {}, "compartment_amounts": {}, "neighbors": null }, "step_unit": null, "origin": null } ``` `Model`, `Process`, `ModelStructure`, `Aggregate`, `Complete` and `Groups` are documented in [Model API](https://munqu.github.io/commol/latest/api/model/index.md). The engine must run the kind of the structure: a model whose engine runs another kind is rejected when it is made, with an error that names the engine, both kinds and the engines of the kind of the model. A variant keeps the kind, the fields and the engines of its structure, and loads back as its own class ([Design decisions](https://munqu.github.io/commol/latest/codebase/decisions/model-kinds/#a-model-is-a-header-a-process-a-structure-and-an-engine)). ## Bins and accumulators ## Bin Bases: `BaseModel` Defines a single bin (base category) of the process of a model. A bin represents a fundamental category before stratification. The combination of a bin with all stratifications produces the actual compartments. Attributes: | Name | Type | Description | | ------ | ----- | ----------------------------------------------- | | `id` | `str` | Identifier of the bin. | | `name` | `str` | A descriptive, human-readable name for the bin. | | `unit` | \`str | None\` | Source code in `commol/context/bin.py` ```python class Bin(BaseModel): """ Defines a single bin (base category) of the process of a model. A bin represents a fundamental category before stratification. The combination of a bin with all stratifications produces the actual compartments. Attributes ---------- id : str Identifier of the bin. name : str A descriptive, human-readable name for the bin. unit : str | None The unit of measurement for this bin. If None, the bin will use the model-level bin_unit when specified. """ model_config = ConfigDict(extra="forbid") id: str = Field(..., description="Identifier of the bin.") name: str = Field(..., description="Descriptive, human-readable name for the bin.") unit: str | None = Field( None, description="Unit of measurement for this bin.", ) @override def __hash__(self) -> int: return hash(self.id) @override def __eq__(self, other: object) -> bool: return isinstance(other, Bin) and self.id == other.id ``` ## Accumulator Bases: `BaseModel` A cumulative event counter tracked as output but not as part of the state. Attributes: | Name | Type | Description | | ------ | ----- | ----------------------------------------------------------------------------------------------- | | `id` | `str` | Identifier of the accumulator, and the name of its output before the stratifications expand it. | | `name` | `str` | A descriptive, human-readable name for the accumulator. | Source code in `commol/context/bin.py` ```python class Accumulator(BaseModel): """ A cumulative event counter tracked as output but not as part of the state. Attributes ---------- id : str Identifier of the accumulator, and the name of its output before the stratifications expand it. name : str A descriptive, human-readable name for the accumulator. """ model_config = ConfigDict(extra="forbid") id: str = Field(..., description="Identifier of the accumulator.") name: str = Field(..., description="Descriptive, human-readable name.") ``` ## Stratifications ## Stratification Bases: `BaseModel` Defines a categorical subdivision of the compartments. Attributes: | Name | Type | Description | | ------------- | ------------------------------- | -------------------------------------------------------- | | `id` | `str` | Identifier of the stratification. | | `categories` | `list[str]` | List of the different stratification groups identifiers. | | `description` | \`str | None\` | | `conditions` | \`list[StratificationCondition] | None\` | Source code in `commol/context/stratification.py` ```python class Stratification(BaseModel): """ Defines a categorical subdivision of the compartments. Attributes ---------- id : str Identifier of the stratification. categories : list[str] List of the different stratification groups identifiers. description : str | None A human-readable description of the stratification. conditions : list[StratificationCondition] | None When set, this stratification only expands compartments whose already-applied stratification categories satisfy ALL conditions. Compartments that do not satisfy the conditions are kept without appending this stratification's categories. May only reference stratifications declared before this one. """ model_config = ConfigDict(extra="forbid") id: str = Field(default=..., description="Identifier of the stratification.") categories: list[str] = Field( default=..., description="List of the different stratification groups identifiers.", ) description: str | None = Field( default=None, description="Human-readable description of the stratification." ) conditions: list[StratificationCondition] | None = Field( default=None, description=( "Conditions on previously-declared stratifications that must be " "satisfied for this stratification to expand a compartment." ), ) @override def __hash__(self) -> int: return hash(self.id) @override def __eq__(self, other: object) -> bool: return isinstance(other, Stratification) and self.id == other.id @model_validator(mode="after") def validate_categories_length(self) -> Self: """ Enforces that categories are not empty. """ if not self.categories: raise ValueError( (f"Stratification '{self.id}' must have at least one category.") ) return self @model_validator(mode="after") def validate_categories_uniqueness(self) -> Self: """ Enforces that categories are not repeated. """ categories_set = set(self.categories) if len(categories_set) != len(self.categories): duplicates = [ item for item in categories_set if self.categories.count(item) > 1 ] raise ValueError( ( f"Categories for stratification '{self.id}' must not be repeated. " f"Found duplicates: {list(set(duplicates))}." ) ) return self @model_validator(mode="after") def validate_conditions_have_categories(self) -> Self: """ Enforces that stratification expansion conditions filter by category. """ if self.conditions is None: return self for condition in self.conditions: if condition.category is None: raise ValueError( ( f"Condition for stratification '{self.id}' referencing " f"'{condition.stratification}' must include a category. " "Category-less conditions are only valid for transition " "target overrides." ) ) return self ``` ### Methods: #### validate_categories_length ```python validate_categories_length() -> Self ``` Enforces that categories are not empty. Source code in `commol/context/stratification.py` ```python @model_validator(mode="after") def validate_categories_length(self) -> Self: """ Enforces that categories are not empty. """ if not self.categories: raise ValueError( (f"Stratification '{self.id}' must have at least one category.") ) return self ``` #### validate_categories_uniqueness ```python validate_categories_uniqueness() -> Self ``` Enforces that categories are not repeated. Source code in `commol/context/stratification.py` ```python @model_validator(mode="after") def validate_categories_uniqueness(self) -> Self: """ Enforces that categories are not repeated. """ categories_set = set(self.categories) if len(categories_set) != len(self.categories): duplicates = [ item for item in categories_set if self.categories.count(item) > 1 ] raise ValueError( ( f"Categories for stratification '{self.id}' must not be repeated. " f"Found duplicates: {list(set(duplicates))}." ) ) return self ``` #### validate_conditions_have_categories ```python validate_conditions_have_categories() -> Self ``` Enforces that stratification expansion conditions filter by category. Source code in `commol/context/stratification.py` ```python @model_validator(mode="after") def validate_conditions_have_categories(self) -> Self: """ Enforces that stratification expansion conditions filter by category. """ if self.conditions is None: return self for condition in self.conditions: if condition.category is None: raise ValueError( ( f"Condition for stratification '{self.id}' referencing " f"'{condition.stratification}' must include a category. " "Category-less conditions are only valid for transition " "target overrides." ) ) return self ``` ## StratificationCondition Bases: `BaseModel` Specifies a category within a stratification for rate matching. When `to` is set, the matched category in the source compartment is replaced with the `to` category in the target compartment name. This enables cross-category transitions within the same bin (e.g., from `g1` to `g2`). Attributes: | Name | Type | Description | | ---------------- | ----- | ---------------------------------------------------------- | | `stratification` | `str` | The ID of the stratification (e.g., "group", "type") | | `category` | `str` | The category within that stratification (e.g., "g1", "t1") | | `to` | \`str | None\` | Source code in `commol/context/stratification_condition.py` ```python class StratificationCondition(BaseModel): """ Specifies a category within a stratification for rate matching. When ``to`` is set, the matched category in the source compartment is replaced with the ``to`` category in the target compartment name. This enables cross-category transitions within the same bin (e.g., from ``g1`` to ``g2``). Attributes ---------- stratification : str The ID of the stratification (e.g., "group", "type") category : str The category within that stratification (e.g., "g1", "t1") to : str | None Target category override. When set, the target compartment will use this category instead of the source compartment's category for this stratification. """ model_config = ConfigDict(extra="forbid") stratification: str = Field(default=..., description="ID of the stratification") category: str | None = Field( default=None, description=( "Category within the stratification. When None, the condition acts as a " "target-only override: it does not filter source compartments but still " "contributes a 'to' category to the computed target compartment name." ), ) to: str | None = Field( default=None, description=( "Target category override. When set, the target compartment uses " "this category instead of the source's for this stratification." ), ) ``` ## Parameters ## Parameter Bases: `BaseModel` Defines a global model parameter. Attributes: | Name | Type | Description | | ------------- | ------- | -------------------------------- | | `id` | `str` | The identifier of the parameter. | | `value` | \`float | str | | `description` | \`str | None\` | | `unit` | \`str | None\` | Source code in `commol/context/parameter.py` ```python class Parameter(BaseModel): """ Defines a global model parameter. Attributes ---------- id : str The identifier of the parameter. value : float | str | SeriesValue | None Value of the parameter. Can be: - float: A numerical constant value - str: A mathematical formula that can reference other parameters, special variables (total, total_, step, t, pi, e), or contain mathematical expressions - SeriesValue: An empirical series evaluated via binary search - None: Indicates that the parameter needs to be calibrated before use description : str | None A human-readable description of the parameter. unit : str | None The unit of the parameter (e.g., "1/day", "dimensionless", "item"). If None, the parameter has no unit specified. """ model_config = ConfigDict(extra="forbid") id: str = Field(default=..., description="Identifier of the parameter.") value: float | str | SeriesValue | None = Field( default=..., description=( "Value of the parameter. Can be a float (constant), " "str (formula), SeriesValue (empirical series), " "or None (requires calibration)." ), ) description: str | None = Field( default=None, description="Human-readable description of the parameter." ) unit: str | None = Field( default=None, description="Unit of the parameter (e.g., '1/day', 'dimensionless', 'item').", ) @field_validator("value") @classmethod def validate_value( cls, value: float | str | SeriesValue | None ) -> float | str | SeriesValue | None: """Validate the parameter value.""" if value is None or isinstance(value, SeriesValue): return value if isinstance(value, (int, float)): return float(value) if not value.strip(): raise ValueError("Formula cannot be empty") return value.strip() @model_validator(mode="after") def validate_formula(self) -> Self: """Check a formula value as a rate is checked: the security check and the check of the language of formulas.""" if isinstance(self.value, str): try: validate_expression_security(self.value) except ValueError as error: raise ValueError(f"Parameter '{self.id}': {error}") return self def is_calibrated(self) -> bool: """ Check if the parameter has a value (is calibrated). Returns ------- bool True if the parameter has a value, False if it needs calibration. """ return self.value is not None def with_value(self, value: ParameterUpdate) -> "Parameter": """ This parameter with another value. Parameters ---------- value : ParameterUpdate The new value, as `Parameter.value` takes it. A list of (position, value) points replaces the points of a series and keeps its mode; a `SeriesValue` sets both. Returns ------- Parameter A new parameter with the same id, description and unit. Raises ------ ValueError If ``value`` is a list and this parameter is not a series, or ``value`` is not a valid value; the message names the parameter. """ try: if isinstance(value, list): if not isinstance(self.value, SeriesValue): raise ValueError( f"Parameter '{self.id}' is not a series, so a list of " "points has no mode: pass a SeriesValue(data=..., mode=...)" ) value = SeriesValue(data=value, mode=self.value.mode) return Parameter( id=self.id, value=value, description=self.description, unit=self.unit ) except ValidationError as error: reasons = "; ".join(detail["msg"] for detail in error.errors()) raise ValueError( f"Parameter '{self.id}' cannot take {value!r}: {reasons}" ) from None ``` ### Methods: #### validate_value ```python validate_value(value: float | str | SeriesValue | None) -> float | str | SeriesValue | None ``` Validate the parameter value. Source code in `commol/context/parameter.py` ```python @field_validator("value") @classmethod def validate_value( cls, value: float | str | SeriesValue | None ) -> float | str | SeriesValue | None: """Validate the parameter value.""" if value is None or isinstance(value, SeriesValue): return value if isinstance(value, (int, float)): return float(value) if not value.strip(): raise ValueError("Formula cannot be empty") return value.strip() ``` #### validate_formula ```python validate_formula() -> Self ``` Check a formula value as a rate is checked: the security check and the check of the language of formulas. Source code in `commol/context/parameter.py` ```python @model_validator(mode="after") def validate_formula(self) -> Self: """Check a formula value as a rate is checked: the security check and the check of the language of formulas.""" if isinstance(self.value, str): try: validate_expression_security(self.value) except ValueError as error: raise ValueError(f"Parameter '{self.id}': {error}") return self ``` #### is_calibrated ```python is_calibrated() -> bool ``` Check if the parameter has a value (is calibrated). Returns: | Type | Description | | ------ | ----------------------------------------------------------------- | | `bool` | True if the parameter has a value, False if it needs calibration. | Source code in `commol/context/parameter.py` ```python def is_calibrated(self) -> bool: """ Check if the parameter has a value (is calibrated). Returns ------- bool True if the parameter has a value, False if it needs calibration. """ return self.value is not None ``` #### with_value ```python with_value(value: ParameterUpdate) -> Parameter ``` This parameter with another value. Parameters: | Name | Type | Description | Default | | ------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `value` | `ParameterUpdate` | The new value, as Parameter.value takes it. A list of (position, value) points replaces the points of a series and keeps its mode; a SeriesValue sets both. | *required* | Returns: | Type | Description | | ----------- | ------------------------------------------------------- | | `Parameter` | A new parameter with the same id, description and unit. | Raises: | Type | Description | | ------------ | ---------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If value is a list and this parameter is not a series, or value is not a valid value; the message names the parameter. | Source code in `commol/context/parameter.py` ```python def with_value(self, value: ParameterUpdate) -> "Parameter": """ This parameter with another value. Parameters ---------- value : ParameterUpdate The new value, as `Parameter.value` takes it. A list of (position, value) points replaces the points of a series and keeps its mode; a `SeriesValue` sets both. Returns ------- Parameter A new parameter with the same id, description and unit. Raises ------ ValueError If ``value`` is a list and this parameter is not a series, or ``value`` is not a valid value; the message names the parameter. """ try: if isinstance(value, list): if not isinstance(self.value, SeriesValue): raise ValueError( f"Parameter '{self.id}' is not a series, so a list of " "points has no mode: pass a SeriesValue(data=..., mode=...)" ) value = SeriesValue(data=value, mode=self.value.mode) return Parameter( id=self.id, value=value, description=self.description, unit=self.unit ) except ValidationError as error: reasons = "; ".join(detail["msg"] for detail in error.errors()) raise ValueError( f"Parameter '{self.id}' cannot take {value!r}: {reasons}" ) from None ``` The value of a series parameter, `SeriesValue`, is documented in [Model API](https://munqu.github.io/commol/latest/api/model/index.md). ## Transitions ## Transition Bases: `BaseModel` Defines a rule for system evolution. Attributes: | Name | Type | Description | | ------------------ | ---------------------- | ---------------------------------------------------------------- | | `id` | `str` | Id of the transition. | | `source` | `list[str]` | The origin compartments. | | `target` | `list[str]` | The destination compartments. | | `accumulators` | `list[str]` | Ids of the accumulators that add up the flow of this transition. | | `rate` | \`str | None\` | | `stratified_rates` | \`list[StratifiedRate] | None\` | | `per_compartment` | \`bool | None\` | | `impulse_at` | \`list[float] | None\` | | `impulse_every` | \`tuple[float, float] | None\` | Source code in `commol/context/transition.py` ```python class Transition(BaseModel): """ Defines a rule for system evolution. Attributes ---------- id : str Id of the transition. source : list[str] The origin compartments. target : list[str] The destination compartments. accumulators : list[str] Ids of the accumulators that add up the flow of this transition. rate : str | None Default mathematical formula, parameter name, or constant value for the flow. Used when no stratified rate matches. Numeric values are automatically converted to strings during validation. Operators: +, -, *, /, % (modulo), ^ or ** (power) Functions: sin, cos, tan, exp, ln, sqrt, abs, min, max, if, etc. Constants: pi, e Note: Both ^ and ** are supported for exponentiation (** is converted to ^). Examples: - "k1" (parameter reference) - "0.5" (constant, can also be passed as float 0.5) - "k1 * A * B / total" (mathematical formula) - "0.3 * sin(2 * pi * t / 365)" (formula that changes along the axis) - "2^10" or "2**10" (power: both syntaxes work) stratified_rates : list[StratifiedRate] | None Stratification-specific rates. Each rate applies to compartments that match all specified stratification conditions. per_compartment : bool | None When True, the id of each source and target bin in a rate reads the compartment of each flow; any other bin keeps its total. A transition between categories ignores it. impulse_at : list[float] | None Positions on the axis where the transition is an impulse: at each one, the rate is a fraction (proportional) or an amount (absolute) moved at once. Between them the transition moves nothing. impulse_every : tuple[float, float] | None ``(period, offset)``: the transition is an impulse at ``offset``, ``offset + period``, ``offset + 2 * period``, and so on. At most one of `impulse_at` and `impulse_every` is set. """ model_config = ConfigDict(extra="forbid") id: str = Field(default=..., description="Id of the transition.") source: list[str] = Field(default=..., description="Origin compartments.") target: list[str] = Field(default=..., description="Destination compartments.") accumulators: list[str] = Field( default_factory=list, description="Cumulative event counters incremented by this transition flow.", ) rate: str | None = Field( None, description=( "Default rate expression (fallback when no stratified rate matches). " "Can be a parameter reference (e.g., 'k1'), a constant (e.g., '0.5'), " "or a mathematical expression (e.g., 'k1 * A * B / total'). " "Numeric values are automatically converted to strings during validation." ), ) stratified_rates: list[StratifiedRate] | None = Field( default=None, description="List of stratification-specific rates" ) per_compartment: bool | None = Field( default=None, description=( "When True, the id of each source and target bin in a rate reads " "the compartment of each flow; any other bin keeps its total. A " "transition between categories ignores it." ), ) impulse_at: list[float] | None = Field( default=None, description=( "Positions on the axis where the transition is an impulse: at each " "one, the rate is a fraction (proportional) or an amount (absolute) " "moved at once. Between them the transition moves nothing." ), ) impulse_every: tuple[float, float] | None = Field( default=None, description=( "(period, offset): the transition is an impulse at offset, " "offset + period, offset + 2 * period, ..." ), ) @property def is_impulse(self) -> bool: """Whether the transition is an impulse rather than a continuous flow.""" return self.impulse_at is not None or self.impulse_every is not None @model_validator(mode="after") def validate_impulse(self) -> Self: """ Check the impulse with the rule of the engine (`check_impulse` of `crates/commol-model/src/values.rs`): one of `impulse_at` and `impulse_every`, at least one position on the axis, a positive period and an offset that is not negative. """ commol_rs.model.check_impulse(self.id, self.impulse_at, self.impulse_every) return self @field_validator("rate", mode="before") @classmethod def validate_rate(cls, value: str | None) -> str | None: """ Convert numeric rates to strings and perform security and syntax validation. """ if value is None: return value return _checked_rate(value) ``` ### Attributes #### is_impulse ```python is_impulse: bool ``` Whether the transition is an impulse rather than a continuous flow. ### Methods: #### validate_impulse ```python validate_impulse() -> Self ``` Check the impulse with the rule of the engine (`check_impulse` of `crates/commol-model/src/values.rs`): one of `impulse_at` and `impulse_every`, at least one position on the axis, a positive period and an offset that is not negative. Source code in `commol/context/transition.py` ```python @model_validator(mode="after") def validate_impulse(self) -> Self: """ Check the impulse with the rule of the engine (`check_impulse` of `crates/commol-model/src/values.rs`): one of `impulse_at` and `impulse_every`, at least one position on the axis, a positive period and an offset that is not negative. """ commol_rs.model.check_impulse(self.id, self.impulse_at, self.impulse_every) return self ``` #### validate_rate ```python validate_rate(value: str | None) -> str | None ``` Convert numeric rates to strings and perform security and syntax validation. Source code in `commol/context/transition.py` ```python @field_validator("rate", mode="before") @classmethod def validate_rate(cls, value: str | None) -> str | None: """ Convert numeric rates to strings and perform security and syntax validation. """ if value is None: return value return _checked_rate(value) ``` ## StratifiedRate Bases: `BaseModel` Defines a rate for specific stratification categories. Attributes: | Name | Type | Description | | ------------ | ------------------------------- | ---------------------------------------------------------- | | `conditions` | `list[StratificationCondition]` | List of stratification-category pairs that must match | | `rate` | `str` | Rate expression for compartments matching these conditions | | `absolute` | \`bool | None\` | Source code in `commol/context/transition.py` ```python class StratifiedRate(BaseModel): """ Defines a rate for specific stratification categories. Attributes ---------- conditions : list[StratificationCondition] List of stratification-category pairs that must match rate : str Rate expression for compartments matching these conditions absolute : bool | None True: the flow is the rate, not multiplied by the source amount. False: the flow is the rate times the source amount. None: the rate is absolute when it reads a compartment, a bin total or a ``total_…`` variable. """ model_config = ConfigDict(extra="forbid") conditions: list[StratificationCondition] = Field( default=..., description="Stratification conditions that must match" ) rate: str = Field( default=..., description="Rate expression for matching compartments" ) absolute: bool | None = Field( default=None, description=( "When True, the flow is treated as absolute (not multiplied by the " "source amount). When None, inferred from whether the rate references " "any compartment variable." ), ) @field_validator("conditions") @classmethod def validate_not_empty( cls, v: list[StratificationCondition] ) -> list[StratificationCondition]: """Ensure at least one stratification condition is specified.""" if not v: raise ValueError( "conditions must contain at least one stratification condition" ) return v @model_validator(mode="after") def validate_unique_stratifications(self) -> Self: """Ensure no duplicate stratification IDs.""" strat_ids = [sc.stratification for sc in self.conditions] if len(strat_ids) != len(set(strat_ids)): duplicates = [s for s in strat_ids if strat_ids.count(s) > 1] raise ValueError( ( f"Duplicate stratifications found: {list(set(duplicates))}. " f"Each stratification can only be specified once per rate." ) ) return self @field_validator("rate", mode="before") @classmethod def validate_rate(cls, value: str) -> str: """Perform security and syntax validation on the rate expression.""" return _checked_rate(value) ``` ### Methods: #### validate_not_empty ```python validate_not_empty(v: list[StratificationCondition]) -> list[StratificationCondition] ``` Ensure at least one stratification condition is specified. Source code in `commol/context/transition.py` ```python @field_validator("conditions") @classmethod def validate_not_empty( cls, v: list[StratificationCondition] ) -> list[StratificationCondition]: """Ensure at least one stratification condition is specified.""" if not v: raise ValueError( "conditions must contain at least one stratification condition" ) return v ``` #### validate_unique_stratifications ```python validate_unique_stratifications() -> Self ``` Ensure no duplicate stratification IDs. Source code in `commol/context/transition.py` ```python @model_validator(mode="after") def validate_unique_stratifications(self) -> Self: """Ensure no duplicate stratification IDs.""" strat_ids = [sc.stratification for sc in self.conditions] if len(strat_ids) != len(set(strat_ids)): duplicates = [s for s in strat_ids if strat_ids.count(s) > 1] raise ValueError( ( f"Duplicate stratifications found: {list(set(duplicates))}. " f"Each stratification can only be specified once per rate." ) ) return self ``` #### validate_rate ```python validate_rate(value: str) -> str ``` Perform security and syntax validation on the rate expression. Source code in `commol/context/transition.py` ```python @field_validator("rate", mode="before") @classmethod def validate_rate(cls, value: str) -> str: """Perform security and syntax validation on the rate expression.""" return _checked_rate(value) ``` ## Engine The engine's list of engines (`crates/commol-core/src/types/engine.rs`) validates the settings: settings of another engine raise an error that names both. `ODESolver` is documented in [Simulation API](https://munqu.github.io/commol/latest/api/simulation/index.md). ## ModelEngine Bases: `BaseModel` The engine that runs a model, and its settings. Attributes: | Name | Type | Description | | ---------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | `str` | Name of an engine of the engine's list, such as "DifferenceEquations" (discrete time) or "DifferentialEquations" (continuous time). The engine must run the kind of the structure of the model. | | `settings` | \`EngineSettings | None\` | Source code in `commol/context/engine.py` ```python class ModelEngine(BaseModel): """ The engine that runs a model, and its settings. Attributes ---------- type : str Name of an engine of the engine's list, such as ``"DifferenceEquations"`` (discrete time) or ``"DifferentialEquations"`` (continuous time). The engine must run the kind of the structure of the model. settings : EngineSettings | None Settings of that engine; None uses its defaults. ``DifferentialEquations`` takes `ODESolver`, ``StochasticEvents`` takes `StochasticEventsSettings` and ``StochasticSteps`` takes `StochasticStepsSettings`. """ model_config = ConfigDict(extra="forbid") type: str = Field(..., description="Name of the engine.") settings: EngineSettings | None = Field( default=None, description="Settings of the engine; None uses its defaults." ) @field_validator("settings", mode="before") @classmethod def settings_of_the_engine( cls, value: EngineSettings | Mapping[str, JsonValue] | None, info: ValidationInfo, ) -> EngineSettings | Mapping[str, JsonValue] | None: """Read settings given as a mapping with the class of their engine.""" settings_class = _SETTINGS.get(info.data.get("type", "")) if isinstance(value, Mapping) and settings_class is not None: return settings_class.model_validate(value) return value @model_validator(mode="after") def check_settings(self) -> Self: """Ask the engine's list whether it has the engine and the settings.""" settings_json = ( None if self.settings is None else self.settings.model_dump_json() ) commol_rs.engine.EngineType(self.type).check_settings(settings_json) return self ``` ### Methods: #### settings_of_the_engine ```python settings_of_the_engine(value: EngineSettings | Mapping[str, JsonValue] | None, info: ValidationInfo) -> EngineSettings | Mapping[str, JsonValue] | None ``` Read settings given as a mapping with the class of their engine. Source code in `commol/context/engine.py` ```python @field_validator("settings", mode="before") @classmethod def settings_of_the_engine( cls, value: EngineSettings | Mapping[str, JsonValue] | None, info: ValidationInfo, ) -> EngineSettings | Mapping[str, JsonValue] | None: """Read settings given as a mapping with the class of their engine.""" settings_class = _SETTINGS.get(info.data.get("type", "")) if isinstance(value, Mapping) and settings_class is not None: return settings_class.model_validate(value) return value ``` #### check_settings ```python check_settings() -> Self ``` Ask the engine's list whether it has the engine and the settings. Source code in `commol/context/engine.py` ```python @model_validator(mode="after") def check_settings(self) -> Self: """Ask the engine's list whether it has the engine and the settings.""" settings_json = ( None if self.settings is None else self.settings.model_dump_json() ) commol_rs.engine.EngineType(self.type).check_settings(settings_json) return self ``` ## Initial conditions `Aggregate` holds the fields and methods of `InitialConditions`, and checks them against the process when the model is built. ## InitialConditions Bases: `BaseModel` How much starts where: the amount of each bin and the split of it. Every amount is in the unit of its bin, so bins of different units, such as a count and a concentration, start without a total over them. `Aggregate` holds these fields and their methods; the checks against the bins and the stratifications of the process are in `Aggregate.check_model`. Attributes: | Name | Type | Description | | -------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `bin_amounts` | \`dict\[str, float | None\]\` | | `stratification_fractions` | `dict[str, dict[str, float]]` | The fraction of each category, by stratification id and category: the split of the amount of every bin the stratification applies to; each from 0 to 1, and a sum of 1 within 1e-6. A conditional stratification splits only the group its conditions select. | | `compartment_amounts` | `dict[str, float]` | Amounts of single expanded compartments, by compartment name, applied after the expansion. | Source code in `commol/context/initial_conditions.py` ```python class InitialConditions(BaseModel): """ How much starts where: the amount of each bin and the split of it. Every amount is in the unit of its bin, so bins of different units, such as a count and a concentration, start without a total over them. `Aggregate` holds these fields and their methods; the checks against the bins and the stratifications of the process are in `Aggregate.check_model`. Attributes ---------- bin_amounts : dict[str, float | None] The amount of each bin at the start, by bin id, in the unit of the bin; finite and not negative. None leaves an amount for calibration. stratification_fractions : dict[str, dict[str, float]] The fraction of each category, by stratification id and category: the split of the amount of every bin the stratification applies to; each from 0 to 1, and a sum of 1 within 1e-6. A conditional stratification splits only the group its conditions select. compartment_amounts : dict[str, float] Amounts of single expanded compartments, by compartment name, applied after the expansion. """ model_config = ConfigDict(extra="forbid") bin_amounts: dict[str, float | None] = Field( ..., description=( "The amount of each bin, by bin id, in the unit of the bin. None " "leaves an amount for calibration." ), ) stratification_fractions: dict[str, dict[str, float]] = Field( default_factory=dict, description="The fraction of each category, by stratification id.", ) compartment_amounts: dict[str, float] = Field( default_factory=dict, description=( "Amounts of single expanded compartments, applied after the expansion." ), ) @model_validator(mode="before") @classmethod def reject_fractions_of_a_total(cls, data: JsonValue) -> JsonValue: """Name the field of amounts that replaces a field of fractions. A total over bins of different units has no meaning, so the initial state is stated as amounts, and a structure with ``total``, ``bin_fractions`` or ``compartment_fractions`` is rejected with the field to use. """ if isinstance(data, Mapping): stated = [field for field in _FRACTION_FIELDS if field in data] if stated: replacements = sorted({_FRACTION_FIELDS[field] for field in stated}) raise ValueError( f"An aggregate structure states the amount of each bin in " f"the unit of the bin, not fractions of a total: replace " f"{', '.join(stated)} with {' and '.join(replacements)}, " f'such as bin_amounts={{"A": 990, "B": 10, "C": 0}}.' ) return data @field_validator("bin_amounts") @classmethod def validate_bin_amounts( cls, value: dict[str, float | None] ) -> dict[str, float | None]: """Check every bin amount with the rule of the engine.""" commol_rs.model.check_bin_amounts(list(value.items())) return value @field_validator("stratification_fractions") @classmethod def validate_stratification_fractions( cls, value: dict[str, dict[str, float]] ) -> dict[str, dict[str, float]]: """Check the fractions of every stratification with the rule of the engine.""" _check_fractions(value) return value @field_validator("compartment_amounts") @classmethod def validate_compartment_amounts(cls, value: dict[str, float]) -> dict[str, float]: """Check every compartment amount with the rule of the engine.""" commol_rs.model.check_compartment_amounts(list(value.items())) return value def get_uncalibrated_bins(self) -> list[str]: """ Get list of bin IDs that have uncalibrated amounts (value = None). Returns ------- list[str] List of bin IDs with uncalibrated initial conditions. """ return [bin_id for bin_id, amount in self.bin_amounts.items() if amount is None] def get_categories_with_fractions(self) -> set[str]: """ Get the categories that have a declared initial fraction. Returns ------- set[str] Category names collected from ``stratification_fractions``. A category declared on a stratification but absent from the initial conditions is not included. """ return { category for categories in self.stratification_fractions.values() for category in categories } def resolve_category(self, category: str) -> tuple[str, str]: """ Resolve a category id to its stratification and its bare name. A qualified id (``group:g1``) names the stratification. A bare name must be declared by exactly one stratification. Parameters ---------- category : str A bare or qualified category id. Returns ------- tuple[str, str] The stratification id and the bare category name. Raises ------ ValueError If the category is not found or a bare name is ambiguous. """ stratification_id, separator, name = category.rpartition(CATEGORY_SEPARATOR) owners = [ stratification for stratification, categories in self.stratification_fractions.items() if (not separator or stratification == stratification_id) and name in categories ] if len(owners) > 1: qualified = " or ".join( f"'{owner}{CATEGORY_SEPARATOR}{name}'" for owner in owners ) raise ValueError( f"Category '{category}' is ambiguous: it is declared by " f"stratifications {owners}. Qualify it as {qualified}." ) if not owners: raise ValueError( f"Category '{category}' not found in initial conditions. " f"Available categories: {sorted(self.get_categories_with_fractions())}" ) return owners[0], name def category_id(self, stratification: str, category: str) -> str: """ Shortest id that names a category without ambiguity. Parameters ---------- stratification : str Id of the stratification that declares the category. category : str Bare category name. Returns ------- str The bare name, or ``stratification:category`` when another stratification declares a category of the same name. """ declaring = sum( category in categories for categories in self.stratification_fractions.values() ) if declaring > 1: return f"{stratification}{CATEGORY_SEPARATOR}{category}" return category def get_category_fraction(self, category: str) -> float: """ Get the fraction of a category within the group it subdivides. Parameters ---------- category : str A category id, bare or qualified (``group:g1``). Returns ------- float The declared fraction, relative to the group the category's stratification applies to rather than to the whole model. Raises ------ ValueError If the category is not found or a bare name is ambiguous. """ stratification, name = self.resolve_category(category) return self.stratification_fractions[stratification][name] def _updated_fractions( self, stratification: str, named: Mapping[str, float] ) -> dict[str, float]: """The fractions of a single stratification after an update that names ``named``; the one category left unnamed takes the rest.""" categories = self.stratification_fractions[stratification] unnamed = [category for category in categories if category not in named] if len(unnamed) > 1: raise ValueError( f"Cannot update stratification '{stratification}': " f"categories {sorted(unnamed)} were left unnamed. At most one " f"category may be omitted, so that it can take the remaining " f"fraction." ) remainder = max(0.0, 1.0 - sum(named.values())) return {category: named.get(category, remainder) for category in categories} def update_stratification_fractions(self, fractions: Mapping[str, float]) -> None: """ Update stratification category fractions by category name. Categories belonging to several stratifications may be given in one call. A category name declared by more than one stratification must be qualified with its stratification, as in ``group:g1``. Within each stratification, at most one category may be omitted; the omitted category receives the rest of 1, or 0 when the given values pass 1. The fractions of every stratification are checked with the rule of the engine before any of them changes. Parameters ---------- fractions : Mapping[str, float] Dictionary mapping category names, bare or qualified, to their new fraction values. Raises ------ ValueError If a category is not found or is ambiguous, or a stratification has more than one category omitted; with the message of the engine, if a fraction is not from 0 to 1 or the fractions of a stratification do not sum to 1 within 1e-6. """ by_stratification: dict[str, dict[str, float]] = {} for category, value in fractions.items(): stratification, name = self.resolve_category(category) by_stratification.setdefault(stratification, {})[name] = value updated = { stratification: self._updated_fractions(stratification, named) for stratification, named in by_stratification.items() } _check_fractions(updated) for stratification, categories in updated.items(): self.stratification_fractions[stratification].update(categories) def _upsert_compartment_amounts(self, amounts: Mapping[str, float]) -> None: """ Upsert per-compartment overrides by expanded compartment name. A compartment already present in ``compartment_amounts`` has its amount replaced; a new one is appended. Compartment names depend on the expansion of the process, so :meth:`~commol.context.aggregate.Aggregate.update_compartment_amounts` validates them against the engine, not here. Raises ------ ValueError With the message of the engine, if an amount is not finite or is negative. """ commol_rs.model.check_compartment_amounts(list(amounts.items())) self.compartment_amounts.update(amounts) def update_bin_amounts(self, amounts: Mapping[str, float | None]) -> None: """ Update bin amounts by bin ID. Every amount is checked before any bin changes, so a rejected call leaves the initial conditions unchanged. The bins of one unit keep no sum: to move an amount between bins, update every bin involved. Parameters ---------- amounts : Mapping[str, float | None] Dictionary mapping bin IDs to their new amount, in the unit of the bin. None marks a bin that needs calibration. Raises ------ ValueError If a bin ID is not found in the initial conditions, or, with the message of the engine, an amount is not finite or is negative. """ unknown = sorted(set(amounts) - set(self.bin_amounts)) if unknown: raise ValueError( f"Bin(s) {unknown} not found in initial conditions. " f"Available bins: {list(self.bin_amounts)}" ) commol_rs.model.check_bin_amounts(list(amounts.items())) self.bin_amounts.update(amounts) ``` ### Methods: #### reject_fractions_of_a_total ```python reject_fractions_of_a_total(data: JsonValue) -> JsonValue ``` Name the field of amounts that replaces a field of fractions. A total over bins of different units has no meaning, so the initial state is stated as amounts, and a structure with `total`, `bin_fractions` or `compartment_fractions` is rejected with the field to use. Source code in `commol/context/initial_conditions.py` ```python @model_validator(mode="before") @classmethod def reject_fractions_of_a_total(cls, data: JsonValue) -> JsonValue: """Name the field of amounts that replaces a field of fractions. A total over bins of different units has no meaning, so the initial state is stated as amounts, and a structure with ``total``, ``bin_fractions`` or ``compartment_fractions`` is rejected with the field to use. """ if isinstance(data, Mapping): stated = [field for field in _FRACTION_FIELDS if field in data] if stated: replacements = sorted({_FRACTION_FIELDS[field] for field in stated}) raise ValueError( f"An aggregate structure states the amount of each bin in " f"the unit of the bin, not fractions of a total: replace " f"{', '.join(stated)} with {' and '.join(replacements)}, " f'such as bin_amounts={{"A": 990, "B": 10, "C": 0}}.' ) return data ``` #### validate_bin_amounts ```python validate_bin_amounts(value: dict[str, float | None]) -> dict[str, float | None] ``` Check every bin amount with the rule of the engine. Source code in `commol/context/initial_conditions.py` ```python @field_validator("bin_amounts") @classmethod def validate_bin_amounts( cls, value: dict[str, float | None] ) -> dict[str, float | None]: """Check every bin amount with the rule of the engine.""" commol_rs.model.check_bin_amounts(list(value.items())) return value ``` #### validate_stratification_fractions ```python validate_stratification_fractions(value: dict[str, dict[str, float]]) -> dict[str, dict[str, float]] ``` Check the fractions of every stratification with the rule of the engine. Source code in `commol/context/initial_conditions.py` ```python @field_validator("stratification_fractions") @classmethod def validate_stratification_fractions( cls, value: dict[str, dict[str, float]] ) -> dict[str, dict[str, float]]: """Check the fractions of every stratification with the rule of the engine.""" _check_fractions(value) return value ``` #### validate_compartment_amounts ```python validate_compartment_amounts(value: dict[str, float]) -> dict[str, float] ``` Check every compartment amount with the rule of the engine. Source code in `commol/context/initial_conditions.py` ```python @field_validator("compartment_amounts") @classmethod def validate_compartment_amounts(cls, value: dict[str, float]) -> dict[str, float]: """Check every compartment amount with the rule of the engine.""" commol_rs.model.check_compartment_amounts(list(value.items())) return value ``` #### get_uncalibrated_bins ```python get_uncalibrated_bins() -> list[str] ``` Get list of bin IDs that have uncalibrated amounts (value = None). Returns: | Type | Description | | ----------- | ----------------------------------------------------- | | `list[str]` | List of bin IDs with uncalibrated initial conditions. | Source code in `commol/context/initial_conditions.py` ```python def get_uncalibrated_bins(self) -> list[str]: """ Get list of bin IDs that have uncalibrated amounts (value = None). Returns ------- list[str] List of bin IDs with uncalibrated initial conditions. """ return [bin_id for bin_id, amount in self.bin_amounts.items() if amount is None] ``` #### get_categories_with_fractions ```python get_categories_with_fractions() -> set[str] ``` Get the categories that have a declared initial fraction. Returns: | Type | Description | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `set[str]` | Category names collected from stratification_fractions. A category declared on a stratification but absent from the initial conditions is not included. | Source code in `commol/context/initial_conditions.py` ```python def get_categories_with_fractions(self) -> set[str]: """ Get the categories that have a declared initial fraction. Returns ------- set[str] Category names collected from ``stratification_fractions``. A category declared on a stratification but absent from the initial conditions is not included. """ return { category for categories in self.stratification_fractions.values() for category in categories } ``` #### resolve_category ```python resolve_category(category: str) -> tuple[str, str] ``` Resolve a category id to its stratification and its bare name. A qualified id (`group:g1`) names the stratification. A bare name must be declared by exactly one stratification. Parameters: | Name | Type | Description | Default | | ---------- | ----- | -------------------------------- | ---------- | | `category` | `str` | A bare or qualified category id. | *required* | Returns: | Type | Description | | ----------------- | ------------------------------------------------- | | `tuple[str, str]` | The stratification id and the bare category name. | Raises: | Type | Description | | ------------ | --------------------------------------------------------- | | `ValueError` | If the category is not found or a bare name is ambiguous. | Source code in `commol/context/initial_conditions.py` ```python def resolve_category(self, category: str) -> tuple[str, str]: """ Resolve a category id to its stratification and its bare name. A qualified id (``group:g1``) names the stratification. A bare name must be declared by exactly one stratification. Parameters ---------- category : str A bare or qualified category id. Returns ------- tuple[str, str] The stratification id and the bare category name. Raises ------ ValueError If the category is not found or a bare name is ambiguous. """ stratification_id, separator, name = category.rpartition(CATEGORY_SEPARATOR) owners = [ stratification for stratification, categories in self.stratification_fractions.items() if (not separator or stratification == stratification_id) and name in categories ] if len(owners) > 1: qualified = " or ".join( f"'{owner}{CATEGORY_SEPARATOR}{name}'" for owner in owners ) raise ValueError( f"Category '{category}' is ambiguous: it is declared by " f"stratifications {owners}. Qualify it as {qualified}." ) if not owners: raise ValueError( f"Category '{category}' not found in initial conditions. " f"Available categories: {sorted(self.get_categories_with_fractions())}" ) return owners[0], name ``` #### category_id ```python category_id(stratification: str, category: str) -> str ``` Shortest id that names a category without ambiguity. Parameters: | Name | Type | Description | Default | | ---------------- | ----- | ---------------------------------------------------- | ---------- | | `stratification` | `str` | Id of the stratification that declares the category. | *required* | | `category` | `str` | Bare category name. | *required* | Returns: | Type | Description | | ----- | ----------------------------------------------------------------------------------------------------------- | | `str` | The bare name, or stratification:category when another stratification declares a category of the same name. | Source code in `commol/context/initial_conditions.py` ```python def category_id(self, stratification: str, category: str) -> str: """ Shortest id that names a category without ambiguity. Parameters ---------- stratification : str Id of the stratification that declares the category. category : str Bare category name. Returns ------- str The bare name, or ``stratification:category`` when another stratification declares a category of the same name. """ declaring = sum( category in categories for categories in self.stratification_fractions.values() ) if declaring > 1: return f"{stratification}{CATEGORY_SEPARATOR}{category}" return category ``` #### get_category_fraction ```python get_category_fraction(category: str) -> float ``` Get the fraction of a category within the group it subdivides. Parameters: | Name | Type | Description | Default | | ---------- | ----- | -------------------------------------------- | ---------- | | `category` | `str` | A category id, bare or qualified (group:g1). | *required* | Returns: | Type | Description | | ------- | --------------------------------------------------------------------------------------------------------------------- | | `float` | The declared fraction, relative to the group the category's stratification applies to rather than to the whole model. | Raises: | Type | Description | | ------------ | --------------------------------------------------------- | | `ValueError` | If the category is not found or a bare name is ambiguous. | Source code in `commol/context/initial_conditions.py` ```python def get_category_fraction(self, category: str) -> float: """ Get the fraction of a category within the group it subdivides. Parameters ---------- category : str A category id, bare or qualified (``group:g1``). Returns ------- float The declared fraction, relative to the group the category's stratification applies to rather than to the whole model. Raises ------ ValueError If the category is not found or a bare name is ambiguous. """ stratification, name = self.resolve_category(category) return self.stratification_fractions[stratification][name] ``` #### update_stratification_fractions ```python update_stratification_fractions(fractions: Mapping[str, float]) -> None ``` Update stratification category fractions by category name. Categories belonging to several stratifications may be given in one call. A category name declared by more than one stratification must be qualified with its stratification, as in `group:g1`. Within each stratification, at most one category may be omitted; the omitted category receives the rest of 1, or 0 when the given values pass 1. The fractions of every stratification are checked with the rule of the engine before any of them changes. Parameters: | Name | Type | Description | Default | | ----------- | --------------------- | ----------------------------------------------------------------------------------- | ---------- | | `fractions` | `Mapping[str, float]` | Dictionary mapping category names, bare or qualified, to their new fraction values. | *required* | Raises: | Type | Description | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If a category is not found or is ambiguous, or a stratification has more than one category omitted; with the message of the engine, if a fraction is not from 0 to 1 or the fractions of a stratification do not sum to 1 within 1e-6. | Source code in `commol/context/initial_conditions.py` ```python def update_stratification_fractions(self, fractions: Mapping[str, float]) -> None: """ Update stratification category fractions by category name. Categories belonging to several stratifications may be given in one call. A category name declared by more than one stratification must be qualified with its stratification, as in ``group:g1``. Within each stratification, at most one category may be omitted; the omitted category receives the rest of 1, or 0 when the given values pass 1. The fractions of every stratification are checked with the rule of the engine before any of them changes. Parameters ---------- fractions : Mapping[str, float] Dictionary mapping category names, bare or qualified, to their new fraction values. Raises ------ ValueError If a category is not found or is ambiguous, or a stratification has more than one category omitted; with the message of the engine, if a fraction is not from 0 to 1 or the fractions of a stratification do not sum to 1 within 1e-6. """ by_stratification: dict[str, dict[str, float]] = {} for category, value in fractions.items(): stratification, name = self.resolve_category(category) by_stratification.setdefault(stratification, {})[name] = value updated = { stratification: self._updated_fractions(stratification, named) for stratification, named in by_stratification.items() } _check_fractions(updated) for stratification, categories in updated.items(): self.stratification_fractions[stratification].update(categories) ``` #### update_bin_amounts ```python update_bin_amounts(amounts: Mapping[str, float | None]) -> None ``` Update bin amounts by bin ID. Every amount is checked before any bin changes, so a rejected call leaves the initial conditions unchanged. The bins of one unit keep no sum: to move an amount between bins, update every bin involved. Parameters: | Name | Type | Description | Default | | --------- | --------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | | `amounts` | \`Mapping\[str, float | None\]\` | Dictionary mapping bin IDs to their new amount, in the unit of the bin. None marks a bin that needs calibration. | Raises: | Type | Description | | ------------ | ------------------------------------------------------------------------------------------------------------------------------- | | `ValueError` | If a bin ID is not found in the initial conditions, or, with the message of the engine, an amount is not finite or is negative. | Source code in `commol/context/initial_conditions.py` ```python def update_bin_amounts(self, amounts: Mapping[str, float | None]) -> None: """ Update bin amounts by bin ID. Every amount is checked before any bin changes, so a rejected call leaves the initial conditions unchanged. The bins of one unit keep no sum: to move an amount between bins, update every bin involved. Parameters ---------- amounts : Mapping[str, float | None] Dictionary mapping bin IDs to their new amount, in the unit of the bin. None marks a bin that needs calibration. Raises ------ ValueError If a bin ID is not found in the initial conditions, or, with the message of the engine, an amount is not finite or is negative. """ unknown = sorted(set(amounts) - set(self.bin_amounts)) if unknown: raise ValueError( f"Bin(s) {unknown} not found in initial conditions. " f"Available bins: {list(self.bin_amounts)}" ) commol_rs.model.check_bin_amounts(list(amounts.items())) self.bin_amounts.update(amounts) ``` # Changelog # Changelog [Back to Home](https://munqu.github.io/commol/latest/index.md) All notable changes to Commol will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [0.10.2] - 2026-10-24 This release makes the engine the one source of every rule: the range of each setting, the values of a model, the windows, the checks of a model and a calibration, and the language of formulas, which commol now reads with a parser of its own. A value that was valid stays valid, and an invalid one still raises `ValueError` when its object is built, but the messages are the engine's. Read the changes before you upgrade: seeded results change value, as every draw comes from one seed scheme in which neighbouring seeds share no replicate; a chain of powers groups from the right, so `2 ** 3 ** 2` is 512; `BootstrapCoverageEta` gives a slightly different η; a stratification fraction outside 0 to 1 is rejected; and two results are equal when their values, a NaN included, and their positions, seed and model are equal, so a run is no longer equal to a plain `dict`. See [Building models](https://munqu.github.io/commol/latest/guide/building-models/index.md) and [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md). ### Changed - The engine holds the range rule of every setting: a count, a share, a length, a scale, a tolerance, a bound of an optimizer, an observation, an observation model, an axis pattern, a distribution, a quantity of interest and the setting of an uncertainty, identifiability or sensitivity method. A value outside its range still raises `ValueError` when the object is built, and the same values are valid; the message is the engine's, and names the setting and its range: `replicates is a whole number of at least 1, got 0`, `sd is a finite number greater than 0, got 0`. A count gives one message for every value outside its range, a negative one included, also when a binding is called with no Python object in between, and for `Simulation.replicates` (`count is a whole number of at least 1, got 0`) and `Calibrator.run` (`starts is a whole number of at least 1, got 0`). A seed outside its range gives `seed is a whole number from 0 to 18446744073709551615, got -1` in every function that takes one: `Simulation.run`, `Simulation.run_at`, `Simulation.replicates`, `CalibrationProblem`, `SensitivityAnalysis` and each binding. - The engine holds the rule of every value of a model and of every window. An initial amount, an impulse and the points of a series fail when their object (`Aggregate`, `Transition`, `SeriesValue`) is built or an amount is updated, as before, and the same values are valid; the message is the one the model compiler gives, which names the bin, the compartment or the transition: `Bin 'B': initial amount is a finite number of at least 0, got -1`, `Transition 't': impulse period is a finite number greater than 0, got 0`, `a series has one point per position, got two at 2`. The windows of `windowed_totals`, of reports, of figures and of `AxisUnits.window_start`, `window_end`, `window_index` and `window_ends` come from the engine, with its messages (`window is a finite number greater than 0, got 0`, `index is a whole number of at least 0, got -1`), and an observation or a quantity whose window starts before 0 gives `the window 4 that ends at 3 starts before 0; an end is at least its window`. A `tick_every` of a figure that is not a finite number greater than 0 raises `ValueError`. - A calibration, a sensitivity analysis or any other method that sets a model parameter raises `ValueError` when it is built if the parameter is a formula or a series parameter. The engine computes such a parameter at each step, so a value set on it was lost and the method fitted nothing. The message is the engine's, for this case and for a parameter the model does not have: `Parameter 'k9' is not in the model; the parameters that can be set are k1, k2`. Python keeps no copy of this check. - The four engines of the aggregate kind name themselves in their messages by the name of the engine: a position an engine does not report at gives `The DifferenceEquations engine reports only at integer steps; 2.5 is not one`, and an impulse or a series point between two steps names `the DifferenceEquations engine` or `the StochasticSteps engine`. - `Predictive.at_observations` and every method that reads a predictive at other observations raise `ValueError` for a position the engine rejects, with the message of the engine, as a calibration does. The bands, the automatic block lengths and their estimates keep their values: one Rust function of each computes them for every method. - The engine decides the practical verdicts of `Collinearity` and `SampleIdentifiability` and the two notes of a profile on its refits, and results read a position with the rule of an observation: the verdicts, reasons, notes and values stay. `windowed_totals` reads each window as a calibration reads a windowed observation, and it gives floats. A figure asked for a series that is neither an output nor a bin or accumulator raises `KeyError` with the message of `Simulation.outputs_for`, and a sum of outputs that the results do not hold gives one message, `Missing output series in results`, in a simulation, a report and a figure. - The engine checks a model, a calibration and the engine settings, and Python repeats none of its checks, so each mistake gets the message of the engine. A parameter named after an engine variable, a bin, a compartment or a subgroup total names each conflict and asks to rename the parameter. An observation of an output the model does not have names the output, the observation and the outputs: `Invalid observed output name 'X' for observation 'series1' (available: A, B, C)`. A factor with a bound of 0 or below fails with `Factor parameter 'f' must have positive bounds (got min=0, max=2)`. `Collinearity` checks `max_subset_size` when it runs, with `max_subset_size must be from 2 to the number of parameters (3), got 9`. The needs of a stochastic engine (`replicates`, `seed` and `replicate_summary` of a calibration; `replicates` and `seed` of a sensitivity analysis) keep their messages, which the engine now gives. - `ODESolver` and `StochasticEventsSettings` take their defaults from the engine and check themselves with the engine when they are built: `StochasticEventsSettings(epsilon=1.5)` gives `epsilon is strictly between 0 and 1, got 1.5`. Every other setting strictly between 0 and 1 (`target_cess`, `stay_probability`, `min_acceptance`, the `quantile` of `ABCRejection`, `correlation_threshold`) gives ` is strictly between 0 and 1, got `, `Quantile(1.5)` gives `A quantile level is between 0 and 1, got 1.5`, and a Sobol analysis of the Rust API with fewer than 2 draws says `draws`, the name of the setting. - A negative count that reaches the engine, such as `Simulation.run(-1)`, the `replicates` of a `SensitivityAnalysis` or the `max_subset_size` of `Collinearity`, raises `ValueError` that names the setting, `num_steps is a whole number from 0, got -1`, in place of an `OverflowError` with no name. Every count of the bindings reads this way. - The engine draws a seed that its user does not set, in one place, and the result gives the seed it ran with, as before: `results.seed`, `SimulationReplicates.seed`, `Calibrator.seed` and `SensitivityAnalysis.seed`. A calibrator with no seed logs the seed it drew when it is built, not when it first runs. - Ruff checks that every signature of the package has annotations and that none is `typing.Any`; the raw input of a validator is a `JsonValue`. A keyword that `Quantile`, `Groups`, `Level` or `Increments` does not take raises `TypeError` that names it, such as `Quantile(0.5, unknown=1)`, in place of the `ValidationError` `Extra inputs are not permitted`. A `ParameterSample` whose `names`, `bounds` or `values` are not valid gets the pydantic message of that field before the engine checks the log weights. - A calibration and every fit of a replicate of a bootstrap, the jackknife, a profile threshold or a conformal method run their starts with one rule: the engine chooses the best start, the first of lowest loss, and the seed gives the Latin hypercube and the seed of each start from two streams of its own. So a calibration with a seed, its structural rank and its fits of replicates give other values than in 0.10.1; a run stays reproducible from its seed on one version. When every start fails, the message lists the reason of each start, also in the message of a replicate that fails. - A particle swarm draws its numbers with the generator of commol from the seed of its start, which a calibration always gives: a verbose calibration with a swarm is the calibration without `verbose`, and `Calibrator.run_with_history` is start 0 of `Calibrator.run`. - The summaries of `PropagationResult`, of the scenarios and of `member_statistics`, the effective size of a propagation and the densities of observation models and priors keep their values: one Rust function computes each, and Python calls the summary as `commol_rs.inference.weighted_summary`. The error of a `Laplace` fit on its bound points to `ProfileLikelihood` and cites no issue. - `BootstrapCoverageEta` takes `η` with the one quantile of commol, the one of `weighted_quantiles`, on the logs of the thresholds, so `η` changes once. It needs at least `1/(2(1 − c))` replicates times parameters, where it needed `1/(1 − c) − 1`. ### Fixed - A fraction of a stratification outside 0 to 1 raises `ValueError` when its `Aggregate` is built, with the message of the engine: `Stratification 'g', category 'g1': fraction is a number from 0 to 1, got 1.5`. Before, only the sum was checked, so fractions such as `{"g1": 1.5, "g2": -0.5}` built a model whose compartment started at a negative amount. The sum of the fractions of a stratification, 1 within 1e-6, is checked at the same time (`Stratification 'g': the fractions of its categories sum to 1 within 1e-6, got 0.9`), where it was checked by `build`, and `update_stratification_fractions` and the model compiler give the same messages. - Two results of commol with the same values are equal, also when they hold a NaN: a NaN equals a NaN, whatever the objects that hold it. Before, `==` compared the identity of the items of a list first, so two runs with one seed gave results that were not equal when one held a NaN, such as a PRCC coefficient of an input that the other inputs explain entirely. A frozen result has a hash that agrees with `==`, also when it holds a list, a dict or a NaN. `ParameterSample` compares as every other result. A result no longer compares its private attributes ([Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/#comparing-results)). - `==` of `SimulationResults` and `SimulationReplicates` now also compares their positions, seed and model, and a NaN equals a NaN. Before, it read only the values, so two runs at other positions or with other seeds and the same values were equal, and two runs with one seed that held a NaN were not. A plain `dict` or `list` with the same values is no longer equal to a result: compare `dict(results)` to read the values only ([Running Simulations](https://munqu.github.io/commol/latest/guide/simulations/#results)). - A profile reads a parameter with one finite bound, such as `[0, +∞)`, on its bound within `1e-8` of `max(1, |bound|)`, the rule of `Laplace`. Before, every value of such a parameter was on its bound, so a profile never saw the ridge leave the box through it, and the structure of a flat profile gave `undetermined="on_bound"` with that parameter in place of a reading. - A window whose start rounds to just below 0, such as a window of `1000 + 1e-7` that ends at 1000, starts at 0 in an observation, a calibration and a quantity, as `windowed_totals` reads it. Before, a calibration rejected it while the same window that ends at 2000 was accepted. One rule of the engine gives the start of a window. - Two neighbouring seeds share no replicate: before, replicate `i + 1` of `simulation.replicates(n, seed=42)` was replicate `i` of `seed=43`, and so were the starts and the swarms of a calibration with neighbouring seeds. Two methods that take one seed no longer draw from one sequence either: every random method draws from a stream of its own of the seed, and with one seed, replicate `i + 1` of a bootstrap no longer has the generator of particle `i` of an SMC sampler, nor the draws of `Laplace` the design of a sensitivity analysis. Every method draws with the samplers of the one generator of commol. So with one seed, the draws of the random methods and the replicates of a stochastic engine differ from 0.10.1; a run stays reproducible from its seed on one version. - One check of the engine defines the language of formulas, the one [Mathematical expressions](https://munqu.github.io/commol/latest/guide/mathematical-expressions/index.md) documents, for rates, formula parameters, calibration constraints and the unit check. A formula outside it fails when it is added, with a message that names the formula and its first part outside the language. Before, `k1 * (step > 30)` was accepted as a formula parameter, and `step > 1` as a rate became a parameter named `step > 1` that failed only when the model ran. A formula that calls a function the guide does not list, such as `erf`, `fma` or `trunc`, fails as a rate always did; a formula parameter accepted it before. - A rate or a formula parameter that reads a variable the model does not define fails when the model is built. Before, only a transition rate was checked, and a formula parameter failed when the model ran. A category id is not a variable: before, a rate that read one passed the build and failed when the model ran. - An error of a rate when the model runs names its transition, not the indices of its compartments. - `SecurityConfig.max_complexity_score` limits the number of nodes of the tree of a formula (numbers, variables, operators and calls), as the guide documents. Before, it limited nothing. - The unit check reads `^` as a power, as the engine does, and `cbrt` takes the cube root of a unit. - A formula with a long chain of terms, such as a sum of 20 000 terms, no longer stops the Python process with a segmentation fault. The engine reads formulas with a parser of its own: a sum or a product of any length is one level, and a formula that nests deeper than 100 levels of parentheses, calls, signs and powers fails with a message that names the limit. Before, the parse used one step of the stack for each term, and the process crashed near 20 000 terms, or near 2 000 on a thread of the parallel work. - A chain of powers groups from the right, as in Python: `2 ** 3 ** 2` is - Before, it was 64, `(2 ** 3) ** 2`. - `ParticleSwarmConfig.inertia("constant")` and `.acceleration("constant")` take the weight and the coefficients of canonical particle swarm, `1/(2 ln 2)` and `0.5 + ln 2`, which a swarm with no inertia or acceleration strategy uses. Before, their defaults were the rounded `0.721` and `1.193`, so a swarm that named the constant strategy moved a little differently from one that did not. - Three messages of a calibration with observation models no longer hold a run of spaces in the middle of a sentence. ## [0.10.1] - 2026-10-23 This release ships the fixes of the audit at 0.9.0 that change no API. In the `StochasticEvents` engine, a parameter or a seed changed between two runs acts from the position where the first run stopped. The documentation reads as one account of the library and names no application field, and commol no longer installs `cffi`. CI runs with read rights and pinned tools, and the test suite checks its own layout. Nothing breaks: code that runs on 0.10.0 runs on 0.10.1. See [Stochastic engines](https://munqu.github.io/commol/latest/guide/stochastic/index.md) and the [Quickstart](https://munqu.github.io/commol/latest/getting-started/quickstart/index.md). ### Removed - `cffi` is no longer a dependency of commol: no code of commol uses it, so `pip install commol` no longer installs it. Code that imports `cffi` installs it itself. ### Fixed - In the `StochasticEvents` engine, a parameter or a seed changed between two runs acts from the position where the first run stopped. The pending wait is drawn again from there, and a pending leap keeps only its events up to there, by binomial thinning. Before, the new rate acted back from the last event, and copies of one state with new seeds shared their next event. In Python this changes only `simulation.engine.set_seed` between two calls of `simulation.engine.step()`; `Simulation.run`, replicates and calibration reset the engine after the seed, so their results do not change. - The documentation and the docstrings name no application field, as [Conventions](https://munqu.github.io/commol/latest/codebase/conventions/index.md) asks: the dispersion of `NegativeBinomialObservations` is "the dispersion `k` (`size` in R)", and bins of different units are "a count and a concentration". A test checks the words of the user pages and of the source of `commol`. ### Documentation - The documentation reads as one account of the library: the home page, the quickstart and the examples show one process run by several engines, then calibration, uncertainty, the comparison of runs and parallel work, and the guide follows that order. Each rule lives in one page and the others link to it; a test checks that every page is in the navigation and that no text repeats across pages. The API reference names every name of `commol.__all__` and the module of each name that the root does not export. The maintainer pages name the paths, symbols and tests of the code, and the design note of per-compartment initial conditions is part of [Initial conditions](https://munqu.github.io/commol/latest/codebase/decisions/initial-conditions/index.md). ## [0.10.0] - 2026-10-23 This release makes equivalent things the same across commol, after an audit of the code, the repository and the documentation at 0.9.0. A setting that means the same thing has one name, every method takes its seed from its source and fits from its starts, equivalent results hold their failed fits in one place, equivalent methods raise one error type, a replicate summary takes the one quantile of commol, and commol adds no log handler. Before you upgrade, read each **Breaking** entry: most code changes only the names of settings and the type of an exception. See [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) and [Log messages](https://munqu.github.io/commol/latest/getting-started/quickstart/#log-messages). ### Added - `Laplace`, `Sandwich`, `InformationMatchingEta` and `BootstrapCoverageEta` take `starts`, at least 1 with the default 1: their fit is the best of `starts` starts, `Calibrator.run(starts)`, as in every bootstrap, jackknife, conformal method and profile. Code that does not pass it runs as before. See [Local Approximations](https://munqu.github.io/commol/latest/guide/uncertainty/local/#laplace-approximation-and-the-delta-method) and [What `η` means](https://munqu.github.io/commol/latest/guide/uncertainty/smc/#what-means). ### Changed - **Breaking:** every uncertainty and identifiability method takes its random numbers from the seed of its source, `Calibrator.seed` (`problem.seed`) or `SensitivityAnalysis.seed`, and has no seed of its own. Remove `seed=` from `StructuralRank` and from `scores.pit`, and set `problem.seed` (or the seed of the analysis) to choose the random numbers. The PIT randomizes ties from a stream of the seed of its draws, so its values at tied draws change for the same seed. See [Scores](https://munqu.github.io/commol/latest/guide/uncertainty/#scores). - `LaplaceMixture.starts` defaults to 1. - **Breaking:** every uncertainty, identifiability and sensitivity method raises one exception for one kind of error: `ValueError` when a setting, an input, the data or the model does not fit the method, and `RuntimeError` when a computation that could start fails (an engine run, an optimizer, every replicate, a sampler that stops before its target). An input error of an SMC method, a bootstrap, the jackknife or a profile that raised `RuntimeError`, such as a loss of 0 at the fit or a fit outside the support for `BootstrapCoverageEta`, now raises `ValueError`. Catch `ValueError` for these errors where your code caught `RuntimeError`. See [Design decisions](https://munqu.github.io/commol/latest/codebase/decisions/uncertainty/#methods-raise-one-error-type). - **Breaking:** a setting that means the same thing has one name in every method. The level of an interval is `confidence_level`, a count is a plain plural noun, and the rule that combines the replicates of a ratio is `replicate_rule`. An old name raises a `ValidationError` that names it as not permitted. Rename each old name in your code: | Where | Before | Now | | ---------------------------------------------------------------------------------------- | --------------------------- | ----------------------------------------- | | `SplitConformal`, `RollingConformal`, `JackknifePlus`, `EnbPI`, `AdaptiveConformal` | `level=` | `confidence_level=` | | `ConformalResult.intervals` (`PredictionIntervals`), and its report block | `.level`, `"level"` | `.confidence_level`, `"confidence_level"` | | `Threshold`, `Threshold.chi_square`, `Threshold.beale`, `Threshold.bootstrap_calibrated` | `level` | `confidence_level` | | `ProfileSection` (`IdentifiabilityReport.profile`) | `.level` | `.confidence_level` | | `ParticleSwarmConfig` | `num_particles=` | `particles=` | | `Sobol` | `base_draws=`, `bootstrap=` | `draws=`, `replicates=` | | `Ratio` | `replicates=` | `replicate_rule=` | | `Simulation.replicates`, `ReplicateRuns` | `n` | `count` | See [A setting has one name and one check](https://munqu.github.io/commol/latest/codebase/decisions/results/#a-setting-has-one-name-and-one-check). - One check, in Rust, rejects a confidence level that is not strictly between 0 and 1, with one message in every method: `confidence_level is strictly between 0 and 1, got 1.5; use 0.95 for a 95% interval`. Every other setting strictly between 0 and 1 (`target_cess`, `stay_probability`, `min_acceptance`, the `quantile` of `ABCRejection`, `correlation_threshold`) says ` is strictly between 0 and 1, got `. - **Breaking:** a calibration with `replicate_summary=Quantile(q)` compares the observations with the one quantile of commol, the one of `weighted_quantiles`, `SimulationReplicates.quantiles` and every interval (NumPy's `method="hazen"` with equal weights), instead of NumPy's default `linear` quantile. `Quantile(0.25)` of the replicates 1, 2, 3, 4, 5 is now 1.75, not 2.0. So for `q` other than 0.5 the loss and the fitted values change slightly; `"median"` and `Quantile(0.5)` do not change. Your code does not change; run again a calibration with `Quantile(q)` whose result you stored. See [One confidence-interval convention](https://munqu.github.io/commol/latest/codebase/decisions/results/#one-confidence-interval-convention). - **Breaking:** results that hold the same thing hold it in the same place, under the same name. Every result of a method that fits several times lists each fit that failed in `failed`, `" failed: "`, logged as a warning, and counts the fits kept although their optimizer did not converge in `not_converged`. Change your code: | Before | Now | | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `CalibrationResult.failed_runs` | `CalibrationResult.failed` | | `BootstrapResult.diagnostics.failed`, `.diagnostics.not_converged` | `BootstrapResult.failed`, `.not_converged` | | `JackknifeResult.diagnostics.failed`, `.diagnostics.not_converged` | `JackknifeResult.failed`, `.not_converged`; an entry names the block by its index from 0, `"Block 2 failed: …"`, not by its span | | `ProfileResult.failed_replicates` | `ProfileResult.failed`, and the new `ProfileResult.not_converged` | | The failed refits of `RollingConformal`, `JackknifePlus` and `EnbPI`, only logged as `"Refit i failed: …"` | `ConformalResult.failed` and `ConformalResult.not_converged` | | The failed replicates of `BootstrapCoverageEta` in `GibbsResult.diagnostics.notes` | `GibbsResult.failed` | | `ProfileResult.report()` | `ProfileResult.identifiability()`; its `report()` gives the data | | `sample.effective_size()` | `sample.effective_size`, a property, as on every other object | When a method has fewer successful fits than it needs, its `RuntimeError` says how many of how many fitted, how many it needs, and the first reasons, and names the unit: `"Only 1 of 5 blocks fitted, and the method needs 2; …"`. It says `"Every origin failed: …"` (or replicate, block) only when none fitted. The jackknife and jackknife+ no longer say that every replicate failed when one fit succeeded. See [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/#the-result). ### Removed - **Breaking:** `commol.add_stderr_logger` is removed: commol configures no logging, and adds only a `NullHandler` to the logger `commol`. Call `logging.basicConfig(level=logging.INFO)` instead, or add a handler of your own to `logging.getLogger("commol")`. See [Log messages](https://munqu.github.io/commol/latest/getting-started/quickstart/#log-messages). ### Fixed - `IdentifiabilityReport.report()` gives the `profile` section, the evidence of each profile likelihood; before, the report data had no such section. ## [0.9.0] - 2026-10-22 This release gives control over the work of a calibration and over what a result records. `commol.threads(n, levels)` sets how many threads the parallel methods use and which levels run in parallel, a particle swarm can stop when it has converged, a polynomial chaos takes a truncated basis for many inputs, and `Ratio` gives a sensitivity analysis the ratio of two quantities. A calibration result records its seed, and `commol.build_info()` names the version and the commit of the package. Before you upgrade, read the **Breaking** entry: `CalibrationResult` requires `seed`. See [Parallelism](https://munqu.github.io/commol/latest/guide/parallelism/index.md) and [Sensitivity Analysis](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/index.md). ### Added - `PolynomialChaos(q_norm=..., max_interaction=...)`: a truncated basis for many inputs. `max_interaction` keeps the terms of at most that many inputs, and `q_norm`, in `(0, 1]`, the terms whose degrees have `Σ α_i^q ≤ degree^q`; the defaults keep the total-degree basis. 20 inputs at degree 4 with `max_interaction=2` have 1,221 terms instead of 10,626, and the number is known before any run. `ChaosResult` records both. See [Bases for many inputs](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/#bases-for-many-inputs). - `ParticleSwarmConfig.improvement_stop(window=..., tol_abs=..., tol_rel=...)` and `ParticleSwarmConfig.radius_stop(tol)`: a particle swarm stops when its best loss improved by at most `tol_abs + tol_rel·|best loss|` over the last `window` iterations, or when every particle is within `tol` of the best position, as a fraction of the size of the search box. Both are off by default and have no default values. A run that stops by a rule reports `converged=True`, so a profile that holds a prediction with a swarm also converges. See [Stop rules](https://munqu.github.io/commol/latest/guide/calibration-optimizers/#stop-rules). - `commol.set_threads(n)`, the context manager `commol.threads(n, levels)`, `commol.get_threads()` and `commol.get_levels()` choose the number of threads of every parallel method and the levels that run in parallel. The setting holds per Python thread (`contextvars`); without it, commol uses one thread per core. A particle swarm scores the particles of each iteration in parallel, in the order of the particles, with `levels="all"`, the default; `levels="outer"` keeps them in order. A seed gives the same result, history included, with any number of threads and either level. See [Threads and Parallelism](https://munqu.github.io/commol/latest/guide/parallelism/index.md). - A calibration result records the seed it used, `CalibrationResult.seed` (`Calibrator.seed`: `problem.seed`, or the seed the calibrator drew), and the sample of every uncertainty method and of `Calibrator.point_sample` names it in `method.settings["seed"]`. Set as `problem.seed`, it repeats the run, also from a result read back from JSON. See [Reproducibility](https://munqu.github.io/commol/latest/guide/calibration-optimizers/#reproducibility). - `commol.__version__` gives the installed version, and `commol.build_info()` gives a `BuildInfo` with the version and the full commit the extension was built from (`None` when the source has no git metadata, such as a source distribution). A build that is not a release, such as an install from a git commit, names its commit as a PEP 440 local version: `0.1.0+g`. See [Check the installation](https://munqu.github.io/commol/latest/getting-started/installation/#check-the-installation). - `Ratio(numerator, denominator, replicates)` is a quantity of interest: one `ValueAt`, `Gain` or `Peak` over another, in each run, so PRCC, Morris, Sobol and a polynomial chaos read a ratio whose denominator depends on the inputs. `replicates` is required: with a stochastic engine, a point of a design holds the mean of the ratios (`"within"`), the mean of the ratios of the replicates with a positive denominator (`"within_positive"`) or the ratio of the means (`"pooled"`). A denominator of 0 makes the ratio of a run NaN, and every method follows the NaN rule. See [A ratio of two quantities](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/#a-ratio-of-two-quantities). ### Changed - The tolerance of a profile under a particle swarm with the improvement stop rule is the improvement the rule allows at the fit, `tol_abs + tol_rel·|objective at the fit|`; without that rule it stays `1e-6·max(1, |objective at the fit|)`. See [Verdicts](https://munqu.github.io/commol/latest/guide/uncertainty/profiles/#verdicts-and-the-identifiability-report). - The parallel methods run in a thread pool of commol, not in the global pool of `rayon`: the environment variable `RAYON_NUM_THREADS` no longer limits them; use `commol.set_threads`. A particle swarm, which scored its particles one after the other, now scores them in parallel by default. - **Breaking:** `CalibrationResult` requires `seed`; a result saved by an earlier version, or built by hand, needs one to load. ### Fixed - A particle swarm without `problem.seed` takes the seed the calibrator draws and logs, so that seed repeats it; before, it drew one of its own that no result or log kept. - A profile that holds a prediction with a particle swarm scores its particles in parallel only when the levels of `commol.threads` are `"all"`, and stops with the error of the first failing particle, as every other swarm does; before, it scored them in parallel at any level and reported the error of any failing particle. ## [0.8.0] - 2026-10-22 This release compares runs. `Scenarios` runs every draw of a sample under several sets of parameter values and compares them in pairs, a quantity of interest reads a series and reduces it (the peak of the gains over windows, for example), and `results.quantities` reads the quantities of one run. Figures are methods of the results they draw: `plot.series`, `plot.observations` and `plot_compared`, which draws several runs or samples on the same panels. Before you upgrade, read the **Breaking** entries: `SimulationPlotter` and seaborn are removed and matplotlib is the optional extra `commol[plot]`, the quantities take a `series`, and the runs of the draws of a stochastic sample follow one tree of seeds, so their numbers change. See [Sensitivity Analysis](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/index.md) and [Simulations](https://munqu.github.io/commol/latest/guide/simulations/index.md). ### Added - `Scenarios(source, sample, scenarios, positions)` runs every draw of a sample under each scenario, a set of parameter values that override those of the model, and is a mapping from the name of a scenario to its `Predictive`. Every scenario runs with the seed of the source, so run `k` of each scenario is the same draw with the same seed (common random numbers). `compare(name, baseline, quantities, confidence_level)` takes the difference of each quantity of interest within each run and reduces the differences with the weights of the runs; `summary` reduces one scenario. Both give a `ScenarioSummary` per label, with no interval when there is one run. `Predictive.quantities(quantities)` gives the quantities of every draw, `Calibrator.point_sample(result)` the point estimate of a fit as a sample of one draw, and `Calibrator.with_model` and `SensitivityAnalysis.with_model` the same source on another model with the same seed. See [Scenarios](https://munqu.github.io/commol/latest/guide/uncertainty/#scenarios). - `plot.observations(observed_data, …)` of `SimulationResults`, `SimulationReplicates` and `Predictive` draws one panel per observed series (one `label`), with the model read through the observation operator of each observation: the sum of its `outputs`, its `window` and its factor. A series that sums some outputs, such as `B_g2 + B_g3`, gets its own panel. A value with a window is a step over its window, and the band of replicates or of a predictive (`at_observations(…, NoNoise())`) a rectangle over it; its legend says that it is a band of the model, not a prediction interval. See [Plotting](https://munqu.github.io/commol/latest/guide/simulations/#a-panel-per-observed-series). - `SimulationResults.quantities(quantities)` reduces one run to its quantities of interest, as `dict[str, float]`, and `SimulationReplicates.quantities(quantities)` gives one such dict per replicate, with the Rust code of `Propagation`. `quantity_schedule` gives the positions to run at; a run that lacks one raises `ValueError` that lists each missing position with its quantity. The guide shows how to take a ratio of two quantities row by row. See [The quantities of one run](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/#the-quantities-of-one-run). - `plot_compared({label: result}, …)` draws several runs, replicates or predictives of the same model on the same panels: one line per result, labelled with its key and in the same color in every panel, the band of each set of replicates and of each predictive, and the observations once. `window=` draws the gain of each series over each window. A result without the outputs of the first raises `ValueError` that names its key. See [Plotting](https://munqu.github.io/commol/latest/guide/simulations/#compare-results-on-the-same-panels). - Every figure takes `axes=`, the `Figure` of an earlier figure of commol, and draws each series on the panel of that series: each panel has the id of its series as the label of its `Axes`. A series with no panel raises `ValueError` that names it. See [Plotting](https://munqu.github.io/commol/latest/guide/simulations/#several-results-on-one-figure). - A quantity of interest reads a series and reduces it. The series is a name or a list of names, their `Level`, or `Increments(outputs, window)`: the gain of their sum over each window that ends at a position. So `Peak(series=Increments("acc", window=7), positions=ends)` is the largest gain of `acc` over a window of 7, and `PeakPosition` with the same arguments the end of that window, with no class of their own. `commol` exports `Level` and `Increments`. See [Quantities of interest](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/#quantities-of-interest). - `PropagationResult.where(mask)` keeps the runs where `mask` is true and renormalises their weights to sum to 1, and `PropagationResult.effective_size` gives Kish's effective size of the weights. A filter conditions the result on the condition of the mask. - `plot_compared(…, panels="observations")` draws one panel per observed series (one `label` of `observed_data`), with each result read through the observation operator of each observation, as `plot.observations` reads one result: the steps, markers and band of each result in the color of its key, and the observations once. The default, `panels="series"`, draws one panel per series. See [Plotting](https://munqu.github.io/commol/latest/guide/simulations/#compare-results-on-the-same-panels). ### Changed - **Breaking:** each figure is a method of the results it draws, and `SimulationPlotter` is removed. Each figure returns the matplotlib `Figure`. A predictive gives its own figures, so no `Simulation` is built only to draw it. See [Plotting](https://munqu.github.io/commol/latest/guide/simulations/#plotting). | Before | Now | | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `SimulationPlotter(simulation, results).plot_series(…)`, `.plot_cumulative(…)` | `results.plot.series(…)`, `results.plot.cumulative(…)` | | `SimulationPlotter(simulation, replicates, confidence_level=c).plot_series(…)` | `replicates.plot.series(…, confidence_level=c)` | | `SimulationPlotter(simulation, run, confidence_level=c).plot_series(calibration_result=predictive, …)`, and `plot_cumulative` | `predictive.plot.series(…, confidence_level=c)`, `predictive.plot.cumulative(…)` | | `SimulationPlotter.plot_prcc(result)`, `plot_morris`, `plot_sobol`, `plot_profiles` | `result.plot()` of `PRCCResult`, `MorrisResult`, `SobolResult`, `ChaosResult` and `ProfileResult` | - **Breaking:** matplotlib draws the figures, and is the optional extra `commol[plot]` (`pip install 'commol[plot]'`); seaborn is no longer a dependency, and `import commol` loads no plotting library. Without matplotlib, a figure raises `ImportError` with the install line. The other keyword arguments of a figure go to `Axes.plot`, not to `seaborn.lineplot`. - **Breaking:** `PlotConfig.style` takes a matplotlib style or a list of styles, and `PlotConfig` has no `palette` and no `context`. The style sheets of matplotlib give the former values: `style="whitegrid", context="talk", palette="colorblind"` is `style=["seaborn-v0_8-whitegrid", "seaborn-v0_8-talk", "seaborn-v0_8-colorblind"]`. The presets (`presentation`, `publication`, `notebook`) use these sheets. - **Breaking:** `ValueAt`, `Peak`, `PeakPosition` and `Trajectory` take `series` in place of `outputs`: `ValueAt(outputs="B", at=10)` is `ValueAt(series="B", at=10)`. `Gain(outputs, start, end)` keeps `outputs`, and the outputs of a quantity read back as a list. The quantities live in `commol.context.inference.quantities`, and cross to Rust as the JSON of their classes. - Every reduction across runs raises `ValueError` on a quantity that is NaN: `PropagationResult.summary()`, `member_statistics`, `weighted_quantiles`, and Morris, Sobol and the polynomial chaos before they estimate, as PRCC did. The message of a method names the quantity, the points where it is NaN, and the point, inputs, replicate and seed of the first such run. `PropagationResult.values` keeps the NaN of each run. - **Breaking:** the runs of the draws of a sample follow one tree of seeds. Draw `j` of a stochastic `Predictive`, `Propagation`, `at_observations` or score takes a seed of its own, and its replicate `i` takes the seed that replicate `i` of `Simulation.replicates(n, seed=…)` takes from it. Before, the seed of a replicate depended on the number of replicates. So the numbers of these methods with a stochastic engine change from 0.7.0. The runs of a draw no longer change with the number of replicates or of draws, and `Predictive.draw_seeds` and `PropagationResult.draw_seeds` give the seed of each draw: `Simulation.replicates(n, seed=draw_seeds[j])` of the model at the values of draw `j` gives its runs. See [The predictive](https://munqu.github.io/commol/latest/guide/uncertainty/#the-predictive). ### Fixed - A NaN in the values a `Peak` or a `PeakPosition` reads makes it NaN. The peak skipped the NaN, and the position of the peak was the first position when the value there was NaN. - A weighted quantile of values with a NaN raises `ValueError`, where it sorted the NaN by its sign bit and gave a wrong number. ## [0.7.0] - 2026-10-22 This release reads a run through the classes of commol. The axis of a model gives the window grid (`AxisUnits.window_end` and the other methods) and, from a calendar origin, the date of each position. `Simulation.sample`, `total_series` and `windowed_totals` read bins, accumulators and output names, and each report is a method of the object it describes. Before you upgrade, read the two **Breaking** entries: the module functions of the window grid and the report functions are removed, and the table under Changed gives the method that replaces each one. See [Reports](https://munqu.github.io/commol/latest/guide/reports/index.md) and [Simulations](https://munqu.github.io/commol/latest/guide/simulations/index.md). ### Added - `commol.sample(series, positions, at)` reads a series at given positions, and `Simulation.sample(results, ids, at)` reads bins and accumulators by id at given positions, next to `Simulation.windowed_totals`: for example, the amount in a bin at the end of each window. An id stands for all its outputs, summed; an output name stands for itself. - An axis of time can start at a calendar date: `ModelBuilder(origin=...)` takes a `date` or a `datetime` with no time zone, and `Model.axis_units()` gives `position_of`, `moment_of`, `day_ends` and `week_ends` (weeks that start on a weekday of the caller). `SimulationPlotter.plot_series` labels the ticks of such a model with dates when it has no `position_label`. An origin needs a step unit of time of a whole number of days or a whole fraction of a day: `month` and `year` raise `ValueError`. - `Simulation.total_series` and `Simulation.windowed_totals` take output names as well as bin and accumulator ids. An output name stands for itself, so `simulation.windowed_totals(results, ["moved_g2", "moved_g3"], 2)` sums two strata of the accumulator `moved`; a bin or accumulator id still stands for all its outputs. An id that is neither raises `KeyError` with the bins, accumulators and outputs of the model. - The window grid on the axis: `AxisUnits.window_start(index, span, unit)`, `window_end(index, span, unit)`, `window_index(at, span, unit)` and `window_ends(span, unit, end)`, for a window of a span in a unit, such as one week. The width must be a whole number of steps. - `SimulationResults.model` and `SimulationReplicates.model`: the results of `Simulation.run`, `run_at` and `replicates` hold a reference to their `Model`, and the runs of `Predictive.runs()` the model of its source. A report reads the outputs of each bin and accumulator from it; results with their model still pickle. See [Reports](https://munqu.github.io/commol/latest/guide/reports/index.md). ### Changed - **Breaking:** the module functions `window_start`, `window_end`, `window_index` and `window_ends` are removed from `commol`. Use the methods of `AxisUnits` (`Model.axis_units()`), with the width as a span and a unit: `window_end(2, 168)` on an axis of hours is `units.window_end(2, 1, "week")`, and a width of 7 steps is `(7, "day")`, or `(7, "step")` when the model declares no step unit. - **Breaking:** each report is a method of the object whose data it describes, and `commol` exports no report function. The private functions of `api/reporting.py` build the blocks, with the same keys in the same order. See [Reports](https://munqu.github.io/commol/latest/guide/reports/index.md). | Before | Now | | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | | `simulation_report(simulation, results, at, window, num_windows, …)` | `results.report(positions, window, windows, …)` | | `simulation_report(…, ensemble_runs=replicates, confidence_level=c)` | `replicates.report(c, positions, window, windows, …)` | | `simulation_report(…, ensemble_runs=predictive, confidence_level=c)` | `predictive.report(c, positions, window, windows, …)`, with no run of a point estimate | | `applied_parameters_report(model, result, problem, …)` | `model.applied_parameters(result, problem, …)` | | `identifiability_report(report)` | `report.report()` | | `commol.api.reporting.observed_report`, `series_report`, `accumulators_report` | The blocks `observed`, `series` and `accumulators` of a report | ## [0.6.1] - 2026-10-22 This release fixes bugs of 0.6.0 and changes no API. A series point or an impulse that the engine cannot run now fails at `ModelBuilder.build` and `Model.update_parameters`, not at `Simulation`. Errors of a `Predictive` draw say what was not feasible. A PRCC analysis with a NaN value raises a `ValueError` and no longer runs forever. The quantities of a sensitivity analysis read a gain at its start and give different labels to close positions. A windowed series is plotted at the position of each observation, and a figure no longer changes the global style of matplotlib. ### Fixed - The default label of a quantity of interest wrote each position with 6 significant digits, so two positions close together gave one label and the analysis raised `ValueError`: `Trajectory(outputs="A", positions=[1000000, 1000001])` gave `"A at 1e+06"` twice. The label now writes each position in full (`"A at 1000000"`, `"A at 1000001"`). - `ModelBuilder.build` and `Model.update_parameters` accepted a series point or an impulse position that the engine cannot run, such as a point or an `impulse_at` of 1.5 under `DifferenceEquations` or `StochasticSteps`, or a `"pulse"` point at 1.5 under any engine; the error came only at `Simulation(model)`, as `RuntimeError`. Both now raise `ValueError` with the message of the engine, and a rejected update leaves the model unchanged. - The `InfeasibleStateError` of a draw of a `Predictive` said only `InfeasibleRun`. It now gives the message of the run after the index of the parameter set: the compartment, the step and the flows, as `Simulation.run` reports them. - The `InfeasibleStateError` of a draw of a `Predictive` whose initial amounts leave the remainder below zero said only `InfeasibleStart { excess: ... }`. It now gives the message a calibration gives for the same values after the index of the parameter set: what the amounts exceed, the remainder and by how much. - A `Gain(start, end)` quantity read its start at `end - (end - start)`, which can round away from `start`: `Gain(start=0.1, end=0.4)` read it at `0.09999999999999998`, so on the `DifferentialEquations` engine the gain did not start where `ValueAt(0.1)` reads. A gain now reads its start at exactly `start`. - `SimulationPlotter.plot_series` read the model of a windowed series with the first window it found, at the window ends of a grid from 0, so with observations at 7.5 and 17.5 no model value sat on an observation and the windows of the other observations were ignored. When the observations of a series have a window, or differ in outputs or factor, the model line, the band of the replicates and the band of a `Predictive` now read each observation through its own operator at its own position, as the loss reads it. Observations with several factors no longer leave the line unscaled or raise `ValueError` with a `Predictive`. - A figure of `SimulationPlotter` with a `PlotConfig` `style`, `palette` or `context` changed the global style of matplotlib, so every figure drawn with matplotlib after it had the style of commol. The style now applies to the figure of that plot only, and `matplotlib.rcParams` is as it was before the call. - `PRCC` never returned when a quantity was NaN at a draw. It now raises `ValueError` that gives the index of the quantity and the draws where it is NaN, before it ranks any value. ## [0.6.0] - 2026-10-22 This release adds the first uncertainty methods with a stated model of the data: observation models and their likelihood, the Bayesian posterior and the Gibbs posterior by sequential Monte Carlo, approximate Bayesian computation, priors, local approximations (Laplace, the sandwich and the Laplace mixture), conformal intervals, the profile likelihood and sensitivity analysis with no data. Before you upgrade, read the **Breaking** entries: an `Aggregate` states the initial amount of each bin in the unit of that bin, a `CalibrationProblem` needs a `loss_function`, a `Predictive` takes a `Calibrator`, and public class names write every letter of an acronym in capitals. See [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md). ### Added - Local approximations through `Calibrator.uncertainty`, on the unbounded scale of the parameters, with a fit on a bound refused: `Laplace(draws)`, the normal `N(θ̂, H⁻¹)` of the observation models, and `Calibrator.delta_band(result, positions, level)`, the band of the outputs by the delta method; `Sandwich(block_length, draws)`, the covariance `H⁻¹ B H⁻¹` with the scores summed over blocks (CR1) and a Student t with `G − 1` degrees of freedom, which needs no noise model and refuses `loss_function="mae"` and too few blocks; `LaplaceMixture(starts, draws, target)`, a Laplace approximation at each optimum of a multi-start fit as the proposal of Pareto-smoothed importance sampling of a `LikelihoodTarget(priors)` or `GibbsTarget(priors, eta)`, which reports `k_hat` and marks a sample with `k_hat > 0.7` as unreliable. `curvature` is `"gauss_newton"` (the expected Fisher information of each observation model, by default) or `"hessian"`. See [Local Approximations](https://munqu.github.io/commol/latest/guide/uncertainty/local/index.md). - `GibbsPosterior`: the Gibbs posterior `prior(θ) · exp(−η · loss(θ))` of the problem's loss, sampled by sequential Monte Carlo from the priors to the posterior; it returns a `GibbsResult` with the `η` it ran with. `eta` is required: a number above 0, `InformationMatchingEta(block_length)` (the curvature of the sum of squares or of observation models against the variance of the scores of its terms summed over blocks, with a deterministic engine) or `BootstrapCoverageEta(replicates, block_length, confidence_level)` (any loss), joined in the union `Eta`. See [Sequential Monte Carlo](https://munqu.github.io/commol/latest/guide/uncertainty/smc/#gibbs-posterior). - `ABCSMC` and `ABCRejection`: approximate Bayesian computation on a required distance, `LossDistance()` or `SeriesDistance(metric)`. With a stochastic engine each distance reads one fresh replicate of its particle, so the problem's `replicates` and `replicate_summary` do not apply, and the result says so. See [ABC](https://munqu.github.io/commol/latest/guide/uncertainty/smc/#abc). - `SMCResult`: the sample, the level of each step, the log evidence, the value each particle keeps and the diagnostics of each step (`SMCDiagnostics`: effective size, resampling, acceptance rate and move count). - Priors: `Uniform()`, `LogUniform()`, `Beta(a, b)`, `Normal(mean, sd)`, `LogNormal(mu, sigma)` and `Gamma(shape, rate)`, each truncated to the bounds of its parameter, and `Priors` (a mapping of parameter to distribution, or `Priors.uniform_on_bounds()`). Every sampling method takes `priors` with no default, and a prior whose bounds do not fit raises a `ValueError` that names the distributions that fit them. Their draws are the same on every platform. See [Priors](https://munqu.github.io/commol/latest/guide/uncertainty/smc/#priors). - `SampleIdentifiability(reference="prior", priors=...)`: the contraction of each parameter against its prior. See [Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/#a-sample). - Conformal prediction intervals through `Calibrator.uncertainty`: intervals for observations with a coverage guarantee that needs no noise model, no prior and no correct model. `SplitConformal` scores held-out observations; `RollingConformal` gives intervals ahead of the data, one quantile per horizon (the `k`-th observation after an origin); `JackknifePlus` refits once per block left out, with the CV+ bound for blocks of more than one observation; `EnbPI` draws its own replicates of blocks for series with dependent errors; `AdaptiveConformal` walks a stream of new observations with a working level that moves with each miss. Each takes a required `score` (`"absolute"`, `"normalized"` or `"spread"`), `by` (`"series"` or `"all"`) and `level`, and returns a `ConformalResult` (`PredictionIntervals`, the scores and the guarantee). See [Conformal Prediction Intervals](https://munqu.github.io/commol/latest/guide/uncertainty/conformal/index.md). - `SimulationPlotter.plot_series(prediction_intervals=...)` draws the band of conformal intervals, and `simulation_report(..., prediction_intervals=...)` reports them. - Observation models: `CalibrationProblem(observation_models={series: model})` takes one model per series label, `NormalObservations(sd)`, `LogNormalObservations(sigma, center)` (`center="mean"` or `"median"`, no default), `StudentTObservations(df, scale)`, `PoissonObservations()`, `NegativeBinomialObservations(overdispersion)` (variance `μ + φμ²`) or `BinomialObservations(trials)` (or the new `ObservedDataPoint.trials` of each observation), and the problem fits by maximum likelihood. A noise argument is a number or a calibration parameter of the new type `"noise"`, which never reaches the engine. Observed values outside a model's support raise a `ValueError` that names the observation. A stochastic engine and `normalize_observations` need a loss. See [Observation Models](https://munqu.github.io/commol/latest/guide/uncertainty/observation-models/index.md). - `BayesianPosterior`: the posterior of the observation models and its log evidence, on the sequential Monte Carlo sampler. `GibbsPosterior` on observation models is the fractional posterior `prior · L^η`. - `DistributionNoise()`: prediction intervals that draw from the observation models; `commol.scores.log_score`, the log density of held-out observations under the predictive, which needs `DistributionNoise()`. - `ParametricBootstrap(replicates)`: fits to data sets drawn from the observation models at the fit. `ResamplingDiagnostics.block_length` is `None` for it. - A model can hold bins of different units, such as a count and a concentration: each bin starts at an amount in its own unit, a model whose bins all start at `0` builds and runs, and the `Calibrator` rejects an initial-condition remainder whose unit is not the unit of a calibrated amount. See [Bins of different units](https://munqu.github.io/commol/latest/guide/building-models/#bins-of-different-units). - Sensitivity analysis with no data: `SensitivityAnalysis(simulation, inputs, replicates=None, seed=None).run(method)`, the third entry point of the registry, with methods that subclass `SensitivityMethod[R]`. What varies is an `InputSpace` of calibration parameters (model parameters or initial conditions): independent distributions (`InputSpace.independent`, `InputSpace.uniform_on_bounds`) or a weighted sample (`InputSpace.from_sample`). Each run is reduced in Rust to quantities of interest: `ValueAt`, `Gain` (as an observation with a window), `Peak`, `PeakPosition` and `Trajectory`. With a stochastic engine `replicates` and `seed` are required; a point of a design holds the mean of its replicates on common seeds, and a propagation keeps every replicate. See [Sensitivity Analysis](https://munqu.github.io/commol/latest/guide/uncertainty/sensitivity/index.md). - `Propagation`: the draws of the inputs and the quantities of each run (`PropagationResult`, with `summary(confidence_level)`); its sample gives the bands of a `Predictive`, which runs the same replicates. - `PRCC`: partial rank correlation coefficients and their p-values (`PRCCResult`), weighted for a sample, with Kish's effective size in the test. - `Morris`: elementary effects, `mu`, `mu_star` and `sigma` (`MorrisResult`), on levels at the centres of strata of equal probability. - `Sobol`: first-order and total indices on the design of Saltelli, with bootstrap intervals (`SobolResult`). - `PolynomialChaos`: a Legendre expansion of `F(x)`, its leave-one-out error, its Sobol indices and `predict` as a surrogate (`ChaosResult`); the number of terms is checked before any run. - `SimulationPlotter.plot_prcc`, `plot_morris` and `plot_sobol`. - `ProfileLikelihood(parameters, threshold, max_steps)`: the profile of each parameter, refitted with the problem's own optimizer at values that step away from the fit by the adaptive step of Raue et al. (2009), its pointwise interval and its verdict (`identifiable`, `practically_non_identifiable`, `structurally_non_identifiable`), in a `ProfileResult`; `ProfileResult.report()` gives the verdicts as an `IdentifiabilityReport` with a `profile` section. A profile is read by resetting the other parameters to their fitted values (`ProfileResult.structure`), against the floor of two evaluations (`ProfileResult.evaluation_floor`): it gives the structural verdict `in_combination` or `non_identifiable`, and names the parameters that compensate it. A crossing made by the bound of another parameter counts as an open side (`Profile.box_crossings`), and `Profile.refit_error` measures the error of a refit at the fit. See [Profile Likelihood](https://munqu.github.io/commol/latest/guide/uncertainty/profiles/index.md). - `PredictionProfile(specs, threshold)`: the interval of each prediction (`PredictionSpec`), held at each value as an equality constraint by an augmented Lagrangian. See [Prediction profiles](https://munqu.github.io/commol/latest/guide/uncertainty/profiles/#prediction-profiles). - `Threshold.chi_square(level)` (observation models), `Threshold.beale(level)` (a sum of squares of independent Gaussian errors) and `Threshold.bootstrap_calibrated(bootstrap, level)` (any loss, calibrated by the replicates of a bootstrap). - `SimulationPlotter.plot_profiles`. ### Changed - Nelder-Mead with an infinite bound fails with the message `Nelder-Mead needs finite bounds`, instead of a rate that is not a number. The sine transform of the bounds and the root finder of the quantiles come from the argmin fork; calibrations, profiles, quantiles and draws give the same results. - **Breaking:** `InformationMatchingEta(block_length)` takes a required `block_length`, an `int` or `"auto"`: the variance of the scores is summed over blocks of positions, as the meat of `Sandwich`, so it holds under dependence; `block_length=1` gives the independent version. It also takes a problem with observation models, where a correct model gives `η ≈ 1`. - **Breaking:** `Predictive(source, sample, positions)` takes a `Calibrator` or a `SensitivityAnalysis` (any `PredictiveSource`), and its attribute `calibrator` is now `source`. `Predictive.at_observations` of an analysis takes only `NoNoise()`, and `scores.log_score` refuses its predictive. - With a stochastic engine, every replicate of every draw of a `Predictive`, of `Predictive.at_observations` and of `scores.log_score` has a seed of its own, derived from the calibrator's seed; before, every draw ran the same replicates of the problem, so a predictive with few replicates showed the same few noise paths for every draw. The bands and scores of a stochastic predictive change; a seed still repeats them. - **Breaking:** `ModelStructure.check_calibration_values(model, engine, parameters, remainder)` takes the values and the remainder instead of a calibration problem; a model kind that implements it changes its signature. - A prior whose family does not fit its bounds is named "The distribution ... of" in its message, instead of "The prior ... of". - The starts of `Calibrator.run(starts=n)` come from the Latin hypercube of `commol-random`; a seed gives the same starts as before. - **Breaking:** an `Aggregate` states the initial amount of each bin in the unit of the bin, not fractions of one total. A structure that states `total`, `bin_fractions` or `compartment_fractions` is rejected with the fields to use. | Before | Now | | ------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `Aggregate(total=1000, bin_fractions={"S": 0.99, "I": 0.01})` | `Aggregate(bin_amounts={"S": 990, "I": 10})` | | `compartment_fractions={"I_g1": 0.005}` (of the total) | `compartment_amounts={"I_g1": 5}` (in the unit of its bin) | | `model.structure.total` | No declared total: `sum(model.structure.bin_amounts.values())` for bins of one unit | | `update_bin_fractions({...})`, which kept the sum at 1 | `update_bin_amounts({...})`; the other bins keep their amounts | | `update_compartment_fractions(process, {...})` | `update_compartment_amounts(process, {...})` | | `stratification_fractions` | Unchanged: the split of the amount of every bin a stratification applies to | - **Breaking:** a calibrated initial condition of a bin, a bin prefix or a compartment is an amount in the unit of its bin, not a fraction of the total; its bounds are amounts too. A category value is still a split. The remainder takes the sum of the amounts the model states less every other compartment, so a bin that no value covers keeps its amount; with a bin left `None`, the remainder holds the sum (`{"S": 1000, "I": None}`). Without a remainder no initial state is infeasible. A remainder below zero is measured as a share of the amounts the values set. The write-back updates the amount of a bin only when the values and the remainder set every compartment of it. - **Breaking:** `applied_parameters_report` gives an aggregate model the sections `initial_amounts` (`amount`, `unit`, `calibrated`) and `category_splits` (`group`, a list of categories, `fraction`, `calibrated`) in place of `initial_conditions`, with no `total` row. A category split is reported as the fraction of its group, with no rescaling of its interval. - **Breaking:** a stochastic engine needs a whole amount in each bin, not a whole total, and `largest_remainder` and `random` round within each bin, so a unit never moves to another bin. - **Breaking:** the description of an aggregate model shows the bin amounts and their units, and the initial state with the unit of each compartment. - **Breaking (Rust):** `InitialConditions` has `bin_amounts` and `compartment_amounts` in place of `total`, `bin_fractions` and `compartment_fractions`; `CompartmentalEngine::total` is the sum of the amounts the model states; `check_model_values` returns the plan it resolves, and `InitialConditionPlan::coverage` gives the compartments each value sets. - **Breaking:** `CalibrationProblem.loss_function` has no default: a problem needs `loss_function` or `observation_models`, exactly one, and raises a `ValueError` with both or neither. Write `loss_function="sse"` where a problem relied on the default. - **Breaking:** public class names write every letter of an acronym in capitals, as PEP 8 asks: `OdeSolver` is `ODESolver`. `EnbPI` keeps the spelling of its authors. See [Conventions](https://munqu.github.io/commol/latest/codebase/conventions/#python). ### Fixed - A `DifferentialEquations` run with `method="tsit45"` whose solution blows up returned NaN with no error. A step that leaves the state not finite now raises `RuntimeError` that names the last position the solver reached, with either method. - The finite-difference step of a parameter with an infinite bound was infinite, so its sensitivities, identifiability and curvature failed: the width of its bounds now counts as 1 in the step. - The quantile of a distribution hundreds of orders of magnitude below 1, such as `Beta(0.01, 1)` at `1e-3`, was far from it, so a prior or a draw of such a shape was wrong: when its Newton and halving steps run out, the quantile now bisects the floating-point numbers between the two ends. - A particle swarm whose first particle had a NaN cost kept no best particle and returned its start point: a NaN cost now counts as the worst. - A transition whose rate holds `$compartment` dropped `per_compartment`, `accumulators` and the `absolute` of its stratified rates: each transition it expands to now keeps them, so with `per_compartment=True` `$compartment` reads the compartment that flows, and its accumulators count the flow. - A `Calibrator` on a `Simulation` that had run shared a calibrated bin, bin prefix or remainder across its compartments in the proportions of the last state of the run, so its fit, its uncertainty and its `Predictive` started from a wrong split: they now share it in the proportions of the initial state. - `Model.update_parameters` wrote each value with no check, so a list of points for a series parameter left a model that `Simulation` could not read, with an error that named no parameter. It now takes a list of points for a series parameter and keeps its mode, or a `SeriesValue` (now in `commol`) with a new mode, and it runs the checks of a new model: a value it cannot take raises `ValueError` at the call, with the id of the parameter, and leaves the model unchanged. - A `Predictive` at positions out of order failed inside a draw with a `RuntimeError` that named a parameter set: it now raises `ValueError` before any draw runs, with the first pair out of order, as it does for a position the engine rejects. ## [0.5.0] - 2026-10-19 This release replaces the ensemble calibration with uncertainty methods that have a stated meaning. `Calibrator.uncertainty` resamples the data (bootstraps and the jackknife) and returns a `ParameterSample`; a `Predictive` runs its draws, and `commol.scores` scores its bands. `Calibrator.identifiability` tells which parameters the model and the data determine, and `Calibrator.run(starts=n)` fits from several start points. Before you upgrade, read the **Breaking** entries: the ensemble calibration is removed (use `BayesianBootstrap` in its place), and every interval uses one quantile, so the intervals of `member_statistics` and `SimulationReplicates.quantiles` move slightly. See [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md) and [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/index.md). ### Added - `Calibrator.run(starts=n)` runs the optimizer from `n` start points in parallel and returns the best: the initial guess, then rows of a Latin hypercube over the bounds drawn from the problem's `seed`. `CalibrationResult.starts` lists every start (`StartSummary`: start, end, loss, converged); distinct ends with equal losses show that the data do not identify the parameters. See [Several starts](https://munqu.github.io/commol/latest/guide/calibration-optimizers/#several-starts). - `CalibrationResult.failed_runs`: why each failed start failed, also logged as a warning. - `ParameterSample`: weighted draws of the parameters of a problem, with their invariants checked at construction and weighted statistics computed in Rust (`mean`, `covariance`, `correlation`, `quantiles`, `interval`, `effective_size`, `draws`, `resample`, `summary`). It saves to JSON and loads back equal. - `Predictive(calibrator, sample, positions)`: the run of every draw, in parallel, its band (`band`, `series_band`) and every draw's value of a set of observations through the observation operator of the loss, with an observation noise that has no default (`at_observations(observed_data, noise)`: `NoNoise()` for a band of the model, `ResidualNoise(...)` for a prediction interval). `SimulationPlotter` and `simulation_report` take a predictive. - `commol.scores`: empirical coverage, interval score, weighted interval score, the CRPS of a weighted sample and PIT values of a predictive, per observation and per series. Each takes the observation noise, so a band of the model is never scored as a prediction interval by accident. - `Calibrator.uncertainty(method)`: one entry point for every uncertainty method. A method's configuration subclasses `UncertaintyMethod[R]` and registers its runner; the result type follows the configuration's type. - `Calibrator.identifiability(method)`: which parameters the model and the data determine, and which combinations they determine in their place. `StructuralRank` (the rank of the sensitivities at random points, on the design of the observations or a dense one), `Collinearity` (Brun's indices at a fit or over a sample), `SampleIdentifiability` (contraction, correlations and modes of a sample) and `StartsIdentifiability` (starts of equal loss that end apart). Each returns an `IdentifiabilityReport` with a structural and a practical verdict per parameter; `merge` joins reports and `identifiability_report` gives one as plain data. See [Identifiability](https://munqu.github.io/commol/latest/guide/uncertainty/identifiability/index.md). - `InferenceMethod[R]`, the common base of `UncertaintyMethod[R]` and `IdentifiabilityMethod[R]`; each entry point refuses the other kind with a `TypeError`. - `weighted_quantiles`, and a `weights` argument for `member_statistics`. - `CalibrationHistory` and `CalibrationEvaluation`, returned by `Calibrator.run_with_history()`. - The crate `commol-inference`: the parameter sample, the one quantile, the predictive and the scores. See [Uncertainty](https://munqu.github.io/commol/latest/guide/uncertainty/index.md). - Resampling methods with no noise model, through `Calibrator.uncertainty`: `BayesianBootstrap`, `ResidualBootstrap` and `WildBootstrap` return a `BootstrapResult` (the fit, one draw per replicate, diagnostics, and the Monte Carlo error of each interval bound, `interval_error`); `BlockJackknife` returns a `JackknifeResult` (estimate, bias, standard error, pseudo-values and the influence of each block). Blocks of consecutive positions span every series; `block_length` is an integer or `"auto"` (Politis and White). See [Resampling](https://munqu.github.io/commol/latest/guide/uncertainty/resampling/index.md). - `ResidualNoise`: observation noise made of blocks of residuals of a fit, which gives a prediction interval with no noise model; `NoNoise` gives a band of the model. - `CalibrationProblem::with_data` (Rust): a copy of a problem with other observed values and weights. ### Changed - The `latest` version of the documentation site is the documentation of the last release, published from its tag; the `dev` version follows `main`. - **Breaking:** every interval uses one quantile: the weighted inverse of the empirical distribution between the midpoints of the cumulative weights, NumPy's `method="hazen"` with equal weights. `member_statistics` and `SimulationReplicates.quantiles` used NumPy's `linear`; their intervals move slightly. - **Breaking:** `CalibrationResult.best_parameters` keeps the order of `CalibrationProblem.parameters`. - **Breaking:** `Calibrator.run_with_history()` returns a `CalibrationHistory` instead of the extension's object. - **Breaking:** `applied_parameters_report(..., sample=, confidence_level=)` reads the statistics of a `ParameterSample`; `parameter_statistics` is gone. - **Breaking (Rust):** `CalibrationProblem` is split into layers (`ForwardModel`, `ObservationOperator`, `Feasibility`, `Discrepancy`, `OptimizationObjective`); `optimize` takes the problem by reference, and the optimizer configurations have no `verbose` field. `CalibrationEvaluation` has no `predictions`. - A verbose optimizer writes its table to Python's `sys.stdout` only, and needs one start; the library never writes to the standard error. - A rate, a formula parameter or a constraint that the expression compiler cannot compile, such as `typeof(k)`, is rejected when the model or the calibration problem is built, with the reason; it no longer fails when it is evaluated. - The functions `erf`, `erfc`, `tgamma`, `lgamma`, `j0`, `j1`, `y0`, `y1`, `fdim` and `remainder` of a rate come from the `libm` crate, so they give the same values on every platform; the other functions still come from the system's math library. - `AxisPattern` is abstract: `AxisPattern()` raises `TypeError`. Build a pattern with one of its class methods. - Error messages that list ids (the stratifications of a transition, the missing and extra keys of the fractions of an `Aggregate`) list them in sorted order. ### Fixed - Several starts of Nelder-Mead began at the same point: each start now comes from the run seed. - A parameter value could land on another parameter when results were read by name from an unordered mapping. - A failed run of a multi-start optimization was written to the standard error and dropped; it is now part of the result. - An error of the model at a vertex of the initial Nelder-Mead simplex made the optimizer panic; it now stops the calibration with its message (the argmin fork returns the error). - A `to` condition on a transition without a source was ignored by its flow but applied by its accumulators; `build` now rejects it, and a `category` condition chooses the compartment the flow enters. - `Model.from_json` read a file in the encoding of the system; it now reads UTF-8, the encoding of JSON. - `Model.check_unit_consistency` checked every flow of a category transition in the unit of the first bin of the model; each flow is now checked in the unit of its own bin. In a model of bins of different units, a rate that reads `total`, a subgroup total, a category or `neighbors` fails the check: these add amounts of different units. - The LaTeX description wrote a part of a rate in parentheses, such as `(I / N)` or the argument of `sin(t / T)`, as upright text, and wrote a unit inside the name of another variable (`A_{g1(\text{item})}`); only units are text now, each after its variable. The expanded form merges `k * p` and `k * (1 - p)` also when a third term shares another factor, and a merged term keeps the place of its first term. - `ModelBuilder` and `Simulation` logged to the root logger of the application; they log to the `commol` logger, as the rest of the library does, so `add_stderr_logger` shows their messages. - `AxisPattern.combine(mode="max")` raised "Unknown combine mode": a mode may be given by its value. - A quantile level outside `[0, 1]`, or NaN, gave the smallest or the largest value; `ParameterSample.quantiles` and `weighted_quantiles` now raise `ValueError`. - `AxisPattern.from_formula` and `AxisPattern.combine` with a `config` that allows formulas longer than the default rejected them anyway; the configuration now applies. - A rate of `StochasticSteps` that is not a number raised `InfeasibleStateError` and said that a rate cannot be negative; it now raises `RuntimeError`, as `StochasticEvents` does, and says that the rate is not a number. - A rate that is not a number gave amounts that are not numbers in `DifferenceEquations` and `DifferentialEquations`, and a calibration only penalized the run; the run now stops with `RuntimeError` that names the flow and the position, as in the stochastic engines. - A rate that read an id with digits, such as `e1`, `E2` or `c1star`, was rejected as a malformed number; and the exponent of a number such as `1e1` was read as the undefined variable `e1`. An identifier now starts only after a character that is not a letter, a digit or `_`, so the digits of an id belong to its name and an exponent belongs to its number. ### Removed - `commol.api.model_loader.ModelLoader`: a copy of `Model.from_json` that no page documented and nothing called. - **Breaking:** the ensemble calibration: `Calibrator.run_probabilistic`, `ProbabilisticCalibrator`, `CalibrationProblem.probabilistic_config`, `ProbabilisticCalibrationConfig`, `ProbabilisticCalibrationResult`, `EnsembleSolution`, `EnsembleSelectionSummary`, `ParameterSetStatistics` and their settings classes. Its intervals had no statistical meaning: its members were points the optimizer visited, and the selection maximized the coverage of the data it was fitted to. The closest replacement with no noise model is `Calibrator.uncertainty(BayesianBootstrap(replicates=..., block_length=...))`: the same parallel fits, each on randomly weighted data, with a stated meaning. - The dependency on `scikit-learn`. ## [0.4.0] - 2026-10-16 ### Added - `Model.describe(layout="default")` returns a `ModelDescription`: the model as data, sections of tables, equations, state graphs and text, for every kind of model. `render(format)` gives text, Markdown (with the state graph as a Mermaid diagram), a LaTeX document, JSON or Graphviz DOT, and `save(path)` writes it in the format of the suffix (`.txt`, `.md`, `.tex`, `.json`, `.dot`). In a notebook it shows as Markdown. See [Describing a Model](https://munqu.github.io/commol/latest/guide/describing-models/index.md). - `layout="odd"`: the description follows the ODD protocol for describing individual- and agent-based models (Grimm et al. 2006, 2010, 2020), and marks what commol cannot fill "To be written by the modeller.". - `EngineType.dynamics_form` and `EngineType.schedule`: how an engine advances (`difference_equation`, `differential_equation`, `stochastic_events`, `rules`), as a description shows it. - The process of a model apart from its structure: `Model.process` (`Process`: bins, accumulators, stratifications, transitions) is written once, and `Model.structure` lays it out. `Aggregate` is the structure of amounts per compartment, the kind `aggregate`. - `neighbors_` and `neighbors` in a transition rate: the amount of a bin among the neighbors of a unit and their total. A rate that reads them and no compartment is proportional. `Aggregate(neighbors=Complete())` makes every unit a neighbor of every other; `Groups(stratification, weights)` makes units neighbors by the categories of a stratification. A rate that reads them needs `neighbors`, with no default. - Variants of a structure: a subclass with the same kind, fields and engines, saved with its `variant` and loaded back as its own class. - Seeds and replicates for stochastic engines: `Simulation.run(…, seed=)` and `run_at(…, seed=)` (`SimulationResults.seed` keeps the seed, drawn when none is given), `Simulation.replicates(n, seed=)` with `run` and `run_at` that return `SimulationReplicates` (runs in parallel, one seed each; `mean`, `quantiles`, `member_statistics`), `Simulation.is_stochastic`, and `EngineType.is_stochastic`. `SimulationPlotter` draws the mean and a band of replicates, and `simulation_report` takes them as `ensemble_runs`. - `CalibrationProblem(replicates=…, replicate_summary=…)` calibrates a stochastic engine with common random numbers: `"mean"`, `"median"`, `Quantile(q)` or `"crps"` (the continuous ranked probability score). A stochastic engine needs them and a `seed`; there is no default summary. - The crate `commol-random`: a portable generator (`Xoshiro256PlusPlus`), the derivation of replicate seeds, samplers and a sum tree of rates, for every stochastic engine. - Two stochastic engines of an aggregate model, in the crate `commol-stochastic`, that run the process of any model as whole units moved at random, with no change to it: `"StochasticEvents"` (continuous time: Gillespie's direct method, exact also when a rate reads `t`, or `method="tau_leaping"`) and `"StochasticSteps"` (the chain binomial, with per-step probabilities). Their settings are `StochasticEventsSettings` and `StochasticStepsSettings`. A total must be whole, and initial amounts that are not whole need `rounding="largest_remainder"` or `"random"`, with no default. A per-step probability outside `[0, 1]`, or probabilities out of one compartment that sum to more than 1, raise `InfeasibleStateError`. See [Stochastic Simulation](https://munqu.github.io/commol/latest/guide/stochastic/index.md). - The `dynamics` section of a stochastic model is an event table: each flow, the change of one event and its propensity or its draw; the `schedule` section lists the settings of the engine. ### Changed - **Breaking:** a model is built from a process and a structure. `ModelBuilder` writes the process, and `build(structure, engine, settings=None)` joins it with a structure; the model is a `Model` of the class of the structure. | Before | Now | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `builder.set_initial_conditions(total=…, bin_fractions=[{"bin": "A", "fraction": 0.9}, …], stratification_fractions=[…], compartment_fractions=[…])` then `builder.build(engine=…)` | `builder.build(Aggregate(total=…, bin_fractions={"A": 0.9, …}, stratification_fractions={"group": {"g1": 0.5, …}}, compartment_fractions={"A_g1": 0.0}), engine=…)` | | `CompartmentalStructure`, `Model[CompartmentalStructure]` | `Aggregate`, `Model[Aggregate]` | | `model.structure.bins`, `stratifications`, `transitions`, `accumulators` | `model.process.bins`, … | | `model.structure.initial_conditions.total`, `bin_fractions`, … | `model.structure.total`, `bin_fractions`, … (mappings by id) | | `model.structure.initial_state()`, `subgroup_total(…)`, `get_conditioning_categories(…)`, `update_compartment_fractions(…)` | The same methods, with the process first: `model.structure.initial_state(model.process)`, … | | `model.structure.update_initial_conditions(…)` | `model.structure.update_bin_fractions(…)` | | `model.structure.outputs_by_source()`, `series_label(id)` | `outputs_by_source(model)`, `series_label(model, id)` | | `CompartmentFraction`, `BinFraction`, `StratificationFraction(s)` | Mappings in `Aggregate` | | `CompartmentalEngineType` | `EngineName` | - **Breaking:** the model file has `process` and `structure`, the structure has `kind: "aggregate"` and `variant`, and the fractions are objects keyed by id. A file of 0.3 does not load; rebuild the model and save it again. - **Breaking:** an engine names a method, not a kind: `EngineType.kinds` replaces `EngineType.kind`, and the error of an engine that does not run a kind lists the engines of that kind. - **Breaking:** `neighbors` and the names that start with `neighbors_` are reserved: no parameter, bin or accumulator may take them. - `EngineType.equation_lhs` comes from the form of the dynamics of the engine (`DynamicsForm` in Rust), and returns `None` for an engine without equations. - Python names follow PEP 8: ruff checks them (`N` rules). ### Fixed - The unit check reads the flows the engine plans, so a proportional rate is checked as its flow, the rate times its source: `k2` in `1/day` from a bin in `item` passes as `item/day` instead of failing as `1/day`. ### Removed - **Breaking:** `Model.print_equations`, `ModelStructure.print_equations` and `PrintEquationsOutputFormat`. `model.describe().render("text")` shows the same compact and expanded forms, and `render("latex")` writes them in LaTeX; a description with some units missing shows none instead of raising. ## [0.3.0] - 2026-10-15 ### Added - An ODE engine: a model built with `engine="DifferentialEquations"` runs in continuous time, `dX/dt = Σ flows`, integrated by `diffsol` (`commol-differential`). It has the same flows, outputs, accumulators, calibration, ensembles, reports and plots as a difference model. On `[n, n + 1)` a rate sees `step = n` and the continuous `t`. The engine restarts its integration at each integer when a rate reads `step`, and where `t` reaches a value a rate compares it with (`t >= t_on`), so the solver cannot step over a jump. - `OdeSolver` (`method` `"tsit45"` or `"bdf"`, `rtol`, `atol`), the settings of the `DifferentialEquations` engine, stored in the model as `Model.engine.settings` and set with `build(engine="DifferentialEquations", settings=...)`. - One list of engines in the Rust engine (`EngineType`): each entry names the model kind it runs, validates its settings, and says the form of its equations and what a rate means (`per_step` or `continuous`). Python asks it through `commol_rs.engine.EngineType` instead of branching on engine names. - `Simulation.run_at(at)`: the outputs at the given sorted positions. A differential model takes any position, such as `0.5`. - `SimulationResults`: what `Simulation.run` and `run_at` return. A `dict` from output name to values, as before, with the positions in `positions` and the rows in `rows()`. - The window grid as module functions: `window_start`, `window_end`, `window_index` and `window_ends`, beside `windowed_totals`. - A public Pydantic class rejects an unknown field and names it (`extra="forbid"` on every class of `commol.__all__`), so a misspelt or an old name is an error instead of being dropped. - Impulses: `add_transition(..., impulse_at=[...])` or `impulse_every=(period, offset)` moves the rate (a fraction or an amount) at once at those steps, in both engines, before the flows of the step. `print_equations` lists impulses apart. - `ModelBuilder(..., step_unit=...)` (`Model.step_unit`): the unit of one step for the unit check, of any single dimension: the axis is the independent variable of the model, which need not be time (for example `"meter"`). Without it, the step unit is `step`, the default increment of any axis. - `AxisUnits(step_unit, engine)` and `Model.axis_units()`: `steps(value, unit)` converts a value in any unit of the step dimension to steps on `pint` (`3 day` with a step of `hour` is `72.0`; `2 km` with a step of `m` is `2000.0`), and raises, naming both units, for a unit of another dimension. `rate_from_mean_span(span, unit)` and `rate_from_probability(probability, period, unit)` ask the engine what a rate means: the per-step rate for `DifferenceEquations` (`1 - exp(-1/n)`, `1 - (1 - p)^(1/n)`) and the continuous rate for `DifferentialEquations` (`1/n`, `-ln(1 - p)/n`). - `EngineType.rate_from_mean_span` and `rate_from_probability` in the engine's list (`RateMeaning` in Rust), which hold those formulas. - The unit check reads `if(condition, a, b)`: both branches share a unit. - One interface for every kind of model: `ModelStructure`, an abstract base class that the structure of each kind inherits. `Simulation`, `Calibrator`, `ProbabilisticCalibrator`, the reports and `SimulationPlotter` use only it, so another kind (for example an agent network) is a new subclass with its engines in the engine's list. `CompartmentalStructure` is the structure of a compartmental model. A subclass that misses a method cannot be made, and the error names the method. - An engine runs one model kind, and a model whose engine runs another kind is rejected when it is made, with an error that names the engine and both kinds. - Each kind declares the values calibration may set besides `parameter` and `factor` (`ModelStructure.calibration_value_types`); a compartmental model accepts `initial_condition`. A type the kind does not accept raises an error that names the kind. ### Changed - **Breaking:** a `DifferenceEquations` step that leaves a compartment below zero (by more than `10⁻¹²` of the total) raises `InfeasibleStateError`, a `ValueError`. The message names the compartment, the step, its amount before the step and the flows out of it. Before, the run went on with negative amounts. A calibration gives such a run a penalty and goes on; any other error of a run still stops it. See [Amounts below zero](https://munqu.github.io/commol/latest/guide/transitions/#amounts-below-zero). - **Breaking (Rust):** `SimulationEngine::run_at_positions_into_buffer`, `run_at`, `run` and `step` return `SimulationError` (`Infeasible` or `Failed`) instead of `String`. - A position on the axis is a `float` everywhere, and the engine alone decides which positions its model type accepts: a difference model integer steps, a differential model any non-negative position. Its error names the observation, constraint, impulse transition or series parameter that holds a rejected position. The engine trait runs only at positions (`run_at_positions_into_buffer`); `run(n)` returns the grid it ran on. - The engine of a model and its settings are one field, `Model.engine = {type, settings}` (`ModelEngine`), which replaces `Model.dynamics.typology`. The builder takes the engine where it builds, so one builder builds the model for each engine with its own settings. Settings of one engine given to another raise an error from the engine that names both. A model file saved before has `dynamics.typology` and does not load: move it to `"engine": {"type": …, "settings": null}`. | Before | Now | | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | | `builder.build(typology="DifferenceEquations")` | `builder.build(engine="DifferenceEquations")` | | `model.dynamics.typology` | `model.engine.type` | | `commol_rs.difference.DifferenceEquations(model)`, `commol_rs.differential.DifferentialEquations(model)` | `commol_rs.engine.Engine(model)`, the engine the model names | | - A model is a header, an engine and a `structure`: `Model.structure`, tagged | | | in JSON by `kind`, replaces `Model.population` and `Model.dynamics`, and the | | | transitions are held once. `Model` is generic in its structure class: | | | `ModelBuilder.build` returns `Model[CompartmentalStructure]`, and | | | `Model.from_json` reads the class from the `kind`. The methods that only a | | | compartmental model has moved to its structure. A model file saved before | | | has `population` and `dynamics` and does not load: move the fields of | | | `population` and `dynamics.transitions` into | | | `"structure": {"kind": "compartmental", …}`. | | | Before | Now | | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | `model.population.bins`, `.accumulators`, `.stratifications`, `.initial_conditions` | `model.structure.bins`, … | | `model.dynamics.transitions` (and `model.population.transitions`) | `model.structure.transitions` | | `model.initial_state()` | `model.structure.initial_state()` | | `model.update_initial_conditions(…)`, `update_stratification_fractions(…)`, `update_compartment_fractions(…)` | `model.structure.update_initial_conditions(…)`, … | | `model.subgroup_population(…)`, `model.get_conditioning_categories(…)` | `model.structure.subgroup_total(…)`, `model.structure.get_conditioning_categories(…)` | | `model.get_outputs_by_source()` | `model.structure.outputs_by_source()` | | `model.get_uncalibrated_initial_conditions()` | `model.structure.uncalibrated_values()` | | `commol.context.dynamics` (`Transition`, `StratifiedRate`) | `commol.context.transition` | | `commol.context.engine.EngineType` | `CompartmentalEngineType`; `ModelEngine.type` is a `str` that the engine's list checks | | `commol_rs.core.Model(population=…, dynamics=…)` | `commol_rs.core.Model(structure=commol_rs.core.CompartmentalStructure(…))` | | `commol_rs.model.initial_state(model)`, `expanded_outputs`, `transition_flows` | they take a `commol_rs.core.CompartmentalStructure` | | `commol_rs.calibration.check_initial_conditions` | `check_model_values` | | `commol_rs.calibration.calibrate(…, initial_population_size, …)` and the other calibration functions | no `initial_population_size`: the engine knows it | - `applied_parameters_report` returns `parameters` and the sections of the kind of the model; a compartmental model adds `initial_conditions`, with the same keys as before. - The Rust engine trait (`SimulationEngine`) is the same for every model kind: `compartments`, `state` (was `population`), `set_initial_condition` and `output_labels` moved to `CompartmentalEngine` (`commol-model`), and the engine resolves and writes the values of its kind (`plan_values`, `apply_values`). The initial-condition plan moved from `commol-calibration` to `commol-model`. The crates are not published. - Names of positions, lengths and counts, and names with more than one meaning. No old name is kept; an old keyword raises an error that names it. | Before | Now | | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `ObservedDataPoint(step=…)` | `ObservedDataPoint(at=…)` | | `ObservedDataPoint(window_steps=…)` | `ObservedDataPoint(window=…)` | | `ObservedDataPoint(compartment="B")` | `ObservedDataPoint(output="B")` | | `ObservedDataPoint(compartment="label", compartments=[…])` | `ObservedDataPoint(label="label", outputs=[…])` | | `ObservedDataPoint(scale_id=…)` | `ObservedDataPoint(factor_id=…)` | | `CalibrationParameter(parameter_type="scale")`, `CalibrationParameterType.SCALE` | `"factor"`, `CalibrationParameterType.FACTOR` | | `CalibrationConstraint(time_steps=…)` | `CalibrationConstraint(at=…)` | | `NelderMeadConfig(initial_step=…)` | `NelderMeadConfig(initial_simplex_size=…)` | | `Simulation.run_at(steps)` | `Simulation.run_at(at)` | | `Simulation.windowed_totals(results, source_ids, window_steps, at_steps)` | `Simulation.windowed_totals(results, ids, window, at)` | | `Simulation.total_series(results, source_ids)`, `group_outputs(source_ids)`, `outputs_for(source_id)` | `ids`, `id` | | `windowed_totals(series, window_steps, at_steps)` | `windowed_totals(series, positions, window, at)` | | `window_end_steps(window_steps, num_steps)` | `window_ends(window, end)` | | `TimeScale.window_start`, `window_end`, `window_index` | `commol.window_start`, `window_end`, `window_index` | | `simulation_report(…, steps, window_steps, …)`, `series_report`, `accumulators_report`, `observed_report` | `at`, `window` | | `SimulationPlotter.plot_series(step_to_label=…)` | `position_label=…`, which takes a `float` | | `TimePattern` (`commol.api.time_patterns`) | `AxisPattern` (`commol.api.patterns`) | | `TimePattern.window(start, end, amount)` | `AxisPattern.interval(start, end, value)` | | `TimeSeriesValue`, "time-series parameter" | `SeriesValue`, "series parameter" | | `EnsembleSolution.windowed_prediction_steps` | `windowed_prediction_positions` | | `engine.current_step()` | `engine.current_position()` | | `simulation.run(n, output_format="list_of_lists")` | `simulation.run(n).rows()` | - Reports, windows, plots and ensemble statistics read a value at a position of the results, not at a list index, so a differential model windows and reports at non-integer positions once its results hold them. The report key `scale_id` is `factor_id`, and `compartments` is `outputs`. - `EnsembleSolution` has `prediction_positions`; its prediction series hold the unit grid and every observation position and window start. - Series parameters take `(position, value)` points with `float` positions. `"linear"` interpolates in `t` and `"step_function"` holds from any position; a differential engine restarts its integration at their points, so a `"linear"` series is a ramp, not a staircase. `"pulse"` keeps integer positions. A difference model gives the same results and rejects a point between integers. - `AxisPattern.interval` and `linear_ramp` read `t` and take `float` bounds. A difference model gives the same results; in a differential model an interval opens at any position and a ramp is continuous. - A pattern parameter that the builder generates is named `_series_` (was `_ts_`). - The model compiler moved from `commol-difference` to the new crate `commol-model`, which both engines use; `commol_rs.difference` no longer has `initial_state`, `expanded_outputs`, `transition_flows` and `check_names`, which are in `commol_rs.model`. No public Python name changes. - `SimulationPlotter.plot_series` and `plot_cumulative` take `series` instead of `bins`. An entry is an output name or a bin or accumulator id; an id plots the sum of its outputs, so a bin of a stratified model can be plotted. There is no `bins` alias: rename the argument. - Every loss uses the observation weights the same way: `"sse"` is `Σ w·r²`, `"rmse"` is `√(Σ w·r² / Σ w)` and `"mae"` is `Σ w·|r| / Σ w`. Before, `"sse"` squared the weight and `"rmse"` and `"mae"` ignored it. With every weight at 1 nothing changes. The ensemble central loss follows the same rule. - `Simulation.run` starts from the initial state at every call, so two calls give the same result. Before, a second `run` continued the first one. To continue a run, call `simulation.engine.step()`. - `Model.check_unit_consistency` infers one step unit from the parameter units (`day`, `week`, `step`, …; `step` when there is none) and requires every flow to be exactly `/`. A model in `"1/step"` now passes. Rates in two units of time, such as `"1/day"` and `"1/week"`, are an error that names each parameter, because the engine converts nothing. Before, the check accepted any convertible unit and defaulted to `day`. - Nelder-Mead searches inside the bounds: it optimizes an unbounded coordinate and scores `x = lo + (hi − lo)·(sin u + 1)/2`. Its first simplex is set by `NelderMeadConfig.initial_simplex_size` (a fraction of each bound width, default 0.1), which replaces `simplex_perturbation`; there is no alias. Every Nelder-Mead result can change. - The library names what the compartments hold without assuming individuals: the **state** (the amount of every compartment), the **amount** (the value of one compartment), the **total** (their sum, which the initial fractions are taken of) and a **subgroup total** (the sum of the compartments with some categories). The total is a `float`, finite and greater than 0, so a model of a mass or a concentration can be stated; before, it was an `int`. The rate variable `N` is `total` and `N_…` is `total_…`, so `N` is a free id (a bin may be named `N`). `AxisPattern` takes a `value`. No old name is kept: a model file with `population_size` does not load, and the error names the field; rename it to `total`, and `N` to `total` in every rate, formula and constraint. | Before | Now | | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | `set_initial_conditions(population_size=…)`, `InitialConditions.population_size: int`, JSON key `population_size` | `set_initial_conditions(total=…)`, `InitialConditions.total: float`, JSON key `total` | | Rate, formula and constraint variables `N`, `N_…` (`N_g1`, `N_g1_t1`) | `total`, `total_…` (`total_g1`, `total_g1_t1`) | | `RESERVED_VARIABLE_NAMES = ("N", …)` | `("total", "step", "t", "pi", "e")` | | `InitialConditions.subgroup_population(…)`, `CompartmentalStructure.subgroup_population(…)` | `subgroup_total(…)` | | `InitialCompartment.population` | `InitialCompartment.amount` | | `applied_parameters_report`: row `"N"`, group `"population"` | row `"total"`, group `"total"` | | `AxisPattern.pulse(at, amount)`, `pulses(at, amount)`, `periodic(period, amount, offset)`, `interval(start, end, amount)` | `value` instead of `amount` | | `get_predefined_variable_units(stratifications, bin_unit)` | `get_predefined_variable_units(subgroup_totals, bin_unit)`, with the names the engine gives | | `commol_rs.engine.Engine.population` | `Engine.state` | | `commol_rs.core.InitialConditions(population_size=…)` | `InitialConditions(total=…)` | | `commol_rs.calibration.calibrated_initial_population` | `calibrated_initial_state` | | — | `commol_rs.model.subgroup_total_names(structure)` | | Rust: `CompartmentalEngine::population`, `population_size`; `DifferenceEquations::population`, `DifferentialEquations::population` | `state`, `total` | - The library and its documentation assume no application and no axis: text says "along the axis" or "position", not "over time", and examples use abstract names. A rate that names no compartment is **proportional** (was "per-capita"), because a source can hold a mass or a concentration. The errors of a constraint at positions say "Constraint '…' evaluation failed at position …" and "Position … not found …" (were "Time-dependent constraint" and "Time step"). | Before | Now | | --------------------------------------------------------------------- | ---------------------------------------------------------- | | `AxisPattern.seasonal(amplitude, period, phase, baseline)` | `AxisPattern.sinusoid(amplitude, period, phase, baseline)` | | `TransitionFlow.per_capita_of` (`commol.context._model.engine_state`) | `TransitionFlow.proportional_to` | | Guide anchor `#absolute-and-per-capita-rates` | `#absolute-and-proportional-rates` | | Guide anchor `#time-dependent-rates` | `#rates-that-change-along-the-axis` | - The unit check no longer infers the step unit from the parameter units: it is the declared `step_unit` or else `step`. A model with rates in `1/day` and no `step_unit` fails the check, and the message says to declare it: add `step_unit="day"` to `ModelBuilder`. - A unit means what `pint` defines. The list of time aliases (`decade`, `quarter`, `semester`, `mo`, `wk`, …) is gone: `pint` reads `quarter` as a mass and `decade` as a number, so `1/quarter` was a mass without error. A name that `pint` does not define is a unit of its own dimension. - In the unit check, `t` has the step unit (it is a position on the axis), and a number written beside it in a sum, a difference or a comparison is read in that unit. `step` stays dimensionless. A rate such as `sin(2 * pi * t / 365)` now fails the check; write `sin(2 * pi * t / period)` with `period` in the step unit. - Guides and docstrings say what a rate means for each engine ([What a rate means](https://munqu.github.io/commol/latest/guide/core-concepts/#what-a-rate-means)) instead of "every flow applies once per step". ### Removed - `TimeScale` (`commol.api.time_scale`). Its rate conversions gave per-step rates only, which a differential model reads as too small. | Before | Now | | ------------------------------------------------------------------ | --------------------------------------------------------------- | | `TimeScale(step_seconds=3600)`, `TimeScale.from_step_unit("hour")` | `AxisUnits(step_unit="hour")`, or `model.axis_units()` | | `steps_from_seconds/minutes/hours/days/weeks(x)` | `steps(x, "second")`, `steps(x, "minute")`, … | | `steps_per_hour`, `steps_per_day`, `steps_per_week` | `steps(1, "hour")`, `steps(1, "day")`, `steps(1, "week")` | | `rate_from_mean_duration(seconds)` | `rate_from_mean_span(span, unit)`, which asks the engine | | `rate_from_probability(p, period_steps)` | `rate_from_probability(p, period, unit)`, which asks the engine | | `window_start`, `window_end`, `window_index` | `commol.window_start`, `window_end`, `window_index` | `steps` returns a `float` and does not raise for a fraction of a step; the engine rejects a position it does not accept where the position is used. - Transition conditions: `Transition.condition`, the `condition` argument of `ModelBuilder.add_transition`, `ModelBuilder.create_condition`, the `Condition` and `Rule` types, and the `LogicOperators` and `VariablePrefixes` enums, in Python and in Rust. The engine never evaluated them, so a condition that was never true still moved population. Write the condition in the rate (`"if(step > 30, k, 0)"`), or use an `AxisPattern` or a stratified rate. A model file with a `condition` key raises an error that names it. - `Simulation.run(..., output_format=...)` and `run_at(..., output_format=...)`: both return `SimulationResults`; use `rows()` for one row per position. - `commol_core::integer_steps`, `SimulationEngine::run_into_buffer` and `run_at_steps_into_buffer`; the Python checks that repeated the engine's rules on positions (`check_steps_for_typology`, the impulse check of `Dynamics`, the order check of `Simulation.run_at`). - The `"weighted_sse"` loss and `LossConfig.weighted_sse()`. Use `"sse"`, which uses the weights. - `commol.constants.ModelTypes`. The names of the compartmental engines are the `Literal` `commol.context.engine.CompartmentalEngineType`. - `Population` and `Dynamics`, in Python (`commol.context.population`, `commol.context.dynamics`) and in Rust, and their PyO3 classes; the structure of a model is `CompartmentalStructure`. - `Model.get_uncalibrated_initial_conditions`, `Model.initial_state`, and the other methods of a compartmental model on `Model`: they are on `model.structure` (see Changed). ### Fixed - Examples 6 to 9 of the guide (`examples.md`) no longer go below zero. Example 6 writes the rate it describes, `k2_max * B * max(0, 1 - C / capacity)`; Examples 7, 8 and 9 set `per_compartment=True` on `flow_AB`, so each compartment of `A` loses an amount computed from its own amount. - A differential model: on a piece of integration that ends where a rate jumps (a value `t` is compared with, a series point), the right-hand side sees `t` below the end. Before, the last solver step of the piece already saw the rate after the jump, so a rate of 0.5 that starts at 2.5 moved `3·10⁻⁸` of the state before 2.5. - `CalibrationResult.converged` is `True` only when the optimizer met its convergence test or `target_cost`. Before, every finished run reported `True`, also one that stopped at `max_iterations`. - Nelder-Mead from a start value of 0 builds a real simplex, and no longer stops at a bound where the clamped loss is flat. Before, the simplex was degenerate at 0, and could collapse on a bound and report `converged=True`. - The verbose Nelder-Mead run uses the configured simplex. Before, it always used a 10% perturbation. - `Model.check_unit_consistency` names every bin without a unit. Before, it failed with "'None' is not defined in the unit registry". - A `Simulation` follows its model: `run` and `engine` rebuild the engine when the model changed after the `Simulation` was created, for example with `update_parameters` or `apply_calibration_parameters`. Before, it used the old values, or failed with "Parameter index … not found" for a parameter that was `None`. - `pi` and `e` work in every transition rate, such as `"pi * k * A"`. Before, outside a function argument, the simulation failed with "Variable 'pi' not found". - `%` (remainder) works as the only operator of a rate, as in `"step % 7"`. Before, the rate was read as a parameter name. - A `total_…` variable that names no subgroup total, such as `total_t1_g1` when `group` is declared before `type`, is a build error that lists the valid names. Before, a formula parameter or the engine gave it the value 0. - A bin or an accumulator id cannot be `total`, `step`, `t`, `pi` or `e`, and a bin cannot expand into a name that is also a `total_` subgroup total. Before, the engine variable hid such a bin, and the two sums shared one value. - A parameter id cannot be `total`, `step`, `t`, `pi`, `e`, an expanded compartment or a subgroup total. Before, only bin ids were rejected, and the parameter and the engine variable shared one value. - `ModelBuilder.reset` removes the accumulators, and `ModelBuilder.clone` copies them and the `bin_unit`. Before, `reset` kept the accumulators and a clone lost them. - An `initial_condition` id is resolved by the engine when the `Calibrator` or the `ProbabilisticCalibrator` is created. A category of a non-binary stratification now gives "needs a binary stratification" instead of "not found", and bin prefixes (`"B_g1"`) and qualified categories (`"group:g1"`) are accepted, as documented. Before, both were rejected as "not found". ### Documentation - The guide is rewritten and split into pages with one job each (building models, transitions, expressions, time patterns, units, simulations, and six calibration pages). Every example runs in CI, and every claim was checked against the engine. Behaviors that were described wrongly are now documented as they are, among them: a violated constraint replaces the loss with a penalty. - The API reference documents every public name, and `Model`, plotting and reports have their own pages. - The site publishes `llms.txt` and `llms-full.txt` for AI agents, and marks the pages of the `dev` version. - `README.md` presents Commol as a library for declarative dynamical modelling: the idea behind it, what it can model and its limits, an example that derives, calibrates and simulates a model, and links to the guides. The citation title is "Commol: High-Performance Declarative Dynamical Modelling in Python". - The package metadata on PyPI has the same description, and keywords and classifiers (Python versions, audience, topic, development status). ## [0.2.0] - 2026-10-12 ### Added - Per-compartment initial conditions: `InitialConditions.compartment_fractions` (Python and Rust) accepts overrides keyed by expanded compartment name, expressed as fractions of the total population and applied after the uniform expansion. Includes `ModelBuilder.set_initial_conditions(compartment_fractions=...)`, `Model.update_compartment_fractions`, and `Model.initial_state()`, which returns each expanded compartment with its bin, categories and initial population as the engine builds it. The Rust engine is the single source of truth for the expansion and validates the overrides (unknown or repeated name, fraction outside `0.0..=1.0`, total above `population_size`). - Rust `commol_difference::initial_state` and `commol_calibration::calibrated_initial_population`, exposed to Python. The calibration objective, the calibrated predictions and the model write-back share one implementation of how calibration values reach the engine. ### Changed - **Breaking:** calibrated initial conditions no longer auto-calculate the last population-fraction value as the remainder of the population. The new `CalibrationProblem.initial_condition_remainder` names the compartment or bin that absorbs the difference; it is not calibrated and keeps its own split. Without it, values are applied as given and only an initial population above `population_size` is penalized. A single exact compartment, such as a composite state, can now be calibrated on its own. - **Breaking:** calibrated initial conditions are applied in fixed layers — bins, category splits, exact compartments, remainder — instead of in parameter order. - **Breaking:** a stratification category may be named with a qualified id (`sex:female`). A bare name that more than one stratification declares is rejected as ambiguous, in calibration and in `update_stratification_fractions`, and the report qualifies its rows. Category pairs come from the engine's compartment labels instead of parsing compartment names. - **Breaking:** `get_category_fraction`, `subgroup_population` and `get_conditioning_categories` resolve category ids like calibration: a qualified id (`sex:female`) selects its stratification and a bare name that more than one stratification declares raises instead of silently using the first one. `get_conditioning_categories` returns qualified ids for shared names. - Bin and bin-prefix initial-condition ids, and the remainder, resolve from the engine's compartment labels instead of name prefixes, so a bin `A` no longer covers the compartments of a bin `A_x`. - The Rust engine expands bins and accumulators in one function; the equation printer and unit checker use it (`expanded_outputs`) instead of their own Python expansion. The Rust `ModelTypes` gains `DifferentialEquations`, so any model definition can be expanded; the difference engine rejects it. - **Breaking:** `update_bin_fractions` / `update_initial_conditions` apply the same rule as construction: the bin fractions must sum to 1.0 after the update. - **Breaking (Rust):** `SimulationEngine` requires `compartment_labels()`. `CalibrationProblem::fix_auto_calculated_parameters` and `get_parameter_fix_info` are removed; `applied_parameter_values` returns the clamped values the objective scored, which is what results now report. - **Breaking:** transitions are planned by the Rust engine from compartment labels (bin and categories) instead of compartment names, in one planner shared by the simulation and `print_equations`. The printed equations are therefore the simulated ones: a per-capita rate is shown multiplied by its source (`gamma * I`), a transition without flows shows `(no flows)`, and the compact form lists the flows of each transition. - **Breaking:** building a model rejects a stratified rate whose `category` conditions match no source compartment (use `to` to route into a stratification the source does not carry), a transition with several sources and a per-capita rate, a category transition without exactly one source and one target, and a flow that ends in categories no compartment has. These used to be skipped without notice. - Constraint aggregates (a bin or bin prefix in a constraint expression) are built from output labels, so a bin `A` no longer sums the outputs of a bin `A_x`. ### Fixed - A transition between bins no longer moves bins whose id starts with the source bin id: `A -> B` moved `AB -> BB` as well. - Transitions between the categories of a stratification (`young -> old`) are now simulated; the engine used to ignore them while `print_equations` showed them. - A transition with several sources or targets (`A + B -> 2 C`) now applies its flow to every source and target, as documented; the engine used to move only the first source to the first target. - The applied-parameters report now shows the effective head count for bin, stratification-category, and subgroup rows — the uniform expansion plus the declared `compartment_fractions` overrides — and reports each `fraction` as that head count over the declared `population_size`, so every row shares one denominator with `N`. An aggregate row written back as overrides no longer reports the stale pre-calibration value as `calibrated`, and a bin left uncalibrated keeps its `null` marker even when overrides touch some of its compartments. - A conditional-stratification category is calibrated as a fraction of its conditioning subgroup but reported as a fraction of the whole population, so the report rescales its `interval` by the subgroup's share of the population; the interval now brackets the reported `fraction`. - `apply_calibration_parameters` writes initial-condition values back so the model rebuilds the initial population the engine scored for them. Bin and category values update the fractions they declare, as the engine applied them; every compartment those fractions do not reproduce is pinned as a per-compartment override. The call computes the result before it changes the model, so a rejected value leaves the model unchanged. - `population_size` must be greater than zero, reported as a domain `ValueError` rather than a later `ZeroDivisionError`. `update_bin_fractions` re-applies the sum-to-1/conservation rule. - Calibrating a bare category name that two stratifications declare no longer builds an initial population larger than `population_size`: the engine used to split the overlapping pairs of both stratifications from one snapshot. - The PyO3 `InitialConditions` binding exposes `population_size`, `bin_fractions`, `stratification_fractions` and `compartment_fractions` with getters, and the `_commol_rs` stub declares only those real attributes (correcting the `bin_fractions` field name). - An initial-condition override that names an unknown compartment raises a `ValueError` when the model is built or updated, and a `RuntimeError` from the `DifferenceEquations` engine constructor. - The greedy ensemble search of probabilistic calibration takes its random starts from the shuffled part of the sample (argmin fork). The starts were biased towards low indices, so the same seed can now select a different ensemble. ### Dependencies - cranelift 0.126, rand 0.10 (with the argmin fork migrated to it), thiserror 2, evalexpr 13 and pyo3 0.29. ## [0.1.0] to [0.1.3] - 2026 See the [GitHub Releases](https://github.com/MUNQU/commol/releases) page for the details of these versions. ### Added - GitHub Actions CI/CD pipelines for code quality, building, and PyPI releases - MkDocs Material documentation site - Comprehensive development workflow documentation - Contributing guidelines and release process documentation ## [0.0.1] - 2025 ### Added - Initial release of Commol - ModelBuilder fluent API for constructing compartment models - Support for difference equations models - Compartment states (bins) and stratifications - Mathematical expression support in transition rates - Rust-powered simulation engine - Python API with Pydantic validation - Basic compartment model support (difference equations) - Initial conditions and parameter management - Simulation runner with multiple output formats ### Security - Expression validation to prevent code injection - Input sanitization for mathematical formulas ______________________________________________________________________ ## Version History - **Unreleased** - Current development version - **0.10.2** - The engine as the one source of rules: settings, model values, windows and formulas; one seed scheme; results equal by value - **0.10.1** - Fixes of the audit: stochastic changes from the position, one account in the documentation, read-only CI and pinned tools - **0.10.0** - One way for equivalent things: setting names, seeds and starts, result shapes, error types, the quantile and logging - **0.9.0** - Control over the work and the record: threads and levels, swarm stop rules, truncated chaos bases, ratios, seeds and build info - **0.8.0** - Comparing runs: scenarios, quantities of a series, figures as methods and runs on the same panels - **0.7.0** - Reading a run through objects: the window grid and dates on the axis, series by output name and reports as methods - **0.6.1** - Fixes of 0.6.0: checks at build, messages of infeasible draws, PRCC with NaN, quantity labels and starts, plots - **0.6.0** - Uncertainty methods with a model of the data: likelihood, posteriors, local approximations, conformal intervals, profiles and sensitivity analysis - **0.5.0** - Uncertainty by resampling the data, identifiability of parameters and the removal of the ensemble calibration - **0.4.0** - One process under several structures and engines, stochastic engines and model descriptions - **0.3.0** - ODE engine, positions on the axis and one interface for every model kind - **0.2.0** - Per-compartment initial conditions and engine-planned transitions - **0.1.0 to 0.1.3** - First 0.1 versions - **0.0.1** - Initial release with core functionality For detailed information about each release, see the [GitHub Releases](https://github.com/MUNQU/commol/releases) page.