diff --git a/CHANGELOG.md b/CHANGELOG.md index aa22dad1..6d5a3a8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.28.3](https://github.com/LAPKB/pharmsol/compare/pharmsol-v0.28.2...pharmsol-v0.28.3) - 2026-07-28 - ### Added -- Support time in the DSL ([#310](https://github.com/LAPKB/pharmsol/pull/310)) -- Rename DSL types and add additional safeguards ([#302](https://github.com/LAPKB/pharmsol/pull/302)) +- Add a caller-controlled, simulation-neutral SDE particle session that pauses + at observation boundaries and can resume with retained or replaced states. +- Add focused Criterion coverage for standard SDE prediction and particle-session + retain/select paths. -### Other +### Removed + +- Remove equation-level and prediction-container observation evaluation APIs; + downstream fitting crates now generate predictions with pharmsol and evaluate + observations themselves. +- Remove residual-distribution declarations, estimator caches, and parameter + optimization helpers from pharmsol's public API. + +### Changed -- Make CI faster ([#303](https://github.com/LAPKB/pharmsol/pull/303)) +- Relocate the existing data-only `ErrorPoly` DTO while preserving its public + path and unchanged C0-C3 transport through observations and predictions. + As before, incomplete C0-C3 rows do not create an `ErrorPoly` value. +- Keep analytical, ODE, and SDE execution focused exclusively on simulation and + prediction generation, including simulation-only examples and benchmarks. ## [0.28.2](https://github.com/LAPKB/pharmsol/compare/pharmsol-v0.28.1...pharmsol-v0.28.2) - 2026-07-20 @@ -106,7 +118,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Handle missing observations in log-likelihood ([#230](https://github.com/LAPKB/pharmsol/pull/230)) +- Handle missing observations during observation evaluation ([#230](https://github.com/LAPKB/pharmsol/pull/230)) ## [0.24.0](https://github.com/LAPKB/pharmsol/compare/v0.23.0...v0.24.0) - 2026-03-23 @@ -119,7 +131,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Remove LikelihoodMatrixOptions ([#222](https://github.com/LAPKB/pharmsol/pull/222)) +- Remove obsolete matrix configuration options ([#222](https://github.com/LAPKB/pharmsol/pull/222)) - Use a sized cache with new subject hashing ([#214](https://github.com/LAPKB/pharmsol/pull/214)) - Normalized rows ([#205](https://github.com/LAPKB/pharmsol/pull/205)) @@ -219,7 +231,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Exhaustive match instead of if-else block - Add setters - Make cens column optional -- Censoring likelihood calculation +- Add censored-observation evaluation - Update argmin requirement from 0.10.0 to 0.11.0 ([#135](https://github.com/LAPKB/pharmsol/pull/135)) ## [0.17.2](https://github.com/LAPKB/pharmsol/compare/v0.17.1...v0.17.2) - 2025-09-30 diff --git a/Cargo.toml b/Cargo.toml index a6a48317..80c886db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,11 +31,11 @@ documentation = "https://lapkb.github.io/pharmsol/" default = [] dsl-core = [] dsl-jit = [ - "dsl-core", - "dep:cranelift", - "dep:cranelift-jit", - "dep:cranelift-module", - "dep:cranelift-native", + "dsl-core", + "dep:cranelift", + "dep:cranelift-jit", + "dep:cranelift-module", + "dep:cranelift-native", ] dsl-aot = ["dsl-core"] dsl-aot-load = ["dsl-core", "dep:libloading"] @@ -86,6 +86,10 @@ bench = false name = "native_matrix" harness = false +[[bench]] +name = "sde_sessions" +harness = false + [[bench]] name = "dsl_matrix" harness = false diff --git a/README.md b/README.md index c4bc56b4..8b1817be 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,7 @@ See [examples/analytical_readme.rs](examples/analytical_readme.rs), [examples/sde_readme.rs](examples/sde_readme.rs), [examples/dsl_jit_analytical_covariates.rs](examples/dsl_jit_analytical_covariates.rs), [examples/analytical_vs_ode.rs](examples/analytical_vs_ode.rs), and -[examples/compare_solvers.rs](examples/compare_solvers.rs). For migration-oriented notes, -see [docs/analytical-authoring-migration.md](docs/analytical-authoring-migration.md) and -[docs/ode-authoring-migration.md](docs/ode-authoring-migration.md). +[examples/compare_solvers.rs](examples/compare_solvers.rs). ### Built-In Analytical Kernels diff --git a/benches/common/mod.rs b/benches/common/mod.rs index 766ac7d9..631032b9 100644 --- a/benches/common/mod.rs +++ b/benches/common/mod.rs @@ -1,4 +1,4 @@ -//! Shared bench fixtures (workloads, subjects, params, error models, model factories) +//! Shared bench fixtures (workloads, subjects, parameters, and model factories) //! used by both `native_matrix.rs` and `dsl_matrix.rs`. Every backend measures the //! same model + subject + params so only the engine varies between cells. //! @@ -11,15 +11,11 @@ #![allow(dead_code)] -use ndarray::Array2; +use pharmsol::equation::{self, Analytical, Route, ODE, SDE}; use pharmsol::prelude::*; use pharmsol::simulator::equation::analytical::{ one_compartment_with_absorption, two_compartments, }; -use pharmsol::{ - equation::{self, Route}, - Analytical, ResidualErrorModel, ResidualErrorModels, ODE, SDE, -}; /// `ModelMetadata` for handwritten factories so route/output labels resolve like the macro/DSL paths. fn model_metadata(workload: Workload, kind: SolverKind) -> equation::ModelMetadata { @@ -106,7 +102,7 @@ impl SolverKind { } } - // SDE bench cells temporarily disabled — too slow for the current matrix. + // Stochastic workloads use the dedicated SDE benchmark target. pub fn all() -> [SolverKind; 2] { [SolverKind::Ode, SolverKind::Analytical] } @@ -117,18 +113,11 @@ impl SolverKind { /// 9 sampling points for the short workload. const SHORT_TIMES: &[f64] = &[0.25, 0.5, 1.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0]; -/// Synthetic plasma concentrations — only need to exercise the likelihood path, not match any model. -const SHORT_OBS: &[f64] = &[0.50, 0.90, 1.60, 2.40, 2.10, 1.50, 1.05, 0.72, 0.48]; - /// 14 sampling points for the repeat workload. const REPEAT_TIMES: &[f64] = &[ 0.5, 2.0, 6.0, 10.0, 14.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0, 96.0, 108.0, 120.0, ]; -const REPEAT_OBS: &[f64] = &[ - 1.80, 1.45, 1.10, 0.90, 1.30, 1.60, 1.55, 1.50, 1.48, 1.45, 1.43, 1.42, 1.41, 0.95, -]; - /// Subject with `missing_observation` slots — used for prediction benches. pub fn subject_for_predictions(workload: Workload) -> Subject { let id = format!("{}-pred", workload.label()); @@ -153,60 +142,6 @@ pub fn subject_for_predictions(workload: Workload) -> Subject { builder.build() } -/// Subject with real observations — used for log-likelihood / matrix benches. -pub fn subject_for_likelihood(workload: Workload) -> Subject { - let id = format!("{}-lik", workload.label()); - let mut builder = Subject::builder(id); - match workload { - Workload::Short => { - builder = builder.bolus(0.0, 100.0, "po"); - for (&t, &y) in SHORT_TIMES.iter().zip(SHORT_OBS.iter()) { - builder = builder.observation(t, y, "plasma"); - } - } - Workload::Repeat => { - for dose in 0..10 { - let t = dose as f64 * 12.0; - builder = builder.bolus(t, 100.0, "iv"); - } - for (&t, &y) in REPEAT_TIMES.iter().zip(REPEAT_OBS.iter()) { - builder = builder.observation(t, y, "plasma"); - } - } - } - builder.build() -} - -/// Population dataset for `log_likelihood_matrix`: `n` subjects, same template with small obs perturbations. -pub fn matrix_data(workload: Workload, n: usize) -> Data { - let subjects = (0..n) - .map(|i| { - let offset = i as f64 * 0.01; - let id = format!("{}-{i:03}", workload.label()); - let mut builder = Subject::builder(id); - match workload { - Workload::Short => { - builder = builder.bolus(0.0, 100.0, "po"); - for (&t, &y) in SHORT_TIMES.iter().zip(SHORT_OBS.iter()) { - builder = builder.observation(t, y + offset, "plasma"); - } - } - Workload::Repeat => { - for dose in 0..10 { - let t = dose as f64 * 12.0; - builder = builder.bolus(t, 100.0, "iv"); - } - for (&t, &y) in REPEAT_TIMES.iter().zip(REPEAT_OBS.iter()) { - builder = builder.observation(t, y + offset, "plasma"); - } - } - } - builder.build() - }) - .collect(); - Data::new(subjects) -} - // ───────────────────────────── Parameters ──────────────────────────── /// Reference named parameters per `(workload, kind)` in the source order shared by bench fixtures. @@ -231,45 +166,6 @@ pub fn named_params(workload: Workload, kind: SolverKind) -> Vec<(&'static str, } } -/// Reference parameter vector per `(workload, kind)`: -/// Short ODE/Analytical `[ka, ke, v]`, Short SDE adds `sigma_ke`; -/// Repeat ODE/Analytical `[ke, kcp, kpc, v]`, Repeat SDE adds `sigma_ke`. -pub fn params(workload: Workload, kind: SolverKind) -> Vec { - named_params(workload, kind) - .into_iter() - .map(|(_, value)| value) - .collect() -} - -/// Support-point grid for `log_likelihood_matrix`, shape `(n, nparams)`. -/// Rows are small perturbations of [`params`]. -pub fn support_points(workload: Workload, kind: SolverKind, n: usize) -> Array2 { - let base = params(workload, kind); - let nparams = base.len(); - Array2::from_shape_fn((n, nparams), |(row, col)| { - let p = base[col]; - let perturbation = (row as f64) * 0.001 * p.abs().max(1e-3); - p + perturbation - }) -} - -// ───────────────────────────── Error models ────────────────────────── - -/// Assay error model for `estimate_log_likelihood` / `log_likelihood_matrix`. -pub fn assay_error_models() -> AssayErrorModels { - AssayErrorModels::new() - .add( - "plasma", - AssayErrorModel::additive(ErrorPoly::new(0.1, 0.1, 0.0, 0.0), 0.0), - ) - .expect("plasma assay error model") -} - -/// Residual error model. Indexes outputs by dense `usize` — assumes a single output at index 0. -pub fn residual_error_models() -> ResidualErrorModels { - ResidualErrorModels::new().add(0, ResidualErrorModel::constant(0.2)) -} - // ───────────────────────────── Handwritten factories ───────────────── pub fn handwritten_ode(workload: Workload) -> ODE { diff --git a/benches/dsl_matrix.rs b/benches/dsl_matrix.rs index 6c109f0f..ac8c8ec8 100644 --- a/benches/dsl_matrix.rs +++ b/benches/dsl_matrix.rs @@ -2,10 +2,8 @@ //! Mirrors `native_matrix.rs` but compiles models from DSL source. //! //! IDs: -//! - `dsl/compile` → `{workload}/{kind}/{backend}` -//! - `dsl/predictions` → `{workload}/{kind}/{backend}/{cache}` -//! - `dsl/log-likelihood` → `{workload}/{kind}/{backend}/{cache}` -//! - `dsl/likelihood-matrix` → `{workload}/{kind}/{backend}` +//! - `dsl/compile` → `{workload}/{kind}/{backend}` +//! - `dsl/predictions` → `{workload}/{kind}/{backend}/{cache}` use std::hint::black_box; use std::path::PathBuf; @@ -16,22 +14,17 @@ use tempfile::TempDir; use pharmsol::dsl::{ compile_module_source_to_runtime, CompiledRuntimeModel, NativeAnalyticalModel, - NativeAotCompileOptions, NativeOdeModel, NativeSdeModel, RuntimeCompilationTarget, + NativeAotCompileOptions, NativeOdeModel, RuntimeCompilationTarget, }; -use pharmsol::prelude::*; use pharmsol::{Cache, Parameters}; mod common; use common::{ - assay_error_models, dsl_model_name, dsl_source, matrix_data, named_params, - subject_for_likelihood, subject_for_predictions, support_points, SolverKind, Workload, + dsl_model_name, dsl_source, named_params, subject_for_predictions, SolverKind, Workload, }; -const MATRIX_N_SUBJECTS: usize = 32; -const MATRIX_N_SUPPORT: usize = 64; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] // Aot/Wasm temporarily disabled in `Backend::all` +#[allow(dead_code)] // Retained for opt-in backend profiling outside the default matrix. enum Backend { Jit, Aot, @@ -47,7 +40,7 @@ impl Backend { } } - // AoT and WASM backends temporarily disabled — too slow for the current matrix. + // The default matrix bounds runtime to JIT; AoT and WASM remain opt-in. fn all() -> [Backend; 1] { [Backend::Jit] } @@ -143,17 +136,6 @@ fn compile_analytical( } } -fn compile_sde(workload: Workload, backend: Backend, aot: &AotWorkspace) -> NativeSdeModel { - match compile_runtime(workload, SolverKind::Sde, backend, aot) { - CompiledRuntimeModel::Sde(model) => model, - other => panic!( - "expected Sde model for {}, got {:?}", - workload.label(), - other.backend() - ), - } -} - fn ode_parameters(model: &NativeOdeModel, workload: Workload) -> Parameters { Parameters::with_model(model, named_params(workload, SolverKind::Ode)) .expect("DSL ODE bench parameters should validate") @@ -164,11 +146,6 @@ fn analytical_parameters(model: &NativeAnalyticalModel, workload: Workload) -> P .expect("DSL analytical bench parameters should validate") } -fn sde_parameters(model: &NativeSdeModel, workload: Workload) -> Parameters { - Parameters::with_model(model, named_params(workload, SolverKind::Sde)) - .expect("DSL SDE bench parameters should validate") -} - // ───────────────────────────── compile group ───────────────────────── fn compile_group(c: &mut Criterion) { @@ -199,7 +176,7 @@ fn compile_group(c: &mut Criterion) { )); group.bench_function(bench_id, |b| { b.iter_custom(|iters| { - let actual = iters.min(MAX_ITERS_PER_BATCH).max(1); + let actual = iters.clamp(1, MAX_ITERS_PER_BATCH); let start = Instant::now(); for _ in 0..actual { black_box(compile_runtime( @@ -283,208 +260,7 @@ fn predictions_group(c: &mut Criterion) { }); }); } - SolverKind::Sde => { - let model = match cache { - CacheState::Hot => compile_sde(workload, backend, &aot), - CacheState::Cold => { - compile_sde(workload, backend, &aot).disable_cache() - } - }; - let theta = sde_parameters(&model, workload); - group.bench_function(bench_id, |b| { - b.iter(|| { - black_box( - model - .estimate_predictions( - black_box(&subject), - black_box(&theta), - ) - .unwrap(), - ) - }); - }); - } - } - } - } - } - } - - group.finish(); -} - -// ───────────────────────────── log-likelihood group ────────────────── - -fn log_likelihood_group(c: &mut Criterion) { - let mut group = c.benchmark_group("dsl/log-likelihood"); - group.sampling_mode(SamplingMode::Flat); - - let aot = AotWorkspace::new(); - let error_models = assay_error_models(); - - for workload in Workload::all() { - let subject = subject_for_likelihood(workload); - for kind in SolverKind::all() { - for backend in Backend::all() { - for cache in CacheState::all() { - let bench_id = BenchmarkId::from_parameter(format!( - "{}/{}/{}/{}", - workload.label(), - kind.label(), - backend.label(), - cache.label() - )); - match kind { - SolverKind::Ode => { - let model = match cache { - CacheState::Hot => compile_ode(workload, backend, &aot), - CacheState::Cold => { - compile_ode(workload, backend, &aot).disable_cache() - } - }; - let theta = ode_parameters(&model, workload); - group.bench_function(bench_id, |b| { - b.iter(|| { - black_box( - model - .estimate_log_likelihood( - black_box(&subject), - black_box(&theta), - black_box(&error_models), - ) - .unwrap(), - ) - }); - }); - } - SolverKind::Analytical => { - let model = match cache { - CacheState::Hot => compile_analytical(workload, backend, &aot), - CacheState::Cold => { - compile_analytical(workload, backend, &aot).disable_cache() - } - }; - let theta = analytical_parameters(&model, workload); - group.bench_function(bench_id, |b| { - b.iter(|| { - black_box( - model - .estimate_log_likelihood( - black_box(&subject), - black_box(&theta), - black_box(&error_models), - ) - .unwrap(), - ) - }); - }); - } - SolverKind::Sde => { - let model = match cache { - CacheState::Hot => compile_sde(workload, backend, &aot), - CacheState::Cold => { - compile_sde(workload, backend, &aot).disable_cache() - } - }; - let theta = sde_parameters(&model, workload); - group.bench_function(bench_id, |b| { - b.iter(|| { - black_box( - model - .estimate_log_likelihood( - black_box(&subject), - black_box(&theta), - black_box(&error_models), - ) - .unwrap(), - ) - }); - }); - } - } - } - } - } - } - - group.finish(); -} - -// ───────────────────────────── likelihood-matrix group ─────────────── - -fn likelihood_matrix_group(c: &mut Criterion) { - use pharmsol::prelude::simulator::log_likelihood_matrix; - - let mut group = c.benchmark_group("dsl/likelihood-matrix"); - group.sampling_mode(SamplingMode::Flat); - group.sample_size(10); - group.measurement_time(Duration::from_secs(20)); - - let aot = AotWorkspace::new(); - let error_models = assay_error_models(); - - for workload in Workload::all() { - let data = matrix_data(workload, MATRIX_N_SUBJECTS); - for kind in SolverKind::all() { - let theta = support_points(workload, kind, MATRIX_N_SUPPORT); - for backend in Backend::all() { - let bench_id = BenchmarkId::from_parameter(format!( - "{}/{}/{}", - workload.label(), - kind.label(), - backend.label() - )); - match kind { - SolverKind::Ode => { - let model = compile_ode(workload, backend, &aot); - group.bench_function(bench_id, |b| { - b.iter(|| { - black_box( - log_likelihood_matrix( - black_box(&model), - black_box(&data), - black_box(&theta), - black_box(&error_models), - false, - ) - .unwrap(), - ) - }); - }); - } - SolverKind::Analytical => { - let model = compile_analytical(workload, backend, &aot); - group.bench_function(bench_id, |b| { - b.iter(|| { - black_box( - log_likelihood_matrix( - black_box(&model), - black_box(&data), - black_box(&theta), - black_box(&error_models), - false, - ) - .unwrap(), - ) - }); - }); - } - SolverKind::Sde => { - let model = compile_sde(workload, backend, &aot); - group.bench_function(bench_id, |b| { - b.iter(|| { - black_box( - log_likelihood_matrix( - black_box(&model), - black_box(&data), - black_box(&theta), - black_box(&error_models), - false, - ) - .unwrap(), - ) - }); - }); + SolverKind::Sde => unreachable!("SDE has a dedicated benchmark"), } } } @@ -494,11 +270,5 @@ fn likelihood_matrix_group(c: &mut Criterion) { group.finish(); } -criterion_group!( - dsl_matrix, - compile_group, - predictions_group, - log_likelihood_group, - likelihood_matrix_group -); +criterion_group!(dsl_matrix, compile_group, predictions_group); criterion_main!(dsl_matrix); diff --git a/benches/native_matrix.rs b/benches/native_matrix.rs index 97ebf0a8..5c70b5e3 100644 --- a/benches/native_matrix.rs +++ b/benches/native_matrix.rs @@ -2,27 +2,21 @@ //! Default features only — this is the CI regression signal. DSL backends are in `dsl_matrix.rs`. //! //! IDs: -//! - `native/predictions` → `{workload}/{solver}/{authoring}/{cache}` -//! - `native/log-likelihood` → `{workload}/{solver}/{authoring}/{cache}` -//! - `native/likelihood-matrix` → `{workload}/{solver}/{authoring}` +//! - `native/predictions` → `{workload}/{solver}/{authoring}/{cache}` use std::hint::black_box; -use std::time::Duration; use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, SamplingMode}; +use pharmsol::equation::{Analytical, ODE}; use pharmsol::prelude::*; -use pharmsol::{Analytical, Cache, Parameters, ODE, SDE}; +use pharmsol::{Cache, Parameters}; mod common; use common::{ - assay_error_models, handwritten_analytical, handwritten_ode, handwritten_sde, macro_analytical, - macro_ode, macro_sde, matrix_data, named_params, subject_for_likelihood, - subject_for_predictions, support_points, SolverKind, Workload, + handwritten_analytical, handwritten_ode, macro_analytical, macro_ode, named_params, + subject_for_predictions, SolverKind, Workload, }; -const MATRIX_N_SUBJECTS: usize = 32; -const MATRIX_N_SUPPORT: usize = 64; - #[derive(Debug, Clone, Copy)] enum Authoring { Handwritten, @@ -81,11 +75,6 @@ fn analytical_parameters(model: &Analytical, workload: Workload) -> Parameters { .expect("native analytical bench parameters should validate") } -fn sde_parameters(model: &SDE, workload: Workload) -> Parameters { - Parameters::with_model(model, named_params(workload, SolverKind::Sde)) - .expect("native SDE bench parameters should validate") -} - fn predictions_group(c: &mut Criterion) { let mut group = c.benchmark_group("native/predictions"); group.sampling_mode(SamplingMode::Flat); @@ -210,62 +199,7 @@ fn predictions_group(c: &mut Criterion) { ) }); } - (SolverKind::Sde, Authoring::Handwritten, CacheState::Hot) => { - let model = handwritten_sde(workload); - let theta = sde_parameters(&model, workload); - b.iter(|| { - black_box( - model - .estimate_predictions( - black_box(&subject), - black_box(&theta), - ) - .unwrap(), - ) - }); - } - (SolverKind::Sde, Authoring::Handwritten, CacheState::Cold) => { - let model = handwritten_sde(workload).disable_cache(); - let theta = sde_parameters(&model, workload); - b.iter(|| { - black_box( - model - .estimate_predictions( - black_box(&subject), - black_box(&theta), - ) - .unwrap(), - ) - }); - } - (SolverKind::Sde, Authoring::Macro, CacheState::Hot) => { - let model = macro_sde(workload); - let theta = sde_parameters(&model, workload); - b.iter(|| { - black_box( - model - .estimate_predictions( - black_box(&subject), - black_box(&theta), - ) - .unwrap(), - ) - }); - } - (SolverKind::Sde, Authoring::Macro, CacheState::Cold) => { - let model = macro_sde(workload).disable_cache(); - let theta = sde_parameters(&model, workload); - b.iter(|| { - black_box( - model - .estimate_predictions( - black_box(&subject), - black_box(&theta), - ) - .unwrap(), - ) - }); - } + (SolverKind::Sde, _, _) => unreachable!("SDE has a dedicated benchmark"), }); } } @@ -275,333 +209,5 @@ fn predictions_group(c: &mut Criterion) { group.finish(); } -fn log_likelihood_group(c: &mut Criterion) { - let mut group = c.benchmark_group("native/log-likelihood"); - group.sampling_mode(SamplingMode::Flat); - let error_models = assay_error_models(); - - for workload in Workload::all() { - let subject = subject_for_likelihood(workload); - for kind in SolverKind::all() { - for authoring in Authoring::all() { - for cache in CacheState::all() { - let bench_id = - BenchmarkId::from_parameter(id(workload, kind, authoring, cache)); - group.bench_function(bench_id, |b| match (kind, authoring, cache) { - (SolverKind::Ode, Authoring::Handwritten, CacheState::Hot) => { - let model = handwritten_ode(workload); - let theta = ode_parameters(&model, workload); - b.iter(|| { - black_box( - model - .estimate_log_likelihood( - black_box(&subject), - black_box(&theta), - black_box(&error_models), - ) - .unwrap(), - ) - }); - } - (SolverKind::Ode, Authoring::Handwritten, CacheState::Cold) => { - let model = handwritten_ode(workload).disable_cache(); - let theta = ode_parameters(&model, workload); - b.iter(|| { - black_box( - model - .estimate_log_likelihood( - black_box(&subject), - black_box(&theta), - black_box(&error_models), - ) - .unwrap(), - ) - }); - } - (SolverKind::Ode, Authoring::Macro, CacheState::Hot) => { - let model = macro_ode(workload); - let theta = ode_parameters(&model, workload); - b.iter(|| { - black_box( - model - .estimate_log_likelihood( - black_box(&subject), - black_box(&theta), - black_box(&error_models), - ) - .unwrap(), - ) - }); - } - (SolverKind::Ode, Authoring::Macro, CacheState::Cold) => { - let model = macro_ode(workload).disable_cache(); - let theta = ode_parameters(&model, workload); - b.iter(|| { - black_box( - model - .estimate_log_likelihood( - black_box(&subject), - black_box(&theta), - black_box(&error_models), - ) - .unwrap(), - ) - }); - } - (SolverKind::Analytical, Authoring::Handwritten, CacheState::Hot) => { - let model = handwritten_analytical(workload); - let theta = analytical_parameters(&model, workload); - b.iter(|| { - black_box( - model - .estimate_log_likelihood( - black_box(&subject), - black_box(&theta), - black_box(&error_models), - ) - .unwrap(), - ) - }); - } - (SolverKind::Analytical, Authoring::Handwritten, CacheState::Cold) => { - let model = handwritten_analytical(workload).disable_cache(); - let theta = analytical_parameters(&model, workload); - b.iter(|| { - black_box( - model - .estimate_log_likelihood( - black_box(&subject), - black_box(&theta), - black_box(&error_models), - ) - .unwrap(), - ) - }); - } - (SolverKind::Analytical, Authoring::Macro, CacheState::Hot) => { - let model = macro_analytical(workload); - let theta = analytical_parameters(&model, workload); - b.iter(|| { - black_box( - model - .estimate_log_likelihood( - black_box(&subject), - black_box(&theta), - black_box(&error_models), - ) - .unwrap(), - ) - }); - } - (SolverKind::Analytical, Authoring::Macro, CacheState::Cold) => { - let model = macro_analytical(workload).disable_cache(); - let theta = analytical_parameters(&model, workload); - b.iter(|| { - black_box( - model - .estimate_log_likelihood( - black_box(&subject), - black_box(&theta), - black_box(&error_models), - ) - .unwrap(), - ) - }); - } - (SolverKind::Sde, Authoring::Handwritten, CacheState::Hot) => { - let model = handwritten_sde(workload); - let theta = sde_parameters(&model, workload); - b.iter(|| { - black_box( - model - .estimate_log_likelihood( - black_box(&subject), - black_box(&theta), - black_box(&error_models), - ) - .unwrap(), - ) - }); - } - (SolverKind::Sde, Authoring::Handwritten, CacheState::Cold) => { - let model = handwritten_sde(workload).disable_cache(); - let theta = sde_parameters(&model, workload); - b.iter(|| { - black_box( - model - .estimate_log_likelihood( - black_box(&subject), - black_box(&theta), - black_box(&error_models), - ) - .unwrap(), - ) - }); - } - (SolverKind::Sde, Authoring::Macro, CacheState::Hot) => { - let model = macro_sde(workload); - let theta = sde_parameters(&model, workload); - b.iter(|| { - black_box( - model - .estimate_log_likelihood( - black_box(&subject), - black_box(&theta), - black_box(&error_models), - ) - .unwrap(), - ) - }); - } - (SolverKind::Sde, Authoring::Macro, CacheState::Cold) => { - let model = macro_sde(workload).disable_cache(); - let theta = sde_parameters(&model, workload); - b.iter(|| { - black_box( - model - .estimate_log_likelihood( - black_box(&subject), - black_box(&theta), - black_box(&error_models), - ) - .unwrap(), - ) - }); - } - }); - } - } - } - } - - group.finish(); -} - -fn likelihood_matrix_group(c: &mut Criterion) { - use pharmsol::prelude::simulator::log_likelihood_matrix; - - let mut group = c.benchmark_group("native/likelihood-matrix"); - group.sampling_mode(SamplingMode::Flat); - // One matrix iteration is multi-second; small sample budget keeps wall-clock sane. - group.sample_size(10); - group.measurement_time(Duration::from_secs(20)); - - let error_models = assay_error_models(); - - for workload in Workload::all() { - let data = matrix_data(workload, MATRIX_N_SUBJECTS); - for kind in SolverKind::all() { - let theta = support_points(workload, kind, MATRIX_N_SUPPORT); - for authoring in Authoring::all() { - let bench_id = BenchmarkId::from_parameter(format!( - "{}/{}/{}", - workload.label(), - kind.label(), - authoring.label() - )); - group.bench_function(bench_id, |b| match (kind, authoring) { - (SolverKind::Ode, Authoring::Handwritten) => { - let model = handwritten_ode(workload); - b.iter(|| { - black_box( - log_likelihood_matrix( - black_box(&model), - black_box(&data), - black_box(&theta), - black_box(&error_models), - false, - ) - .unwrap(), - ) - }); - } - (SolverKind::Ode, Authoring::Macro) => { - let model = macro_ode(workload); - b.iter(|| { - black_box( - log_likelihood_matrix( - black_box(&model), - black_box(&data), - black_box(&theta), - black_box(&error_models), - false, - ) - .unwrap(), - ) - }); - } - (SolverKind::Analytical, Authoring::Handwritten) => { - let model = handwritten_analytical(workload); - b.iter(|| { - black_box( - log_likelihood_matrix( - black_box(&model), - black_box(&data), - black_box(&theta), - black_box(&error_models), - false, - ) - .unwrap(), - ) - }); - } - (SolverKind::Analytical, Authoring::Macro) => { - let model = macro_analytical(workload); - b.iter(|| { - black_box( - log_likelihood_matrix( - black_box(&model), - black_box(&data), - black_box(&theta), - black_box(&error_models), - false, - ) - .unwrap(), - ) - }); - } - (SolverKind::Sde, Authoring::Handwritten) => { - let model = handwritten_sde(workload); - b.iter(|| { - black_box( - log_likelihood_matrix( - black_box(&model), - black_box(&data), - black_box(&theta), - black_box(&error_models), - false, - ) - .unwrap(), - ) - }); - } - (SolverKind::Sde, Authoring::Macro) => { - let model = macro_sde(workload); - b.iter(|| { - black_box( - log_likelihood_matrix( - black_box(&model), - black_box(&data), - black_box(&theta), - black_box(&error_models), - false, - ) - .unwrap(), - ) - }); - } - }); - } - } - } - - group.finish(); -} - -criterion_group!( - native_matrix, - predictions_group, - log_likelihood_group, - likelihood_matrix_group -); +criterion_group!(native_matrix, predictions_group); criterion_main!(native_matrix); diff --git a/benches/sde_sessions.rs b/benches/sde_sessions.rs new file mode 100644 index 00000000..7252d2e1 --- /dev/null +++ b/benches/sde_sessions.rs @@ -0,0 +1,68 @@ +//! Focused stochastic simulation benchmarks without deterministic cache modes. + +use std::hint::black_box; + +use criterion::{criterion_group, criterion_main, Criterion}; +use pharmsol::prelude::*; +use pharmsol::Parameters; +use rand::{rngs::StdRng, SeedableRng}; + +mod common; +use common::{handwritten_sde, named_params, subject_for_predictions, SolverKind, Workload}; + +fn sde_group(c: &mut Criterion) { + let workload = Workload::Short; + let model = handwritten_sde(workload); + let subject = subject_for_predictions(workload); + let parameters = Parameters::with_model(&model, named_params(workload, SolverKind::Sde)) + .expect("SDE benchmark parameters should validate"); + + let mut group = c.benchmark_group("sde/simulation"); + group.sample_size(10); + + group.bench_function("standard-prediction", |b| { + b.iter(|| { + black_box( + model + .estimate_predictions(black_box(&subject), black_box(¶meters)) + .unwrap(), + ) + }) + }); + + group.bench_function("session-retain", |b| { + let mut seed = 1_u64; + b.iter(|| { + let mut rng = StdRng::seed_from_u64(seed); + seed = seed.wrapping_add(1); + let mut session = model + .particle_session(&subject, ¶meters, 16, &mut rng) + .unwrap(); + while session.next_observation().unwrap().is_some() { + session.retain_particles().unwrap(); + } + black_box(session.particle_count()) + }) + }); + + group.bench_function("session-select-ancestors", |b| { + let ancestors = (0..16).rev().collect::>(); + let mut seed = 10_000_u64; + b.iter(|| { + let mut rng = StdRng::seed_from_u64(seed); + seed = seed.wrapping_add(1); + let mut session = model + .particle_session(&subject, ¶meters, 16, &mut rng) + .unwrap(); + while session.next_observation().unwrap().is_some() { + session.select_ancestors(&ancestors).unwrap(); + } + black_box(session.particle_count()) + }) + }); + + group.finish(); +} + +criterion_group!(sde_sessions, sde_group); +criterion_main!(sde_sessions); diff --git a/examples/dsl_runtime_jit.rs b/examples/dsl_runtime_jit.rs index bf99e0d0..5023a2ce 100644 --- a/examples/dsl_runtime_jit.rs +++ b/examples/dsl_runtime_jit.rs @@ -2,7 +2,7 @@ //! //! A simple one-compartment IV infusion model is defined as a DSL string, //! compiled at runtime via `compile_module_source_to_runtime`, and then used -//! to estimate predictions — no recompilation of the host binary needed. +//! to generate predictions — no recompilation of the host binary needed. //! //! Run with: //! cargo run --example dsl_runtime_jit --features dsl-jit @@ -47,7 +47,7 @@ out(cp) = central / v pharmsol::dsl::RuntimeCompilationTarget::Jit, on_compile_event, )?; - let support_point = Parameters::with_model(&model, [("ke", 1.2), ("v", 50.0)])?; + let parameters = Parameters::with_model(&model, [("ke", 1.2), ("v", 50.0)])?; // 3. Define the subject data. let subject = Subject::builder("bimodal_ke") @@ -61,9 +61,9 @@ out(cp) = central / v .missing_observation(8.0, "cp") .build(); - // 4. Estimate predictions for one support point. + // 4. Generate predictions for one parameter vector. let predictions = model - .estimate_predictions(&subject, &support_point)? + .estimate_predictions(&subject, ¶meters)? .into_subject() .ok_or_else(|| io::Error::other("expected subject predictions"))?; diff --git a/examples/macro_vs_handwritten_one_cpt.rs b/examples/macro_vs_handwritten_one_cpt.rs deleted file mode 100644 index 4b4fa03c..00000000 --- a/examples/macro_vs_handwritten_one_cpt.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! Compares a declaration-first macro ODE with the equivalent handwritten ODE. -//! -//! This is the advanced comparison path for users who want to confirm that the -//! preferred macro surface and the low-level API produce the same metadata and -//! predictions on the same one-compartment IV problem. - -use pharmsol::{prelude::*, Parameters}; - -fn macro_model() -> equation::ODE { - ode! { - name: "one_cpt_macro_parity", - params: [ke, v], - states: [central], - outputs: [cp], - routes: [ - infusion(iv) -> central, - ], - diffeq: |x, _p, _t, dx, _cov| { - dx[central] = -ke * x[central]; - }, - out: |x, _p, _t, _cov, y| { - y[cp] = x[central] / v; - }, - } -} - -fn handwritten_model() -> equation::ODE { - equation::ODE::new( - |x, p, _t, dx, _bolus, rateiv, _cov| { - fetch_params!(p, ke, _v); - dx[0] = rateiv[0] - ke * x[0]; - }, - |_p, _t, _cov| lag! {}, - |_p, _t, _cov| fa! {}, - |_p, _t, _cov, _x| {}, - |x, p, _t, _cov, y| { - fetch_params!(p, _ke, v); - y[0] = x[0] / v; - }, - ) - .with_nstates(1) - .with_ndrugs(1) - .with_nout(1) - .with_metadata( - equation::metadata::new("one_cpt_macro_parity") - .parameters(["ke", "v"]) - .states(["central"]) - .outputs(["cp"]) - .route( - equation::Route::infusion("iv") - .to_state("central") - .inject_input_to_destination(), - ), - ) - .expect("handwritten one-compartment metadata should validate") -} - -fn max_abs_diff(left: &[f64], right: &[f64]) -> f64 { - left.iter() - .zip(right.iter()) - .map(|(lhs, rhs)| (lhs - rhs).abs()) - .fold(0.0_f64, f64::max) -} - -fn main() -> Result<(), pharmsol::PharmsolError> { - let macro_ode = macro_model(); - let handwritten_ode = handwritten_model(); - - assert_eq!(macro_ode.metadata(), handwritten_ode.metadata()); - - let subject = Subject::builder("macro-vs-handwritten-one-cpt") - .infusion(0.0, 500.0, "iv", 0.5) - .missing_observation(0.5, "cp") - .missing_observation(1.0, "cp") - .missing_observation(2.0, "cp") - .missing_observation(4.0, "cp") - .missing_observation(8.0, "cp") - .build(); - - let params = Parameters::with_model(¯o_ode, [("ke", 1.022), ("v", 194.0)]) - .expect("valid named parameters"); - let macro_predictions = macro_ode.estimate_predictions(&subject, ¶ms)?; - let handwritten_predictions = handwritten_ode.estimate_predictions(&subject, ¶ms)?; - - let macro_flat = macro_predictions.flat_predictions(); - let handwritten_flat = handwritten_predictions.flat_predictions(); - let diff = max_abs_diff(¯o_flat, &handwritten_flat); - - assert!( - diff <= 1e-10, - "macro and handwritten one-compartment predictions diverged: {diff:e}" - ); - - println!("one-compartment parity max abs diff: {diff:e}"); - for ((time, macro_pred), handwritten_pred) in macro_predictions - .flat_times() - .iter() - .zip(macro_flat.iter()) - .zip(handwritten_flat.iter()) - { - println!("t={time:>4.1} macro={macro_pred:>12.8} handwritten={handwritten_pred:>12.8}"); - } - - Ok(()) -} diff --git a/examples/macro_vs_handwritten_two_cpt.rs b/examples/macro_vs_handwritten_two_cpt.rs deleted file mode 100644 index da73eab3..00000000 --- a/examples/macro_vs_handwritten_two_cpt.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! Compares a declaration-first macro ODE with the equivalent handwritten ODE -//! on a two-compartment IV problem that shares one numeric input across -//! a loading bolus and a maintenance infusion. -//! -//! This keeps the macro story as the default surface while showing the -//! low-level API as an explicit advanced comparison path. - -use pharmsol::{prelude::*, Parameters}; - -fn macro_model() -> equation::ODE { - ode! { - name: "two_cpt_shared_input_parity", - params: [ke, kcp, kpc, v], - states: [central, peripheral], - outputs: [cp], - routes: [ - bolus(load) -> central, - infusion(iv) -> central, - ], - diffeq: |x, _p, _t, dx, _cov| { - dx[central] = -ke * x[central] - kcp * x[central] + kpc * x[peripheral]; - dx[peripheral] = kcp * x[central] - kpc * x[peripheral]; - }, - out: |x, _p, _t, _cov, y| { - y[cp] = x[central] / v; - }, - } -} - -fn handwritten_model() -> equation::ODE { - equation::ODE::new( - |x, p, _t, dx, bolus, rateiv, _cov| { - fetch_params!(p, ke, kcp, kpc, _v); - dx[0] = -ke * x[0] - kcp * x[0] + kpc * x[1] + rateiv[0] + bolus[0]; - dx[1] = kcp * x[0] - kpc * x[1]; - }, - |_p, _t, _cov| lag! {}, - |_p, _t, _cov| fa! {}, - |_p, _t, _cov, _x| {}, - |x, p, _t, _cov, y| { - fetch_params!(p, _ke, _kcp, _kpc, v); - y[0] = x[0] / v; - }, - ) - .with_nstates(2) - .with_ndrugs(1) - .with_nout(1) - .with_metadata( - equation::metadata::new("two_cpt_shared_input_parity") - .parameters(["ke", "kcp", "kpc", "v"]) - .states(["central", "peripheral"]) - .outputs(["cp"]) - .routes([ - equation::Route::bolus("load") - .to_state("central") - .inject_input_to_destination(), - equation::Route::infusion("iv") - .to_state("central") - .inject_input_to_destination(), - ]), - ) - .expect("handwritten two-compartment metadata should validate") -} - -fn max_abs_diff(left: &[f64], right: &[f64]) -> f64 { - left.iter() - .zip(right.iter()) - .map(|(lhs, rhs)| (lhs - rhs).abs()) - .fold(0.0_f64, f64::max) -} - -fn main() -> Result<(), pharmsol::PharmsolError> { - let macro_ode = macro_model(); - let handwritten_ode = handwritten_model(); - let macro_metadata = macro_ode.metadata().expect("macro metadata exists"); - - assert_eq!(macro_ode.metadata(), handwritten_ode.metadata()); - assert_eq!( - macro_metadata - .route("load") - .map(|route| route.input_index()), - macro_metadata.route("iv").map(|route| route.input_index()), - "load and iv should share one numeric input" - ); - assert!(macro_metadata.output("cp").is_some()); - - let subject = Subject::builder("macro-vs-handwritten-two-cpt") - .bolus(0.0, 100.0, "load") - .infusion(12.0, 200.0, "iv", 2.0) - .missing_observation(0.5, "cp") - .missing_observation(1.0, "cp") - .missing_observation(2.0, "cp") - .missing_observation(4.0, "cp") - .missing_observation(8.0, "cp") - .missing_observation(12.0, "cp") - .missing_observation(12.5, "cp") - .missing_observation(13.0, "cp") - .missing_observation(14.0, "cp") - .missing_observation(16.0, "cp") - .missing_observation(24.0, "cp") - .build(); - - let params = Parameters::with_model( - ¯o_ode, - [("ke", 0.1), ("kcp", 0.05), ("kpc", 0.03), ("v", 50.0)], - ) - .expect("valid named parameters"); - let macro_predictions = macro_ode.estimate_predictions(&subject, ¶ms)?; - let handwritten_predictions = handwritten_ode.estimate_predictions(&subject, ¶ms)?; - - let macro_flat = macro_predictions.flat_predictions(); - let handwritten_flat = handwritten_predictions.flat_predictions(); - let diff = max_abs_diff(¯o_flat, &handwritten_flat); - - assert!( - diff <= 1e-10, - "macro and handwritten two-compartment predictions diverged: {diff:e}" - ); - - println!("two-compartment parity max abs diff: {diff:e}"); - for ((time, macro_pred), handwritten_pred) in macro_predictions - .flat_times() - .iter() - .zip(macro_flat.iter()) - .zip(handwritten_flat.iter()) - { - println!("t={time:>5.1} macro={macro_pred:>12.8} handwritten={handwritten_pred:>12.8}"); - } - - Ok(()) -} diff --git a/examples/one_compartment.rs b/examples/one_compartment.rs index e1daa2c3..a4944a9a 100644 --- a/examples/one_compartment.rs +++ b/examples/one_compartment.rs @@ -1,8 +1,7 @@ //! Compares analytical and ODE solutions for a one-compartment IV model. //! -//! Both the `analytical!` and `ode!` macros define the same model. Predictions -//! and log-likelihoods are computed with each and printed side by side to -//! confirm they match. +//! Both the `analytical!` and `ode!` macros define the same model. Their +//! noiseless predictions are printed side by side to confirm that they match. fn main() -> Result<(), pharmsol::PharmsolError> { use pharmsol::{prelude::*, Parameters}; @@ -50,13 +49,6 @@ fn main() -> Result<(), pharmsol::PharmsolError> { .missing_observation(12.0, "cp") .build(); - // Define the assay error models once by label and reuse them across both - // equations. - let ems = AssayErrorModels::new().add( - "cp", - AssayErrorModel::additive(ErrorPoly::new(0.0, 0.05, 0.0, 0.0), 0.0), - )?; - // Define the parameter values for the simulations let ke = 1.022; // Elimination rate constant let v = 194.0; // Volume of distribution @@ -65,28 +57,14 @@ fn main() -> Result<(), pharmsol::PharmsolError> { let ode_parameters = Parameters::with_model(&ode, [("ke", ke), ("v", v)]).expect("valid named parameters"); - // Compute likelihoods and predictions for both models - let analytical_likelihoods = - analytical.estimate_log_likelihood(&subject, &analytical_parameters, &ems)?; - let analytical_predictions = analytical.estimate_predictions(&subject, &analytical_parameters)?; - - let ode_likelihoods = ode.estimate_log_likelihood(&subject, &ode_parameters, &ems)?; - let ode_predictions = ode.estimate_predictions(&subject, &ode_parameters)?; - // Print comparison table + // Structural prediction parity is a simulation-engine invariant. println!("\n┌───────────┬─────────────────┬─────────────────┬─────────────────────┐"); println!("│ │ Analytical │ ODE │ Difference │"); println!("├───────────┼─────────────────┼─────────────────┼─────────────────────┤"); - println!( - "│ Log-Likeli│ {:>15.6} │ {:>15.6} │ {:>19.2e} │", - analytical_likelihoods, - ode_likelihoods, - analytical_likelihoods - ode_likelihoods - ); - println!("├───────────┼─────────────────┼─────────────────┼─────────────────────┤"); println!("│ Time │ Prediction │ Prediction │ │"); println!("├───────────┼─────────────────┼─────────────────┼─────────────────────┤"); diff --git a/src/data/auc.rs b/src/data/auc.rs index fb72a492..9510fadc 100644 --- a/src/data/auc.rs +++ b/src/data/auc.rs @@ -1,7 +1,7 @@ //! Trapezoidal AUC/AUMC calculation on `&[f64]` slices. //! -//! Building blocks for [`ObservationProfile`](crate::data::observation::ObservationProfile) -//! and NCA analysis. Supports linear, lin-up/log-down, and lin-log methods. +//! Building blocks for internal `ObservationProfile` processing and NCA analysis. +//! Supports linear, lin-up/log-down, and lin-log methods. //! //! ```rust //! use pharmsol::data::auc::{auc, auc_interval, aumc, interpolate_linear}; diff --git a/src/data/builder.rs b/src/data/builder.rs index ed0a57a8..d879917b 100644 --- a/src/data/builder.rs +++ b/src/data/builder.rs @@ -199,15 +199,7 @@ impl SubjectBuilder { self.event(event) } - /// Add an observed value with an explicit assay error polynomial. - /// - /// # Arguments - /// - /// * `time` - Time of the observation - /// * `value` - Observed value (e.g., drug concentration) - /// * `outeq` - Public output label for this observation - /// * `errorpoly` - Error polynomial coefficients (c0, c1, c2, c3) - /// * `censored` - Censoring status for the observation value + /// Add an observed value with explicit C0-C3 data. pub fn observation_with_error( self, time: f64, @@ -269,35 +261,9 @@ impl SubjectBuilder { infusion.duration(), ), Event::Observation(observation) => { - if observation.value().is_some() { - if observation.errorpoly().is_some() { - self.observation_with_error( - observation.time() + delta * i as f64, - observation.value().unwrap(), - observation.outeq(), - observation.errorpoly().unwrap(), - observation.censoring(), - ) - } else if observation.censored() { - self.censored_observation( - observation.time() + delta * i as f64, - observation.value().unwrap(), - observation.outeq(), - observation.censoring(), - ) - } else { - self.observation( - observation.time() + delta * i as f64, - observation.value().unwrap(), - observation.outeq(), - ) - } - } else { - self.missing_observation( - observation.time() + delta * i as f64, - observation.outeq(), - ) - } + let mut repeated = observation.clone(); + repeated.set_time(observation.time() + delta * i as f64); + self.event(Event::Observation(repeated)) } }; } @@ -420,6 +386,32 @@ mod tests { assert_eq!(second_occasion.events().len(), 3); // 1 bolus + 2 observations } + #[test] + fn repeat_preserves_errorpoly_and_censoring() { + let errorpoly = ErrorPoly::new(0.1, 0.0, 0.0, 0.4); + let subject = Subject::builder("repeat_errorpoly") + .observation_with_error(1.0, 5.0, "cp", errorpoly, Censor::BLOQ) + .repeat(2, 1.0) + .build(); + + let observations: Vec<_> = subject.occasions()[0] + .events() + .iter() + .filter_map(|event| match event { + Event::Observation(observation) => Some(observation.clone()), + _ => None, + }) + .collect(); + + assert_eq!(observations.len(), 3); + for (index, observation) in observations.iter().enumerate() { + assert_eq!(observation.time(), 1.0 + index as f64); + assert_eq!(observation.value(), Some(5.0)); + assert_eq!(observation.errorpoly(), Some(errorpoly)); + assert_eq!(observation.censoring(), Censor::BLOQ); + } + } + #[test] fn test_infusion_and_repetition() { let subject = Subject::builder("patient_003") diff --git a/src/data/error_model.rs b/src/data/error_model.rs deleted file mode 100644 index 1fffca20..00000000 --- a/src/data/error_model.rs +++ /dev/null @@ -1,1967 +0,0 @@ -use std::{ - collections::BTreeMap, - hash::{Hash, Hasher}, - ops::Deref, - sync::Arc, -}; - -use crate::{data::event::OutputLabel, simulator::likelihood::Prediction}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -/// Parameter that can be either fixed or variable for estimation -/// -/// This enum allows specifying whether a factor parameter (like lambda or gamma) -/// should be fixed at a specific value or allowed to vary during estimation. -#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq)] -pub enum Factor { - /// Parameter can be estimated/varied during optimization - Variable(f64), - /// Parameter is fixed at this value and won't be estimated - Fixed(f64), -} - -impl Factor { - /// Get the current value of the parameter - pub fn value(&self) -> f64 { - match self { - Self::Variable(val) | Self::Fixed(val) => *val, - } - } - - /// Check if the parameter is fixed - pub fn is_fixed(&self) -> bool { - matches!(self, Self::Fixed(_)) - } - - /// Check if the parameter is variable (can be estimated) - pub fn is_variable(&self) -> bool { - matches!(self, Self::Variable(_)) - } - - /// Set the value while preserving the fixed/variable state - pub fn set_value(&mut self, new_value: f64) { - match self { - Self::Variable(val) => *val = new_value, - Self::Fixed(val) => *val = new_value, - } - } - - /// Convert the parameter to fixed at its current value - pub fn make_fixed(&mut self) { - if let Self::Variable(val) = self { - *self = Self::Fixed(*val); - } - } - - /// Convert the parameter to variable at its current value - pub fn make_variable(&mut self) { - if let Self::Fixed(val) = self { - *self = Self::Variable(*val); - } - } - - /// Replace the current factor with a new factor value - pub fn set_factor(&mut self, factor: &Factor) { - match factor { - Factor::Variable(val) => *self = Self::Variable(*val), - Factor::Fixed(val) => *self = Self::Fixed(*val), - } - } -} - -/// Error polynomial coefficients for the error model -/// -/// This struct holds the coefficients for a polynomial used to model -/// the error in pharmacometric analyses. It represents the error associated with quantification -/// of e.g. the drug concentration in a biological sample, such as blood or plasma. -/// More simply, it is the error associated with the observed value. -/// The polynomial is defined as: -/// -/// ```text -/// error = c0 + c1 * observation + c2 * observation^2 + c3 * observation^3 -/// ``` -/// -/// where `c0`, `c1`, `c2`, and `c3` are the coefficients of the polynomial. -#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq)] -pub struct ErrorPoly { - c0: f64, - c1: f64, - c2: f64, - c3: f64, -} - -impl ErrorPoly { - pub fn new(c0: f64, c1: f64, c2: f64, c3: f64) -> Self { - Self { c0, c1, c2, c3 } - } - - /// Get the coefficients of the error polynomial - pub fn coefficients(&self) -> (f64, f64, f64, f64) { - (self.c0, self.c1, self.c2, self.c3) - } - - pub fn c0(&self) -> f64 { - self.c0 - } - pub fn c1(&self) -> f64 { - self.c1 - } - pub fn c2(&self) -> f64 { - self.c2 - } - pub fn c3(&self) -> f64 { - self.c3 - } - - /// Set the coefficients of the error polynomial - pub fn set_coefficients(&mut self, c0: f64, c1: f64, c2: f64, c3: f64) { - self.c0 = c0; - self.c1 = c1; - self.c2 = c2; - self.c3 = c3; - } -} - -impl From> for AssayErrorModels { - fn from(models: Vec) -> Self { - Self { - models, - output_lookup: BTreeMap::new(), - named_models: BTreeMap::new(), - } - } -} - -/// Collection of assay/measurement error models for all outputs. -/// -/// This struct represents **measurement/assay noise** - the error associated with -/// quantification of drug concentration in biological samples. Sigma is computed -/// from the **observation** value. -/// -/// Used by non-parametric algorithms (NPAG, NPOD, etc.). -/// -/// For parametric algorithms (SAEM, FOCE), use [`crate::ResidualErrorModels`] instead, -/// which computes sigma from the **prediction**. -/// -/// This is a wrapper around a vector of [AssayErrorModel]s, its size is determined by -/// the number of outputs in the model/dataset. -#[derive(Serialize, Debug, Clone, Deserialize)] -pub struct AssayErrorModels { - models: Vec, - output_lookup: BTreeMap, - named_models: BTreeMap, -} - -/// Deprecated alias for [`AssayErrorModels`]. -/// -/// This type alias is provided for backward compatibility. -/// New code should use [`AssayErrorModels`] directly. -#[deprecated( - since = "0.23.0", - note = "Use AssayErrorModels instead. ErrorModels has been renamed to better reflect its purpose (assay/measurement error)." -)] -pub type ErrorModels = AssayErrorModels; - -#[doc(hidden)] -#[derive(Debug)] -pub enum BoundAssayErrorModels<'a> { - Borrowed(&'a AssayErrorModels), - Owned(AssayErrorModels), - Shared(Arc), -} - -impl Deref for BoundAssayErrorModels<'_> { - type Target = AssayErrorModels; - - fn deref(&self) -> &Self::Target { - match self { - Self::Borrowed(models) => models, - Self::Owned(models) => models, - Self::Shared(models) => models.as_ref(), - } - } -} - -impl Default for AssayErrorModels { - fn default() -> Self { - Self::new() - } -} - -impl AssayErrorModels { - /// Create a new reusable label-first [`AssayErrorModels`] definition. - /// - /// Output labels are resolved once per equation when the error models are - /// used through simulation or likelihood entrypoints. - /// - /// This lets the same public definition be reused safely across multiple - /// equations while keeping the dense bound representation internal to the - /// runtime path. - /// - /// ```rust - /// # use pharmsol::prelude::*; - /// let error_models = AssayErrorModels::new() - /// .add("cp", AssayErrorModel::additive(ErrorPoly::new(0.0, 0.05, 0.0, 0.0), 0.0))?; - /// # Ok::<(), pharmsol::data::error_model::ErrorModelError>(()) - /// ``` - pub fn new() -> Self { - Self::empty() - } - - pub(crate) fn assert_compatible_output_names( - &self, - outputs: I, - ) -> Result<(), ErrorModelError> - where - I: IntoIterator, - S: AsRef, - { - if self.output_lookup.is_empty() { - return Ok(()); - } - - let expected = self.bound_output_names(); - let found = outputs - .into_iter() - .map(|output| output.as_ref().to_string()) - .collect::>(); - if expected == found { - return Ok(()); - } - - Err(ErrorModelError::IncompatibleOutputContext { expected, found }) - } - - pub(crate) fn bind_to( - &self, - context: &impl crate::Equation, - ) -> Result, ErrorModelError> { - self.bind_output_names(context.assay_error_models().bound_output_names()) - } - - pub(crate) fn bind_output_names( - &self, - outputs: I, - ) -> Result, ErrorModelError> - where - I: IntoIterator, - S: AsRef, - { - let outputs = outputs - .into_iter() - .map(|output| output.as_ref().to_string()) - .collect::>(); - - if !self.output_lookup.is_empty() { - self.assert_compatible_output_names(outputs.iter().map(String::as_str))?; - return Ok(BoundAssayErrorModels::Borrowed(self)); - } - - if self.named_models.is_empty() { - return Ok(BoundAssayErrorModels::Borrowed(self)); - } - - let mut bound = Self::with_output_names(outputs.iter().map(String::as_str)); - bound.models = self.models.clone(); - - for (label, model) in &self.named_models { - bound = bound.add(label.clone(), model.clone())?; - } - - Ok(BoundAssayErrorModels::Owned(bound)) - } - - /// Create an unbound error-model set for dense-slot callers. - /// - /// This keeps the pre-existing numeric-slot setup path available for low-level - /// tests or workflows that deliberately operate on dense output indices. - pub(crate) fn empty() -> Self { - Self { - models: vec![], - output_lookup: BTreeMap::new(), - named_models: BTreeMap::new(), - } - } - - /// Create an error-model set with output labels resolved up front. - /// - /// This is the label-aware constructor for public workflows. It binds names - /// to dense output slots once during setup so that likelihood evaluation can - /// keep using direct vector indexing with no additional runtime lookup cost. - pub(crate) fn with_output_names(outputs: I) -> Self - where - I: IntoIterator, - S: AsRef, - { - let output_lookup = outputs - .into_iter() - .enumerate() - .map(|(index, output)| (OutputLabel::new(output.as_ref()), index)) - .collect(); - - Self { - models: vec![], - output_lookup, - named_models: BTreeMap::new(), - } - } - - fn bound_output_names(&self) -> Vec { - let mut names = self - .output_lookup - .iter() - .map(|(label, index)| (*index, label.to_string())) - .collect::>(); - names.sort_by_key(|(index, _)| *index); - names.into_iter().map(|(_, label)| label).collect() - } - - fn resolve_output_binding(&self, outeq: impl ToString) -> Result { - let label = OutputLabel::new(outeq); - self.output_lookup - .get(&label) - .copied() - .or_else(|| label.index()) - .ok_or_else(|| ErrorModelError::UnknownOutputLabel(label.to_string())) - } - - fn insert_model_at( - &mut self, - outeq: usize, - model: AssayErrorModel, - ) -> Result<(), ErrorModelError> { - if outeq >= self.models.len() { - self.models.resize(outeq + 1, AssayErrorModel::None); - } - if self.models[outeq] != AssayErrorModel::None { - return Err(ErrorModelError::ExistingOutputEquation(outeq)); - } - self.models[outeq] = model; - Ok(()) - } - - /// Get the error model for a specific output equation - /// - /// # Arguments - /// * `outeq` - The index of the output equation for which to retrieve the error model. - /// # Returns - /// A reference to the [AssayErrorModel] for the specified output equation. - /// # Errors - /// If the output equation index is invalid, an [ErrorModelError::InvalidOutputEquation] is returned. - pub fn error_model(&self, outeq: usize) -> Result<&AssayErrorModel, ErrorModelError> { - if outeq >= self.models.len() { - return Err(ErrorModelError::InvalidOutputEquation(outeq)); - } - Ok(&self.models[outeq]) - } - - /// Add a new error model for a specific output equation or declared label. - /// # Arguments - /// * `outeq` - The output slot index or public output label. - /// * `model` - The [AssayErrorModel] to add for the specified output equation. - /// # Returns - /// A new instance of AssayErrorModels with the added model. - /// # Errors - /// If the output label is unknown or if a model already exists for that output equation, an error is returned. - pub fn add( - mut self, - outeq: impl ToString, - model: AssayErrorModel, - ) -> Result { - let label = OutputLabel::new(outeq); - - if !self.output_lookup.is_empty() { - let outeq = self.resolve_output_binding(label.clone())?; - self.insert_model_at(outeq, model)?; - return Ok(self); - } - - if let Some(outeq) = label.index() { - self.insert_model_at(outeq, model)?; - return Ok(self); - } - - if self.named_models.contains_key(&label) { - return Err(ErrorModelError::ExistingOutputLabel(label.to_string())); - } - self.named_models.insert(label, model); - Ok(self) - } - /// Returns an iterator over the error models in the collection. - /// - /// # Returns - /// An iterator that yields tuples containing the index and a reference to each [AssayErrorModel]. - pub fn iter(&self) -> impl Iterator { - self.models.iter().enumerate() - } - - /// Returns a mutable iterator that yields mutable references to the error models in the collection. - /// # Returns - /// An iterator that yields tuples containing the index and a mutable reference to each [AssayErrorModel]. - pub fn iter_mut(&mut self) -> impl Iterator { - self.models.iter_mut().enumerate() - } - - /// Computes a hash for the error models collection. - /// This hash is based on the output equations and their associated error models. - /// # Returns - /// A `u64` hash value representing the error models collection. - pub fn hash(&self) -> u64 { - let mut hasher = ahash::AHasher::default(); - - for (label, model) in &self.named_models { - 3u8.hash(&mut hasher); - label.hash(&mut hasher); - - match model { - AssayErrorModel::Additive { lambda, .. } => { - 0u8.hash(&mut hasher); - lambda.value().to_bits().hash(&mut hasher); - lambda.is_fixed().hash(&mut hasher); - } - AssayErrorModel::Proportional { gamma, .. } => { - 1u8.hash(&mut hasher); - gamma.value().to_bits().hash(&mut hasher); - gamma.is_fixed().hash(&mut hasher); - } - AssayErrorModel::None => { - 2u8.hash(&mut hasher); - } - } - } - - for outeq in 0..self.models.len() { - // Find the model with the matching outeq ID - - let model = &self.models[outeq]; - outeq.hash(&mut hasher); - - match model { - AssayErrorModel::Additive { lambda, .. } => { - 0u8.hash(&mut hasher); // Use 0 for additive model - lambda.value().to_bits().hash(&mut hasher); - lambda.is_fixed().hash(&mut hasher); // Include fixed/variable state in hash - } - AssayErrorModel::Proportional { gamma, .. } => { - 1u8.hash(&mut hasher); // Use 1 for proportional model - gamma.value().to_bits().hash(&mut hasher); - gamma.is_fixed().hash(&mut hasher); // Include fixed/variable state in hash - } - AssayErrorModel::None => { - 2u8.hash(&mut hasher); // Use 2 for no model - } - } - } - - hasher.finish() - } - /// Returns the number of error models in the collection. - pub fn len(&self) -> usize { - if self.models.is_empty() && !self.named_models.is_empty() && self.output_lookup.is_empty() - { - return self.named_models.len(); - } - self.models.len() - } - - /// Returns whether the collection contains no error models. - pub fn is_empty(&self) -> bool { - self.models.is_empty() && self.named_models.is_empty() - } - - /// Returns the error polynomial associated with the specified output equation. - /// - /// # Arguments - /// - /// * `outeq` - The index of the output equation. - /// - /// # Returns - /// - /// The [`ErrorPoly`] for the given output equation. - pub fn errorpoly(&self, outeq: usize) -> Result { - if outeq >= self.models.len() { - return Err(ErrorModelError::InvalidOutputEquation(outeq)); - } - if self.models[outeq] == AssayErrorModel::None { - return Err(ErrorModelError::NoneErrorModel(outeq)); - } - self.models[outeq].errorpoly() - } - - /// Returns the factor value associated with the specified output equation. - /// - /// # Arguments - /// - /// * `outeq` - The index of the output equation. - /// - /// # Returns - /// - /// The factor value for the given output equation. - pub fn factor(&self, outeq: usize) -> Result { - if outeq >= self.models.len() { - return Err(ErrorModelError::InvalidOutputEquation(outeq)); - } - if self.models[outeq] == AssayErrorModel::None { - return Err(ErrorModelError::NoneErrorModel(outeq)); - } - self.models[outeq].factor() - } - - /// Sets the error polynomial for the specified output equation. - /// - /// # Arguments - /// - /// * `outeq` - The index of the output equation. - /// * `poly` - The new [`ErrorPoly`] to set. - pub fn set_errorpoly(&mut self, outeq: usize, poly: ErrorPoly) -> Result<(), ErrorModelError> { - if outeq >= self.models.len() { - return Err(ErrorModelError::InvalidOutputEquation(outeq)); - } - if self.models[outeq] == AssayErrorModel::None { - return Err(ErrorModelError::NoneErrorModel(outeq)); - } - self.models[outeq].set_errorpoly(poly); - Ok(()) - } - - /// Sets the factor value for the specified output equation. - /// - /// # Arguments - /// - /// * `outeq` - The index of the output equation. - /// * `factor` - The new factor value to set. - pub fn set_factor(&mut self, outeq: usize, factor: f64) -> Result<(), ErrorModelError> { - if outeq >= self.models.len() { - return Err(ErrorModelError::InvalidOutputEquation(outeq)); - } - if self.models[outeq] == AssayErrorModel::None { - return Err(ErrorModelError::NoneErrorModel(outeq)); - } - self.models[outeq].set_factor(factor); - Ok(()) - } - - /// Gets the factor parameter (including fixed/variable state) for the specified output equation. - /// - /// # Arguments - /// - /// * `outeq` - The index of the output equation. - /// - /// # Returns - /// - /// The [`Factor`] for the given output equation. - pub fn factor_param(&self, outeq: usize) -> Result { - if outeq >= self.models.len() { - return Err(ErrorModelError::InvalidOutputEquation(outeq)); - } - if self.models[outeq] == AssayErrorModel::None { - return Err(ErrorModelError::NoneErrorModel(outeq)); - } - self.models[outeq].factor_param() - } - - /// Sets the factor parameter (including fixed/variable state) for the specified output equation. - /// - /// # Arguments - /// - /// * `outeq` - The index of the output equation. - /// * `param` - The new [`Factor`] to set. - pub fn set_factor_param(&mut self, outeq: usize, param: Factor) -> Result<(), ErrorModelError> { - if outeq >= self.models.len() { - return Err(ErrorModelError::InvalidOutputEquation(outeq)); - } - if self.models[outeq] == AssayErrorModel::None { - return Err(ErrorModelError::NoneErrorModel(outeq)); - } - self.models[outeq].set_factor_param(param); - Ok(()) - } - - /// Checks if the factor parameter is fixed for the specified output equation. - /// - /// # Arguments - /// - /// * `outeq` - The index of the output equation. - /// - /// # Returns - /// - /// `true` if the factor parameter is fixed, `false` if it's variable. - pub fn is_factor_fixed(&self, outeq: usize) -> Result { - if outeq >= self.models.len() { - return Err(ErrorModelError::InvalidOutputEquation(outeq)); - } - if self.models[outeq] == AssayErrorModel::None { - return Err(ErrorModelError::NoneErrorModel(outeq)); - } - self.models[outeq].is_factor_fixed() - } - - /// Makes the factor parameter fixed at its current value for the specified output equation. - /// - /// # Arguments - /// - /// * `outeq` - The index of the output equation. - pub fn fix_factor(&mut self, outeq: usize) -> Result<(), ErrorModelError> { - if outeq >= self.models.len() { - return Err(ErrorModelError::InvalidOutputEquation(outeq)); - } - if self.models[outeq] == AssayErrorModel::None { - return Err(ErrorModelError::NoneErrorModel(outeq)); - } - self.models[outeq].fix_factor(); - Ok(()) - } - - /// Makes the factor parameter variable at its current value for the specified output equation. - /// - /// # Arguments - /// - /// * `outeq` - The index of the output equation. - pub fn unfix_factor(&mut self, outeq: usize) -> Result<(), ErrorModelError> { - if outeq >= self.models.len() { - return Err(ErrorModelError::InvalidOutputEquation(outeq)); - } - if self.models[outeq] == AssayErrorModel::None { - return Err(ErrorModelError::NoneErrorModel(outeq)); - } - self.models[outeq].unfix_factor(); - Ok(()) - } - - /// Check if the error model for a specific output equation is proportional - /// - /// # Arguments - /// - /// * `outeq` - The index of the output equation - /// - /// # Returns - /// - /// `true` if the error model for `outeq` is proportional, `false` otherwise - pub fn is_proportional(&self, outeq: usize) -> bool { - if outeq >= self.models.len() { - return false; - } - self.models[outeq].is_proportional() - } - - /// Check if the error model for a specific output equation is additive - /// - /// # Arguments - /// - /// * `outeq` - The index of the output equation - /// - /// # Returns - /// - /// `true` if the error model for `outeq` is additive, `false` otherwise - pub fn is_additive(&self, outeq: usize) -> bool { - if outeq >= self.models.len() { - return false; - } - self.models[outeq].is_additive() - } - - /// Computes the standard deviation (sigma) for the specified output equation and prediction. - /// - /// This always uses the **observation** value to compute sigma, which is appropriate - /// for non-parametric algorithms (NPAG, NPOD). For parametric algorithms (SAEM, FOCE), - /// use [`crate::ResidualErrorModels`] instead, which computes sigma from the prediction. - /// - /// # Arguments - /// - /// * `prediction` - The [`Prediction`] to use for the calculation. - /// - /// # Returns - /// - /// A [`Result`] containing the computed sigma value or an [`ErrorModelError`] if the calculation fails. - pub fn sigma(&self, prediction: &Prediction) -> Result { - let outeq = prediction.outeq; - if outeq >= self.models.len() { - return Err(ErrorModelError::InvalidOutputEquation(outeq)); - } - if self.models[outeq] == AssayErrorModel::None { - return Err(ErrorModelError::NoneErrorModel(outeq)); - } - self.models[prediction.outeq].sigma(prediction) - } - - /// Computes the variance for the specified output equation and prediction. - /// - /// # Arguments - /// - /// * `outeq` - The index of the output equation. - /// * `prediction` - The [`Prediction`] to use for the calculation. - /// - /// # Returns - /// - /// A [`Result`] containing the computed variance or an [`ErrorModelError`] if the calculation fails. - pub fn variance(&self, prediction: &Prediction) -> Result { - let outeq = prediction.outeq; - if outeq >= self.models.len() { - return Err(ErrorModelError::InvalidOutputEquation(outeq)); - } - if self.models[outeq] == AssayErrorModel::None { - return Err(ErrorModelError::NoneErrorModel(outeq)); - } - self.models[prediction.outeq].variance(prediction) - } - - /// Computes the standard deviation (sigma) for the specified output equation and value. - /// - /// # Arguments - /// - /// * `outeq` - The index of the output equation. - /// * `value` - The value to use for the calculation. - /// - /// # Returns - /// - /// A [`Result`] containing the computed sigma value or an [`ErrorModelError`] if the calculation fails. - pub fn sigma_from_value(&self, outeq: usize, value: f64) -> Result { - if outeq >= self.models.len() { - return Err(ErrorModelError::InvalidOutputEquation(outeq)); - } - if self.models[outeq] == AssayErrorModel::None { - return Err(ErrorModelError::NoneErrorModel(outeq)); - } - self.models[outeq].sigma_from_value(value) - } - - /// Computes the variance for the specified output equation and value. - /// - /// # Arguments - /// - /// * `outeq` - The index of the output equation. - /// * `value` - The value to use for the calculation. - /// - /// # Returns - /// - /// A [`Result`] containing the computed variance or an [`ErrorModelError`] if the calculation fails. - pub fn variance_from_value(&self, outeq: usize, value: f64) -> Result { - if outeq >= self.models.len() { - return Err(ErrorModelError::InvalidOutputEquation(outeq)); - } - if self.models[outeq] == AssayErrorModel::None { - return Err(ErrorModelError::NoneErrorModel(outeq)); - } - self.models[outeq].variance_from_value(value) - } -} - -impl IntoIterator for AssayErrorModels { - type Item = (usize, AssayErrorModel); - type IntoIter = std::vec::IntoIter; - - fn into_iter(self) -> Self::IntoIter { - self.models - .into_iter() - .enumerate() - .collect::>() - .into_iter() - } -} - -impl<'a> IntoIterator for &'a AssayErrorModels { - type Item = (usize, &'a AssayErrorModel); - type IntoIter = std::iter::Enumerate>; - - fn into_iter(self) -> Self::IntoIter { - self.models.iter().enumerate() - } -} - -impl<'a> IntoIterator for &'a mut AssayErrorModels { - type Item = (usize, &'a mut AssayErrorModel); - type IntoIter = std::iter::Enumerate>; - - fn into_iter(self) -> Self::IntoIter { - self.models.iter_mut().enumerate() - } -} - -/// Model for calculating observation errors in pharmacometric analyses -/// -/// An [AssayErrorModel] defines how the standard deviation of observations is calculated -/// based on the type of error model used and its parameters. -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] -pub enum AssayErrorModel { - /// Additive error model, where error is independent of concentration - /// - /// Contains: - /// * `lambda` - Lambda parameter for scaling errors (can be fixed or variable) - /// * `poly` - Error polynomial coefficients (c0, c1, c2, c3) - Additive { - /// Lambda parameter for scaling errors (can be fixed or variable) - lambda: Factor, - /// Error polynomial coefficients (c0, c1, c2, c3) - poly: ErrorPoly, - }, - - /// Proportional error model, where error scales with concentration - /// - /// Contains: - /// * `gamma` - Gamma parameter for scaling errors (can be fixed or variable) - /// * `poly` - Error polynomial coefficients (c0, c1, c2, c3) - Proportional { - /// Gamma parameter for scaling errors (can be fixed or variable) - gamma: Factor, - /// Error polynomial coefficients (c0, c1, c2, c3) - poly: ErrorPoly, - }, - #[default] - None, -} - -/// Deprecated alias for [`AssayErrorModel`]. -/// -/// This type alias is provided for backward compatibility. -/// New code should use [`AssayErrorModel`] directly. -#[deprecated( - since = "0.23.0", - note = "Use AssayErrorModel instead. ErrorModel has been renamed to better reflect its purpose (assay/measurement error)." -)] -pub type ErrorModel = AssayErrorModel; - -impl AssayErrorModel { - /// Create a new additive error model with a variable lambda parameter - /// - /// # Arguments - /// - /// * `poly` - Error polynomial coefficients (c0, c1, c2, c3) - /// * `lambda` - Lambda parameter for scaling errors (will be variable) - /// - /// # Returns - /// - /// A new additive error model - pub fn additive(poly: ErrorPoly, lambda: f64) -> Self { - Self::Additive { - lambda: Factor::Variable(lambda), - poly, - } - } - - /// Create a new additive error model with a fixed lambda parameter - /// - /// # Arguments - /// - /// * `poly` - Error polynomial coefficients (c0, c1, c2, c3) - /// * `lambda` - Lambda parameter for scaling errors (will be fixed) - /// - /// # Returns - /// - /// A new additive error model with fixed lambda - pub fn additive_fixed(poly: ErrorPoly, lambda: f64) -> Self { - Self::Additive { - lambda: Factor::Fixed(lambda), - poly, - } - } - - /// Create a new additive error model with a specified Factor for lambda - /// - /// # Arguments - /// - /// * `poly` - Error polynomial coefficients (c0, c1, c2, c3) - /// * `lambda` - Lambda parameter (can be Variable or Fixed) using [Factor] - /// - /// # Returns - /// - /// A new additive error model - pub fn additive_with_param(poly: ErrorPoly, lambda: Factor) -> Self { - Self::Additive { lambda, poly } - } - - /// Create a new proportional error model with a variable gamma parameter - /// - /// # Arguments - /// - /// * `poly` - Error polynomial coefficients (c0, c1, c2, c3) - /// * `gamma` - Gamma parameter for scaling errors (will be variable) - /// - /// # Returns - /// - /// A new proportional error model - pub fn proportional(poly: ErrorPoly, gamma: f64) -> Self { - Self::Proportional { - gamma: Factor::Variable(gamma), - poly, - } - } - - /// Create a new proportional error model with a fixed gamma parameter - /// - /// # Arguments - /// - /// * `poly` - Error polynomial coefficients (c0, c1, c2, c3) - /// * `gamma` - Gamma parameter for scaling errors (will be fixed) - /// - /// # Returns - /// - /// A new proportional error model with fixed gamma - pub fn proportional_fixed(poly: ErrorPoly, gamma: f64) -> Self { - Self::Proportional { - gamma: Factor::Fixed(gamma), - poly, - } - } - - /// Create a new proportional error model with a specified Factor for gamma - /// - /// # Arguments - /// - /// * `poly` - Error polynomial coefficients (c0, c1, c2, c3) - /// * `gamma` - Gamma parameter (can be Variable or Fixed) using [Factor] - /// - /// # Returns - /// - /// A new proportional error model - pub fn proportional_with_param(poly: ErrorPoly, gamma: Factor) -> Self { - Self::Proportional { gamma, poly } - } - - /// Get the error polynomial coefficients - /// - /// # Returns - /// - /// The error polynomial coefficients (c0, c1, c2, c3) - pub fn errorpoly(&self) -> Result { - match self { - Self::Additive { poly, .. } => Ok(*poly), - Self::Proportional { poly, .. } => Ok(*poly), - Self::None => Err(ErrorModelError::MissingErrorModel), - } - } - - /// Set the error polynomial coefficients - /// - /// # Arguments - /// - /// * `poly` - New error polynomial coefficients (c0, c1, c2, c3) - /// - /// # Returns - /// - /// The updated error model with the new polynomial coefficients - pub fn set_errorpoly(&mut self, poly: ErrorPoly) { - match self { - Self::Additive { poly: p, .. } => *p = poly, - Self::Proportional { poly: p, .. } => *p = poly, - Self::None => {} - } - } - - /// Get the scaling parameter value - pub fn factor(&self) -> Result { - match self { - Self::Additive { lambda, .. } => Ok(lambda.value()), - Self::Proportional { gamma, .. } => Ok(gamma.value()), - Self::None => Err(ErrorModelError::MissingErrorModel), - } - } - - /// Set the scaling parameter value (preserves fixed/variable state) - pub fn set_factor(&mut self, factor: f64) { - match self { - Self::Additive { lambda, .. } => lambda.set_value(factor), - Self::Proportional { gamma, .. } => gamma.set_value(factor), - Self::None => {} - } - } - - /// Get the scaling parameter (including its fixed/variable state) - pub fn factor_param(&self) -> Result { - match self { - Self::Additive { lambda, .. } => Ok(*lambda), - Self::Proportional { gamma, .. } => Ok(*gamma), - Self::None => Err(ErrorModelError::MissingErrorModel), - } - } - - /// Set the scaling parameter (including its fixed/variable state) - pub fn set_factor_param(&mut self, param: Factor) { - match self { - Self::Additive { lambda, .. } => *lambda = param, - Self::Proportional { gamma, .. } => *gamma = param, - Self::None => {} - } - } - - /// Check if the scaling parameter is fixed - pub fn is_factor_fixed(&self) -> Result { - match self { - Self::Additive { lambda, .. } => Ok(lambda.is_fixed()), - Self::Proportional { gamma, .. } => Ok(gamma.is_fixed()), - Self::None => Err(ErrorModelError::MissingErrorModel), - } - } - - /// Make the scaling parameter fixed at its current value - pub fn fix_factor(&mut self) { - match self { - Self::Additive { lambda, .. } => lambda.make_fixed(), - Self::Proportional { gamma, .. } => gamma.make_fixed(), - Self::None => {} - } - } - - /// Make the scaling parameter variable at its current value - pub fn unfix_factor(&mut self) { - match self { - Self::Additive { lambda, .. } => lambda.make_variable(), - Self::Proportional { gamma, .. } => gamma.make_variable(), - Self::None => {} - } - } - - /// Check if this is a proportional error model - /// - /// # Returns - /// - /// `true` if this is a `Proportional` variant, `false` otherwise - pub fn is_proportional(&self) -> bool { - matches!(self, Self::Proportional { .. }) - } - - /// Check if this is an additive error model - /// - /// # Returns - /// - /// `true` if this is an `Additive` variant, `false` otherwise - pub fn is_additive(&self) -> bool { - matches!(self, Self::Additive { .. }) - } - - /// Estimate the standard deviation for a prediction - /// - /// Calculates the standard deviation based on the error model type, - /// using either observation-specific error polynomial coefficients or - /// the model's default coefficients. - /// - /// # Arguments - /// - /// * `prediction` - The prediction for which to estimate the standard deviation - /// - /// # Returns - /// - /// The estimated standard deviation of the prediction - pub fn sigma(&self, prediction: &Prediction) -> Result { - if prediction.observation.is_none() { - return Err(ErrorModelError::MissingObservation); - } - - // Get appropriate polynomial coefficients from prediction or default - let errorpoly = match prediction.errorpoly() { - Some(poly) => poly, - None => self.errorpoly()?, - }; - - let (c0, c1, c2, c3) = (errorpoly.c0, errorpoly.c1, errorpoly.c2, errorpoly.c3); - - // Calculate alpha term - let alpha = c0 - + c1 * prediction.observation().unwrap() - + c2 * prediction.observation().unwrap().powi(2) - + c3 * prediction.observation().unwrap().powi(3); - - // Calculate standard deviation based on error model type - let sigma = match self { - Self::Additive { lambda, .. } => (alpha.powi(2) + lambda.value().powi(2)).sqrt(), - Self::Proportional { gamma, .. } => gamma.value() * alpha, - Self::None => { - return Err(ErrorModelError::MissingErrorModel); - } - }; - - if sigma < 0.0 { - Err(ErrorModelError::NegativeSigma) - } else if !sigma.is_finite() { - Err(ErrorModelError::NonFiniteSigma) - } else { - Ok(sigma) - } - } - - /// Estimate the variance of the observation - /// - /// This is a convenience function which calls [AssayErrorModel::sigma], and squares the result. - pub fn variance(&self, prediction: &Prediction) -> Result { - let sigma = self.sigma(prediction)?; - Ok(sigma.powi(2)) - } - - /// Estimate the standard deviation for a raw observation value - /// - /// Calculates the standard deviation based on the error model type, - /// using the model's default coefficients and a provided observation value. - /// - /// # Arguments - /// - /// * `value` - The observation value for which to estimate the standard deviation - /// - /// # Returns - /// - /// The estimated standard deviation for the given value - pub fn sigma_from_value(&self, value: f64) -> Result { - // Get polynomial coefficients from the model - let (c0, c1, c2, c3) = self.errorpoly()?.coefficients(); - - // Calculate alpha term - let alpha = c0 + c1 * value + c2 * value.powi(2) + c3 * value.powi(3); - - // Calculate standard deviation based on error model type - let sigma = match self { - Self::Additive { lambda, .. } => (alpha.powi(2) + lambda.value().powi(2)).sqrt(), - Self::Proportional { gamma, .. } => gamma.value() * alpha, - Self::None => { - return Err(ErrorModelError::MissingErrorModel); - } - }; - - if sigma < 0.0 { - Err(ErrorModelError::NegativeSigma) - } else if !sigma.is_finite() { - Err(ErrorModelError::NonFiniteSigma) - } else if sigma == 0.0 { - Err(ErrorModelError::ZeroSigma) - } else { - Ok(sigma) - } - } - - /// Estimate the variance for a raw observation value - /// - /// This is a convenience function which calls [AssayErrorModel::sigma_from_value], and squares the result. - pub fn variance_from_value(&self, value: f64) -> Result { - let sigma = self.sigma_from_value(value)?; - Ok(sigma.powi(2)) - } - - /// Get a boolean indicating if the error model should be optimized - /// - /// In other words, if the error model is not None, and the [Factor] is variable, it should be optimized. - pub fn optimize(&self) -> bool { - match self { - Self::Additive { lambda, .. } => lambda.is_variable(), - Self::Proportional { gamma, .. } => gamma.is_variable(), - Self::None => false, - } - } -} - -#[derive(Error, Debug, Clone)] -pub enum ErrorModelError { - #[error("The computed standard deviation is negative")] - NegativeSigma, - #[error("The computed standard deviation is zero")] - ZeroSigma, - #[error("The computed standard deviation is non-finite")] - NonFiniteSigma, - #[error("The output equation index {0} is invalid")] - InvalidOutputEquation(usize), - #[error("The output label `{0}` is not declared in this error model context")] - UnknownOutputLabel(String), - #[error("The output label `{0}` already exists in this assay error model specification")] - ExistingOutputLabel(String), - #[error("The output equation number {0} already exists")] - ExistingOutputEquation(usize), - #[error( - "Assay error models were bound for outputs {expected:?} but used with outputs {found:?}" - )] - IncompatibleOutputContext { - expected: Vec, - found: Vec, - }, - #[error("An output equation does not have an error model defined")] - MissingErrorModel, - #[error("The output equation index {0} is of type ErrorModel::None")] - NoneErrorModel(usize), - #[error("The prediction does not have an observation associated with it")] - MissingObservation, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{Censor, Observation}; - - #[test] - fn test_additive_error_model() { - let observation = Observation::new(0.0, Some(20.0), 0, None, 0, Censor::None); - let prediction = observation.to_prediction(10.0, vec![]); - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - assert_eq!(model.sigma(&prediction).unwrap(), (26.0_f64).sqrt()); - } - - #[test] - fn test_proportional_error_model() { - let observation = Observation::new(0.0, Some(20.0), 0, None, 0, Censor::None); - let prediction = observation.to_prediction(10.0, vec![]); - let model = AssayErrorModel::proportional(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 2.0); - assert_eq!(model.sigma(&prediction).unwrap(), 2.0); - } - - #[test] - fn test_polynomial() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 2.0, 3.0, 4.0), 5.0); - assert_eq!( - model.errorpoly().unwrap().coefficients(), - (1.0, 2.0, 3.0, 4.0) - ); - } - - #[test] - fn test_set_errorpoly() { - let mut model = AssayErrorModel::additive(ErrorPoly::new(1.0, 2.0, 3.0, 4.0), 5.0); - assert_eq!( - model.errorpoly().unwrap().coefficients(), - (1.0, 2.0, 3.0, 4.0) - ); - model.set_errorpoly(ErrorPoly::new(5.0, 6.0, 7.0, 8.0)); - assert_eq!( - model.errorpoly().unwrap().coefficients(), - (5.0, 6.0, 7.0, 8.0) - ); - } - - #[test] - fn test_set_factor() { - let mut model = AssayErrorModel::additive(ErrorPoly::new(1.0, 2.0, 3.0, 4.0), 5.0); - assert_eq!(model.factor().unwrap(), 5.0); - model.set_factor(10.0); - assert_eq!(model.factor().unwrap(), 10.0); - } - - #[test] - fn test_sigma_from_value() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - assert_eq!(model.sigma_from_value(20.0).unwrap(), (26.0_f64).sqrt()); - - let model = AssayErrorModel::proportional(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 2.0); - assert_eq!(model.sigma_from_value(20.0).unwrap(), 2.0); - } - - #[test] - fn test_error_models_new() { - let models = AssayErrorModels::new(); - assert_eq!(models.len(), 0); - } - - #[test] - fn test_error_models_default() { - let models = AssayErrorModels::default(); - assert_eq!(models.len(), 0); - } - - #[test] - fn test_error_models_add_single() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); - assert_eq!(models.len(), 1); - } - - #[test] - fn test_error_models_add_multiple() { - let model1 = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let model2 = AssayErrorModel::proportional(ErrorPoly::new(2.0, 0.0, 0.0, 0.0), 3.0); - - let models = AssayErrorModels::empty() - .add(0, model1) - .unwrap() - .add(1, model2) - .unwrap(); - - assert_eq!(models.len(), 2); - } - - #[test] - fn test_error_models_add_label_with_output_names() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::with_output_names(["cp", "effect"]) - .add("effect", model) - .unwrap(); - - assert_eq!(models.len(), 2); - assert!(models.error_model(1).is_ok()); - } - - #[test] - fn test_error_models_bind_output_names() { - let error_models = AssayErrorModels::new() - .add( - "effect", - AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0), - ) - .unwrap(); - - let models = error_models.bind_output_names(["cp", "effect"]).unwrap(); - assert_eq!(models.len(), 2); - assert!(models.error_model(1).is_ok()); - } - - #[test] - fn test_error_models_add_unknown_label_fails() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let result = AssayErrorModels::with_output_names(["cp"]).add("effect", model); - - assert!(result.is_err()); - match result { - Err(ErrorModelError::UnknownOutputLabel(label)) => assert_eq!(label, "effect"), - _ => panic!("Expected UnknownOutputLabel error"), - } - } - - #[test] - fn test_error_models_duplicate_label_fails() { - let result = AssayErrorModels::new() - .add( - "cp", - AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0), - ) - .unwrap() - .add( - "cp", - AssayErrorModel::proportional(ErrorPoly::new(2.0, 0.0, 0.0, 0.0), 3.0), - ); - - match result { - Err(ErrorModelError::ExistingOutputLabel(label)) => assert_eq!(label, "cp"), - _ => panic!("Expected ExistingOutputLabel error"), - } - } - - #[test] - fn test_bound_error_models_reject_mismatched_output_context() { - let error_models = AssayErrorModels::new() - .add( - "cp", - AssayErrorModel::additive(ErrorPoly::new(0.0, 0.05, 0.0, 0.0), 0.0), - ) - .unwrap(); - let error_models = error_models.bind_output_names(["cp", "effect"]).unwrap(); - - match error_models.assert_compatible_output_names(["effect", "cp"]) { - Err(ErrorModelError::IncompatibleOutputContext { expected, found }) => { - assert_eq!(expected, vec!["cp".to_string(), "effect".to_string()]); - assert_eq!(found, vec!["effect".to_string(), "cp".to_string()]); - } - _ => panic!("Expected IncompatibleOutputContext error"), - } - } - - #[test] - fn test_error_models_sigma_from_label_bound_output() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::with_output_names(["cp"]) - .add("cp", model) - .unwrap(); - - let observation = Observation::new(0.0, Some(20.0), 0, None, 0, Censor::None); - let prediction = observation.to_prediction(10.0, vec![]); - - assert_eq!(models.sigma(&prediction).unwrap(), (26.0_f64).sqrt()); - } - - #[test] - fn test_error_models_add_duplicate_outeq_fails() { - let model1 = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let model2 = AssayErrorModel::proportional(ErrorPoly::new(2.0, 0.0, 0.0, 0.0), 3.0); - - let result = AssayErrorModels::empty() - .add(0, model1) - .unwrap() - .add(0, model2); // Same outeq should fail - - assert!(result.is_err()); - match result { - Err(ErrorModelError::ExistingOutputEquation(outeq)) => assert_eq!(outeq, 0), - _ => panic!("Expected ExistingOutputEquation error"), - } - } - - #[test] - fn test_error_models_factor() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); - - assert_eq!(models.factor(0).unwrap(), 5.0); - } - - #[test] - fn test_error_models_factor_invalid_outeq() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); - - let result = models.factor(1); - assert!(result.is_err()); - match result { - Err(ErrorModelError::InvalidOutputEquation(outeq)) => assert_eq!(outeq, 1), - _ => panic!("Expected InvalidOutputEquation error"), - } - } - - #[test] - fn test_error_models_set_factor() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let mut models = AssayErrorModels::empty().add(0, model).unwrap(); - - assert_eq!(models.factor(0).unwrap(), 5.0); - models.set_factor(0, 10.0).unwrap(); - assert_eq!(models.factor(0).unwrap(), 10.0); - } - - #[test] - fn test_error_models_set_factor_invalid_outeq() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let mut models = AssayErrorModels::empty().add(0, model).unwrap(); - - let result = models.set_factor(1, 10.0); - assert!(result.is_err()); - match result { - Err(ErrorModelError::InvalidOutputEquation(outeq)) => assert_eq!(outeq, 1), - _ => panic!("Expected InvalidOutputEquation error"), - } - } - - #[test] - fn test_error_models_errorpoly() { - let poly = ErrorPoly::new(1.0, 2.0, 3.0, 4.0); - let model = AssayErrorModel::additive(poly, 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); - - let retrieved_poly = models.errorpoly(0).unwrap(); - assert_eq!(retrieved_poly.coefficients(), (1.0, 2.0, 3.0, 4.0)); - } - - #[test] - fn test_error_models_errorpoly_invalid_outeq() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); - - let result = models.errorpoly(1); - assert!(result.is_err()); - match result { - Err(ErrorModelError::InvalidOutputEquation(outeq)) => assert_eq!(outeq, 1), - _ => panic!("Expected InvalidOutputEquation error"), - } - } - - #[test] - fn test_error_models_set_errorpoly() { - let poly1 = ErrorPoly::new(1.0, 2.0, 3.0, 4.0); - let poly2 = ErrorPoly::new(5.0, 6.0, 7.0, 8.0); - let model = AssayErrorModel::additive(poly1, 5.0); - let mut models = AssayErrorModels::empty().add(0, model).unwrap(); - - assert_eq!( - models.errorpoly(0).unwrap().coefficients(), - (1.0, 2.0, 3.0, 4.0) - ); - models.set_errorpoly(0, poly2).unwrap(); - assert_eq!( - models.errorpoly(0).unwrap().coefficients(), - (5.0, 6.0, 7.0, 8.0) - ); - } - - #[test] - fn test_error_models_set_errorpoly_invalid_outeq() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let mut models = AssayErrorModels::empty().add(0, model).unwrap(); - - let result = models.set_errorpoly(1, ErrorPoly::new(5.0, 6.0, 7.0, 8.0)); - assert!(result.is_err()); - match result { - Err(ErrorModelError::InvalidOutputEquation(outeq)) => assert_eq!(outeq, 1), - _ => panic!("Expected InvalidOutputEquation error"), - } - } - - #[test] - fn test_error_models_sigma() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); - - let observation = Observation::new(0.0, Some(20.0), 0, None, 0, Censor::None); - let prediction = observation.to_prediction(10.0, vec![]); - - // Non-parametric: sigma from observation - let sigma = models.sigma(&prediction).unwrap(); - assert_eq!(sigma, (26.0_f64).sqrt()); - } - - #[test] - fn test_error_models_sigma_invalid_outeq() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); - - let observation = Observation::new(0.0, Some(20.0), 1, None, 0, Censor::None); // outeq=1 not in models - let prediction = observation.to_prediction(10.0, vec![]); - - let result = models.sigma(&prediction); - assert!(result.is_err()); - match result { - Err(ErrorModelError::InvalidOutputEquation(outeq)) => assert_eq!(outeq, 1), - _ => panic!("Expected InvalidOutputEquation error"), - } - } - - #[test] - fn test_error_models_variance() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); - - let observation = Observation::new(0.0, Some(20.0), 0, None, 0, Censor::None); - let prediction = observation.to_prediction(10.0, vec![]); - - let variance = models.variance(&prediction).unwrap(); - let expected_sigma = (26.0_f64).sqrt(); - assert_eq!(variance, expected_sigma.powi(2)); - } - - #[test] - fn test_error_models_variance_invalid_outeq() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); - - let observation = Observation::new(0.0, Some(20.0), 1, None, 0, Censor::None); // outeq=1 not in models - let prediction = observation.to_prediction(10.0, vec![]); - - let result = models.variance(&prediction); - assert!(result.is_err()); - match result { - Err(ErrorModelError::InvalidOutputEquation(outeq)) => assert_eq!(outeq, 1), - _ => panic!("Expected InvalidOutputEquation error"), - } - } - - #[test] - fn test_error_models_sigma_from_value() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); - - let sigma = models.sigma_from_value(0, 20.0).unwrap(); - assert_eq!(sigma, (26.0_f64).sqrt()); - } - - #[test] - fn test_error_models_sigma_from_value_invalid_outeq() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); - - let result = models.sigma_from_value(1, 20.0); - assert!(result.is_err()); - match result { - Err(ErrorModelError::InvalidOutputEquation(outeq)) => assert_eq!(outeq, 1), - _ => panic!("Expected InvalidOutputEquation error"), - } - } - - #[test] - fn test_error_models_variance_from_value() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); - - let variance = models.variance_from_value(0, 20.0).unwrap(); - let expected_sigma = (26.0_f64).sqrt(); - assert_eq!(variance, expected_sigma.powi(2)); - } - - #[test] - fn test_error_models_variance_from_value_invalid_outeq() { - let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); - - let result = models.variance_from_value(1, 20.0); - assert!(result.is_err()); - match result { - Err(ErrorModelError::InvalidOutputEquation(outeq)) => assert_eq!(outeq, 1), - _ => panic!("Expected InvalidOutputEquation error"), - } - } - - #[test] - fn test_error_models_hash_consistency() { - let model1 = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let model2 = AssayErrorModel::proportional(ErrorPoly::new(2.0, 0.0, 0.0, 0.0), 3.0); - - let models1 = AssayErrorModels::empty() - .add(0, model1.clone()) - .unwrap() - .add(1, model2.clone()) - .unwrap(); - - let models2 = AssayErrorModels::empty() - .add(0, model1) - .unwrap() - .add(1, model2) - .unwrap(); - - // Same models should produce same hash - assert_eq!(models1.hash(), models2.hash()); - } - - #[test] - fn test_error_models_hash_order_independence() { - let model1 = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let model2 = AssayErrorModel::proportional(ErrorPoly::new(2.0, 0.0, 0.0, 0.0), 3.0); - - // Add in different orders - let models1 = AssayErrorModels::empty() - .add(0, model1.clone()) - .unwrap() - .add(1, model2.clone()) - .unwrap(); - - let models2 = AssayErrorModels::empty() - .add(1, model2) - .unwrap() - .add(0, model1) - .unwrap(); - - // Hash should be the same regardless of insertion order - assert_eq!(models1.hash(), models2.hash()); - } - - #[test] - fn test_error_models_multiple_outeqs() { - let additive_model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.1, 0.0, 0.0), 0.5); - let proportional_model = - AssayErrorModel::proportional(ErrorPoly::new(0.0, 0.05, 0.0, 0.0), 0.1); - - let models = AssayErrorModels::empty() - .add(0, additive_model) - .unwrap() - .add(1, proportional_model) - .unwrap(); - - assert_eq!(models.len(), 2); - - // Test factor retrieval for different outeqs - assert_eq!(models.factor(0).unwrap(), 0.5); - assert_eq!(models.factor(1).unwrap(), 0.1); - - // Test polynomial retrieval for different outeqs - assert_eq!( - models.errorpoly(0).unwrap().coefficients(), - (1.0, 0.1, 0.0, 0.0) - ); - assert_eq!( - models.errorpoly(1).unwrap().coefficients(), - (0.0, 0.05, 0.0, 0.0) - ); - } - - #[test] - fn test_error_models_with_predictions_different_outeqs() { - let additive_model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let proportional_model = - AssayErrorModel::proportional(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 2.0); - - let models = AssayErrorModels::empty() - .add(0, additive_model) - .unwrap() - .add(1, proportional_model) - .unwrap(); - - // Test with outeq=0 (additive model) - let obs1 = Observation::new(0.0, Some(20.0), 0, None, 0, Censor::None); - let pred1 = obs1.to_prediction(10.0, vec![]); - let sigma1 = models.sigma(&pred1).unwrap(); - assert_eq!(sigma1, (26.0_f64).sqrt()); // additive: sqrt(alpha^2 + lambda^2) = sqrt(1^2 + 5^2) = sqrt(26) - - // Test with outeq=1 (proportional model) - let obs2 = Observation::new(0.0, Some(20.0), 1, None, 0, Censor::None); - let pred2 = obs2.to_prediction(10.0, vec![]); - let sigma2 = models.sigma(&pred2).unwrap(); - assert_eq!(sigma2, 2.0); // proportional: gamma * alpha = 2 * 1 = 2 - } - - #[test] - fn test_factor_param_new_constructors() { - // Test variable constructors (default behavior) - let additive = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - assert_eq!(additive.factor().unwrap(), 5.0); - assert!(!additive.is_factor_fixed().unwrap()); - - let proportional = AssayErrorModel::proportional(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 2.0); - assert_eq!(proportional.factor().unwrap(), 2.0); - assert!(!proportional.is_factor_fixed().unwrap()); - - // Test fixed constructors - let additive_fixed = - AssayErrorModel::additive_fixed(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - assert_eq!(additive_fixed.factor().unwrap(), 5.0); - assert!(additive_fixed.is_factor_fixed().unwrap()); - - let proportional_fixed = - AssayErrorModel::proportional_fixed(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 2.0); - assert_eq!(proportional_fixed.factor().unwrap(), 2.0); - assert!(proportional_fixed.is_factor_fixed().unwrap()); - - // Test Factor constructors - let additive_with_param = AssayErrorModel::additive_with_param( - ErrorPoly::new(1.0, 0.0, 0.0, 0.0), - Factor::Fixed(5.0), - ); - assert_eq!(additive_with_param.factor().unwrap(), 5.0); - assert!(additive_with_param.is_factor_fixed().unwrap()); - - let proportional_with_param = AssayErrorModel::proportional_with_param( - ErrorPoly::new(1.0, 0.0, 0.0, 0.0), - Factor::Variable(2.0), - ); - assert_eq!(proportional_with_param.factor().unwrap(), 2.0); - assert!(!proportional_with_param.is_factor_fixed().unwrap()); - } - - #[test] - fn test_factor_param_methods() { - let mut model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - - // Test initial state - assert_eq!(model.factor().unwrap(), 5.0); - assert!(!model.is_factor_fixed().unwrap()); - - // Test fixing parameter - model.fix_factor(); - assert_eq!(model.factor().unwrap(), 5.0); - assert!(model.is_factor_fixed().unwrap()); - - // Test unfixing parameter - model.unfix_factor(); - assert_eq!(model.factor().unwrap(), 5.0); - assert!(!model.is_factor_fixed().unwrap()); - - // Test setting factor param directly - model.set_factor_param(Factor::Fixed(10.0)); - assert_eq!(model.factor().unwrap(), 10.0); - assert!(model.is_factor_fixed().unwrap()); - - // Test getting factor param - let param = model.factor_param().unwrap(); - assert_eq!(param.value(), 10.0); - assert!(param.is_fixed()); - } - - #[test] - fn test_factor_param_functionality() { - let mut param = Factor::Variable(5.0); - - // Test basic functionality - assert_eq!(param.value(), 5.0); - assert!(param.is_variable()); - assert!(!param.is_fixed()); - - // Test setting value - param.set_value(10.0); - assert_eq!(param.value(), 10.0); - assert!(param.is_variable()); - - // Test making fixed - param.make_fixed(); - assert_eq!(param.value(), 10.0); - assert!(param.is_fixed()); - assert!(!param.is_variable()); - - // Test making variable again - param.make_variable(); - assert_eq!(param.value(), 10.0); - assert!(param.is_variable()); - assert!(!param.is_fixed()); - } - - #[test] - fn test_error_models_factor_param_methods() { - let additive_model = - AssayErrorModel::additive_fixed(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let proportional_model = - AssayErrorModel::proportional(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 2.0); - - let mut models = AssayErrorModels::empty() - .add(0, additive_model) - .unwrap() - .add(1, proportional_model) - .unwrap(); - - // Test factor param retrieval - let param0 = models.factor_param(0).unwrap(); - assert_eq!(param0.value(), 5.0); - assert!(param0.is_fixed()); - - let param1 = models.factor_param(1).unwrap(); - assert_eq!(param1.value(), 2.0); - assert!(param1.is_variable()); - - // Test is_factor_fixed - assert!(models.is_factor_fixed(0).unwrap()); - assert!(!models.is_factor_fixed(1).unwrap()); - - // Test fixing/unfixing - models.fix_factor(1).unwrap(); - assert!(models.is_factor_fixed(1).unwrap()); - - models.unfix_factor(0).unwrap(); - assert!(!models.is_factor_fixed(0).unwrap()); - - // Test setting factor param - models.set_factor_param(0, Factor::Fixed(10.0)).unwrap(); - assert_eq!(models.factor(0).unwrap(), 10.0); - assert!(models.is_factor_fixed(0).unwrap()); - } - - #[test] - fn test_fixed_parameters_in_calculations() { - // Test that fixed and variable parameters produce the same calculation results - let observation = Observation::new(0.0, Some(20.0), 0, None, 0, Censor::None); - let prediction = observation.to_prediction(10.0, vec![]); - - let model_variable = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let model_fixed = AssayErrorModel::additive_fixed(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - - let sigma_variable = model_variable.sigma(&prediction).unwrap(); - let sigma_fixed = model_fixed.sigma(&prediction).unwrap(); - - assert_eq!(sigma_variable, sigma_fixed); - assert_eq!(sigma_variable, (26.0_f64).sqrt()); - - // Test with sigma_from_value - let sigma_variable_val = model_variable.sigma_from_value(20.0).unwrap(); - let sigma_fixed_val = model_fixed.sigma_from_value(20.0).unwrap(); - - assert_eq!(sigma_variable_val, sigma_fixed_val); - assert_eq!(sigma_variable_val, (26.0_f64).sqrt()); - } - - #[test] - fn test_hash_includes_fixed_state() { - let model1_variable = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let model1_fixed = AssayErrorModel::additive_fixed(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - - let models1 = AssayErrorModels::empty().add(0, model1_variable).unwrap(); - let models2 = AssayErrorModels::empty().add(0, model1_fixed).unwrap(); - - // Different fixed/variable states should produce different hashes - assert_ne!(models1.hash(), models2.hash()); - } - - #[test] - fn test_error_models_into_iter_functionality() { - let additive_model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let proportional_model = - AssayErrorModel::proportional(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 2.0); - - let mut models = AssayErrorModels::empty() - .add(0, additive_model) - .unwrap() - .add(1, proportional_model) - .unwrap(); - - // Verify initial state - both should be variable - assert!(!models.is_factor_fixed(0).unwrap()); - assert!(!models.is_factor_fixed(1).unwrap()); - assert_eq!(models.factor(0).unwrap(), 5.0); - assert_eq!(models.factor(1).unwrap(), 2.0); - - // First iteration: update values using iter_mut - for (outeq, model) in models.iter_mut() { - match outeq { - 0 => model.set_factor(10.0), // Update additive lambda from 5.0 to 10.0 - 1 => model.set_factor(4.0), // Update proportional gamma from 2.0 to 4.0 - _ => {} - } - } - - // Verify values were updated - assert_eq!(models.factor(0).unwrap(), 10.0); - assert_eq!(models.factor(1).unwrap(), 4.0); - assert!(!models.is_factor_fixed(0).unwrap()); // Still variable - assert!(!models.is_factor_fixed(1).unwrap()); // Still variable - - // Second iteration: fix all parameters using iter_mut - for (_outeq, model) in models.iter_mut() { - model.fix_factor(); - } - - // Verify all parameters are now fixed - assert!(models.is_factor_fixed(0).unwrap()); - assert!(models.is_factor_fixed(1).unwrap()); - assert_eq!(models.factor(0).unwrap(), 10.0); // Values should remain the same - assert_eq!(models.factor(1).unwrap(), 4.0); - - // Test read-only iteration with iter() - let mut count = 0; - for (outeq, model) in models.iter() { - count += 1; - match outeq { - 0 => { - assert!(model.is_factor_fixed().unwrap()); - assert_eq!(model.factor().unwrap(), 10.0); - } - 1 => { - assert!(model.is_factor_fixed().unwrap()); - assert_eq!(model.factor().unwrap(), 4.0); - } - _ => panic!("Unexpected outeq: {}", outeq), - } - } - assert_eq!(count, 2); - - // Test consuming iteration with into_iter() - let collected_models: Vec<(usize, AssayErrorModel)> = models.into_iter().collect(); - assert_eq!(collected_models.len(), 2); - - // Verify the collected models retain their state - let (outeq0, model0) = &collected_models[0]; - let (outeq1, model1) = &collected_models[1]; - - assert_eq!(*outeq0, 0); - assert_eq!(*outeq1, 1); - assert!(model0.is_factor_fixed().unwrap()); - assert!(model1.is_factor_fixed().unwrap()); - assert_eq!(model0.factor().unwrap(), 10.0); - assert_eq!(model1.factor().unwrap(), 4.0); - } - - #[test] - fn error_model_hash_deterministic() { - let models = AssayErrorModels::empty() - .add( - 0, - AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0), - ) - .unwrap(); - assert_eq!(models.hash(), models.hash()); - } - - #[test] - fn error_model_hash_differs_on_value() { - let a = AssayErrorModels::empty() - .add( - 0, - AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0), - ) - .unwrap(); - let b = AssayErrorModels::empty() - .add( - 0, - AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 10.0), - ) - .unwrap(); - assert_ne!(a.hash(), b.hash()); - } - - #[test] - fn error_model_hash_differs_on_type() { - let a = AssayErrorModels::empty() - .add( - 0, - AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0), - ) - .unwrap(); - let b = AssayErrorModels::empty() - .add( - 0, - AssayErrorModel::proportional(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0), - ) - .unwrap(); - assert_ne!(a.hash(), b.hash()); - } -} diff --git a/src/data/error_poly.rs b/src/data/error_poly.rs new file mode 100644 index 00000000..3f13d5c3 --- /dev/null +++ b/src/data/error_poly.rs @@ -0,0 +1,54 @@ +//! Data-only transport for per-observation Pmetrics C0-C3 values. + +use serde::{Deserialize, Serialize}; + +/// Four floating-point coefficients attached to an observation. +/// +/// pharmsol transports these values verbatim and does not interpret them. +#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq)] +pub struct ErrorPoly { + c0: f64, + c1: f64, + c2: f64, + c3: f64, +} + +impl ErrorPoly { + /// Create a coefficient tuple from C0-C3. + pub fn new(c0: f64, c1: f64, c2: f64, c3: f64) -> Self { + Self { c0, c1, c2, c3 } + } + + /// Get all four coefficients. + pub fn coefficients(&self) -> (f64, f64, f64, f64) { + (self.c0, self.c1, self.c2, self.c3) + } + + /// Get C0. + pub fn c0(&self) -> f64 { + self.c0 + } + + /// Get C1. + pub fn c1(&self) -> f64 { + self.c1 + } + + /// Get C2. + pub fn c2(&self) -> f64 { + self.c2 + } + + /// Get C3. + pub fn c3(&self) -> f64 { + self.c3 + } + + /// Replace all four coefficients. + pub fn set_coefficients(&mut self, c0: f64, c1: f64, c2: f64, c3: f64) { + self.c0 = c0; + self.c1 = c1; + self.c2 = c2; + self.c3 = c3; + } +} diff --git a/src/data/event.rs b/src/data/event.rs index d0f96f7e..cc1bcb62 100644 --- a/src/data/event.rs +++ b/src/data/event.rs @@ -10,8 +10,7 @@ //! `"cp"`. Numeric values are accepted, but they remain labels until a //! downstream workflow explicitly interprets them as indices. -use crate::data::error_model::ErrorPoly; -use crate::prelude::simulator::Prediction; +use crate::{simulator::prediction::Prediction, ErrorPoly}; use serde::{Deserialize, Serialize}; use std::fmt; @@ -135,9 +134,9 @@ impl InputLabel { /// Try to interpret the label as a numeric index. /// - /// This is mainly a compatibility helper for lower-level paths that still - /// operate on dense indices after label resolution. - pub fn index(&self) -> Option { + /// Internal helper for lower-level paths that resolve a label to a dense + /// index after metadata resolution. + pub(crate) fn index(&self) -> Option { self.0.parse::().ok() } } @@ -217,9 +216,9 @@ impl OutputLabel { /// Try to interpret the label as a numeric index. /// - /// This is mainly a compatibility helper for lower-level paths that still - /// operate on dense indices after label resolution. - pub fn index(&self) -> Option { + /// Internal helper for lower-level paths that resolve a label to a dense + /// index after metadata resolution. + pub(crate) fn index(&self) -> Option { self.0.parse::().ok() } } @@ -368,10 +367,11 @@ impl Bolus { &self.input } - /// Try to interpret the input label as a numeric index. + /// Resolve the input label to a dense execution index, if it is numeric. /// - /// Prefer [`Bolus::input`] when working with the public label itself. - pub fn input_index(&self) -> Option { + /// Internal helper for the execution layer. Public code works with the + /// [`InputLabel`] returned by [`Bolus::input`]. + pub(crate) fn input_index(&self) -> Option { self.input.index() } @@ -468,10 +468,11 @@ impl Infusion { &self.input } - /// Try to interpret the input label as a numeric index. + /// Resolve the input label to a dense execution index, if it is numeric. /// - /// Prefer [`Infusion::input`] when working with the public label itself. - pub fn input_index(&self) -> Option { + /// Internal helper for the execution layer. Public code works with the + /// [`InputLabel`] returned by [`Infusion::input`]. + pub(crate) fn input_index(&self) -> Option { self.input.index() } @@ -553,13 +554,14 @@ pub enum Censor { /// Observation of a model output. /// /// An [`Observation`] can carry a measured value or `None` for a prediction-only -/// time point. Observations also carry the public output label, optional assay -/// error polynomial, occasion index, and censoring state. +/// time point. Observations also carry the public output label, optional +/// [`ErrorPoly`] data, occasion index, and censoring state. #[derive(Serialize, Debug, Clone, Deserialize)] pub struct Observation { time: f64, value: Option, outeq: OutputLabel, + #[serde(default, alias = "coefficients")] errorpoly: Option, occasion: usize, censoring: Censor, @@ -610,16 +612,15 @@ impl Observation { &self.outeq } - /// Try to interpret the output label as a numeric index. + /// Resolve the output label to a dense execution index, if it is numeric. /// - /// Prefer [`Observation::outeq`] when working with the public label itself. - pub fn outeq_index(&self) -> Option { + /// Internal helper for the execution layer. Public code works with the + /// [`OutputLabel`] returned by [`Observation::outeq`]. + pub(crate) fn outeq_index(&self) -> Option { self.outeq.index() } - /// Get the error polynomial coefficients (c0, c1, c2, c3) if available - /// - /// The error polynomial is used to model the observation error. + /// Get the C0-C3 data attached to this observation, if present. pub fn errorpoly(&self) -> Option { self.errorpoly } @@ -639,7 +640,7 @@ impl Observation { self.outeq = OutputLabel::new(outeq); } - /// Set the [ErrorPoly] for this observation + /// Set the [`ErrorPoly`] coefficients for this observation. pub fn set_errorpoly(&mut self, errorpoly: Option) { self.errorpoly = errorpoly; } @@ -659,7 +660,7 @@ impl Observation { &mut self.outeq } - /// Get a mutable reference to the error polynomial + /// Get a mutable reference to the optional [`ErrorPoly`] coefficients. pub fn mut_errorpoly(&mut self) -> &mut Option { &mut self.errorpoly } @@ -674,22 +675,14 @@ impl Observation { &mut self.occasion } - /// Create a [`Prediction`] from this observation. - /// - /// This is a low-level helper for code paths that already operate on a - /// resolved or numeric output index. Named output labels must be resolved by - /// the caller before this conversion happens. - pub fn to_prediction(&self, pred: f64, state: Vec) -> Prediction { + /// Create a [`Prediction`] from this observation using a resolved output label. + pub(crate) fn to_prediction(&self, outeq: OutputLabel, prediction: f64) -> Prediction { Prediction { time: self.time(), observation: self.value(), - prediction: pred, - outeq: self - .outeq_index() - .expect("prediction requires a resolved or numeric output label"), + prediction, + outeq, errorpoly: self.errorpoly(), - state, - occasion: self.occasion(), censoring: self.censoring(), } } @@ -735,15 +728,10 @@ impl fmt::Display for Event { Event::Observation(observation) => { let errpoly_desc = match observation.errorpoly { Some(errorpoly) => { - format!( - "with error poly {} {} {} {}", - errorpoly.coefficients().0, - errorpoly.coefficients().1, - errorpoly.coefficients().2, - errorpoly.coefficients().3 - ) + let (c0, c1, c2, c3) = errorpoly.coefficients(); + format!("with error poly {c0} {c1} {c2} {c3}") } - None => "".to_string(), + None => String::new(), }; write!( f, @@ -811,14 +799,14 @@ mod tests { #[test] fn test_observation_creation() { - let error_poly = Some(ErrorPoly::new(0.1, 0.2, 0.3, 0.4)); - let observation = Observation::new(5.0, Some(75.5), 2, error_poly, 0, Censor::None); + let errorpoly = Some(ErrorPoly::new(0.1, 0.2, 0.3, 0.4)); + let observation = Observation::new(5.0, Some(75.5), 2, errorpoly, 0, Censor::None); assert_eq!(observation.time(), 5.0); assert_eq!(observation.value(), Some(75.5)); assert_eq!(observation.outeq(), 2); assert_eq!(observation.outeq().as_str(), "2"); - assert_eq!(observation.errorpoly(), error_poly); + assert_eq!(observation.errorpoly(), errorpoly); } #[test] @@ -833,17 +821,51 @@ mod tests { ); observation.set_time(6.0); - assert_eq!(observation.time(), 6.0); - observation.set_value(Some(80.0)); - assert_eq!(observation.value(), Some(80.0)); - observation.set_outeq(3); - assert_eq!(observation.outeq(), 3); - let new_error_poly = Some(ErrorPoly::new(0.2, 0.3, 0.4, 0.5)); - observation.set_errorpoly(new_error_poly); - assert_eq!(observation.errorpoly(), new_error_poly); + let replacement = Some(ErrorPoly::new(0.2, 0.3, 0.4, 0.5)); + observation.set_errorpoly(replacement); + assert_eq!(observation.errorpoly(), replacement); + assert_eq!(observation.mut_errorpoly(), &mut replacement.clone()); + } + + #[test] + fn observation_to_prediction_carries_output_label() { + let observation = Observation::new(5.0, Some(75.5), 2, None, 0, Censor::None); + let prediction = observation.to_prediction(OutputLabel::new("cp"), 70.0); + + assert_eq!(prediction.output().as_str(), "cp"); + assert_eq!(prediction.prediction(), 70.0); + } + + #[test] + fn observation_errorpoly_roundtrips_through_serde() { + let observation = Observation::new( + 1.5, + Some(2.5), + "cp", + Some(ErrorPoly::new(0.1, 0.2, 0.3, 0.4)), + 2, + Censor::BLOQ, + ); + + let serialized = serde_json::to_string(&observation).unwrap(); + assert!(serialized.contains("\"errorpoly\"")); + assert!(!serialized.contains("\"coefficients\"")); + + let deserialized: Observation = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized.errorpoly(), observation.errorpoly()); + } + + #[test] + fn observation_accepts_interim_coefficients_alias() { + let serialized = r#"{"time":1.5,"value":2.5,"outeq":"cp","coefficients":{"c0":0.1,"c1":0.2,"c2":0.3,"c3":0.4},"occasion":2,"censoring":"none"}"#; + let observation: Observation = serde_json::from_str(serialized).unwrap(); + assert_eq!( + observation.errorpoly(), + Some(ErrorPoly::new(0.1, 0.2, 0.3, 0.4)) + ); } #[test] diff --git a/src/data/mod.rs b/src/data/mod.rs index 28a80b32..26555e16 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -24,8 +24,11 @@ //! - [`Event`], [`Bolus`], [`Infusion`], and [`Observation`] for explicit //! event-level control. //! - [`Covariate`] and [`Covariates`] for time-varying subject characteristics. -//! - [`ErrorModel`], [`ResidualErrorModel`], and [`ObservationError`] for the -//! different error surfaces used by downstream workflows. +//! - [`ErrorPoly`] for the transport-neutral Pmetrics C0-C3 values attached to +//! observations. +//! - [`ObservationError`] for failures while extracting and validating +//! concentration-time observations, including insufficient data, invalid time +//! ordering, and missing observations for a requested output. //! //! # Choose A Data Input Path //! @@ -52,16 +55,10 @@ //! [`Occasion`] indices are different: they are integer period markers used to //! separate repeated dosing blocks within one subject. //! -//! # Error Surfaces +//! # Observation ErrorPoly Data //! -//! This module exposes three related but different error families: -//! -//! - [`ErrorModel`] for assay or measurement error driven by the observation -//! value, commonly used in non-parametric workflows. -//! - [`ResidualErrorModel`] for residual unexplained variability driven by the -//! prediction value, commonly used in parametric workflows. -//! - [`ObservationError`] for invalid or insufficient observation data during -//! profile construction and related preprocessing. +//! Observations can carry Pmetrics C0-C3 values in [`ErrorPoly`]. pharmsol +//! stores and round-trips these values verbatim. //! //! # Example //! @@ -83,17 +80,15 @@ pub mod auc; pub mod builder; pub mod covariate; -pub mod error_model; +pub mod error_poly; pub mod event; pub mod observation_error; pub mod parser; -pub mod residual_error; pub mod row; pub mod structs; pub use crate::nca::{MetricsError, ObservationMetrics}; pub use covariate::*; -pub use error_model::*; +pub use error_poly::ErrorPoly; pub use event::*; pub use observation_error::ObservationError; -pub use residual_error::*; pub use structs::{Data, Occasion, Subject}; diff --git a/src/data/observation_error.rs b/src/data/observation_error.rs index 627f8132..009978ed 100644 --- a/src/data/observation_error.rs +++ b/src/data/observation_error.rs @@ -12,8 +12,7 @@ use thiserror::Error; /// Errors arising from observation data processing /// /// These represent problems with the input data itself, not with NCA analysis. -/// Used by [`ObservationProfile`](crate::nca::observation::ObservationProfile) -/// construction methods. +/// Used by internal `ObservationProfile` construction methods. #[derive(Error, Debug, Clone)] pub enum ObservationError { /// Insufficient data points for the requested operation diff --git a/src/data/parser/pmetrics.rs b/src/data/parser/pmetrics.rs index 999aba48..0a89ddbc 100644 --- a/src/data/parser/pmetrics.rs +++ b/src/data/parser/pmetrics.rs @@ -53,7 +53,7 @@ use std::str::FromStr; /// # Expected columns /// /// The canonical columns are `ID`, `TIME`, `EVID`, `DOSE`, `DUR`, `ADDL`, -/// `II`, `INPUT`, `OUT`, `OUTEQ`, `CENS`, and optional `C0..C3` error +/// `II`, `INPUT`, `OUT`, `OUTEQ`, `CENS`, and optional `C0..C3` observation /// coefficients. /// /// All other numeric columns are treated as covariates. @@ -134,16 +134,16 @@ struct Row { /// Censoring output #[serde(default, deserialize_with = "deserialize_option_censor")] cens: Option, - /// First element of the error polynomial + /// C0 observation coefficient #[serde(deserialize_with = "deserialize_option_f64")] c0: Option, - /// Second element of the error polynomial + /// C1 observation coefficient #[serde(deserialize_with = "deserialize_option_f64")] c1: Option, - /// Third element of the error polynomial + /// C2 observation coefficient #[serde(deserialize_with = "deserialize_option_f64")] c2: Option, - /// Fourth element of the error polynomial + /// C3 observation coefficient #[serde(deserialize_with = "deserialize_option_f64")] c3: Option, /// All other columns are covariates @@ -331,8 +331,8 @@ impl Data { }; let (c0, c1, c2, c3) = obs .errorpoly() - .map(|poly| { - let (c0, c1, c2, c3) = poly.coefficients(); + .map(|errorpoly| { + let (c0, c1, c2, c3) = errorpoly.coefficients(); ( c0.to_string(), c1.to_string(), @@ -539,6 +539,68 @@ mod tests { assert_eq!(second.get(14), Some(".")); } + #[test] + fn pmetrics_roundtrip_preserves_exact_observation_metadata() { + let subject = Subject::builder("roundtrip") + .observation_with_error( + 0.25, + 2.5, + "cp", + ErrorPoly::new(0.1, 0.2, 0.3, 0.4), + Censor::BLOQ, + ) + .censored_observation(1.5, 3.5, "effect", Censor::ALOQ) + .build(); + let data = Data::new(vec![subject]); + let file = NamedTempFile::new().unwrap(); + data.write_pmetrics(file.as_file()).unwrap(); + + let reparsed = read_pmetrics(file.path().display().to_string()).unwrap(); + let events = reparsed.subjects()[0].occasions()[0].events(); + let observations: Vec<_> = events + .iter() + .filter_map(|event| match event { + Event::Observation(observation) => Some(observation), + _ => None, + }) + .collect(); + + assert_eq!(observations.len(), 2); + assert_eq!(observations[0].time(), 0.25); + assert_eq!(observations[0].value(), Some(2.5)); + assert_eq!(observations[0].outeq().as_str(), "cp"); + assert_eq!(observations[0].censoring(), Censor::BLOQ); + assert_eq!( + observations[0].errorpoly().unwrap().coefficients(), + (0.1, 0.2, 0.3, 0.4) + ); + assert_eq!(observations[1].time(), 1.5); + assert_eq!(observations[1].value(), Some(3.5)); + assert_eq!(observations[1].outeq().as_str(), "effect"); + assert_eq!(observations[1].censoring(), Censor::ALOQ); + assert_eq!(observations[1].errorpoly(), None); + } + + #[test] + fn pmetrics_errorpoly_flows_from_observation_to_prediction() { + let file = NamedTempFile::new().unwrap(); + std::fs::write( + file.path(), + "ID,EVID,TIME,DUR,DOSE,ADDL,II,INPUT,OUT,OUTEQ,CENS,C0,C1,C2,C3\npt1,0,1,.,.,.,.,.,42,0,0,0.1,0.2,0.3,0.4\n", + ) + .unwrap(); + + let data = read_pmetrics(file.path().display().to_string()).unwrap(); + let Event::Observation(observation) = &data.subjects()[0].occasions()[0].events()[0] else { + panic!("expected observation") + }; + let errorpoly = ErrorPoly::new(0.1, 0.2, 0.3, 0.4); + assert_eq!(observation.errorpoly(), Some(errorpoly)); + + let prediction = observation.to_prediction(observation.outeq().clone(), 40.0); + assert_eq!(prediction.errorpoly(), Some(errorpoly)); + } + #[test] fn read_pmetrics_preserves_named_route_and_output_labels() { let file = NamedTempFile::new().unwrap(); diff --git a/src/data/residual_error.rs b/src/data/residual_error.rs deleted file mode 100644 index 51dd9763..00000000 --- a/src/data/residual_error.rs +++ /dev/null @@ -1,519 +0,0 @@ -//! Residual error models for parametric algorithms (SAEM, FOCE, etc.) -//! -//! This module provides error model implementations that use the **prediction** -//! (model output) rather than the **observation** for computing residual error. -//! -//! # Conceptual Difference from [`crate::ErrorModel`] -//! -//! - [`crate::ErrorModel`] (in `error_model.rs`): Represents **measurement/assay noise**. -//! Sigma is computed from the **observation** using polynomial characterization. -//! Used by non-parametric algorithms (NPAG, NPOD, etc.). -//! -//! - [`ResidualErrorModel`] (this module): Represents **residual unexplained variability** -//! in population models. Sigma is computed from the **prediction**. -//! Used by parametric algorithms (SAEM, FOCE, etc.). -//! -//! # R saemix Correspondence -//! -//! The error model in saemix (func_aux.R): -//! ```R -//! error.typ <- function(f, ab) { -//! g <- cutoff(sqrt(ab[1]^2 + ab[2]^2 * f^2)) -//! return(g) -//! } -//! ``` -//! -//! | saemix parameter | This implementation | -//! |------------------|---------------------| -//! | `ab[1]` (a) | `Constant::a` or `Combined::a` | -//! | `ab[2]` (b) | `Proportional::b` or `Combined::b` | -//! -//! # Error Model Types -//! -//! - **Constant**: σ = a (independent of prediction) -//! - **Proportional**: σ = b * |f| (scales with prediction) -//! - **Combined**: σ = sqrt(a² + b²*f²) (most flexible, default in saemix) -//! - **Exponential**: σ for log-transformed data - -use serde::{Deserialize, Serialize}; - -/// Residual error model for parametric estimation algorithms. -/// -/// Unlike [`crate::ErrorModel`] which uses observations, this uses -/// the model **prediction** to compute the standard deviation. -/// -/// # Usage in SAEM -/// -/// The error model affects: -/// 1. **Likelihood computation** in E-step: L(y|f) = N(y; f, σ²) -/// 2. **Residual weighting** in M-step: weighted_res² = (y-f)²/σ² -/// -/// # Examples -/// -/// ```rust -/// use pharmsol::ResidualErrorModel; -/// -/// // Constant (additive) error: σ = 0.5 -/// let constant = ResidualErrorModel::Constant { a: 0.5 }; -/// assert!((constant.sigma(100.0) - 0.5).abs() < 1e-10); -/// -/// // Proportional error: σ = 0.1 * |f| -/// let proportional = ResidualErrorModel::Proportional { b: 0.1 }; -/// assert!((proportional.sigma(100.0) - 10.0).abs() < 1e-10); -/// -/// // Combined error: σ = sqrt(0.5² + 0.1² * f²) -/// let combined = ResidualErrorModel::Combined { a: 0.5, b: 0.1 }; -/// // For f=100: σ = sqrt(0.25 + 100) = sqrt(100.25) ≈ 10.01 -/// ``` -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] -pub enum ResidualErrorModel { - /// Constant (additive) error model - /// - /// σ = a - /// - /// Error is independent of the predicted value. - /// Appropriate when measurement error is constant regardless of concentration. - Constant { - /// Additive error standard deviation - a: f64, - }, - - /// Proportional error model - /// - /// σ = b * |f| - /// - /// Error scales linearly with the prediction. - /// Appropriate when measurement error is a constant percentage of the value. - /// - /// Note: Uses |f| to handle negative predictions gracefully. - Proportional { - /// Proportional coefficient (e.g., 0.1 = 10% CV) - b: f64, - }, - - /// Combined (additive + proportional) error model - /// - /// σ = sqrt(a² + b² * f²) - /// - /// This is the standard saemix error model from func_aux.R: - /// ```R - /// g <- cutoff(sqrt(ab[1]^2 + ab[2]^2 * f^2)) - /// ``` - /// - /// The combined model: - /// - Dominates at low concentrations (a term) - /// - Scales proportionally at high concentrations (b term) - Combined { - /// Additive component (a) - a: f64, - /// Proportional component (b) - b: f64, - }, - - /// Exponential error model (for log-transformed data) - /// - /// σ = σ_exp (constant on log scale) - /// - /// When data is analyzed on the log scale: - /// ```text - /// log(Y) = log(f) + ε, where ε ~ N(0, σ²) - /// ``` - /// - /// This corresponds to multiplicative error on the original scale. - Exponential { - /// Error standard deviation on log scale - sigma: f64, - }, -} - -impl Default for ResidualErrorModel { - fn default() -> Self { - // Default to constant error with σ = 1.0 - ResidualErrorModel::Constant { a: 1.0 } - } -} - -impl ResidualErrorModel { - /// Create a constant (additive) error model - /// - /// # Arguments - /// * `a` - Standard deviation (must be positive) - pub fn constant(a: f64) -> Self { - ResidualErrorModel::Constant { a } - } - - /// Create a proportional error model - /// - /// # Arguments - /// * `b` - Proportional coefficient (e.g., 0.1 for 10% CV) - pub fn proportional(b: f64) -> Self { - ResidualErrorModel::Proportional { b } - } - - /// Create a combined (additive + proportional) error model - /// - /// # Arguments - /// * `a` - Additive component - /// * `b` - Proportional component - pub fn combined(a: f64, b: f64) -> Self { - ResidualErrorModel::Combined { a, b } - } - - /// Create an exponential error model - /// - /// # Arguments - /// * `sigma` - Standard deviation on log scale - pub fn exponential(sigma: f64) -> Self { - ResidualErrorModel::Exponential { sigma } - } - - /// Compute sigma (standard deviation) for a given prediction - /// - /// # Arguments - /// * `prediction` - The model prediction (f) - /// - /// # Returns - /// The standard deviation σ at this prediction value. - /// Returns a cutoff minimum to avoid numerical issues with very small σ. - pub fn sigma(&self, prediction: f64) -> f64 { - let raw_sigma = match self { - ResidualErrorModel::Constant { a } => *a, - ResidualErrorModel::Proportional { b } => b * prediction.abs(), - ResidualErrorModel::Combined { a, b } => { - (a.powi(2) + b.powi(2) * prediction.powi(2)).sqrt() - } - ResidualErrorModel::Exponential { sigma } => *sigma, - }; - - // Apply cutoff to prevent division by zero in likelihood - // R saemix uses cutoff function with default .Machine$double.eps - raw_sigma.max(f64::EPSILON.sqrt()) - } - - /// Compute variance for a given prediction - /// - /// # Arguments - /// * `prediction` - The model prediction (f) - /// - /// # Returns - /// The variance σ² at this prediction value. - pub fn variance(&self, prediction: f64) -> f64 { - let sigma = self.sigma(prediction); - sigma.powi(2) - } - - /// Compute the weighted residual for M-step sigma updates - /// - /// For the M-step in SAEM, we compute the normalized residual: - /// - For constant/additive: (y - f)² (unweighted) - /// - For proportional: (y - f)² / f² (weighted by prediction) - /// - For combined: (y - f)² / (a² + b²*f²) (using current sigma params) - /// - /// This matches R saemix's approach in main_mstep.R where for proportional - /// error: `resk <- sum((yobs - fk)**2 / cutoff(fk**2, .Machine$double.eps))` - /// - /// # Arguments - /// * `observation` - The observed value (y) - /// * `prediction` - The model prediction (f) - /// - /// # Returns - /// The weighted squared residual for sigma estimation. - pub fn weighted_squared_residual(&self, observation: f64, prediction: f64) -> f64 { - let residual = observation - prediction; - let residual_sq = residual * residual; - - match self { - ResidualErrorModel::Constant { .. } => { - // Constant error: unweighted residuals - // new_sigma² = Σ(y - f)² / n - residual_sq - } - ResidualErrorModel::Proportional { .. } => { - // Proportional error: weight by 1/f² - // new_sigma² = Σ(y - f)²/f² / n = b² (the proportional coefficient) - // This matches R saemix: resk <- sum((yobs - fk)**2 / cutoff(fk**2, ...)) - let pred_sq = prediction.powi(2).max(f64::EPSILON); - residual_sq / pred_sq - } - ResidualErrorModel::Combined { a, b } => { - // Combined error: weight by current variance estimate - // This is more complex - use current sigma² = a² + b²*f² - let variance = (a.powi(2) + b.powi(2) * prediction.powi(2)).max(f64::EPSILON); - residual_sq / variance - } - ResidualErrorModel::Exponential { .. } => { - // Exponential: residuals on log scale - // This should be computed differently for log-transformed data - residual_sq - } - } - } - - /// Compute log-likelihood contribution for a single observation - /// - /// Assuming normal distribution: - /// ```text - /// log L(y|f,σ) = -0.5 * [log(2π) + log(σ²) + (y-f)²/σ²] - /// ``` - /// - /// # Arguments - /// * `observation` - The observed value (y) - /// * `prediction` - The model prediction (f) - /// - /// # Returns - /// The log-likelihood contribution. - pub fn log_likelihood(&self, observation: f64, prediction: f64) -> f64 { - let sigma = self.sigma(prediction); - let residual = observation - prediction; - let normalized_residual = residual / sigma; - - -0.5 * (std::f64::consts::TAU.ln() + 2.0 * sigma.ln() + normalized_residual.powi(2)) - } - - /// Update the error model parameters based on M-step sufficient statistics - /// - /// In SAEM, the residual error is estimated in the M-step. This method - /// updates the appropriate parameter based on the new estimate. - /// - /// # Arguments - /// * `new_sigma` - The new sigma estimate from M-step - /// - /// # Returns - /// A new error model with updated parameters. - pub fn with_updated_sigma(self, new_sigma: f64) -> Self { - match self { - ResidualErrorModel::Constant { .. } => ResidualErrorModel::Constant { a: new_sigma }, - ResidualErrorModel::Proportional { .. } => { - ResidualErrorModel::Proportional { b: new_sigma } - } - ResidualErrorModel::Combined { a: _, b } => { - // For combined model, we update the additive component - // and keep the proportional component fixed - // This is a simplification - full estimation would estimate both - ResidualErrorModel::Combined { a: new_sigma, b } - } - ResidualErrorModel::Exponential { .. } => { - ResidualErrorModel::Exponential { sigma: new_sigma } - } - } - } - - /// Get the primary sigma parameter value - /// - /// For Constant: returns a - /// For Proportional: returns b - /// For Combined: returns a (additive component) - /// For Exponential: returns sigma - pub fn primary_parameter(&self) -> f64 { - match self { - ResidualErrorModel::Constant { a } => *a, - ResidualErrorModel::Proportional { b } => *b, - ResidualErrorModel::Combined { a, .. } => *a, - ResidualErrorModel::Exponential { sigma } => *sigma, - } - } - - /// Check if this is a proportional error model - pub fn is_proportional(&self) -> bool { - matches!(self, ResidualErrorModel::Proportional { .. }) - } - - /// Check if this is a constant (additive) error model - pub fn is_constant(&self) -> bool { - matches!(self, ResidualErrorModel::Constant { .. }) - } - - /// Check if this is a combined error model - pub fn is_combined(&self) -> bool { - matches!(self, ResidualErrorModel::Combined { .. }) - } - - /// Check if this is an exponential error model - pub fn is_exponential(&self) -> bool { - matches!(self, ResidualErrorModel::Exponential { .. }) - } -} - -/// Collection of residual error models for multiple output equations -/// -/// This mirrors [`crate::ErrorModels`] but for parametric algorithms. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ResidualErrorModels { - models: Vec, -} - -impl ResidualErrorModels { - /// Create an empty collection - pub fn new() -> Self { - Self { models: vec![] } - } - - /// Add an error model for a specific output equation - pub fn add(mut self, outeq: usize, model: ResidualErrorModel) -> Self { - if outeq >= self.models.len() { - self.models.resize(outeq + 1, ResidualErrorModel::default()); - } - self.models[outeq] = model; - self - } - - /// Get the error model for a specific output equation - pub fn get(&self, outeq: usize) -> Option<&ResidualErrorModel> { - self.models.get(outeq) - } - - /// Get a mutable reference to the error model for a specific output equation - pub fn get_mut(&mut self, outeq: usize) -> Option<&mut ResidualErrorModel> { - self.models.get_mut(outeq) - } - - /// Compute sigma for a specific output equation and prediction - pub fn sigma(&self, outeq: usize, prediction: f64) -> Option { - self.models.get(outeq).map(|m| m.sigma(prediction)) - } - - /// Number of error models - pub fn len(&self) -> usize { - self.models.len() - } - - /// Check if collection is empty - pub fn is_empty(&self) -> bool { - self.models.is_empty() - } - - /// Iterate over (outeq, model) pairs - pub fn iter(&self) -> impl Iterator { - self.models.iter().enumerate() - } - - /// Compute log-likelihood for a single observation given its prediction - /// - /// # Arguments - /// * `outeq` - Output equation index - /// * `observation` - The observed value (y) - /// * `prediction` - The model prediction (f) - /// - /// # Returns - /// The log-likelihood contribution, or None if outeq is invalid. - pub fn log_likelihood(&self, outeq: usize, observation: f64, prediction: f64) -> Option { - self.models - .get(outeq) - .map(|m| m.log_likelihood(observation, prediction)) - } - - /// Compute total log-likelihood for multiple observation-prediction pairs - /// - /// # Arguments - /// * `obs_pred_pairs` - Iterator of (outeq, observation, prediction) tuples - /// - /// # Returns - /// The sum of log-likelihood contributions. Returns `f64::NEG_INFINITY` if any - /// outeq is invalid. - pub fn total_log_likelihood(&self, obs_pred_pairs: I) -> f64 - where - I: IntoIterator, - { - let mut total = 0.0; - for (outeq, obs, pred) in obs_pred_pairs { - match self.log_likelihood(outeq, obs, pred) { - Some(ll) => total += ll, - None => return f64::NEG_INFINITY, - } - } - total - } - - /// Update all models with a new sigma estimate - pub fn update_sigma(&mut self, new_sigma: f64) { - for model in &mut self.models { - *model = model.with_updated_sigma(new_sigma); - } - } -} - -/// Convert from [`ErrorModels`] to [`ResidualErrorModels`] -/// -/// This allows backward compatibility when users have existing `ErrorModels` -/// configurations that need to be used with parametric algorithms. -/// -/// # Conversion Mapping -/// -/// | pharmsol ErrorModel | ResidualErrorModel | -/// |---------------------|-------------------| -/// | `Additive { lambda, .. }` | `Constant { a: lambda }` | -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_constant_error() { - let model = ResidualErrorModel::constant(0.5); - assert!((model.sigma(0.0) - 0.5).abs() < 1e-10); - assert!((model.sigma(100.0) - 0.5).abs() < 1e-10); - assert!((model.sigma(-50.0) - 0.5).abs() < 1e-10); - } - - #[test] - fn test_proportional_error() { - let model = ResidualErrorModel::proportional(0.1); - assert!((model.sigma(100.0) - 10.0).abs() < 1e-10); - assert!((model.sigma(50.0) - 5.0).abs() < 1e-10); - // Uses absolute value, so negative predictions work - assert!((model.sigma(-100.0) - 10.0).abs() < 1e-10); - } - - #[test] - fn test_combined_error() { - let model = ResidualErrorModel::combined(0.5, 0.1); - // At f=0: sigma = sqrt(0.25 + 0) = 0.5 - assert!((model.sigma(0.0) - 0.5).abs() < 1e-10); - // At f=100: sigma = sqrt(0.25 + 100) = sqrt(100.25) - assert!((model.sigma(100.0) - 100.25_f64.sqrt()).abs() < 1e-10); - } - - #[test] - fn test_weighted_residual() { - let model = ResidualErrorModel::constant(1.0); - // Constant error: unweighted residual = (obs - pred)² - let wr = model.weighted_squared_residual(5.0, 3.0); - assert!((wr - 4.0).abs() < 1e-10); // (5-3)² = 4 - - let prop_model = ResidualErrorModel::proportional(0.1); - // Proportional: weighted by 1/pred², NOT 1/sigma² - // At pred=10, residual = 12-10 = 2, weighted = (2)²/(10)² = 4/100 = 0.04 - let wr2 = prop_model.weighted_squared_residual(12.0, 10.0); - assert!((wr2 - 0.04).abs() < 1e-10); - } - - #[test] - fn test_sigma_cutoff() { - let model = ResidualErrorModel::proportional(0.1); - // At prediction = 0, raw sigma would be 0, but cutoff prevents this - let sigma = model.sigma(0.0); - assert!(sigma > 0.0); - assert!(sigma >= f64::EPSILON.sqrt()); - } - - #[test] - fn test_log_likelihood() { - let model = ResidualErrorModel::constant(1.0); - // Standard normal: log L = -0.5 * (log(2π) + 0 + z²) - let ll = model.log_likelihood(1.0, 0.0); - let expected = -0.5 * (std::f64::consts::TAU.ln() + 1.0); - assert!((ll - expected).abs() < 1e-10); - } - - #[test] - fn test_residual_error_models_collection() { - let models = ResidualErrorModels::new() - .add(0, ResidualErrorModel::constant(0.5)) - .add(1, ResidualErrorModel::proportional(0.1)); - - assert_eq!(models.len(), 2); - assert!(models.get(0).unwrap().is_constant()); - assert!(models.get(1).unwrap().is_proportional()); - assert!((models.sigma(0, 100.0).unwrap() - 0.5).abs() < 1e-10); - assert!((models.sigma(1, 100.0).unwrap() - 10.0).abs() < 1e-10); - } -} diff --git a/src/data/row.rs b/src/data/row.rs index f11194dd..2eded586 100644 --- a/src/data/row.rs +++ b/src/data/row.rs @@ -101,13 +101,13 @@ pub struct DataRow { pub outeq: Option, /// Censoring indicator pub cens: Option, - /// Error polynomial coefficients + /// Pmetrics ErrorPoly C0 coefficient pub c0: Option, - /// Error polynomial coefficients + /// Pmetrics ErrorPoly C1 coefficient pub c1: Option, - /// Error polynomial coefficients + /// Pmetrics ErrorPoly C2 coefficient pub c2: Option, - /// Error polynomial coefficients + /// Pmetrics ErrorPoly C3 coefficient pub c3: Option, /// Covariate values at this time point pub covariates: HashMap, @@ -136,7 +136,7 @@ impl DataRow { DataRowBuilder::new(id, time) } - /// Get error polynomial if all coefficients are present + /// Get ErrorPoly data if all four coefficients are present. fn get_errorpoly(&self) -> Option { match (self.c0, self.c1, self.c2, self.c3) { (Some(c0), Some(c1), Some(c2), Some(c3)) => Some(ErrorPoly::new(c0, c1, c2, c3)), @@ -150,7 +150,7 @@ impl DataRow { /// - EVID interpretation (0=observation, 1=dose, 4=reset) /// - ADDL/II expansion (both positive and negative directions) /// - Infusion vs bolus detection based on DUR - /// - Censoring and error polynomial handling + /// - Censoring and C0–C3 coefficient preservation /// - Preservation of public input and output labels /// /// # ADDL Expansion @@ -427,10 +427,7 @@ impl DataRowBuilder { self } - /// Set error polynomial coefficients - /// - /// The error polynomial models observation error as: - /// SD = c0 + c1*Y + c2*Y² + c3*Y³ + /// Set ErrorPoly coefficients. pub fn error_poly(mut self, c0: f64, c1: f64, c2: f64, c3: f64) -> Self { self.row.c0 = Some(c0); self.row.c1 = Some(c1); @@ -921,13 +918,32 @@ mod tests { let events = row.into_events().unwrap(); match &events[0] { Event::Observation(obs) => { - let ep = obs.errorpoly().unwrap(); - assert_eq!(ep.coefficients(), (0.1, 0.2, 0.0, 0.0)); + let errorpoly = obs.errorpoly().unwrap(); + assert_eq!(errorpoly.coefficients(), (0.1, 0.2, 0.0, 0.0)); + + let prediction = obs.to_prediction(obs.outeq().clone(), 24.5); + assert_eq!(prediction.errorpoly(), Some(errorpoly)); } _ => panic!("Expected observation"), } } + #[test] + fn partial_error_poly_is_ignored() { + let mut row = DataRow::builder("partial", 1.0) + .evid(0) + .out(25.0) + .outeq(1) + .build(); + row.c0 = Some(0.1); + + let events = row.into_events().unwrap(); + let Event::Observation(observation) = &events[0] else { + panic!("expected observation") + }; + assert_eq!(observation.errorpoly(), None); + } + #[test] fn test_censoring() { let row = DataRow::builder("pt1", 1.0) diff --git a/src/data/structs.rs b/src/data/structs.rs index e457bf47..b0c9e88b 100644 --- a/src/data/structs.rs +++ b/src/data/structs.rs @@ -59,10 +59,6 @@ impl Data { self.subjects.iter().collect() } - pub(crate) fn subjects_slice(&self) -> &[Subject] { - &self.subjects - } - /// Add a subject to the dataset /// /// # Arguments @@ -135,11 +131,44 @@ impl Data { .collect(); Data::new(subjects) } + /// Convenience wrapper around [`Data::expand`] that retrieves the declared + /// output labels from the model metadata and expands the dataset accordingly. + /// + /// The dataset is expanded for every output equation already present in the + /// data as well as for every output declared by `model`, so simulations have + /// a dense grid for all model outputs even if some were never observed. + /// + /// # Arguments + /// + /// * `idelta` - Time interval between added observations. See [`Data::expand`]. + /// * `tad` - Additional time to add after the last dose (time after dose). + /// * `model` - The model whose declared outputs are added to the grid. + /// + /// # Returns + /// + /// A new `Data` object with expanded observations + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + pub fn expand_with_model(&self, idelta: f64, tad: f64, model: &E) -> Data + where + E: crate::simulator::equation::Equation, + { + let outputs: Vec = model + .metadata() + .map(|metadata| { + metadata + .output_labels() + .into_iter() + .map(OutputLabel::new) + .collect() + }) + .unwrap_or_default(); + self.expand(idelta, tad, &outputs) + } - /// Expand the dataset by adding observations at regular time intervals + /// Expand the dataset by adding observations at regular time intervals for given output labels. /// /// This is useful for creating a dense grid of time points for simulations. - /// Observations are only added if they don't already exist at that time/outeq combination. + /// Observations are only added if they don't already exist at combination of time and `OutputLabel`]. /// /// # Arguments /// @@ -148,11 +177,12 @@ impl Data { /// (0.5 µs, which rounds up to 1 µs); any smaller positive value rounds /// to zero and the dataset is returned unchanged. /// * `tad` - Additional time to add after the last dose (time after dose) + /// * `outputs` - Additional output labels for which to add observations. If empty, only output labels in the dataset are used. /// /// # Returns /// /// A new `Data` object with expanded observations - pub fn expand(&self, idelta: f64, tad: f64) -> Data { + pub fn expand(&self, idelta: f64, tad: f64, outputs: &[OutputLabel]) -> Data { if idelta <= 0.0 { return self.clone(); } @@ -165,8 +195,12 @@ impl Data { return self.clone(); } - // Collect unique output equations more efficiently - let outeq_values = self.get_output_equations(); + // Expand for every output equation already present in the dataset, plus + // any additional output labels the caller explicitly requested. + let mut outeq_values = self.get_output_equations(); + outeq_values.extend(outputs.iter().cloned()); + outeq_values.sort(); + outeq_values.dedup(); // Create new data structure with expanded observations let new_subjects = self @@ -504,10 +538,35 @@ impl Subject { crate::data::event::Event::Observation(obs) => { 2u8.hash(&mut hasher); obs.time().to_bits().hash(&mut hasher); - if let Some(v) = obs.value() { - v.to_bits().hash(&mut hasher); + match obs.value() { + Some(value) => { + 1u8.hash(&mut hasher); + value.to_bits().hash(&mut hasher); + } + None => 0u8.hash(&mut hasher), } obs.outeq().hash(&mut hasher); + match obs.errorpoly() { + Some(errorpoly) => { + 1u8.hash(&mut hasher); + for coefficient in [ + errorpoly.c0(), + errorpoly.c1(), + errorpoly.c2(), + errorpoly.c3(), + ] { + coefficient.to_bits().hash(&mut hasher); + } + } + None => 0u8.hash(&mut hasher), + } + obs.occasion().hash(&mut hasher); + match obs.censoring() { + Censor::None => 0u8, + Censor::BLOQ => 1u8, + Censor::ALOQ => 2u8, + } + .hash(&mut hasher); } } } @@ -759,9 +818,7 @@ impl Occasion { self.add_event(Event::Observation(observation)); } - /// Add a missing [Observation] with a custom [ErrorPoly] to the [Occasion] - /// - /// This is useful if you want a different weight for the observation + /// Add an [`Observation`] with custom [`ErrorPoly`] data. pub fn add_observation_with_error( &mut self, time: f64, @@ -1703,6 +1760,62 @@ mod tests { assert_ne!(a.hash(), b.hash()); } + #[test] + fn hash_covers_errorpoly_option_and_bits() { + let baseline = Subject::builder("hash-metadata") + .missing_observation(1.0, "cp") + .build(); + let with_value = Subject::builder("hash-metadata") + .observation(1.0, 0.0, "cp") + .build(); + let with_coefficients = Subject::builder("hash-metadata") + .observation_with_error( + 1.0, + 0.0, + "cp", + ErrorPoly::new(0.0, 0.0, 0.0, 0.0), + Censor::None, + ) + .build(); + let censored = Subject::builder("hash-metadata") + .censored_observation(1.0, 0.0, "cp", Censor::BLOQ) + .build(); + + assert_ne!(baseline.hash(), with_value.hash()); + assert_ne!(with_value.hash(), with_coefficients.hash()); + assert_ne!(with_value.hash(), censored.hash()); + + let base_coefficients = [0.1, 0.2, 0.3, 0.4]; + let base = Subject::builder("hash-coefficients") + .observation_with_error( + 1.0, + 5.0, + "cp", + ErrorPoly::new( + base_coefficients[0], + base_coefficients[1], + base_coefficients[2], + base_coefficients[3], + ), + Censor::None, + ) + .build(); + for index in 0..4 { + let mut changed = base_coefficients; + changed[index] += 1.0; + let subject = Subject::builder("hash-coefficients") + .observation_with_error( + 1.0, + 5.0, + "cp", + ErrorPoly::new(changed[0], changed[1], changed[2], changed[3]), + Censor::None, + ) + .build(); + assert_ne!(base.hash(), subject.hash(), "coefficient C{index}"); + } + } + #[test] fn hash_identical_subjects_match() { let a = Subject::builder("s1") @@ -1729,7 +1842,7 @@ mod tests { .build(); let data = Data::from(subject); - let expanded = data.expand(1.0, 3.0); + let expanded = data.expand(1.0, 3.0, &[]); let occasion = &expanded.subjects()[0].occasions()[0]; let mut obs_times: Vec = occasion @@ -1758,7 +1871,7 @@ mod tests { .build(); let data = Data::from(subject); - let expanded = data.expand(5.0, 0.0); + let expanded = data.expand(5.0, 0.0, &[]); let subject = &expanded.subjects()[0]; let count_obs = |occ: &Occasion| { diff --git a/src/dsl/jit.rs b/src/dsl/jit.rs index 4e4eae70..79a38e83 100644 --- a/src/dsl/jit.rs +++ b/src/dsl/jit.rs @@ -1343,10 +1343,11 @@ pub fn compile_sde_model_to_jit(model: &ExecutionModel) -> Result( Ok(sorted) } -fn sorted_state_metadata<'a>( - info: &'a NativeModelInfo, -) -> Result, PharmsolError> { +fn sorted_state_metadata(info: &NativeModelInfo) -> Result, PharmsolError> { if info.state_len == 0 { if info.states.is_empty() { return Ok(Vec::new()); @@ -1031,7 +1025,8 @@ impl SharedNativeModel { ) })?; self.validate_output(outeq)?; - Ok(observation.to_prediction(outputs[outeq], state.to_vec())) + let label = OutputLabel::new(self.metadata().output_labels()[outeq]); + Ok(observation.to_prediction(label, outputs[outeq])) } } @@ -1042,7 +1037,6 @@ pub struct NativeOdeModel { rtol: f64, atol: f64, cache: Option, - error_model_cache: Option, } #[derive(Clone, Debug)] @@ -1056,7 +1050,6 @@ pub struct NativeAnalyticalModel { pub struct NativeSdeModel { shared: Arc, nparticles: usize, - cache: Option, } #[derive(Clone, Debug)] @@ -1087,18 +1080,17 @@ impl NativeOdeModel { rtol: DEFAULT_ODE_RTOL, atol: DEFAULT_ODE_ATOL, cache: Some(PredictionCache::new(DEFAULT_CACHE_SIZE)), - error_model_cache: Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )), }) } pub fn with_solver(mut self, solver: OdeSolver) -> Self { + self.cache = self.cache.as_ref().map(PredictionCache::detached); self.solver = solver; self } pub fn with_tolerances(mut self, rtol: f64, atol: f64) -> Self { + self.cache = self.cache.as_ref().map(PredictionCache::detached); self.rtol = rtol; self.atol = atol; self @@ -1132,6 +1124,7 @@ impl NativeOdeModel { ) -> Result { self.shared.validate_support_point(support_point)?; let mut output = SubjectPredictions::default(); + output.set_id(subject.id()); let support_vector: V = DVector::from_vec(support_point.to_vec()).into(); for occasion in subject.occasions() { @@ -1323,7 +1316,7 @@ impl NativeOdeModel { covariates, infusions, )?; - output.add_prediction(prediction); + output.add_prediction(prediction, observation.occasion()); } } @@ -1414,17 +1407,11 @@ fn runtime_ode_predictions( impl crate::simulator::equation::Cache for NativeOdeModel { fn with_cache_capacity(mut self, size: usize) -> Self { self.cache = Some(PredictionCache::new(size)); - self.error_model_cache = Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )); self } fn enable_cache(mut self) -> Self { self.cache = Some(PredictionCache::new(DEFAULT_CACHE_SIZE)); - self.error_model_cache = Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )); self } @@ -1432,14 +1419,10 @@ impl crate::simulator::equation::Cache for NativeOdeModel { if let Some(cache) = &self.cache { cache.invalidate_all(); } - if let Some(cache) = &self.error_model_cache { - cache.invalidate_all(); - } } fn disable_cache(mut self) -> Self { self.cache = None; - self.error_model_cache = None; self } } @@ -1488,13 +1471,11 @@ impl EquationPriv for NativeOdeModel { fn process_observation( &self, - _support_point: &[f64], + _parameters: &[f64], _observation: &Observation, - _error_models: Option<&AssayErrorModels>, _time: f64, _covariates: &Covariates, - _x: &mut Self::S, - _likelihood: &mut Vec, + _state: &mut Self::S, _output: &mut Self::P, ) -> Result<(), PharmsolError> { unimplemented!("process_observation is not used for runtime ODE models") @@ -1511,32 +1492,6 @@ impl EquationPriv for NativeOdeModel { } impl Equation for NativeOdeModel { - fn bound_error_model_cache(&self) -> Option<&BoundErrorModelCache> { - self.error_model_cache.as_ref() - } - - fn estimate_likelihood( - &self, - subject: &Subject, - parameters: &Parameters, - error_models: &AssayErrorModels, - ) -> Result { - Ok(self - .estimate_log_likelihood(subject, parameters, error_models)? - .exp()) - } - - fn estimate_log_likelihood( - &self, - subject: &Subject, - parameters: &Parameters, - error_models: &AssayErrorModels, - ) -> Result { - let bound_error_models = self.bind_error_models(error_models)?; - let predictions = runtime_ode_predictions(self, subject, parameters.as_slice())?; - predictions.log_likelihood(&bound_error_models) - } - fn estimate_predictions_dense( &self, subject: &Subject, @@ -1545,49 +1500,18 @@ impl Equation for NativeOdeModel { runtime_ode_predictions(self, subject, parameters) } - fn estimate_log_likelihood_dense( - &self, - subject: &Subject, - parameters: &[f64], - error_models: &AssayErrorModels, - ) -> Result { - let bound_error_models = self.bind_error_models(error_models)?; - let predictions = runtime_ode_predictions(self, subject, parameters)?; - predictions.log_likelihood(&bound_error_models) - } - fn simulate_subject_dense( &self, subject: &Subject, parameters: &[f64], - error_models: Option<&AssayErrorModels>, - ) -> Result<(Self::P, Option), PharmsolError> { - let bound_error_models = match error_models { - Some(error_models) => Some(self.bind_error_models(error_models)?), - None => None, - }; - - let predictions = runtime_ode_predictions(self, subject, parameters)?; - let likelihood = match bound_error_models.as_ref() { - Some(error_models) => Some(predictions.log_likelihood(error_models)?.exp()), - None => None, - }; - Ok((predictions, likelihood)) + ) -> Result { + runtime_ode_predictions(self, subject, parameters) } fn kind() -> EqnKind { EqnKind::ODE } - fn assay_error_models(&self) -> AssayErrorModels { - AssayErrorModels::with_output_names( - self.info() - .outputs - .iter() - .map(|output| output.name.as_str()), - ) - } - fn estimate_predictions( &self, subject: &Subject, @@ -1595,26 +1519,6 @@ impl Equation for NativeOdeModel { ) -> Result { runtime_ode_predictions(self, subject, parameters.as_slice()) } - - fn simulate_subject( - &self, - subject: &Subject, - parameters: &Parameters, - error_models: Option<&AssayErrorModels>, - ) -> Result<(Self::P, Option), PharmsolError> { - let support_point = parameters.as_slice(); - let bound_error_models = match error_models { - Some(error_models) => Some(self.bind_error_models(error_models)?), - None => None, - }; - - let predictions = runtime_ode_predictions(self, subject, support_point)?; - let likelihood = match bound_error_models.as_ref() { - Some(error_models) => Some(predictions.log_likelihood(error_models)?.exp()), - None => None, - }; - Ok((predictions, likelihood)) - } } impl NativeAnalyticalModel { @@ -1648,16 +1552,17 @@ impl NativeAnalyticalModel { subject: &Subject, parameters: &Parameters, ) -> Result { - self.estimate_predictions_dense(subject, parameters.as_slice()) + runtime_analytical_predictions(self, subject, parameters.as_slice()) } - fn estimate_predictions_dense( + fn estimate_predictions_dense_uncached( &self, subject: &Subject, support_point: &[f64], ) -> Result { self.shared.validate_support_point(support_point)?; let mut output = SubjectPredictions::default(); + output.set_id(subject.id()); for occasion in subject.occasions() { let mut events = self.shared.resolve_events(occasion)?; @@ -1696,14 +1601,17 @@ impl NativeAnalyticalModel { } Event::Infusion(_) => {} Event::Observation(observation) => { - output.add_prediction(self.shared.observation_prediction( - &mut *session, - observation, - &state, - support_point, - occasion.covariates(), - infusions.as_slice(), - )?); + output.add_prediction( + self.shared.observation_prediction( + &mut *session, + observation, + &state, + support_point, + occasion.covariates(), + infusions.as_slice(), + )?, + observation.occasion(), + ); } } @@ -1817,13 +1725,13 @@ fn runtime_analytical_predictions( } let result = model - .estimate_predictions_dense(subject, support_point) + .estimate_predictions_dense_uncached(subject, support_point) .map_err(add_context)?; cache.insert(key, result.clone()); Ok(result) } else { model - .estimate_predictions_dense(subject, support_point) + .estimate_predictions_dense_uncached(subject, support_point) .map_err(add_context) } } @@ -1895,13 +1803,11 @@ impl EquationPriv for NativeAnalyticalModel { fn process_observation( &self, - _support_point: &[f64], + _parameters: &[f64], _observation: &Observation, - _error_models: Option<&AssayErrorModels>, _time: f64, _covariates: &Covariates, - _x: &mut Self::S, - _likelihood: &mut Vec, + _state: &mut Self::S, _output: &mut Self::P, ) -> Result<(), PharmsolError> { unimplemented!("process_observation is not used for runtime analytical models") @@ -1918,83 +1824,32 @@ impl EquationPriv for NativeAnalyticalModel { } impl Equation for NativeAnalyticalModel { - fn estimate_likelihood( - &self, - subject: &Subject, - parameters: &Parameters, - error_models: &AssayErrorModels, - ) -> Result { - Ok(self - .estimate_log_likelihood(subject, parameters, error_models)? - .exp()) - } - - fn estimate_log_likelihood( - &self, - subject: &Subject, - parameters: &Parameters, - error_models: &AssayErrorModels, - ) -> Result { - let bound_error_models = self.bind_error_models(error_models)?; - let predictions = runtime_analytical_predictions(self, subject, parameters.as_slice())?; - predictions.log_likelihood(&bound_error_models) - } - fn estimate_predictions_dense( &self, subject: &Subject, parameters: &[f64], ) -> Result { - NativeAnalyticalModel::estimate_predictions_dense(self, subject, parameters) + runtime_analytical_predictions(self, subject, parameters) } fn kind() -> EqnKind { EqnKind::Analytical } - fn assay_error_models(&self) -> AssayErrorModels { - AssayErrorModels::with_output_names( - self.info() - .outputs - .iter() - .map(|output| output.name.as_str()), - ) - } - fn estimate_predictions( &self, subject: &Subject, parameters: &Parameters, ) -> Result { - NativeAnalyticalModel::estimate_predictions(self, subject, parameters) - } - - fn simulate_subject( - &self, - subject: &Subject, - parameters: &Parameters, - error_models: Option<&AssayErrorModels>, - ) -> Result<(Self::P, Option), PharmsolError> { - self.simulate_subject_dense(subject, parameters.as_slice(), error_models) + runtime_analytical_predictions(self, subject, parameters.as_slice()) } fn simulate_subject_dense( &self, subject: &Subject, parameters: &[f64], - error_models: Option<&AssayErrorModels>, - ) -> Result<(Self::P, Option), PharmsolError> { - let bound_error_models = match error_models { - Some(error_models) => Some(self.bind_error_models(error_models)?), - None => None, - }; - - let predictions = runtime_analytical_predictions(self, subject, parameters)?; - let likelihood = match bound_error_models.as_ref() { - Some(error_models) => Some(predictions.log_likelihood(error_models)?.exp()), - None => None, - }; - Ok((predictions, likelihood)) + ) -> Result { + runtime_analytical_predictions(self, subject, parameters) } } @@ -2012,7 +1867,6 @@ impl NativeSdeModel { Ok(Self { shared: Arc::new(SharedNativeModel::new(info, artifact)?), nparticles, - cache: Some(SdeLikelihoodCache::new(DEFAULT_CACHE_SIZE)), }) } @@ -2021,10 +1875,6 @@ impl NativeSdeModel { return self; } - if let Some(cache) = &self.cache { - cache.invalidate_all(); - } - let mut info = self.shared.info.as_ref().clone(); info.particles = Some(nparticles); self.shared = Arc::new( @@ -2159,6 +2009,7 @@ impl NativeSdeModel { let shared = Arc::clone(&self.shared); let support = Arc::new(support_point.to_vec()); let infusion_events = Arc::new(infusions.to_vec()); + let discontinuities = Arc::new(infusion_discontinuities(infusions, start_time, end_time)); let covariates = covariates.clone(); if !shared.artifact.has_function(ModelFunctionKind::Drift) { return Err(PharmsolError::OtherError(format!( @@ -2179,6 +2030,7 @@ impl NativeSdeModel { let shared = Arc::clone(&shared); let support = Arc::clone(&support); let infusions = Arc::clone(&infusion_events); + let discontinuities = Arc::clone(&discontinuities); let covariates = covariates.clone(); let shared_for_diffusion = Arc::clone(&shared); let support_for_diffusion = Arc::clone(&support); @@ -2280,6 +2132,7 @@ impl NativeSdeModel { drift_state, start_time, end_time, + &discontinuities, ); if let Some(error) = function_error.into_inner() { return Err(error); @@ -2292,56 +2145,6 @@ impl NativeSdeModel { } } -#[inline(always)] -fn runtime_sde_log_likelihood( - model: &NativeSdeModel, - subject: &Subject, - support_point: &[f64], - error_models: &AssayErrorModels, -) -> Result { - if let Some(cache) = &model.cache { - let key = ( - subject.hash(), - crate::simulator::equation::parameters_hash(support_point), - error_models.hash(), - ); - if let Some(cached) = cache.get(&key) { - return Ok(cached); - } - - let predictions = model.estimate_predictions_dense(subject, support_point)?; - let log_lik = predictions.log_likelihood(error_models)?; - cache.insert(key, log_lik); - Ok(log_lik) - } else { - let predictions = model.estimate_predictions_dense(subject, support_point)?; - predictions.log_likelihood(error_models) - } -} - -impl crate::simulator::equation::Cache for NativeSdeModel { - fn with_cache_capacity(mut self, size: usize) -> Self { - self.cache = Some(SdeLikelihoodCache::new(size)); - self - } - - fn enable_cache(mut self) -> Self { - self.cache = Some(SdeLikelihoodCache::new(DEFAULT_CACHE_SIZE)); - self - } - - fn clear_cache(&self) { - if let Some(cache) = &self.cache { - cache.invalidate_all(); - } - } - - fn disable_cache(mut self) -> Self { - self.cache = None; - self - } -} - impl EquationTypes for NativeSdeModel { type S = Vec>; type P = Array2; @@ -2372,10 +2175,6 @@ impl EquationPriv for NativeSdeModel { self.nparticles } - fn is_sde(&self) -> bool { - true - } - fn metadata(&self) -> Option<&crate::ValidatedModelMetadata> { Some(self.shared.metadata()) } @@ -2394,13 +2193,11 @@ impl EquationPriv for NativeSdeModel { fn process_observation( &self, - _support_point: &[f64], + _parameters: &[f64], _observation: &Observation, - _error_models: Option<&AssayErrorModels>, _time: f64, _covariates: &Covariates, - _x: &mut Self::S, - _likelihood: &mut Vec, + _state: &mut Self::S, _output: &mut Self::P, ) -> Result<(), PharmsolError> { unimplemented!("process_observation is not used for runtime SDE models") @@ -2417,39 +2214,10 @@ impl EquationPriv for NativeSdeModel { } impl Equation for NativeSdeModel { - fn estimate_likelihood( - &self, - subject: &Subject, - parameters: &Parameters, - error_models: &AssayErrorModels, - ) -> Result { - let log_lik = self.estimate_log_likelihood(subject, parameters, error_models)?; - Ok(log_lik.exp()) - } - - fn estimate_log_likelihood( - &self, - subject: &Subject, - parameters: &Parameters, - error_models: &AssayErrorModels, - ) -> Result { - let bound_error_models = self.bind_error_models(error_models)?; - runtime_sde_log_likelihood(self, subject, parameters.as_slice(), &bound_error_models) - } - fn kind() -> EqnKind { EqnKind::SDE } - fn assay_error_models(&self) -> AssayErrorModels { - AssayErrorModels::with_output_names( - self.info() - .outputs - .iter() - .map(|output| output.name.as_str()), - ) - } - fn estimate_predictions( &self, subject: &Subject, @@ -2466,42 +2234,12 @@ impl Equation for NativeSdeModel { NativeSdeModel::estimate_predictions_dense(self, subject, parameters) } - fn estimate_log_likelihood_dense( - &self, - subject: &Subject, - parameters: &[f64], - error_models: &AssayErrorModels, - ) -> Result { - let bound_error_models = self.bind_error_models(error_models)?; - runtime_sde_log_likelihood(self, subject, parameters, &bound_error_models) - } - - fn simulate_subject( - &self, - subject: &Subject, - parameters: &Parameters, - error_models: Option<&AssayErrorModels>, - ) -> Result<(Self::P, Option), PharmsolError> { - self.simulate_subject_dense(subject, parameters.as_slice(), error_models) - } - fn simulate_subject_dense( &self, subject: &Subject, parameters: &[f64], - error_models: Option<&AssayErrorModels>, - ) -> Result<(Self::P, Option), PharmsolError> { - let bound_error_models = match error_models { - Some(error_models) => Some(self.bind_error_models(error_models)?), - None => None, - }; - - let predictions = NativeSdeModel::estimate_predictions_dense(self, subject, parameters)?; - let likelihood = match bound_error_models.as_ref() { - Some(error_models) => Some(predictions.log_likelihood(error_models)?.exp()), - None => None, - }; - Ok((predictions, likelihood)) + ) -> Result { + NativeSdeModel::estimate_predictions_dense(self, subject, parameters) } } @@ -2511,9 +2249,14 @@ fn active_route_inputs(infusions: &[Infusion], time: f64, route_len: usize) -> V let input = infusion .input_index() .expect("resolved infusions should use numeric input labels"); + // Infusion activity is half-open `[start, end)`: the rate is delivered + // up to but not including the finish time. Using `<=` here would keep + // the rate active at the exact end time, which is also a segment + // boundary during native SDE propagation, and would add an extra + // end-time rate step that over-delivers the infused amount. if input < route_len && time >= infusion.time() - && time <= infusion.time() + infusion.duration() + && time < infusion.time() + infusion.duration() { values[input] += infusion.amount() / infusion.duration(); } @@ -2757,6 +2500,7 @@ mod tests { NativeOutputInfo, NativeRouteInfo, NativeSdeModel, NativeStateInfo, RuntimeArtifact, RuntimeBackend, SharedNativeModel, }; + use super::{runtime_analytical_predictions, PredictionCache}; #[cfg(any( feature = "dsl-jit", all(feature = "dsl-aot", feature = "dsl-aot-load"), @@ -2765,11 +2509,7 @@ mod tests { not(all(target_arch = "wasm32", target_os = "unknown")) ) ))] - use super::{ - runtime_ode_predictions, BoundErrorModelCache, PredictionCache, - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, DEFAULT_ODE_ATOL, DEFAULT_ODE_RTOL, - }; - use crate::PharmsolError; + use super::{runtime_ode_predictions, DEFAULT_ODE_ATOL, DEFAULT_ODE_RTOL}; #[cfg(any( feature = "dsl-jit", all(feature = "dsl-aot", feature = "dsl-aot-load"), @@ -2778,11 +2518,12 @@ mod tests { not(all(target_arch = "wasm32", target_os = "unknown")) ) ))] + use crate::dsl::{CompiledRuntimeModel, RuntimePredictions}; use crate::{ data::builder::SubjectBuilderExt, - dsl::{CompiledRuntimeModel, RuntimePredictions}, prelude::SubjectPredictions, - Parameters, Subject, + simulator::equation::{Cache, Equation}, + Parameters, PharmsolError, Subject, }; use diffsol::VectorHost; use pharmsol_dsl::execution::ModelFunctionKind; @@ -2790,15 +2531,10 @@ mod tests { AnalyticalKernel, AnalyticalStructureInputKind, CovariateInterpolation, ModelKind, RouteKind, }; - #[cfg(any( - feature = "dsl-jit", - all(feature = "dsl-aot", feature = "dsl-aot-load"), - all( - feature = "dsl-wasm", - not(all(target_arch = "wasm32", target_os = "unknown")) - ) - ))] - use std::sync::Arc; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; #[derive(Debug)] struct DummyArtifact; @@ -2817,6 +2553,94 @@ mod tests { } } + #[derive(Debug)] + struct CountingAnalyticalArtifact { + session_count: Arc, + } + + impl RuntimeArtifact for CountingAnalyticalArtifact { + fn backend(&self) -> RuntimeBackend { + panic!("counting analytical artifact backend should not be used in tests") + } + + fn has_function(&self, role: ModelFunctionKind) -> bool { + role == ModelFunctionKind::Outputs + } + + fn start_session(&self) -> Result, PharmsolError> { + self.session_count.fetch_add(1, Ordering::SeqCst); + Ok(Box::new(CountingAnalyticalSession)) + } + } + + struct CountingAnalyticalSession; + + impl FunctionSession for CountingAnalyticalSession { + unsafe fn invoke_raw( + &mut self, + role: ModelFunctionKind, + _time: f64, + states: *const f64, + _params: *const f64, + _covariates: *const f64, + _routes: *const f64, + _derived: *const f64, + out: *mut f64, + ) -> Result<(), PharmsolError> { + if role != ModelFunctionKind::Outputs { + return Err(PharmsolError::OtherError(format!( + "unexpected kernel role {role:?} in analytical cache test" + ))); + } + unsafe { *out = *states }; + Ok(()) + } + } + + fn counting_analytical_model() -> (NativeAnalyticalModel, Arc) { + let session_count = Arc::new(AtomicUsize::new(0)); + let model = NativeAnalyticalModel::new( + NativeModelInfo { + name: "analytical_cache".to_string(), + kind: ModelKind::Analytical, + parameters: vec!["ke".to_string()], + derived: Vec::new(), + covariates: Vec::new(), + states: vec![NativeStateInfo { + name: "central".to_string(), + offset: 0, + }], + routes: Vec::new(), + outputs: vec![NativeOutputInfo { + name: "cp".to_string(), + index: 0, + }], + state_len: 1, + derived_len: 0, + output_len: 1, + route_len: 0, + analytical: Some(AnalyticalKernel::OneCompartment), + particles: None, + }, + CountingAnalyticalArtifact { + session_count: Arc::clone(&session_count), + }, + ) + .expect("counting analytical model should build"); + (model, session_count) + } + + fn analytical_cache_subject() -> Subject { + Subject::builder("analytical_cache") + .missing_observation(0.0, "cp") + .build() + } + + fn analytical_cache_parameters(model: &NativeAnalyticalModel) -> Parameters { + Parameters::with_model(model, [("ke", 0.1)]) + .expect("analytical cache parameters should validate") + } + fn bolus_only_shared_model() -> SharedNativeModel { SharedNativeModel::new( NativeModelInfo { @@ -3092,6 +2916,147 @@ mod tests { assert_eq!(model.metadata().particles(), Some(64)); } + /// Test-only runtime artifact that models a single central compartment with + /// zero intrinsic drift and zero diffusion. The infused rate reaches the + /// state purely through `apply_route_inputs_to_rates`, so the delivered mass + /// equals the exact time integral of the active infusion rate. + #[derive(Debug)] + struct SdeInfusionDeliveryArtifact; + + impl RuntimeArtifact for SdeInfusionDeliveryArtifact { + fn backend(&self) -> RuntimeBackend { + panic!("infusion-delivery test artifact backend should not be used") + } + + fn has_function(&self, role: ModelFunctionKind) -> bool { + matches!( + role, + ModelFunctionKind::Drift + | ModelFunctionKind::Diffusion + | ModelFunctionKind::Outputs + ) + } + + fn start_session(&self) -> Result, PharmsolError> { + Ok(Box::new(SdeInfusionDeliverySession)) + } + } + + struct SdeInfusionDeliverySession; + + impl FunctionSession for SdeInfusionDeliverySession { + unsafe fn invoke_raw( + &mut self, + role: ModelFunctionKind, + _time: f64, + states: *const f64, + _params: *const f64, + _covariates: *const f64, + _routes: *const f64, + _derived: *const f64, + out: *mut f64, + ) -> Result<(), PharmsolError> { + match role { + // Intrinsic drift is zero; the injected infusion rate is added + // by `apply_route_inputs_to_rates` after this kernel returns. + ModelFunctionKind::Drift => unsafe { *out = 0.0 }, + // Deterministic (noise-free) diffusion keeps delivered mass exact. + ModelFunctionKind::Diffusion => unsafe { *out = 0.0 }, + // Single-state passthrough output: cp == central. + ModelFunctionKind::Outputs => unsafe { *out = *states }, + other => { + return Err(PharmsolError::OtherError(format!( + "unexpected kernel role {other:?} in SDE infusion-delivery test" + ))) + } + } + Ok(()) + } + } + + /// Regression: an infusion whose finish time lands strictly between two + /// observations must deliver its exact declared amount. The infusion end is + /// also a native SDE segment boundary, so a closed `[start, end]` activity + /// window (or an extra end-time rate step) would over-deliver mass. + #[test] + fn native_sde_infusion_ending_between_observations_delivers_exact_amount() { + use crate::data::builder::SubjectBuilderExt; + + let model = NativeSdeModel::new( + NativeModelInfo { + name: "sde_infusion_delivery".to_string(), + kind: ModelKind::Sde, + parameters: Vec::new(), + derived: Vec::new(), + covariates: Vec::new(), + states: vec![NativeStateInfo { + name: "central".to_string(), + offset: 0, + }], + routes: vec![NativeRouteInfo { + name: "iv".to_string(), + declaration_index: 0, + index: 0, + kind: Some(RouteKind::Infusion), + destination_offset: 0, + destination_name: "central".to_string(), + has_lag: false, + has_bioavailability: false, + inject_input_to_destination: true, + }], + outputs: vec![NativeOutputInfo { + name: "cp".to_string(), + index: 0, + }], + state_len: 1, + derived_len: 0, + output_len: 1, + route_len: 1, + analytical: None, + particles: Some(2), + }, + SdeInfusionDeliveryArtifact, + ) + .expect("SDE infusion-delivery metadata should build"); + + // Infusion delivers 20 units over [1, 3] (rate 10/unit-time). The finish + // time (t = 3) sits strictly between the observations at t = 2 and t = 4. + let subject = crate::Subject::builder("sde_infusion_delivery") + .infusion(1.0, 20.0, "iv", 2.0) + .missing_observation(0.0, "cp") + .missing_observation(2.0, "cp") + .missing_observation(4.0, "cp") + .build(); + + let predictions = model + .estimate_predictions_dense(&subject, &[]) + .expect("noise-free SDE infusion model should simulate"); + + assert_eq!(predictions.dim(), (2, 3)); + + for particle in 0..predictions.nrows() { + // Before the infusion begins nothing has been delivered. + assert!( + predictions[(particle, 0)].prediction().abs() < 1e-9, + "particle {particle} should start empty, got {}", + predictions[(particle, 0)].prediction() + ); + // Halfway through the infusion exactly half the mass is delivered. + assert!( + (predictions[(particle, 1)].prediction() - 10.0).abs() < 1e-9, + "particle {particle} should hold 10 units mid-infusion, got {}", + predictions[(particle, 1)].prediction() + ); + // After the infusion ends between observations the full declared + // amount is delivered and no extra end-time rate step is added. + assert!( + (predictions[(particle, 2)].prediction() - 20.0).abs() < 1e-9, + "particle {particle} should hold the full 20 units, got {}", + predictions[(particle, 2)].prediction() + ); + } + } + fn analytical_projection_values( model: &NativeAnalyticalModel, support_point: &[f64], @@ -3117,9 +3082,6 @@ mod tests { rtol: DEFAULT_ODE_RTOL, atol: DEFAULT_ODE_ATOL, cache: Some(PredictionCache::new(1)), - error_model_cache: Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )), } } @@ -3138,6 +3100,67 @@ mod tests { .build() } + #[test] + fn compiled_analytical_predictions_consistently_obey_cache_controls() { + let (model, session_count) = counting_analytical_model(); + let subject = analytical_cache_subject(); + let parameters = analytical_cache_parameters(&model); + + model + .estimate_predictions(&subject, ¶meters) + .expect("first analytical prediction should compute"); + model + .estimate_predictions(&subject, ¶meters) + .expect("repeated inherent prediction should hit the cache"); + Equation::estimate_predictions_dense(&model, &subject, parameters.as_slice()) + .expect("dense prediction should hit the same cache"); + Equation::estimate_predictions(&model, &subject, ¶meters) + .expect("Equation prediction should hit the same cache"); + Equation::simulate_subject_dense(&model, &subject, parameters.as_slice()) + .expect("deterministic simulation should hit the same cache"); + runtime_analytical_predictions(&model, &subject, parameters.as_slice()) + .expect("runtime helper should hit the same cache without recursion"); + assert_eq!(session_count.load(Ordering::SeqCst), 1); + + model.clear_cache(); + model + .estimate_predictions(&subject, ¶meters) + .expect("prediction after clear should recompute"); + assert_eq!(session_count.load(Ordering::SeqCst), 2); + + let resized = model.clone().with_cache_capacity(1); + resized + .estimate_predictions(&subject, ¶meters) + .expect("replacement cache should start empty"); + resized + .estimate_predictions(&subject, ¶meters) + .expect("replacement cache should retain its entry"); + assert_eq!(session_count.load(Ordering::SeqCst), 3); + Equation::estimate_predictions_dense(&resized, &subject, &[0.2]) + .expect("a second key should compute"); + Equation::estimate_predictions_dense(&resized, &subject, parameters.as_slice()) + .expect("capacity-one cache should evict the first key"); + assert_eq!(session_count.load(Ordering::SeqCst), 5); + + let reenabled = model.clone().enable_cache(); + reenabled + .estimate_predictions(&subject, ¶meters) + .expect("reenabled cache should start empty"); + reenabled + .estimate_predictions(&subject, ¶meters) + .expect("reenabled cache should retain its entry"); + assert_eq!(session_count.load(Ordering::SeqCst), 6); + + let uncached = model.disable_cache(); + uncached + .estimate_predictions(&subject, ¶meters) + .expect("disabled cache should compute"); + uncached + .estimate_predictions(&subject, ¶meters) + .expect("disabled cache should recompute"); + assert_eq!(session_count.load(Ordering::SeqCst), 8); + } + #[test] fn validate_input_for_kind_reports_structured_route_kind_error() { let model = bolus_only_shared_model(); diff --git a/src/dsl/runtime.rs b/src/dsl/runtime.rs index c09d794f..72f2fc3d 100644 --- a/src/dsl/runtime.rs +++ b/src/dsl/runtime.rs @@ -105,7 +105,7 @@ use super::wasm_compile::{ compile_execution_model_to_wasm_bytes, compile_module_source_to_wasm_bytes, WasmError, }; use crate::{ - simulator::likelihood::{Prediction, SubjectPredictions}, + simulator::prediction::{Prediction, SubjectPredictions}, Parameters, PharmsolError, Subject, ValidatedModelMetadata, }; use pharmsol_dsl::{ diff --git a/src/error/mod.rs b/src/error/mod.rs index 883b53c2..54f85f1c 100644 --- a/src/error/mod.rs +++ b/src/error/mod.rs @@ -2,8 +2,6 @@ use thiserror::Error; use pharmsol_dsl::RouteKind; -#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -use crate::data::error_model::ErrorModelError; #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] use crate::data::row::DataError; @@ -19,9 +17,6 @@ pub enum PharmsolError { #[error("Parameter error: {0}")] ParameterError(#[from] ParameterError), #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - #[error("Error in the error model: {0}")] - ErrorModelError(#[from] ErrorModelError), - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] #[error("Covariate error: {0}")] CovariateError(#[from] CovariateError), #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] @@ -34,14 +29,6 @@ pub enum PharmsolError { DiffsolError(String), #[error("Other error: {0}")] OtherError(String), - #[error("Error setting up progress bar: {0}")] - ProgressBarError(String), - #[error("Likelihood is not finite: {0}")] - NonFiniteLikelihood(f64), - #[error("The calculated likelihood is zero")] - ZeroLikelihood, - #[error("Missing observation in prediction")] - MissingObservation, #[error("Input label `{label}` could not be resolved to a route input{available}")] UnknownInputLabel { label: String, available: String }, #[error("Output label `{label}` could not be resolved to an output{available}")] @@ -52,6 +39,8 @@ pub enum PharmsolError { InputOutOfRange { input: usize, ndrugs: usize }, #[error("Output equation {outeq} is out of range (nout = {nout})")] OuteqOutOfRange { outeq: usize, nout: usize }, + #[error("model has no metadata; input and output labels require a model authored with the ode!/analytical!/sde! macros or the DSL")] + MissingMetadata, #[error("Compiled model `{model}` has invalid runtime metadata: {detail}")] InvalidMetadata { model: String, detail: String }, } diff --git a/src/lib.rs b/src/lib.rs index 815d6993..28d85194 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,9 @@ //! - [`nca`] to calculate NCA metrics from the same data structures //! - [`optimize`] for optimizer-oriented workflows //! +//! pharmsol provides model execution and prediction generation; scoring and +//! estimation are handled by downstream estimation crates. +//! //! The DSL runtime surface is feature-gated. When you enable `dsl-core`, the //! `pharmsol::dsl` module adds parsing, analysis, compilation, and runtime //! entrypoints for models written as DSL source text. @@ -141,8 +144,6 @@ pub use crate::data::*; #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] pub use crate::optimize::effect::get_e2; #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -pub use crate::optimize::parameters::ParameterOptimizer; -#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] pub use crate::simulator::equation::analytical::*; #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] pub use crate::simulator::equation::metadata; @@ -150,8 +151,8 @@ pub use crate::simulator::equation::metadata; pub use crate::simulator::equation::{ self, ode::{ExplicitRkTableau, OdeSolver, SdirkTableau}, - Analytical, AnalyticalKernel, Cache, Equation, ModelKind, ModelMetadata, ModelMetadataError, - NameDomain, Predictions, RouteInputPolicy, RouteKind, State, ValidatedModelMetadata, ODE, SDE, + AnalyticalKernel, Cache, Equation, ModelKind, ModelMetadata, ModelMetadataError, NameDomain, + Predictions, RouteInputPolicy, RouteKind, State, ValidatedModelMetadata, }; pub use error::PharmsolError; #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] @@ -205,19 +206,16 @@ pub mod prelude { // Data submodule for organized access and backward compatibility pub mod data { pub use crate::data::{ - error_model::{AssayErrorModel, AssayErrorModels}, parser::{read_pmetrics, DataRow, DataRowBuilder}, - residual_error::{ResidualErrorModel, ResidualErrorModels}, - Covariates, Data, Event, Occasion, Subject, + Covariates, Data, ErrorPoly, Event, Occasion, Subject, }; } // Direct data re-exports for convenience pub use crate::data::{ builder::SubjectBuilderExt, - error_model::{AssayErrorModel, AssayErrorModels, ErrorPoly}, event::{AUCMethod, BLQRule, Route}, - Covariates, Data, Event, Interpolation, Occasion, Subject, + Covariates, Data, ErrorPoly, Event, Interpolation, Occasion, Subject, }; // NCA extension traits (provides .nca(), .nca_all(), etc. on data types) @@ -228,29 +226,25 @@ pub mod prelude { // AUC primitives for direct use on raw arrays pub use crate::data::auc::{auc, auc_interval, aumc, interpolate_linear}; - #[allow(deprecated)] // Simulator submodule for organized access to simulation types. pub mod simulator { pub use crate::simulator::{ - cache::{self, PredictionCache, SdeLikelihoodCache, DEFAULT_CACHE_SIZE}, + cache::{self, PredictionCache, DEFAULT_CACHE_SIZE}, equation, equation::Equation, - likelihood::{ - log_likelihood_batch, log_likelihood_matrix, log_likelihood_subject, log_psi, psi, - PopulationPredictions, Prediction, SubjectPredictions, - }, + prediction::{Prediction, SubjectPredictions}, }; } // Direct simulator re-exports for convenience pub use crate::simulator::{ - cache::{PredictionCache, SdeLikelihoodCache, DEFAULT_CACHE_SIZE}, + cache::{PredictionCache, DEFAULT_CACHE_SIZE}, equation::{ self, ode::{ExplicitRkTableau, OdeSolver, SdirkTableau}, Equation, }, - likelihood::{Prediction, SubjectPredictions}, + prediction::{Prediction, SubjectPredictions}, }; // Analytical model functions diff --git a/src/optimize/mod.rs b/src/optimize/mod.rs index b044108b..da17a849 100644 --- a/src/optimize/mod.rs +++ b/src/optimize/mod.rs @@ -4,8 +4,5 @@ //! //! - [`effect`] — Find the maximum effect (`E2`) for dual-site PD models //! via Nelder‑Mead optimization in log‑space. -//! - [`parameters`] — Nelder‑Mead parameter refinement for an [`Equation`] -//! against a [`Data`] set and [`AssayErrorModels`]. pub mod effect; -pub mod parameters; diff --git a/src/optimize/parameters.rs b/src/optimize/parameters.rs deleted file mode 100644 index fcf21a14..00000000 --- a/src/optimize/parameters.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! Nelder‑Mead parameter refinement for pharmacometric models. -//! -//! This module provides a [`ParameterOptimizer`] that refines a single parameter -//! Given an [`Equation`], observed [`Data`], and [`AssayErrorModels`] via -//! Nelder‑Mead optimization in log‑space. The optimizer finds the parameter vector -//! that minimizes the negative log-likelihood of the model predictions against the data, -//! as measured by the provided error models. - -use argmin::{ - core::{CostFunction, Error, Executor}, - solver::neldermead::NelderMead, -}; - -use ndarray::{Array1, Axis}; - -use crate::{prelude::simulator::log_likelihood_matrix, AssayErrorModels, Data, Equation}; - -/// Optimizer that refines a single parameter vector against observed data. -pub struct ParameterOptimizer<'a, E: Equation> { - equation: &'a E, - data: &'a Data, - sig: &'a AssayErrorModels, - pyl: &'a Array1, -} - -impl CostFunction for ParameterOptimizer<'_, E> { - type Param = Vec; - type Output = f64; - - fn cost(&self, parameters: &Self::Param) -> Result { - let theta = Array1::from(parameters.clone()).insert_axis(Axis(0)); - - let log_psi = log_likelihood_matrix(self.equation, self.data, &theta, self.sig, false)?; - let psi = log_psi.mapv(f64::exp); - - if psi.ncols() > 1 { - tracing::error!("Psi in ParameterOptimizer has more than one column"); - } - if psi.nrows() != self.pyl.len() { - tracing::error!( - "Psi in ParameterOptimizer has {} rows, but pyl has {}", - psi.nrows(), - self.pyl.len() - ); - } - let nsub = psi.nrows() as f64; - let mut sum = -nsub; - for (p_i, pyl_i) in psi.iter().zip(self.pyl.iter()) { - sum += p_i / pyl_i; - } - Ok(-sum) - } -} - -impl<'a, E: Equation> ParameterOptimizer<'a, E> { - /// Create a new optimizer. - /// - /// * `equation` — the model to evaluate. - /// * `data` — observed subject data. - /// * `sig` — assay error models per output. - /// * `pyl` — reference (target) likelihood vector. - pub fn new( - equation: &'a E, - data: &'a Data, - sig: &'a AssayErrorModels, - pyl: &'a Array1, - ) -> Self { - Self { - equation, - data, - sig, - pyl, - } - } - - /// Optimize the parameters to minimize the negative log-likelihood against the data. - - pub fn optimize_point(self, parameters: Array1) -> Result, Error> { - let simplex = create_initial_simplex(¶meters.to_vec()); - let solver: NelderMead, f64> = NelderMead::new(simplex).with_sd_tolerance(1e-2)?; - let res = Executor::new(self, solver) - .configure(|state| state.max_iters(5)) - // .add_observer(SlogLogger::term(), ObserverMode::Always) - .run()?; - Ok(Array1::from(res.state.best_param.unwrap())) - } -} - -fn create_initial_simplex(initial_point: &[f64]) -> Vec> { - let num_dimensions = initial_point.len(); - let perturbation_percentage = 0.008; - - let mut vertices = Vec::new(); - vertices.push(initial_point.to_vec()); - - for i in 0..num_dimensions { - let perturbation = if initial_point[i] == 0.0 { - 0.00025 - } else { - perturbation_percentage * initial_point[i] - }; - - let mut perturbed_point = initial_point.to_owned(); - perturbed_point[i] += perturbation; - vertices.push(perturbed_point); - } - - vertices -} diff --git a/src/parameters.rs b/src/parameters.rs index 872dd560..20842979 100644 --- a/src/parameters.rs +++ b/src/parameters.rs @@ -16,7 +16,7 @@ use crate::parameter_order::{ParameterOrderError, ParameterOrderPlan}; ))] use crate::dsl::{CompiledRuntimeModel, RuntimeAnalyticalModel, RuntimeOdeModel, RuntimeSdeModel}; #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -use crate::{Analytical, ODE, SDE}; +use crate::equation::{Analytical, ODE, SDE}; /// Errors produced while validating named parameter input against model order. #[derive(Clone, Debug, Error, PartialEq, Eq)] @@ -52,7 +52,7 @@ impl From for ParameterError { } } -/// Thin owned dense parameter storage for one support point. +/// Thin owned storage for one dense model-order parameter vector. #[derive(Clone, Debug, PartialEq)] pub struct Parameters(Vec); @@ -120,14 +120,14 @@ impl ParameterOrder { Ok(Self { plan }) } - /// Reorder one dense support point from source order into model order. + /// Reorder one dense parameter vector from source order into model order. pub fn values(&self, source_values: &[f64]) -> Result, ParameterError> { self.plan .reorder_values(source_values) .map_err(ParameterError::from) } - /// Reorder a dense support-point matrix whose rows are support points. + /// Reorder a dense parameter matrix whose rows are parameter vectors. #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] pub fn matrix(&self, source_values: Array2) -> Result, ParameterError> { if source_values.ncols() != self.width() { @@ -315,7 +315,8 @@ mod tests { use ndarray::array; #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - use crate::{fa, lag, metadata, Equation, ModelKind, Subject, SubjectBuilderExt, ODE}; + use crate::equation::ODE; + use crate::{fa, lag, metadata, Equation, ModelKind, Subject, SubjectBuilderExt}; #[cfg(feature = "dsl-jit")] use crate::dsl::{compile_module_source_to_runtime, RuntimeCompilationTarget}; diff --git a/src/simulator/cache.rs b/src/simulator/cache.rs index 7ed5006d..3ab926a2 100644 --- a/src/simulator/cache.rs +++ b/src/simulator/cache.rs @@ -1,8 +1,9 @@ -//! Cache mechanisms for [Equation]s +//! Cache mechanisms for deterministic [`Equation`](crate::Equation) models. //! //! This module provides lightweight cache wrappers that can be embedded -//! directly in equation structs ([`ODE`], [`Analytical`], [`SDE`]). -//! Each equation instance owns a cache by default; cloning the equation +//! directly in [`ODE`](crate::equation::ODE) and +//! [`Analytical`](crate::simulator::equation::Analytical) models. +//! Each deterministic equation instance owns a cache by default; cloning the equation //! produces a shallow clone that shares the same cache data. //! //! # Example @@ -23,59 +24,61 @@ use std::{fmt, sync::Arc}; use quick_cache::sync::Cache; -use crate::{data::error_model::AssayErrorModels, simulator::likelihood::SubjectPredictions}; +use crate::simulator::prediction::SubjectPredictions; /// Default maximum number of entries per cache. pub const DEFAULT_CACHE_SIZE: usize = 100_000; -/// Default maximum number of cached bound assay-error contexts per equation. -pub const DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE: usize = 32; - /// Cache key: (subject_hash, parameters_hash) pub(crate) type PredictionKey = (u64, u64); -/// Cache key for SDE: (subject_hash, parameters_hash, error_model_hash) -pub(crate) type SdeKey = (u64, u64, u64); - -/// Cache key for bound assay error models. -pub(crate) type BoundErrorModelKey = u64; - /// Thread-safe bounded cache for subject predictions. /// -/// Used by [`ODE`](crate::ODE) and [`Analytical`](crate::simulator::equation::Analytical) +/// Used by [`ODE`](crate::equation::ODE) and [`Analytical`](crate::simulator::equation::Analytical) /// to avoid recomputing predictions for the same (subject, parameters) pair. /// /// `Clone` produces a shallow clone that shares the same underlying cache data, /// so cloned equations share cache hits. #[derive(Clone)] -pub struct PredictionCache(Arc>); +pub struct PredictionCache { + inner: Arc>, + capacity: usize, +} impl PredictionCache { /// Create a new prediction cache with a given maximum number of entries. pub fn new(size: usize) -> Self { - Self(Arc::new(Cache::new(size))) + Self { + inner: Arc::new(Cache::new(size)), + capacity: size, + } + } + + /// Create an empty cache with the same capacity but no shared entries. + pub(crate) fn detached(&self) -> Self { + Self::new(self.capacity) } /// Look up a cached prediction. #[inline] pub fn get(&self, key: &PredictionKey) -> Option { - self.0.get(key) + self.inner.get(key) } /// Insert a prediction into the cache. #[inline] pub fn insert(&self, key: PredictionKey, value: SubjectPredictions) { - self.0.insert(key, value); + self.inner.insert(key, value); } /// Remove all entries from the cache. pub fn invalidate_all(&self) { - self.0.clear(); + self.inner.clear(); } /// Return the number of entries currently in the cache. pub fn entry_count(&self) -> usize { - self.0.len() + self.inner.len() } } @@ -87,98 +90,10 @@ impl fmt::Debug for PredictionCache { } } -/// Cache for equation-bound assay error models. -/// -/// Entries are keyed by the public error-model definition hash and hold the -/// dense, equation-specific binding that likelihood evaluation needs. -#[derive(Clone)] -pub struct BoundErrorModelCache(Arc>>); - -impl BoundErrorModelCache { - pub fn new(size: usize) -> Self { - Self(Arc::new(Cache::new(size))) - } - - #[inline] - pub fn get(&self, key: &BoundErrorModelKey) -> Option> { - self.0.get(key) - } - - #[inline] - pub fn insert(&self, key: BoundErrorModelKey, value: Arc) { - self.0.insert(key, value); - } - - pub fn invalidate_all(&self) { - self.0.clear(); - } - - pub fn entry_count(&self) -> usize { - self.0.len() - } -} - -impl fmt::Debug for BoundErrorModelCache { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("BoundErrorModelCache") - .field("entry_count", &self.entry_count()) - .finish() - } -} - -/// Cache for SDE likelihood values. -/// -/// SDEs do not produce subject predictions that can be cached, but -/// the likelihood values for a given subject and parameters can still be cached. -/// -/// Note that the use of a cache could be counterproductive for SDEs, as this removes the -/// stochastic nature of the likelihood evaluation. However, it can be useful for -/// producing a deterministic likelihood for an otherwise stochastic process. -/// -/// `Clone` produces a shallow clone that shares the same underlying cache data. -#[derive(Clone)] -pub struct SdeLikelihoodCache(Arc>); - -impl SdeLikelihoodCache { - /// Create a new SDE likelihood cache with the given maximum number of entries. - pub fn new(size: usize) -> Self { - Self(Arc::new(Cache::new(size))) - } - - /// Look up a cached likelihood value. - #[inline] - pub fn get(&self, key: &SdeKey) -> Option { - self.0.get(key) - } - - /// Insert a likelihood value into the cache. - #[inline] - pub fn insert(&self, key: SdeKey, value: f64) { - self.0.insert(key, value); - } - - /// Remove all entries from the cache. - pub fn invalidate_all(&self) { - self.0.clear(); - } - - /// Return the number of entries currently in the cache. - pub fn entry_count(&self) -> usize { - self.0.len() - } -} - -impl fmt::Debug for SdeLikelihoodCache { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("SdeLikelihoodCache") - .field("entry_count", &self.entry_count()) - .finish() - } -} - #[cfg(test)] mod tests { use super::*; + use crate::{Censor, ErrorPoly, Subject, SubjectBuilderExt}; #[test] fn prediction_cache_miss_returns_none() { @@ -238,83 +153,40 @@ mod tests { } #[test] - fn prediction_cache_debug_format() { + fn observation_metadata_changes_do_not_reuse_cached_predictions() { let cache = PredictionCache::new(10); - let dbg = format!("{:?}", cache); - assert!(dbg.contains("PredictionCache")); - assert!(dbg.contains("entry_count")); - } - - #[test] - fn sde_cache_miss_returns_none() { - let cache = SdeLikelihoodCache::new(10); - assert!(cache.get(&(1, 2, 3)).is_none()); - } - - #[test] - fn sde_cache_hit_returns_value() { - let cache = SdeLikelihoodCache::new(10); - let key: SdeKey = (10, 20, 30); - cache.insert(key, -42.5); - assert_eq!(cache.get(&key), Some(-42.5)); - } - - #[test] - fn bound_error_model_cache_clone_shares_data() { - let cache = BoundErrorModelCache::new(10); - let models = Arc::new(AssayErrorModels::empty()); - cache.insert(7, Arc::clone(&models)); - - let clone = cache.clone(); - assert!(clone.get(&7).is_some()); - assert!(Arc::ptr_eq(&clone.get(&7).unwrap(), &models)); - } - - #[test] - fn sde_cache_entry_count() { - let cache = SdeLikelihoodCache::new(10); - cache.insert((1, 1, 1), 0.0); - cache.insert((2, 2, 2), 1.0); - assert_eq!(cache.entry_count(), 2); - } - - #[test] - fn sde_cache_invalidate_all_clears_entries() { - let cache = SdeLikelihoodCache::new(10); - cache.insert((1, 1, 1), 0.0); - cache.insert((2, 2, 2), 1.0); - assert_eq!(cache.entry_count(), 2); - - cache.invalidate_all(); - assert_eq!(cache.entry_count(), 0); - assert!(cache.get(&(1, 1, 1)).is_none()); - } - - #[test] - fn sde_cache_overwrite_same_key() { - let cache = SdeLikelihoodCache::new(10); - let key: SdeKey = (1, 1, 1); - cache.insert(key, 1.0); - cache.insert(key, 2.0); - assert_eq!(cache.entry_count(), 1); - assert_eq!(cache.get(&key), Some(2.0)); - } - - #[test] - fn sde_cache_clone_shares_data() { - let cache = SdeLikelihoodCache::new(10); - cache.insert((1, 1, 1), 5.0); - let clone = cache.clone(); - assert_eq!(clone.get(&(1, 1, 1)), Some(5.0)); - clone.insert((2, 2, 2), 10.0); - assert_eq!(cache.get(&(2, 2, 2)), Some(10.0)); + let baseline = Subject::builder("cache-subject") + .observation_with_error( + 1.0, + 5.0, + "cp", + ErrorPoly::new(0.1, 0.2, 0.3, 0.4), + Censor::None, + ) + .build(); + let changed = Subject::builder("cache-subject") + .observation_with_error( + 1.0, + 5.0, + "cp", + ErrorPoly::new(0.1, 0.2, 0.3, 1.4), + Censor::None, + ) + .build(); + let baseline_key = (baseline.hash(), 99); + let changed_key = (changed.hash(), 99); + + cache.insert(baseline_key, SubjectPredictions::default()); + + assert!(cache.get(&baseline_key).is_some()); + assert!(cache.get(&changed_key).is_none()); } #[test] - fn sde_cache_debug_format() { - let cache = SdeLikelihoodCache::new(10); + fn prediction_cache_debug_format() { + let cache = PredictionCache::new(10); let dbg = format!("{:?}", cache); - assert!(dbg.contains("SdeLikelihoodCache")); + assert!(dbg.contains("PredictionCache")); assert!(dbg.contains("entry_count")); } } diff --git a/src/simulator/equation/analytical/mod.rs b/src/simulator/equation/analytical/mod.rs index 996995d7..61a33927 100644 --- a/src/simulator/equation/analytical/mod.rs +++ b/src/simulator/equation/analytical/mod.rs @@ -15,15 +15,13 @@ pub use three_compartment_models::*; pub use two_compartment_cl_models::*; pub use two_compartment_models::*; -use super::parameters_hash; - use super::{ - EqnKind, Equation, EquationPriv, EquationTypes, ModelMetadata, ModelMetadataError, - ValidatedModelMetadata, + parameters_hash, EqnKind, Equation, EquationPriv, EquationTypes, ModelMetadata, + ModelMetadataError, ValidatedModelMetadata, }; -use crate::data::error_model::AssayErrorModels; -use crate::simulator::cache::{ - BoundErrorModelCache, PredictionCache, DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, DEFAULT_CACHE_SIZE, +use crate::simulator::{ + cache::{PredictionCache, DEFAULT_CACHE_SIZE}, + prediction::SubjectPredictions, }; use crate::PharmsolError; use crate::{data::Covariates, simulator::*, Observation, Parameters, Subject}; @@ -44,6 +42,11 @@ pub enum AnalyticalMetadataError { /// /// This implementation uses closed-form analytical solutions for the model /// equations rather than numerical integration. +/// +/// This low-level constructor is an internal building block for the +/// `analytical!` macro and is not part of the supported public API. Author +/// models with the [`analytical!`](crate::analytical) macro or the DSL instead. +#[doc(hidden)] #[derive(Clone, Debug)] pub struct Analytical { eq: AnalyticalEq, @@ -55,7 +58,6 @@ pub struct Analytical { neqs: Neqs, metadata: Option, cache: Option, - error_model_cache: Option, } #[inline(always)] @@ -110,9 +112,6 @@ impl Analytical { neqs: Neqs::default(), metadata: None, cache: Some(PredictionCache::new(DEFAULT_CACHE_SIZE)), - error_model_cache: Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )), } } @@ -144,10 +143,8 @@ impl Analytical { ) -> Result { let metadata = metadata.validate_for(ModelKind::Analytical)?; validate_metadata_dimensions(&metadata, &self.neqs)?; + self.detach_cache(); self.metadata = Some(metadata); - self.error_model_cache = Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )); Ok(self) } @@ -170,9 +167,11 @@ impl Analytical { fn invalidate_metadata(&mut self) { self.metadata = None; - self.error_model_cache = Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )); + self.detach_cache(); + } + + fn detach_cache(&mut self) { + self.cache = self.cache.as_ref().map(PredictionCache::detached); } } @@ -210,17 +209,11 @@ fn validate_metadata_dimensions( impl super::Cache for Analytical { fn with_cache_capacity(mut self, size: usize) -> Self { self.cache = Some(PredictionCache::new(size)); - self.error_model_cache = Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )); self } fn enable_cache(mut self) -> Self { self.cache = Some(PredictionCache::new(DEFAULT_CACHE_SIZE)); - self.error_model_cache = Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )); self } @@ -228,14 +221,10 @@ impl super::Cache for Analytical { if let Some(cache) = &self.cache { cache.invalidate_all(); } - if let Some(cache) = &self.error_model_cache { - cache.invalidate_all(); - } } fn disable_cache(mut self) -> Self { self.cache = None; - self.error_model_cache = None; self } } @@ -374,11 +363,9 @@ impl EquationPriv for Analytical { &self, parameters: &[f64], observation: &Observation, - error_models: Option<&AssayErrorModels>, _time: f64, covariates: &Covariates, x: &mut Self::S, - likelihood: &mut Vec, output: &mut Self::P, ) -> Result<(), PharmsolError> { let mut y = V::zeros(self.get_nouteqs(), NalgebraContext::new()); @@ -397,12 +384,15 @@ impl EquationPriv for Analytical { .unwrap_or_default(); PharmsolError::unknown_output_label(observation.outeq(), &available) })?; - let pred = y[outeq]; - let pred = observation.to_prediction(pred, x.as_slice().to_vec()); - if let Some(error_models) = error_models { - likelihood.push(pred.log_likelihood(error_models)?.exp()); + if outeq >= y.len() { + return Err(PharmsolError::OuteqOutOfRange { + outeq, + nout: y.len(), + }); } - output.add_prediction(pred); + let pred = y[outeq]; + let pred = observation.to_prediction(self.output_label(outeq), pred); + output.add_prediction(pred, observation.occasion()); Ok(()) } #[inline(always)] @@ -430,11 +420,19 @@ impl EquationPriv for Analytical { #[cfg(test)] pub(crate) mod tests { use super::*; - use crate::SubjectBuilderExt; + use crate::{Subject, SubjectBuilderExt}; use approx::assert_relative_eq; use diffsol::Vector; use pharmsol_dsl::AnalyticalKernel; use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static ANALYTICAL_CALLS: AtomicUsize = AtomicUsize::new(0); + + fn counting_analytical(x: &V, _p: &V, _dt: f64, _rateiv: &V, _cov: &Covariates) -> V { + ANALYTICAL_CALLS.fetch_add(1, Ordering::SeqCst); + x.clone() + } pub(crate) enum SubjectInfo { InfusionDosing, @@ -444,51 +442,132 @@ pub(crate) mod tests { pub(crate) fn get_subject(&self) -> Subject { match self { SubjectInfo::InfusionDosing => Subject::builder("id1") - .bolus(0.0, 100.0, 0) - .infusion(24.0, 150.0, 0, 3.0) - .missing_observation(0.0, 0) - .missing_observation(1.0, 0) - .missing_observation(2.0, 0) - .missing_observation(4.0, 0) - .missing_observation(8.0, 0) - .missing_observation(12.0, 0) - .missing_observation(24.0, 0) - .missing_observation(25.0, 0) - .missing_observation(26.0, 0) - .missing_observation(27.0, 0) - .missing_observation(28.0, 0) - .missing_observation(32.0, 0) - .missing_observation(36.0, 0) + .bolus(0.0, 100.0, "iv_bolus") + .infusion(24.0, 150.0, "iv", 3.0) + .missing_observation(0.0, "cp") + .missing_observation(1.0, "cp") + .missing_observation(2.0, "cp") + .missing_observation(4.0, "cp") + .missing_observation(8.0, "cp") + .missing_observation(12.0, "cp") + .missing_observation(24.0, "cp") + .missing_observation(25.0, "cp") + .missing_observation(26.0, "cp") + .missing_observation(27.0, "cp") + .missing_observation(28.0, "cp") + .missing_observation(32.0, "cp") + .missing_observation(36.0, "cp") .build(), SubjectInfo::OralInfusionDosage => Subject::builder("id1") - .bolus(0.0, 100.0, 1) - .infusion(24.0, 150.0, 0, 3.0) - .bolus(48.0, 100.0, 0) - .missing_observation(0.0, 0) - .missing_observation(1.0, 0) - .missing_observation(2.0, 0) - .missing_observation(4.0, 0) - .missing_observation(8.0, 0) - .missing_observation(12.0, 0) - .missing_observation(24.0, 0) - .missing_observation(25.0, 0) - .missing_observation(26.0, 0) - .missing_observation(27.0, 0) - .missing_observation(28.0, 0) - .missing_observation(32.0, 0) - .missing_observation(36.0, 0) - .missing_observation(48.0, 0) - .missing_observation(49.0, 0) - .missing_observation(50.0, 0) - .missing_observation(52.0, 0) - .missing_observation(56.0, 0) - .missing_observation(60.0, 0) + .bolus(0.0, 100.0, "oral") + .infusion(24.0, 150.0, "iv", 3.0) + .bolus(48.0, 100.0, "iv_bolus") + .missing_observation(0.0, "cp") + .missing_observation(1.0, "cp") + .missing_observation(2.0, "cp") + .missing_observation(4.0, "cp") + .missing_observation(8.0, "cp") + .missing_observation(12.0, "cp") + .missing_observation(24.0, "cp") + .missing_observation(25.0, "cp") + .missing_observation(26.0, "cp") + .missing_observation(27.0, "cp") + .missing_observation(28.0, "cp") + .missing_observation(32.0, "cp") + .missing_observation(36.0, "cp") + .missing_observation(48.0, "cp") + .missing_observation(49.0, "cp") + .missing_observation(50.0, "cp") + .missing_observation(52.0, "cp") + .missing_observation(56.0, "cp") + .missing_observation(60.0, "cp") .build(), } } } + /// Build metadata for the non-absorption infusion-dosing analytical tests. + /// + /// Declares a single bolus route (`iv_bolus`, input index 0) and an + /// infusion route (`iv`, input index 0), padding extra infusion routes so + /// `route_input_count == ndrugs`. The `cp` output is index 0, padded up to + /// `nout`. Pass `explicit = true` for ODE/SDE models (explicit input + /// vector) and `false` for analytical models. + pub(crate) fn infusion_metadata( + name: &str, + params: &[&str], + states: &[&str], + ndrugs: usize, + nout: usize, + explicit: bool, + ) -> super::super::ModelMetadata { + let mut routes = vec![ + super::super::Route::bolus("iv_bolus").to_state(states[0]), + super::super::Route::infusion("iv").to_state(states[0]), + ]; + for i in 1..ndrugs { + routes.push(super::super::Route::infusion(format!("iv_pad_{i}")).to_state(states[0])); + } + build_handwritten_metadata(name, params, states, routes, nout, explicit) + } + + /// Build metadata for the absorption oral-infusion-dosing analytical tests. + /// + /// Declares bolus routes in order `iv_bolus` (input index 0) then `oral` + /// (input index 1), plus infusion route `iv` (input index 0). Extra + /// infusion routes are padded so `route_input_count == ndrugs`. The `cp` + /// output is index 0, padded up to `nout`. Pass `explicit = true` for + /// ODE/SDE models and `false` for analytical models. + pub(crate) fn oral_metadata( + name: &str, + params: &[&str], + states: &[&str], + ndrugs: usize, + nout: usize, + explicit: bool, + ) -> super::super::ModelMetadata { + let mut routes = vec![ + super::super::Route::bolus("iv_bolus").to_state(states[0]), + super::super::Route::bolus("oral") + .to_state(states.get(1).copied().unwrap_or(states[0])), + super::super::Route::infusion("iv").to_state(states[0]), + ]; + for i in 1..ndrugs { + routes.push(super::super::Route::infusion(format!("iv_pad_{i}")).to_state(states[0])); + } + build_handwritten_metadata(name, params, states, routes, nout, explicit) + } + + fn build_handwritten_metadata( + name: &str, + params: &[&str], + states: &[&str], + routes: Vec, + nout: usize, + explicit: bool, + ) -> super::super::ModelMetadata { + let routes = routes + .into_iter() + .map(|route| { + if explicit { + route.expect_explicit_input() + } else { + route + } + }) + .collect::>(); + let mut outputs = vec!["cp".to_string()]; + for i in 1..nout { + outputs.push(format!("out_{i}")); + } + super::super::metadata::new(name) + .parameters(params.iter().copied()) + .states(states.iter().copied()) + .outputs(outputs) + .routes(routes) + } + #[test] fn secondary_equations_accumulate_within_single_solve() { let eq = |x: &V, p: &V, dt: f64, _rateiv: &V, _cov: &Covariates| { @@ -511,11 +590,13 @@ pub(crate) mod tests { let analytical = Analytical::new(eq, seq_eq, lag, fa, init, out) .with_nstates(1) .with_ndrugs(1) - .with_nout(1); + .with_nout(1) + .with_metadata(infusion_metadata("seq", &["p"], &["central"], 1, 1, false)) + .expect("metadata should validate"); let subject = Subject::builder("seq") - .bolus(0.0, 0.0, 0) - .infusion(0.25, 1.0, 0, 0.25) - .observation(1.0, 0.0, 0) + .bolus(0.0, 0.0, "iv_bolus") + .infusion(0.25, 1.0, "iv", 0.25) + .observation(1.0, 0.0, "cp") .build(); let predictions = analytical @@ -546,10 +627,22 @@ pub(crate) mod tests { let analytical = Analytical::new(eq, seq_eq, lag, fa, init, out) .with_nstates(4) .with_ndrugs(4) - .with_nout(1); + .with_nout(1) + .with_metadata( + super::super::metadata::new("inf") + .states(["central", "c1", "c2", "c3"]) + .outputs(["cp"]) + .routes([ + super::super::Route::infusion("in0").to_state("central"), + super::super::Route::infusion("in1").to_state("c1"), + super::super::Route::infusion("in2").to_state("c2"), + super::super::Route::infusion("iv").to_state("c3"), + ]), + ) + .expect("metadata should validate"); let subject = Subject::builder("inf") - .infusion(0.0, 4.0, 3, 1.0) - .observation(1.0, 0.0, 0) + .infusion(0.0, 4.0, "iv", 1.0) + .observation(1.0, 0.0, "cp") .build(); let predictions = analytical @@ -577,6 +670,51 @@ pub(crate) mod tests { .with_nout(1) } + #[test] + fn handwritten_analytical_prediction_entrypoints_use_cache() { + ANALYTICAL_CALLS.store(0, Ordering::SeqCst); + let mut model = simple_analytical(); + model.eq = counting_analytical; + let model = model + .with_metadata(infusion_metadata( + "analytical-cache", + &["ke"], + &["central"], + 1, + 1, + false, + )) + .expect("metadata should validate"); + let subject = Subject::builder("analytical-cache") + .bolus(0.0, 1.0, "iv_bolus") + .observation(1.0, 0.0, "cp") + .build(); + let parameters = crate::parameters::dense([]); + + model.estimate_predictions(&subject, ¶meters).unwrap(); + let after_first = ANALYTICAL_CALLS.load(Ordering::SeqCst); + assert!(after_first > 0); + model.simulate_subject(&subject, ¶meters).unwrap(); + assert_eq!(ANALYTICAL_CALLS.load(Ordering::SeqCst), after_first); + + let configured = model + .clone() + .with_nstates(1) + .with_metadata(infusion_metadata( + "analytical-cache", + &["ke"], + &["central"], + 1, + 1, + false, + )) + .expect("metadata should validate"); + configured + .estimate_predictions(&subject, ¶meters) + .unwrap(); + assert!(ANALYTICAL_CALLS.load(Ordering::SeqCst) > after_first); + } + #[test] fn handwritten_analytical_metadata_exposes_name_lookup() { let analytical = simple_analytical() @@ -888,68 +1026,101 @@ pub(crate) mod tests { vec![2.0], ); } + + #[test] + fn handwritten_analytical_rejects_out_of_range_numeric_event_labels() { + let model = simple_analytical() + .with_metadata(infusion_metadata( + "reject", + &["ke"], + &["central"], + 1, + 1, + false, + )) + .expect("metadata should validate"); + let parameters = crate::parameters::dense([]); + for subject in [ + Subject::builder("bad-bolus").bolus(0.0, 1.0, 1).build(), + Subject::builder("bad-infusion") + .infusion(0.0, 1.0, 1, 1.0) + .build(), + ] { + assert!(matches!( + model.simulate_subject(&subject, ¶meters), + Err(PharmsolError::UnknownInputLabel { .. }) + )); + } + let subject = Subject::builder("bad-output") + .observation(0.0, 0.0, 1) + .build(); + assert!(matches!( + model.simulate_subject(&subject, ¶meters), + Err(PharmsolError::UnknownOutputLabel { .. }) + )); + } } -impl Equation for Analytical { - fn bound_error_model_cache(&self) -> Option<&BoundErrorModelCache> { - self.error_model_cache.as_ref() +fn analytical_subject_predictions( + model: &Analytical, + subject: &Subject, + parameters: &[f64], +) -> Result { + let key = (subject.hash(), parameters_hash(parameters)); + if let Some(cached) = model.cache.as_ref().and_then(|cache| cache.get(&key)) { + return Ok(cached); + } + + let mut output = SubjectPredictions::default(); + for occasion in subject.occasions() { + let covariates = occasion.covariates(); + let mut state = model.initial_state(parameters, covariates, occasion.index()); + let mut infusions = Vec::new(); + let events = model.resolve_occasion_events(occasion, parameters, covariates)?; + for (index, event) in events.iter().enumerate() { + model.simulate_event( + parameters, + event, + events.get(index + 1), + covariates, + &mut state, + &mut infusions, + &mut output, + )?; + } } - fn estimate_likelihood( + if let Some(cache) = &model.cache { + cache.insert(key, output.clone()); + } + Ok(output) +} + +impl Equation for Analytical { + fn estimate_predictions( &self, subject: &Subject, parameters: &Parameters, - error_models: &AssayErrorModels, - ) -> Result { - _estimate_likelihood(self, subject, parameters.as_slice(), error_models) + ) -> Result { + analytical_subject_predictions(self, subject, parameters.as_slice()) } - fn estimate_log_likelihood( + fn estimate_predictions_dense( &self, subject: &Subject, - parameters: &Parameters, - error_models: &AssayErrorModels, - ) -> Result { - let bound_error_models = self.bind_error_models(error_models)?; - let ypred = _subject_predictions(self, subject, parameters.as_slice())?; - ypred.log_likelihood(&bound_error_models) + parameters: &[f64], + ) -> Result { + analytical_subject_predictions(self, subject, parameters) } - fn kind() -> EqnKind { - EqnKind::Analytical + fn simulate_subject_dense( + &self, + subject: &Subject, + parameters: &[f64], + ) -> Result { + analytical_subject_predictions(self, subject, parameters) } -} -#[inline(always)] -fn _subject_predictions( - analytical: &Analytical, - subject: &Subject, - parameters: &[f64], -) -> Result { - if let Some(cache) = &analytical.cache { - let key = (subject.hash(), parameters_hash(parameters)); - if let Some(cached) = cache.get(&key) { - return Ok(cached); - } - - let result = analytical - .simulate_subject_dense(subject, parameters, None)? - .0; - cache.insert(key, result.clone()); - Ok(result) - } else { - Ok(analytical - .simulate_subject_dense(subject, parameters, None)? - .0) + fn kind() -> EqnKind { + EqnKind::Analytical } } - -fn _estimate_likelihood( - ode: &Analytical, - subject: &Subject, - parameters: &[f64], - error_models: &AssayErrorModels, -) -> Result { - let bound_error_models = ode.bind_error_models(error_models)?; - let ypred = _subject_predictions(ode, subject, parameters)?; - Ok(ypred.log_likelihood(&bound_error_models)?.exp()) -} diff --git a/src/simulator/equation/analytical/one_compartment_cl_models.rs b/src/simulator/equation/analytical/one_compartment_cl_models.rs index f1830938..829c9a3f 100755 --- a/src/simulator/equation/analytical/one_compartment_cl_models.rs +++ b/src/simulator/equation/analytical/one_compartment_cl_models.rs @@ -82,7 +82,16 @@ mod tests { }, ) .with_nstates(1) - .with_nout(1); + .with_nout(1) + .with_metadata(super::super::tests::infusion_metadata( + "one_compartment_cl", + &["cl", "v"], + &["central"], + 5, + 1, + true, + )) + .expect("metadata should validate"); let analytical = equation::Analytical::new( one_compartment_cl, @@ -96,7 +105,16 @@ mod tests { }, ) .with_nstates(1) - .with_nout(1); + .with_nout(1) + .with_metadata(super::super::tests::infusion_metadata( + "one_compartment_cl", + &["cl", "v"], + &["central"], + 5, + 1, + false, + )) + .expect("metadata should validate"); let op_ode = ode .estimate_predictions(&subject, &crate::parameters::dense([0.1, 1.0])) @@ -135,7 +153,16 @@ mod tests { }, ) .with_nstates(2) - .with_nout(1); + .with_nout(1) + .with_metadata(super::super::tests::oral_metadata( + "one_compartment_cl_abs", + &["ka", "cl", "v"], + &["gut", "central"], + 5, + 1, + true, + )) + .expect("metadata should validate"); let analytical = equation::Analytical::new( one_compartment_cl_with_absorption, @@ -149,7 +176,16 @@ mod tests { }, ) .with_nstates(2) - .with_nout(1); + .with_nout(1) + .with_metadata(super::super::tests::oral_metadata( + "one_compartment_cl_abs", + &["ka", "cl", "v"], + &["gut", "central"], + 5, + 1, + false, + )) + .expect("metadata should validate"); let op_ode = ode .estimate_predictions(&subject, &crate::parameters::dense([1.0, 0.1, 1.0])) diff --git a/src/simulator/equation/analytical/one_compartment_models.rs b/src/simulator/equation/analytical/one_compartment_models.rs index 127483e5..5854143d 100644 --- a/src/simulator/equation/analytical/one_compartment_models.rs +++ b/src/simulator/equation/analytical/one_compartment_models.rs @@ -75,7 +75,16 @@ mod tests { ) .with_nstates(1) .with_ndrugs(1) - .with_nout(1); + .with_nout(1) + .with_metadata(super::super::tests::infusion_metadata( + "one_compartment", + &["ke", "v"], + &["central"], + 1, + 1, + true, + )) + .expect("metadata should validate"); let analytical = equation::Analytical::new( one_compartment, @@ -90,7 +99,16 @@ mod tests { ) .with_nstates(1) .with_ndrugs(1) - .with_nout(1); + .with_nout(1) + .with_metadata(super::super::tests::infusion_metadata( + "one_compartment", + &["ke", "v"], + &["central"], + 1, + 1, + false, + )) + .expect("metadata should validate"); let op_ode = ode .estimate_predictions(&subject, &crate::parameters::dense([0.1, 1.0])) @@ -132,7 +150,16 @@ mod tests { ) .with_nstates(2) .with_ndrugs(2) - .with_nout(1); + .with_nout(1) + .with_metadata(super::super::tests::oral_metadata( + "one_compartment_abs", + &["ka", "ke", "v"], + &["gut", "central"], + 2, + 1, + true, + )) + .expect("metadata should validate"); let analytical = equation::Analytical::new( one_compartment_with_absorption, @@ -147,7 +174,16 @@ mod tests { ) .with_nstates(2) .with_ndrugs(2) - .with_nout(1); + .with_nout(1) + .with_metadata(super::super::tests::oral_metadata( + "one_compartment_abs", + &["ka", "ke", "v"], + &["gut", "central"], + 2, + 1, + false, + )) + .expect("metadata should validate"); let op_ode = ode .estimate_predictions(&subject, &crate::parameters::dense([1.0, 0.1, 1.0])) diff --git a/src/simulator/equation/analytical/three_compartment_cl_models.rs b/src/simulator/equation/analytical/three_compartment_cl_models.rs index bcfb544f..3c2943d9 100644 --- a/src/simulator/equation/analytical/three_compartment_cl_models.rs +++ b/src/simulator/equation/analytical/three_compartment_cl_models.rs @@ -114,7 +114,16 @@ mod tests { ) .with_nstates(3) .with_nout(1) - .with_ndrugs(3); + .with_ndrugs(3) + .with_metadata(super::super::tests::infusion_metadata( + "three_compartments_cl", + &["cl", "q2", "q3", "vc", "v2", "v3"], + &["central", "p1", "p2"], + 3, + 1, + true, + )) + .expect("metadata should validate"); let analytical = equation::Analytical::new( three_compartments_cl, @@ -129,7 +138,16 @@ mod tests { ) .with_nstates(3) .with_nout(1) - .with_ndrugs(3); + .with_ndrugs(3) + .with_metadata(super::super::tests::infusion_metadata( + "three_compartments_cl", + &["cl", "q2", "q3", "vc", "v2", "v3"], + &["central", "p1", "p2"], + 3, + 1, + false, + )) + .expect("metadata should validate"); let op_ode = ode .estimate_predictions( @@ -185,7 +203,16 @@ mod tests { fetch_params!(p, _ka, _cl, _q2, _q3, vc, _v2, _v3); y[0] = x[1] / vc; }, - ); + ) + .with_metadata(super::super::tests::oral_metadata( + "three_compartments_cl_abs", + &["ka", "cl", "q2", "q3", "vc", "v2", "v3"], + &["gut", "central", "p1", "p2", "s4"], + 5, + 5, + true, + )) + .expect("metadata should validate"); let analytical = equation::Analytical::new( three_compartments_cl_with_absorption, @@ -197,7 +224,16 @@ mod tests { fetch_params!(p, _ka, _cl, _q2, _q3, vc, _v2, _v3); y[0] = x[1] / vc; }, - ); + ) + .with_metadata(super::super::tests::oral_metadata( + "three_compartments_cl_abs", + &["ka", "cl", "q2", "q3", "vc", "v2", "v3"], + &["gut", "central", "p1", "p2", "s4"], + 5, + 5, + false, + )) + .expect("metadata should validate"); let op_ode = ode .estimate_predictions( diff --git a/src/simulator/equation/analytical/three_compartment_models.rs b/src/simulator/equation/analytical/three_compartment_models.rs index f5d96daa..bf814cf9 100644 --- a/src/simulator/equation/analytical/three_compartment_models.rs +++ b/src/simulator/equation/analytical/three_compartment_models.rs @@ -279,7 +279,16 @@ mod tests { ) .with_nstates(3) .with_ndrugs(1) - .with_nout(1); + .with_nout(1) + .with_metadata(super::super::tests::infusion_metadata( + "three_compartments", + &["k10", "k12", "k13", "k21", "k31", "v"], + &["central", "p1", "p2"], + 1, + 1, + true, + )) + .expect("metadata should validate"); let analytical = equation::Analytical::new( three_compartments, @@ -294,7 +303,16 @@ mod tests { ) .with_nstates(3) .with_ndrugs(1) - .with_nout(1); + .with_nout(1) + .with_metadata(super::super::tests::infusion_metadata( + "three_compartments", + &["k10", "k12", "k13", "k21", "k31", "v"], + &["central", "p1", "p2"], + 1, + 1, + false, + )) + .expect("metadata should validate"); let op_ode = ode .estimate_predictions( @@ -348,7 +366,16 @@ mod tests { ) .with_nstates(4) .with_ndrugs(2) - .with_nout(1); + .with_nout(1) + .with_metadata(super::super::tests::oral_metadata( + "three_compartments_abs", + &["ka", "k10", "k12", "k13", "k21", "k31", "v"], + &["gut", "central", "p1", "p2"], + 2, + 1, + true, + )) + .expect("metadata should validate"); let analytical = equation::Analytical::new( three_compartments_with_absorption, @@ -363,7 +390,16 @@ mod tests { ) .with_nstates(4) .with_ndrugs(2) - .with_nout(1); + .with_nout(1) + .with_metadata(super::super::tests::oral_metadata( + "three_compartments_abs", + &["ka", "k10", "k12", "k13", "k21", "k31", "v"], + &["gut", "central", "p1", "p2"], + 2, + 1, + false, + )) + .expect("metadata should validate"); let op_ode = ode .estimate_predictions( diff --git a/src/simulator/equation/analytical/two_compartment_cl_models.rs b/src/simulator/equation/analytical/two_compartment_cl_models.rs index b91d79a2..06572598 100644 --- a/src/simulator/equation/analytical/two_compartment_cl_models.rs +++ b/src/simulator/equation/analytical/two_compartment_cl_models.rs @@ -95,7 +95,16 @@ mod tests { ) .with_nstates(2) .with_nout(1) - .with_ndrugs(2); + .with_ndrugs(2) + .with_metadata(super::super::tests::infusion_metadata( + "two_compartments_cl", + &["cl", "q", "vc", "vp"], + &["central", "peripheral"], + 2, + 1, + true, + )) + .expect("metadata should validate"); let analytical = equation::Analytical::new( two_compartments_cl, @@ -110,7 +119,16 @@ mod tests { ) .with_nstates(2) .with_nout(1) - .with_ndrugs(2); + .with_ndrugs(2) + .with_metadata(super::super::tests::infusion_metadata( + "two_compartments_cl", + &["cl", "q", "vc", "vp"], + &["central", "peripheral"], + 2, + 1, + false, + )) + .expect("metadata should validate"); let op_ode = ode .estimate_predictions(&subject, &crate::parameters::dense([0.1, 3.0, 1.0, 3.0])) @@ -154,7 +172,16 @@ mod tests { ) .with_nstates(3) .with_nout(1) - .with_ndrugs(3); + .with_ndrugs(3) + .with_metadata(super::super::tests::oral_metadata( + "two_compartments_cl_abs", + &["ka", "cl", "q", "vc", "vp"], + &["gut", "central", "peripheral"], + 3, + 1, + true, + )) + .expect("metadata should validate"); let analytical = equation::Analytical::new( two_compartments_cl_with_absorption, @@ -169,7 +196,16 @@ mod tests { ) .with_nstates(3) .with_nout(1) - .with_ndrugs(3); + .with_ndrugs(3) + .with_metadata(super::super::tests::oral_metadata( + "two_compartments_cl_abs", + &["ka", "cl", "q", "vc", "vp"], + &["gut", "central", "peripheral"], + 3, + 1, + false, + )) + .expect("metadata should validate"); let op_ode = ode .estimate_predictions( diff --git a/src/simulator/equation/analytical/two_compartment_models.rs b/src/simulator/equation/analytical/two_compartment_models.rs index 490b56ed..423a1ee9 100644 --- a/src/simulator/equation/analytical/two_compartment_models.rs +++ b/src/simulator/equation/analytical/two_compartment_models.rs @@ -144,7 +144,16 @@ mod tests { ) .with_nstates(2) .with_ndrugs(1) - .with_nout(1); + .with_nout(1) + .with_metadata(super::super::tests::infusion_metadata( + "two_compartments", + &["ke", "kcp", "kpc", "v"], + &["central", "peripheral"], + 1, + 1, + true, + )) + .expect("metadata should validate"); let analytical = equation::Analytical::new( two_compartments, @@ -159,7 +168,16 @@ mod tests { ) .with_nstates(2) .with_ndrugs(1) - .with_nout(1); + .with_nout(1) + .with_metadata(super::super::tests::infusion_metadata( + "two_compartments", + &["ke", "kcp", "kpc", "v"], + &["central", "peripheral"], + 1, + 1, + false, + )) + .expect("metadata should validate"); let op_ode = ode .estimate_predictions(&subject, &crate::parameters::dense([0.1, 3.0, 1.0, 1.0])) @@ -202,7 +220,16 @@ mod tests { ) .with_nstates(3) .with_ndrugs(2) - .with_nout(1); + .with_nout(1) + .with_metadata(super::super::tests::oral_metadata( + "two_compartments_abs", + &["ke", "ka", "kcp", "kpc", "v"], + &["gut", "central", "peripheral"], + 2, + 1, + true, + )) + .expect("metadata should validate"); let analytical = equation::Analytical::new( two_compartments_with_absorption, @@ -217,7 +244,16 @@ mod tests { ) .with_nstates(3) .with_ndrugs(2) - .with_nout(1); + .with_nout(1) + .with_metadata(super::super::tests::oral_metadata( + "two_compartments_abs", + &["ke", "ka", "kcp", "kpc", "v"], + &["gut", "central", "peripheral"], + 2, + 1, + false, + )) + .expect("metadata should validate"); let op_ode = ode .estimate_predictions( diff --git a/src/simulator/equation/metadata.rs b/src/simulator/equation/metadata.rs index 5204bcb0..ac5a74d2 100644 --- a/src/simulator/equation/metadata.rs +++ b/src/simulator/equation/metadata.rs @@ -1,7 +1,7 @@ //! Metadata builders and validated metadata views for handwritten models. //! -//! Use this module when a handwritten [`crate::ODE`], [`crate::Analytical`], or -//! [`crate::SDE`] model should expose the same public names that appear in data +//! Use this module when a handwritten [`crate::equation::ODE`], [`crate::equation::Analytical`], or +//! [`crate::equation::SDE`] model should expose the same public names that appear in data //! rows, subject builders, or parsed files. //! //! Metadata gives names to parameters, covariates, states, routes, and outputs. @@ -333,8 +333,8 @@ impl ValidatedRoute { /// /// Use [`ModelMetadata`] to declare the public names that should be attached to /// a handwritten equation. After validation, the resulting metadata can be -/// attached to handwritten [`crate::ODE`], [`crate::Analytical`], and -/// [`crate::SDE`] models through their `with_metadata(...)` methods. +/// attached to handwritten [`crate::equation::ODE`], [`crate::equation::Analytical`], and +/// [`crate::equation::SDE`] models through their `with_metadata(...)` methods. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ModelMetadata { name: String, diff --git a/src/simulator/equation/mod.rs b/src/simulator/equation/mod.rs index 974eb89c..a2a2b176 100644 --- a/src/simulator/equation/mod.rs +++ b/src/simulator/equation/mod.rs @@ -8,7 +8,7 @@ //! - choose between deterministic ODE, analytical, and stochastic SDE models //! - attach metadata so dataset labels such as `"iv"` and `"cp"` resolve by //! name instead of by dense numeric index -//! - work with prediction or likelihood APIs across equation families +//! - generate predictions across equation families //! //! # Equation Families //! @@ -46,7 +46,7 @@ //! assert!(metadata.output("cp").is_some()); //! ``` -use std::{fmt::Debug, sync::Arc}; +use std::fmt::Debug; pub mod analytical; pub mod metadata; pub mod ode; @@ -58,13 +58,12 @@ pub use pharmsol_dsl::{AnalyticalKernel, ModelKind}; pub use sde::*; use crate::{ - error_model::{AssayErrorModels, BoundAssayErrorModels}, - simulator::{cache::BoundErrorModelCache, Fa, Lag}, + simulator::{Fa, Lag}, Covariates, Event, Infusion, InputLabel, Observation, Occasion, OutputLabel, Parameters, PharmsolError, Subject, }; -use super::likelihood::Prediction; +use super::prediction::Prediction; /// Trait for state vectors that can receive bolus doses. pub trait State { @@ -89,34 +88,31 @@ pub trait Predictions: Default { Default::default() } - /// Calculate the sum of squared errors for all predictions. - /// - /// # Returns - /// The sum of squared errors - fn squared_error(&self) -> f64; - /// Get all predictions as a vector. /// /// # Returns /// Vector of prediction objects fn get_predictions(&self) -> Vec; - /// Calculate the log-likelihood of the predictions given an error model. - /// - /// This is numerically more stable than computing the likelihood and taking its log, - /// especially for extreme values or many observations. - /// - /// # Parameters - /// - `error_models`: The error models for computing observation variance + /// Visit each effective prediction without requiring callers to own a `Vec`. + fn for_each_prediction(&self, mut f: impl FnMut(&Prediction)) { + let predictions = self.get_predictions(); + for prediction in &predictions { + f(prediction); + } + } + + /// Record the subject identifier these predictions belong to. /// - /// # Returns - /// The sum of log-likelihoods for all predictions - fn log_likelihood(&self, error_models: &AssayErrorModels) -> Result; + /// The default implementation is a no-op for containers that do not carry a + /// subject identifier (for example the particle grid used by SDE models). + fn set_subject_id(&mut self, _id: &str) {} } -/// Trait for enabling prediction caching on equation types. +/// Trait for prediction caching on deterministic ODE and analytical equations. /// -/// Caching is **enabled by default** with a capacity of 100,000 entries. +/// Stochastic SDE equations do not implement this trait. Caching is **enabled by +/// default** for deterministic equations with a capacity of 100,000 entries. /// Use these methods to adjust capacity, clear entries, or disable caching. /// /// # Example @@ -212,12 +208,17 @@ pub(crate) trait EquationPriv: EquationTypes { }); } - return Ok(route.input_index()); + let input = route.input_index(); + if input >= self.get_ndrugs() { + return Err(PharmsolError::InputOutOfRange { + input, + ndrugs: self.get_ndrugs(), + }); + } + return Ok(input); } - label - .index() - .ok_or_else(|| PharmsolError::unknown_input_label(label.as_str(), &[])) + Err(PharmsolError::MissingMetadata) } fn resolve_output_label(&self, label: &OutputLabel) -> Result { @@ -227,9 +228,16 @@ pub(crate) trait EquationPriv: EquationTypes { }); } - label - .index() - .ok_or_else(|| PharmsolError::unknown_output_label(label.as_str(), &[])) + Err(PharmsolError::MissingMetadata) + } + + /// Resolve the public output label for a dense output index. + /// + /// Resolution requires metadata, so this returns the declared output name. + fn output_label(&self, index: usize) -> OutputLabel { + self.metadata() + .and_then(|metadata| metadata.output_labels().get(index).map(OutputLabel::new)) + .unwrap_or_else(|| OutputLabel::from(index)) } fn resolve_occasion_events( @@ -259,21 +267,13 @@ pub(crate) trait EquationPriv: EquationTypes { Ok(resolved.process_events(Some((self.fa(), self.lag(), parameters, covariates)))) } - #[allow(dead_code)] - fn is_sde(&self) -> bool { - false - } - - #[allow(clippy::too_many_arguments)] fn process_observation( &self, parameters: &[f64], observation: &Observation, - error_models: Option<&AssayErrorModels>, time: f64, covariates: &Covariates, x: &mut Self::S, - likelihood: &mut Vec, output: &mut Self::P, ) -> Result<(), PharmsolError>; @@ -290,11 +290,9 @@ pub(crate) trait EquationPriv: EquationTypes { parameters: &[f64], event: &Event, next_event: Option<&Event>, - error_models: Option<&AssayErrorModels>, covariates: &Covariates, x: &mut Self::S, infusions: &mut Vec, - likelihood: &mut Vec, output: &mut Self::P, ) -> Result<(), PharmsolError> { match event { @@ -322,11 +320,9 @@ pub(crate) trait EquationPriv: EquationTypes { self.process_observation( parameters, observation, - error_models, event.time(), covariates, x, - likelihood, output, )?; } @@ -356,91 +352,13 @@ pub(crate) trait EquationPriv: EquationTypes { /// name before simulation. Otherwise, the execution layer expects numeric labels /// that can be interpreted as dense indices. /// -/// # Likelihood Calculation +/// # Estimation boundary /// -/// Use [`estimate_log_likelihood`](Self::estimate_log_likelihood) for numerically stable -/// likelihood computation. The deprecated [`estimate_likelihood`](Self::estimate_likelihood) -/// is provided for backward compatibility. +/// Estimation algorithms call [`estimate_predictions`](Self::estimate_predictions) +/// and perform all scoring outside this crate. Equation simulation exposes only +/// structural predictions. #[allow(private_bounds)] pub trait Equation: EquationPriv + 'static + Clone + Sync { - #[doc(hidden)] - fn bound_error_model_cache(&self) -> Option<&BoundErrorModelCache> { - None - } - - #[doc(hidden)] - fn bind_error_models<'a>( - &'a self, - error_models: &'a AssayErrorModels, - ) -> Result, PharmsolError> { - if let Some(cache) = self.bound_error_model_cache() { - let key = error_models.hash(); - if let Some(bound_error_models) = cache.get(&key) { - return Ok(BoundAssayErrorModels::Shared(bound_error_models)); - } - - return match error_models.bind_to(self)? { - BoundAssayErrorModels::Owned(bound_error_models) => { - let bound_error_models = Arc::new(bound_error_models); - cache.insert(key, Arc::clone(&bound_error_models)); - Ok(BoundAssayErrorModels::Shared(bound_error_models)) - } - bound_error_models => Ok(bound_error_models), - }; - } - - Ok(error_models.bind_to(self)?) - } - - /// Estimate the likelihood of the subject given the parameters and error model. - /// - /// **Deprecated**: Use [`estimate_log_likelihood`](Self::estimate_log_likelihood) instead - /// for better numerical stability, especially with many observations or extreme parameter values. - /// - /// This function calculates how likely the observed data is given the model - /// parameters and error model. It may use caching for performance. - /// - /// # Parameters - /// - `subject`: The subject data - /// - `parameters`: The parameter values - /// - `error_model`: The error model - /// - /// # Returns - /// The likelihood value (product of individual observation likelihoods) - #[deprecated( - since = "0.23.0", - note = "Use estimate_log_likelihood() instead for better numerical stability" - )] - fn estimate_likelihood( - &self, - subject: &Subject, - parameters: &Parameters, - error_models: &AssayErrorModels, - ) -> Result; - - /// Estimate the log-likelihood of the subject given the parameters and error model. - /// - /// This function calculates the log of how likely the observed data is given the model - /// parameters and error model. It is numerically more stable than `estimate_likelihood` - /// for extreme values or many observations. - /// - /// Uses observation-based sigma, appropriate for non-parametric algorithms. - /// For parametric algorithms (SAEM, FOCE), use [`crate::ResidualErrorModels`] directly. - /// - /// # Parameters - /// - `subject`: The subject data - /// - `parameters`: The parameter values - /// - `error_models`: The error model - /// - /// # Returns - /// The log-likelihood value (sum of individual observation log-likelihoods) - fn estimate_log_likelihood( - &self, - subject: &Subject, - parameters: &Parameters, - error_models: &AssayErrorModels, - ) -> Result; - fn kind() -> EqnKind; #[doc(hidden)] @@ -449,19 +367,7 @@ pub trait Equation: EquationPriv + 'static + Clone + Sync { subject: &Subject, parameters: &[f64], ) -> Result { - Ok(self.simulate_subject_dense(subject, parameters, None)?.0) - } - - #[doc(hidden)] - fn estimate_log_likelihood_dense( - &self, - subject: &Subject, - parameters: &[f64], - error_models: &AssayErrorModels, - ) -> Result { - let bound_error_models = self.bind_error_models(error_models)?; - let predictions = self.estimate_predictions_dense(subject, parameters)?; - predictions.log_likelihood(&bound_error_models) + self.simulate_subject_dense(subject, parameters) } #[doc(hidden)] @@ -469,16 +375,9 @@ pub trait Equation: EquationPriv + 'static + Clone + Sync { &self, subject: &Subject, parameters: &[f64], - error_models: Option<&AssayErrorModels>, - ) -> Result<(Self::P, Option), PharmsolError> { - let bound_error_models = match error_models { - Some(error_models) => Some(self.bind_error_models(error_models)?), - None => None, - }; - let bound_error_models = bound_error_models.as_ref().map(|models| &**models); - + ) -> Result { let mut output = Self::P::new(self.nparticles()); - let mut likelihood = Vec::new(); + output.set_subject_id(subject.id()); for occasion in subject.occasions() { let covariates = occasion.covariates(); @@ -490,20 +389,17 @@ pub trait Equation: EquationPriv + 'static + Clone + Sync { parameters, event, events.get(index + 1), - bound_error_models, covariates, &mut x, &mut infusions, - &mut likelihood, &mut output, )?; } } - let ll = bound_error_models.map(|_| likelihood.iter().product::()); - Ok((output, ll)) + Ok(output) } - /// Generate predictions for a subject with given parameters. + /// Generate predictions for a subject with the given parameter vector. /// /// # Parameters /// - `subject`: The subject data @@ -529,38 +425,13 @@ pub trait Equation: EquationPriv + 'static + Clone + Sync { self.get_nstates() } - /// Build a label-aware [`AssayErrorModels`] set for this equation. - /// - /// Handwritten equations resolve output labels from attached metadata. - /// Equations without metadata fall back to an explicit unbound set so dense - /// output-slot workflows remain available without adding runtime lookup cost. - #[doc(hidden)] - fn assay_error_models(&self) -> AssayErrorModels { - self.metadata() - .map(|metadata| { - AssayErrorModels::with_output_names( - metadata.outputs().iter().map(|output| output.name()), - ) - }) - .unwrap_or_else(AssayErrorModels::empty) - } - - /// Simulate a subject with given parameters and optionally calculate likelihood. - /// - /// # Parameters - /// - `subject`: The subject data - /// - `parameters`: The parameter values - /// - `error_model`: The error model (optional) - /// - /// # Returns - /// A tuple containing predictions and optional likelihood + /// Simulate a subject and return prediction data. fn simulate_subject( &self, subject: &Subject, parameters: &Parameters, - error_models: Option<&AssayErrorModels>, - ) -> Result<(Self::P, Option), PharmsolError> { - self.simulate_subject_dense(subject, parameters.as_slice(), error_models) + ) -> Result { + self.simulate_subject_dense(subject, parameters.as_slice()) } } diff --git a/src/simulator/equation/ode/mod.rs b/src/simulator/equation/ode/mod.rs index cff749f5..c84b81e9 100644 --- a/src/simulator/equation/ode/mod.rs +++ b/src/simulator/equation/ode/mod.rs @@ -12,21 +12,18 @@ pub(crate) mod closure_helpers { use crate::{ data::{Covariates, Infusion}, - error_model::AssayErrorModels, prelude::simulator::SubjectPredictions, simulator::{DiffEq, Fa, Init, Lag, Neqs, Out, M, V}, Event, Observation, Parameters, PharmsolError, Subject, }; use super::parameters_hash; -use crate::simulator::cache::{ - BoundErrorModelCache, PredictionCache, DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, DEFAULT_CACHE_SIZE, -}; +use crate::simulator::cache::{PredictionCache, DEFAULT_CACHE_SIZE}; use crate::simulator::equation::Predictions; use closure::PMProblem; use diffsol::{ - error::OdeSolverError, ode_solver::method::OdeSolverMethod, NalgebraContext, OdeBuilder, - OdeSolverStopReason, Vector, VectorHost, + error::OdeSolverError, ode_solver::method::OdeSolverMethod, NalgebraContext, NonLinearOp, + OdeBuilder, OdeEquations, OdeSolverStopReason, Vector, }; use nalgebra::DVector; use pharmsol_dsl::ModelKind; @@ -95,6 +92,12 @@ pub enum OdeMetadataError { OutputCountMismatch { expected: usize, declared: usize }, } +/// Handwritten ODE model. +/// +/// This low-level constructor is an internal building block for the `ode!` +/// macro and is not part of the supported public API. Author models with the +/// [`ode!`](crate::ode) macro or the DSL instead. +#[doc(hidden)] #[derive(Clone, Debug)] pub struct ODE { diffeq: DiffEq, @@ -108,7 +111,6 @@ pub struct ODE { atol: f64, metadata: Option, cache: Option, - error_model_cache: Option, } impl ODE { @@ -125,9 +127,6 @@ impl ODE { atol: ATOL, metadata: None, cache: Some(PredictionCache::new(DEFAULT_CACHE_SIZE)), - error_model_cache: Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )), } } @@ -154,12 +153,14 @@ impl ODE { /// Set the ODE solver algorithm. pub fn with_solver(mut self, solver: OdeSolver) -> Self { + self.detach_cache(); self.solver = solver; self } /// Set the relative and absolute tolerances for the ODE solver. pub fn with_tolerances(mut self, rtol: f64, atol: f64) -> Self { + self.detach_cache(); self.rtol = rtol; self.atol = atol; self @@ -169,10 +170,8 @@ impl ODE { pub fn with_metadata(mut self, metadata: ModelMetadata) -> Result { let metadata = metadata.validate_for(ModelKind::Ode)?; validate_metadata_dimensions(&metadata, &self.neqs)?; + self.detach_cache(); self.metadata = Some(metadata); - self.error_model_cache = Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )); Ok(self) } @@ -195,9 +194,11 @@ impl ODE { fn invalidate_metadata(&mut self) { self.metadata = None; - self.error_model_cache = Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )); + self.detach_cache(); + } + + fn detach_cache(&mut self) { + self.cache = self.cache.as_ref().map(PredictionCache::detached); } } @@ -235,17 +236,11 @@ fn validate_metadata_dimensions( impl super::Cache for ODE { fn with_cache_capacity(mut self, size: usize) -> Self { self.cache = Some(PredictionCache::new(size)); - self.error_model_cache = Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )); self } fn enable_cache(mut self) -> Self { self.cache = Some(PredictionCache::new(DEFAULT_CACHE_SIZE)); - self.error_model_cache = Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )); self } @@ -253,14 +248,10 @@ impl super::Cache for ODE { if let Some(cache) = &self.cache { cache.invalidate_all(); } - if let Some(cache) = &self.error_model_cache { - cache.invalidate_all(); - } } fn disable_cache(mut self) -> Self { self.cache = None; - self.error_model_cache = None; self } } @@ -272,17 +263,6 @@ impl State for V { } } -fn _estimate_likelihood( - ode: &ODE, - subject: &Subject, - parameters: &[f64], - error_models: &AssayErrorModels, -) -> Result { - let bound_error_models = ode.bind_error_models(error_models)?; - let ypred = _subject_predictions(ode, subject, parameters)?; - Ok(ypred.log_likelihood(&bound_error_models)?.exp()) -} - #[inline(always)] fn _subject_predictions( ode: &ODE, @@ -295,11 +275,11 @@ fn _subject_predictions( return Ok(cached); } - let result = _simulate_subject_dense(ode, subject, parameters, None)?.0; + let result = _simulate_subject_dense(ode, subject, parameters)?; cache.insert(key, result.clone()); Ok(result) } else { - Ok(_simulate_subject_dense(ode, subject, parameters, None)?.0) + _simulate_subject_dense(ode, subject, parameters) } } @@ -307,19 +287,9 @@ fn _simulate_subject_dense( ode: &ODE, subject: &Subject, parameters: &[f64], - error_models: Option<&AssayErrorModels>, -) -> Result<(SubjectPredictions, Option), PharmsolError> { - let bound_error_models = match error_models { - Some(error_models) => Some(ode.bind_error_models(error_models)?), - None => None, - }; - let bound_error_models = bound_error_models.as_ref().map(|models| &**models); - +) -> Result { let mut output = SubjectPredictions::new(ode.nparticles()); - let event_count: usize = subject.occasions().iter().map(|o| o.events().len()).sum(); - let mut likelihood = Vec::with_capacity(event_count); - let nstates = ode.get_nstates(); let ndrugs = ode.get_ndrugs(); @@ -370,14 +340,12 @@ fn _simulate_subject_dense( &events, ¶meters_v, covariates, - bound_error_models, &mut bolus_v, &zero_bolus, &zero_rateiv, &mut state_with_bolus, &mut state_without_bolus, &mut y_out, - &mut likelihood, &mut output, )?; } @@ -389,14 +357,12 @@ fn _simulate_subject_dense( &events, ¶meters_v, covariates, - bound_error_models, &mut bolus_v, &zero_bolus, &zero_rateiv, &mut state_with_bolus, &mut state_without_bolus, &mut y_out, - &mut likelihood, &mut output, )?; } @@ -408,14 +374,12 @@ fn _simulate_subject_dense( &events, ¶meters_v, covariates, - bound_error_models, &mut bolus_v, &zero_bolus, &zero_rateiv, &mut state_with_bolus, &mut state_without_bolus, &mut y_out, - &mut likelihood, &mut output, )?; } @@ -427,14 +391,12 @@ fn _simulate_subject_dense( &events, ¶meters_v, covariates, - bound_error_models, &mut bolus_v, &zero_bolus, &zero_rateiv, &mut state_with_bolus, &mut state_without_bolus, &mut y_out, - &mut likelihood, &mut output, )?; } @@ -450,8 +412,7 @@ fn _simulate_subject_dense( })?; } - let ll = bound_error_models.map(|_| likelihood.iter().product::()); - Ok((output, ll)) + Ok(output) } impl EquationTypes for ODE { @@ -516,11 +477,9 @@ impl EquationPriv for ODE { &self, _parameters: &[f64], _observation: &Observation, - _error_models: Option<&AssayErrorModels>, _time: f64, _covariates: &Covariates, _x: &mut Self::S, - _likelihood: &mut Vec, _output: &mut Self::P, ) -> Result<(), PharmsolError> { unimplemented!("process_observation not implemented for ODE"); @@ -552,14 +511,12 @@ impl ODE { events: &[Event], parameters_v: &V, covariates: &Covariates, - error_models: Option<&AssayErrorModels>, bolus_v: &mut V, zero_bolus: &V, zero_rateiv: &V, state_with_bolus: &mut V, state_without_bolus: &mut V, y_out: &mut V, - likelihood: &mut Vec, output: &mut SubjectPredictions, ) -> Result<(), PharmsolError> where @@ -613,6 +570,13 @@ impl ODE { state_with_bolus.axpy(-1.0, state_without_bolus, 1.0); solver.state_mut().y.axpy(1.0, state_with_bolus, 1.0); + + solver.problem().eqn.rhs().call_inplace( + solver.state().y, + event.time(), + state_without_bolus, + ); + solver.state_mut().dy.copy_from(state_without_bolus); } Event::Infusion(_) => { // Infusions are handled within the ODE function itself @@ -633,13 +597,15 @@ impl ODE { .unwrap_or_default(); PharmsolError::unknown_output_label(observation.outeq(), &available) })?; - let pred = y_out[outeq]; - let pred = - observation.to_prediction(pred, solver.state().y.as_slice().to_vec()); - if let Some(error_models) = error_models { - likelihood.push(pred.log_likelihood(error_models)?.exp()); + if outeq >= y_out.len() { + return Err(PharmsolError::OuteqOutOfRange { + outeq, + nout: y_out.len(), + }); } - output.add_prediction(pred); + let pred = y_out[outeq]; + let pred = observation.to_prediction(self.output_label(outeq), pred); + output.add_prediction(pred, observation.occasion()); } } @@ -683,19 +649,6 @@ impl ODE { } impl Equation for ODE { - fn bound_error_model_cache(&self) -> Option<&BoundErrorModelCache> { - self.error_model_cache.as_ref() - } - - fn estimate_likelihood( - &self, - subject: &Subject, - parameters: &Parameters, - error_models: &AssayErrorModels, - ) -> Result { - _estimate_likelihood(self, subject, parameters.as_slice(), error_models) - } - fn estimate_predictions( &self, subject: &Subject, @@ -704,17 +657,6 @@ impl Equation for ODE { _subject_predictions(self, subject, parameters.as_slice()) } - fn estimate_log_likelihood( - &self, - subject: &Subject, - parameters: &Parameters, - error_models: &AssayErrorModels, - ) -> Result { - let bound_error_models = self.bind_error_models(error_models)?; - let ypred = _subject_predictions(self, subject, parameters.as_slice())?; - ypred.log_likelihood(&bound_error_models) - } - fn estimate_predictions_dense( &self, subject: &Subject, @@ -723,38 +665,17 @@ impl Equation for ODE { _subject_predictions(self, subject, parameters) } - fn estimate_log_likelihood_dense( - &self, - subject: &Subject, - parameters: &[f64], - error_models: &AssayErrorModels, - ) -> Result { - let bound_error_models = self.bind_error_models(error_models)?; - let ypred = _subject_predictions(self, subject, parameters)?; - ypred.log_likelihood(&bound_error_models) - } - fn simulate_subject_dense( &self, subject: &Subject, parameters: &[f64], - error_models: Option<&AssayErrorModels>, - ) -> Result<(Self::P, Option), PharmsolError> { - _simulate_subject_dense(self, subject, parameters, error_models) + ) -> Result { + _subject_predictions(self, subject, parameters) } fn kind() -> EqnKind { EqnKind::ODE } - - fn simulate_subject( - &self, - subject: &Subject, - parameters: &Parameters, - error_models: Option<&AssayErrorModels>, - ) -> Result<(Self::P, Option), PharmsolError> { - _simulate_subject_dense(self, subject, parameters.as_slice(), error_models) - } } #[cfg(test)] @@ -932,9 +853,8 @@ mod tests { .expect("metadata attachment should validate"); let predictions = ode - .simulate_subject(&route_policy_subject(), &crate::parameters::dense([]), None) - .expect("simulation should succeed") - .0; + .simulate_subject(&route_policy_subject(), &crate::parameters::dense([])) + .expect("simulation should succeed"); let metadata = ode.metadata().expect("metadata exists"); assert_eq!( @@ -980,9 +900,8 @@ mod tests { .expect("metadata attachment should validate"); let predictions = ode - .simulate_subject(&route_policy_subject(), &crate::parameters::dense([]), None) - .expect("simulation should succeed") - .0; + .simulate_subject(&route_policy_subject(), &crate::parameters::dense([])) + .expect("simulation should succeed"); assert_relative_eq!( predictions.predictions()[0].prediction(), @@ -1021,13 +940,11 @@ mod tests { .build(); let canonical_predictions = ode - .simulate_subject(&canonical, &crate::parameters::dense([]), None) - .expect("canonical labels should simulate") - .0; + .simulate_subject(&canonical, &crate::parameters::dense([])) + .expect("canonical labels should simulate"); let aliased_predictions = ode - .simulate_subject(&aliased, &crate::parameters::dense([]), None) - .expect("raw numeric aliases should simulate") - .0; + .simulate_subject(&aliased, &crate::parameters::dense([])) + .expect("raw numeric aliases should simulate"); assert_relative_eq!( canonical_predictions.predictions()[0].prediction(), @@ -1064,10 +981,21 @@ mod tests { ) .with_nstates(1) .with_ndrugs(1) - .with_nout(1); + .with_nout(1) + .with_metadata( + super::super::metadata::new("cached_predictions") + .states(["central"]) + .outputs(["cp"]) + .route( + super::super::Route::bolus("iv_bolus") + .to_state("central") + .expect_explicit_input(), + ), + ) + .expect("metadata attachment should validate"); let subject = Subject::builder("cached_predictions") - .bolus(0.0, 100.0, 0) - .observation(1.0, 0.0, 0) + .bolus(0.0, 100.0, "iv_bolus") + .observation(1.0, 0.0, "cp") .build(); let first = ode @@ -1084,4 +1012,42 @@ mod tests { assert_eq!(first.predictions().len(), second.predictions().len()); assert_eq!(first_calls, second_calls); } + + #[test] + fn handwritten_ode_rejects_out_of_range_numeric_event_labels() { + let model = simple_ode() + .with_metadata( + super::super::metadata::new("reject") + .states(["central"]) + .outputs(["cp"]) + .routes([ + super::super::Route::bolus("iv_bolus") + .to_state("central") + .expect_explicit_input(), + super::super::Route::infusion("iv") + .to_state("central") + .expect_explicit_input(), + ]), + ) + .expect("metadata attachment should validate"); + let parameters = crate::parameters::dense([]); + for subject in [ + Subject::builder("bad-bolus").bolus(0.0, 1.0, 1).build(), + Subject::builder("bad-infusion") + .infusion(0.0, 1.0, 1, 1.0) + .build(), + ] { + assert!(matches!( + model.simulate_subject(&subject, ¶meters), + Err(PharmsolError::UnknownInputLabel { .. }) + )); + } + let subject = Subject::builder("bad-output") + .observation(0.0, 0.0, 1) + .build(); + assert!(matches!( + model.simulate_subject(&subject, ¶meters), + Err(PharmsolError::UnknownOutputLabel { .. }) + )); + } } diff --git a/src/simulator/equation/sde/em.rs b/src/simulator/equation/sde/em.rs index b80cfc07..7f99e0f2 100644 --- a/src/simulator/equation/sde/em.rs +++ b/src/simulator/equation/sde/em.rs @@ -1,5 +1,7 @@ use nalgebra::DVector; +#[cfg(test)] use rand::rng; +use rand::Rng; use rand_distr::{Distribution, Normal}; /// Implementation of the Euler-Maruyama method for solving stochastic differential equations. @@ -88,9 +90,11 @@ where /// /// The adjusted step size for the next iteration. fn compute_new_step(&self, dt: f64, error: f64, safety: f64) -> f64 { - let mut new_dt = dt * safety * (1.0 / error).powf(0.5); - new_dt = new_dt.clamp(self.min_step, self.max_step); - new_dt + if error == 0.0 { + return self.max_step; + } + let new_dt = dt * safety * (1.0 / error).powf(0.5); + new_dt.clamp(self.min_step, self.max_step) } /// Performs a single Euler-Maruyama integration step. @@ -100,7 +104,18 @@ where /// * `time` - Current simulation time /// * `dt` - Step size /// * `state` - Current state of the system (modified in-place) - fn euler_maruyama_step(&self, time: f64, dt: f64, state: &mut DVector) { + fn sample_brownian_increment(&self, dt: f64, rng: &mut R) -> DVector { + let normal_dist = Normal::new(0.0, dt.sqrt()).expect("positive integration step"); + DVector::from_fn(self.state.len(), |_, _| normal_dist.sample(rng)) + } + + fn euler_maruyama_step( + &self, + time: f64, + dt: f64, + state: &mut DVector, + brownian_increment: &DVector, + ) { let n = state.len(); let mut drift_term = DVector::zeros(n); (self.drift)(time, state, &mut drift_term); @@ -108,12 +123,8 @@ where let mut diffusion_term = DVector::zeros(n); (self.diffusion)(time, state, &mut diffusion_term); - let mut rng = rng(); - let normal_dist = Normal::new(0.0, 1.0).unwrap(); - for i in 0..n { - state[i] += - drift_term[i] * dt + diffusion_term[i] * normal_dist.sample(&mut rng) * dt.sqrt(); + state[i] += drift_term[i] * dt + diffusion_term[i] * brownian_increment[i]; } } @@ -131,7 +142,28 @@ where /// A tuple containing: /// * Vector of time points where solutions were computed /// * Vector of state vectors corresponding to each time point + #[cfg(test)] pub fn solve(&mut self, t0: f64, tf: f64) -> (Vec, Vec>) { + let mut rng = rng(); + self.solve_impl(t0, tf, &mut rng) + } + + /// Solve using only random draws from the supplied generator. + pub fn solve_with_rng( + &mut self, + t0: f64, + tf: f64, + rng: &mut R, + ) -> (Vec, Vec>) { + self.solve_impl(t0, tf, rng) + } + + fn solve_impl( + &mut self, + t0: f64, + tf: f64, + rng: &mut R, + ) -> (Vec, Vec>) { let mut t = t0; let mut dt = self.max_step; let safety = 0.9; @@ -139,25 +171,25 @@ where let mut solution = vec![self.state.clone()]; while t < tf { + dt = dt.min(tf - t); let mut y1 = self.state.clone(); let mut y2 = self.state.clone(); - // Single step - self.euler_maruyama_step(t, dt, &mut y1); + let first_half_increment = self.sample_brownian_increment(dt / 2.0, rng); + let second_half_increment = self.sample_brownian_increment(dt / 2.0, rng); + let full_increment = &first_half_increment + &second_half_increment; - // Two half steps - self.euler_maruyama_step(t, dt / 2.0, &mut y2); - self.euler_maruyama_step(t + dt / 2.0, dt / 2.0, &mut y2); + self.euler_maruyama_step(t, dt, &mut y1, &full_increment); + self.euler_maruyama_step(t, dt / 2.0, &mut y2, &first_half_increment); + self.euler_maruyama_step(t + dt / 2.0, dt / 2.0, &mut y2, &second_half_increment); let error = self.calculate_error(&y1, &y2); - if error <= 1.0 { t += dt; - self.state = y2; // Use more accurate solution + self.state = y2; times.push(t); solution.push(self.state.clone()); dt = self.compute_new_step(dt, error, safety); - dt = dt.min(tf - t); // Don't step beyond tf } else { dt = self.compute_new_step(dt, error, safety); } @@ -166,3 +198,65 @@ where (times, solution) } } + +#[cfg(test)] +mod tests { + use super::*; + use rand::rngs::StdRng; + use rand::SeedableRng; + + type TestFn = fn(f64, &DVector, &mut DVector); + + fn test_drift(_time: f64, state: &DVector, out: &mut DVector) { + out[0] = 0.25 * state[0]; + } + + fn test_diffusion(_time: f64, state: &DVector, out: &mut DVector) { + out[0] = 0.5 + 0.1 * state[0]; + } + + fn solver(initial_state: DVector) -> EM { + EM::new(test_drift, test_diffusion, initial_state, 1e-2, 1e-2) + } + + #[test] + fn full_step_increment_is_sum_of_seeded_half_step_increments() { + let solver = solver(DVector::from_vec(vec![2.0])); + let mut rng = StdRng::seed_from_u64(7); + let first = solver.sample_brownian_increment(0.05, &mut rng); + let second = solver.sample_brownian_increment(0.05, &mut rng); + let full = &first + &second; + + let mut full_state = DVector::from_vec(vec![2.0]); + solver.euler_maruyama_step(0.0, 0.1, &mut full_state, &full); + let expected = 2.0 + 0.25 * 2.0 * 0.1 + (0.5 + 0.1 * 2.0) * (first[0] + second[0]); + assert_eq!(full[0].to_bits(), (first[0] + second[0]).to_bits()); + assert!((full_state[0] - expected).abs() <= f64::EPSILON); + } + + #[test] + fn unseeded_solve_clamps_a_short_interval() { + let drift = |_time: f64, _state: &DVector, out: &mut DVector| out[0] = 1.0; + let diffusion = |_time: f64, _state: &DVector, out: &mut DVector| out[0] = 0.0; + let mut solver = EM::new(drift, diffusion, DVector::from_vec(vec![0.0]), 1e-2, 1e-2); + + let (times, states) = solver.solve(0.0, 0.025); + + assert_eq!(times, vec![0.0, 0.025]); + assert!((states.last().unwrap()[0] - 0.025).abs() < 1e-15); + } + + #[test] + fn seeded_adaptive_transition_is_reproducible() { + let mut first = solver(DVector::from_vec(vec![2.0])); + let mut second = solver(DVector::from_vec(vec![2.0])); + let mut first_rng = StdRng::seed_from_u64(19); + let mut second_rng = StdRng::seed_from_u64(19); + + let first_transition = first.solve_with_rng(0.0, 0.25, &mut first_rng); + let second_transition = second.solve_with_rng(0.0, 0.25, &mut second_rng); + + assert_eq!(first_transition, second_transition); + assert_eq!(first_transition.0.last(), Some(&0.25)); + } +} diff --git a/src/simulator/equation/sde/mod.rs b/src/simulator/equation/sde/mod.rs index 726daad6..42e2e62a 100644 --- a/src/simulator/equation/sde/mod.rs +++ b/src/simulator/equation/sde/mod.rs @@ -4,22 +4,15 @@ use diffsol::{NalgebraContext, Vector}; use nalgebra::DVector; use ndarray::{concatenate, Array2, Axis}; use pharmsol_dsl::ModelKind; -use rand::{rng, RngExt}; +use rand::Rng; use rayon::prelude::*; use thiserror::Error; use crate::{ data::{Covariates, Infusion}, - error_model::AssayErrorModels, prelude::simulator::Prediction, simulator::{Diffusion, Drift, Fa, Init, Lag, Neqs, Out, V}, - Parameters, Subject, -}; - -use super::parameters_hash; -use crate::simulator::cache::{ - BoundErrorModelCache, SdeLikelihoodCache, DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - DEFAULT_CACHE_SIZE, + Event, Observation, Parameters, Subject, }; use diffsol::VectorCommon; @@ -109,6 +102,7 @@ fn simulate_sde_event( ndrugs: usize, ti: f64, tf: f64, + discontinuities: &[f64], ) -> V { if ti == tf { return x; @@ -124,7 +118,7 @@ fn simulate_sde_event( let drift_closure = move |time: f64, state: &DVector, out: &mut DVector| { let mut rateiv = V::zeros(ndrugs, NalgebraContext::new()); for infusion in &infusion_events { - if time >= infusion.time() && time <= infusion.duration() + infusion.time() { + if time >= infusion.time() && time < infusion.duration() + infusion.time() { let input = infusion .input_index() .expect("resolved infusions should use numeric input labels"); @@ -151,7 +145,26 @@ fn simulate_sde_event( out.copy_from(out_v.inner()); }; - simulate_sde_event_with(drift_closure, diffusion_closure, x.inner().clone(), ti, tf).into() + simulate_sde_event_with( + drift_closure, + diffusion_closure, + x.inner().clone(), + ti, + tf, + discontinuities, + ) + .into() +} + +pub(crate) fn infusion_discontinuities(infusions: &[Infusion], ti: f64, tf: f64) -> Vec { + let mut discontinuities = infusions + .iter() + .flat_map(|infusion| [infusion.time(), infusion.time() + infusion.duration()]) + .filter(|&time| time > ti && time < tf) + .collect::>(); + discontinuities.sort_by(f64::total_cmp); + discontinuities.dedup_by(|left, right| left.total_cmp(right).is_eq()); + discontinuities } pub(crate) fn simulate_sde_event_with( @@ -160,6 +173,7 @@ pub(crate) fn simulate_sde_event_with( initial_state: DVector, ti: f64, tf: f64, + discontinuities: &[f64], ) -> DVector where D: Fn(f64, &DVector, &mut DVector), @@ -169,18 +183,56 @@ where return initial_state; } - let mut sde = em::EM::new(drift, diffusion, initial_state, 1e-2, 1e-2); - let (_time, solution) = sde.solve(ti, tf); - solution.last().unwrap().clone() + let mut rng = rand::rng(); + solve_sde_event_with_rng( + drift, + diffusion, + initial_state, + ti, + tf, + discontinuities, + &mut rng, + ) +} + +#[allow(clippy::too_many_arguments)] +fn solve_sde_event_with_rng( + drift: D, + diffusion: G, + initial_state: DVector, + ti: f64, + tf: f64, + discontinuities: &[f64], + rng: &mut R, +) -> DVector +where + D: Fn(f64, &DVector, &mut DVector), + G: Fn(f64, &DVector, &mut DVector), + R: Rng + ?Sized, +{ + let mut sde = em::EM::new(drift, diffusion, initial_state.clone(), 1e-2, 1e-2); + let mut segment_start = ti; + let mut final_state = initial_state; + for segment_end in discontinuities.iter().copied().chain(std::iter::once(tf)) { + let (_times, mut solution) = sde.solve_with_rng(segment_start, segment_end, rng); + final_state = solution.pop().unwrap(); + segment_start = segment_end; + } + final_state } /// Stochastic Differential Equation solver for pharmacometric models. /// /// This struct represents a stochastic differential equation system and provides -/// methods to simulate particles and estimate likelihood for PKPD modeling. +/// methods to generate structural predictions from multiple particles. /// /// SDE models introduce stochasticity into the system dynamics, allowing for more /// realistic modeling of biological variability and uncertainty. +/// +/// This low-level constructor is an internal building block for the `sde!` +/// macro and is not part of the supported public API. Author models with the +/// [`sde!`](crate::sde) macro or the DSL instead. +#[doc(hidden)] #[derive(Clone, Debug)] pub struct SDE { drift: Drift, @@ -193,8 +245,6 @@ pub struct SDE { nparticles: usize, metadata: Option, injected_bolus_mappings: InjectedBolusMappings, - cache: Option, - error_model_cache: Option, } impl SDE { @@ -227,10 +277,6 @@ impl SDE { nparticles, metadata: None, injected_bolus_mappings: InjectedBolusMappings::default(), - cache: Some(SdeLikelihoodCache::new(DEFAULT_CACHE_SIZE)), - error_model_cache: Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )), } } @@ -260,9 +306,6 @@ impl SDE { let metadata = metadata.validate_for_with_particles(ModelKind::Sde, self.nparticles)?; validate_metadata_dimensions(&metadata, &self.neqs)?; self.metadata = Some(metadata); - self.error_model_cache = Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )); Ok(self) } @@ -292,12 +335,416 @@ impl SDE { fn invalidate_metadata(&mut self) { self.metadata = None; - self.error_model_cache = Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )); self.injected_bolus_mappings .invalidate_for_ndrugs(self.neqs.ndrugs); } + + /// Start a stateful particle simulation controlled by the supplied generator. + /// + /// The session stops at every observation. The caller must explicitly keep + /// the cloud or select ancestors before asking for the next observation. + pub fn particle_session<'a, R: Rng + ?Sized>( + &'a self, + subject: &Subject, + parameters: &'a Parameters, + particle_count: usize, + rng: &'a mut R, + ) -> Result, SdeSessionError> { + if particle_count == 0 { + return Err(SdeSessionError::EmptyCloud); + } + + let mut events = Vec::with_capacity(subject.occasions().len()); + let mut covariates = Vec::with_capacity(subject.occasions().len()); + for occasion in subject.occasions() { + covariates.push(occasion.covariates().clone()); + events.push(self.resolve_occasion_events( + occasion, + parameters.as_slice(), + occasion.covariates(), + )?); + } + + let states = if let Some(occasion) = subject.occasions().first() { + self.initial_particles( + parameters.as_slice(), + occasion.covariates(), + occasion.index(), + particle_count, + ) + } else { + Vec::new() + }; + + Ok(SdeParticleSession { + model: self, + parameters: parameters.as_slice(), + events, + covariates, + occasion_indices: subject + .occasions() + .iter() + .map(|occasion| occasion.index()) + .collect(), + occasion: 0, + event: 0, + states, + spare: Vec::with_capacity(particle_count), + infusions: Vec::new(), + predictions: Vec::with_capacity(particle_count), + observation: None, + waiting: false, + rng, + particle_count, + }) + } + + fn initial_particles( + &self, + parameters: &[f64], + covariates: &Covariates, + occasion_index: usize, + particle_count: usize, + ) -> Vec> { + let mut particles = Vec::with_capacity(particle_count); + for _ in 0..particle_count { + let mut state: V = DVector::zeros(self.get_nstates()).into(); + if occasion_index == 0 { + (self.init)( + &V::from_vec(parameters.to_vec(), NalgebraContext::new()), + 0.0, + covariates, + &mut state, + ); + } + particles.push(state.inner().clone()); + } + particles + } + + #[allow(clippy::too_many_arguments)] + fn advance_particle( + &self, + state: DVector, + parameters: &[f64], + covariates: &Covariates, + infusions: &[Infusion], + ti: f64, + tf: f64, + discontinuities: &[f64], + rng: &mut R, + ) -> DVector { + if ti == tf { + return state; + } + let parameter_values = V::from_vec(parameters.to_vec(), NalgebraContext::new()); + let infusion_events = infusions.to_vec(); + let ndrugs = self.get_ndrugs(); + let drift = self.drift; + let diffusion = self.diffusion; + let drift_parameters = parameter_values.clone(); + let covariates = covariates.clone(); + + let drift_closure = move |time: f64, state: &DVector, out: &mut DVector| { + let mut rateiv = V::zeros(ndrugs, NalgebraContext::new()); + for infusion in &infusion_events { + if time >= infusion.time() && time < infusion.duration() + infusion.time() { + let input = infusion + .input_index() + .expect("resolved infusions should use numeric input labels"); + rateiv[input] += infusion.amount() / infusion.duration(); + } + } + let mut out_v = V::zeros(state.len(), NalgebraContext::new()); + drift( + &state.clone().into(), + &drift_parameters, + time, + &mut out_v, + &rateiv, + &covariates, + ); + out.copy_from(out_v.inner()); + }; + let diffusion_closure = move |_time: f64, _state: &DVector, out: &mut DVector| { + let mut out_v = V::zeros(out.len(), NalgebraContext::new()); + diffusion(¶meter_values, &mut out_v); + out.copy_from(out_v.inner()); + }; + solve_sde_event_with_rng( + drift_closure, + diffusion_closure, + state, + ti, + tf, + discontinuities, + rng, + ) + } +} + +/// Errors raised by caller-controlled SDE particle sessions. +#[derive(Debug, Error)] +pub enum SdeSessionError { + /// The underlying simulation rejected the subject, labels, or parameters. + #[error(transparent)] + Simulation(#[from] PharmsolError), + /// A session was requested with zero particles. + #[error("a particle session requires at least one particle")] + EmptyCloud, + /// The caller requested another observation before choosing how to resume. + #[error("choose how to resume from the current observation boundary before advancing")] + BoundaryPending, + /// A resume operation was requested when no observation is pending. + #[error("no observation boundary is currently pending")] + NoBoundary, + /// The replacement ancestor list does not contain one index per particle. + #[error("expected {expected} ancestor indices, received {actual}")] + AncestorCount { + /// Required number of indices. + expected: usize, + /// Number supplied by the caller. + actual: usize, + }, + /// An ancestor index does not identify a particle in the current cloud. + #[error("ancestor index {index} is outside the particle cloud of size {particle_count}")] + AncestorOutOfRange { + /// Invalid ancestor index. + index: usize, + /// Number of particles in the current cloud. + particle_count: usize, + }, +} + +/// Borrowed data exposed while a session is stopped at an observation. +#[derive(Debug)] +pub struct SdeParticleObservation<'a> { + observation: &'a Observation, + predictions: &'a [Prediction], + states: &'a [DVector], +} + +impl<'a> SdeParticleObservation<'a> { + /// Borrow the source observation at this boundary. + pub fn observation(&self) -> &'a Observation { + self.observation + } + + /// Borrow one noiseless model prediction per particle. + pub fn predictions(&self) -> &'a [Prediction] { + self.predictions + } + + /// Borrow the full particle states at this boundary. + pub fn states(&self) -> &'a [DVector] { + self.states + } + + /// Return the observation time. + pub fn time(&self) -> f64 { + self.observation.time() + } + + /// Return the resolved dense output index. + pub fn output_index(&self) -> usize { + self.observation + .outeq_index() + .expect("session observations are resolved") + } +} + +/// Stateful particle simulation that pauses at each observation boundary. +pub struct SdeParticleSession<'a, R: Rng + ?Sized> { + model: &'a SDE, + parameters: &'a [f64], + events: Vec>, + covariates: Vec, + occasion_indices: Vec, + occasion: usize, + event: usize, + states: Vec>, + spare: Vec>, + infusions: Vec, + predictions: Vec, + observation: Option, + waiting: bool, + rng: &'a mut R, + particle_count: usize, +} + +impl SdeParticleSession<'_, R> { + /// Return the fixed number of particles in this session. + pub fn particle_count(&self) -> usize { + self.particle_count + } + + /// Advance to the next observation, or return `None` when the schedule ends. + pub fn next_observation( + &mut self, + ) -> Result>, SdeSessionError> { + if self.waiting { + return Err(SdeSessionError::BoundaryPending); + } + + loop { + if self.occasion >= self.events.len() { + return Ok(None); + } + if self.event >= self.events[self.occasion].len() { + self.occasion += 1; + self.event = 0; + self.infusions.clear(); + if self.occasion >= self.events.len() { + return Ok(None); + } + self.states = self.model.initial_particles( + self.parameters, + &self.covariates[self.occasion], + self.occasion_indices[self.occasion], + self.particle_count, + ); + continue; + } + + let event = self.events[self.occasion][self.event].clone(); + match &event { + Event::Bolus(bolus) => { + let input = bolus.input_index().ok_or_else(|| { + let available = self + .model + .metadata() + .map(|metadata| metadata.route_labels()) + .unwrap_or_default(); + PharmsolError::unknown_input_label(bolus.input(), &available) + })?; + if input >= self.model.get_ndrugs() { + return Err(PharmsolError::InputOutOfRange { + input, + ndrugs: self.model.get_ndrugs(), + } + .into()); + } + if !self.model.injected_bolus_mappings.apply( + &mut self.states, + input, + bolus.amount(), + ) { + self.states.add_bolus(input, bolus.amount()); + } + self.event += 1; + self.advance_after(event.time()); + } + Event::Infusion(infusion) => { + self.infusions.push(infusion.clone()); + self.event += 1; + self.advance_after(event.time()); + } + Event::Observation(observation) => { + self.predictions.clear(); + let output_index = observation + .outeq_index() + .expect("session observations are resolved"); + let output_label = self.model.output_label(output_index); + let parameter_values = + V::from_vec(self.parameters.to_vec(), NalgebraContext::new()); + for state in &self.states { + let mut output = V::zeros(self.model.get_nouteqs(), NalgebraContext::new()); + (self.model.out)( + &state.clone().into(), + ¶meter_values, + observation.time(), + &self.covariates[self.occasion], + &mut output, + ); + self.predictions.push( + observation.to_prediction(output_label.clone(), output[output_index]), + ); + } + self.observation = Some(observation.clone()); + self.event += 1; + self.waiting = true; + return Ok(Some(SdeParticleObservation { + observation: self.observation.as_ref().unwrap(), + predictions: &self.predictions, + states: &self.states, + })); + } + } + } + } + + /// Resume after the current boundary without changing particle states. + pub fn retain_particles(&mut self) -> Result<(), SdeSessionError> { + if !self.waiting { + return Err(SdeSessionError::NoBoundary); + } + let time = self.observation.as_ref().unwrap().time(); + self.waiting = false; + self.advance_after(time); + Ok(()) + } + + /// Replace particle states from `ancestors`, then resume after the boundary. + /// + /// The slice must contain exactly one in-range ancestor index for each + /// particle in the session. Indices may repeat. + pub fn select_ancestors(&mut self, ancestors: &[usize]) -> Result<(), SdeSessionError> { + if !self.waiting { + return Err(SdeSessionError::NoBoundary); + } + if ancestors.len() != self.particle_count { + return Err(SdeSessionError::AncestorCount { + expected: self.particle_count, + actual: ancestors.len(), + }); + } + if let Some(&index) = ancestors + .iter() + .find(|&&index| index >= self.particle_count) + { + return Err(SdeSessionError::AncestorOutOfRange { + index, + particle_count: self.particle_count, + }); + } + + self.spare.clear(); + self.spare.reserve(self.particle_count); + self.spare + .extend(ancestors.iter().map(|&index| self.states[index].clone())); + std::mem::swap(&mut self.states, &mut self.spare); + let time = self.observation.as_ref().unwrap().time(); + self.waiting = false; + self.advance_after(time); + Ok(()) + } + + fn advance_after(&mut self, time: f64) { + let Some(next) = self.events[self.occasion].get(self.event) else { + return; + }; + let end = next.time(); + if time == end { + return; + } + let discontinuities = infusion_discontinuities(&self.infusions, time, end); + let old_states = std::mem::take(&mut self.states); + self.states = old_states + .into_iter() + .map(|state| { + self.model.advance_particle( + state, + self.parameters, + &self.covariates[self.occasion], + &self.infusions, + time, + end, + &discontinuities, + self.rng, + ) + }) + .collect(); + } } fn validate_metadata_dimensions( @@ -331,39 +778,6 @@ fn validate_metadata_dimensions( Ok(()) } -impl super::Cache for SDE { - fn with_cache_capacity(mut self, size: usize) -> Self { - self.cache = Some(SdeLikelihoodCache::new(size)); - self.error_model_cache = Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )); - self - } - - fn enable_cache(mut self) -> Self { - self.cache = Some(SdeLikelihoodCache::new(DEFAULT_CACHE_SIZE)); - self.error_model_cache = Some(BoundErrorModelCache::new( - DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, - )); - self - } - - fn clear_cache(&self) { - if let Some(cache) = &self.cache { - cache.invalidate_all(); - } - if let Some(cache) = &self.error_model_cache { - cache.invalidate_all(); - } - } - - fn disable_cache(mut self) -> Self { - self.cache = None; - self.error_model_cache = None; - self - } -} - /// State trait implementation for particle-based SDE simulation. /// /// This implementation allows adding bolus doses to all particles in the system. @@ -388,9 +802,6 @@ impl Predictions for Array2 { fn new(nparticles: usize) -> Self { Array2::from_shape_fn((nparticles, 0), |_| Prediction::default()) } - fn squared_error(&self) -> f64 { - unimplemented!(); - } fn get_predictions(&self) -> Vec { // Make this return the mean prediction across all particles if self.is_empty() || self.ncols() == 0 { @@ -415,21 +826,6 @@ impl Predictions for Array2 { result } - fn log_likelihood(&self, error_models: &AssayErrorModels) -> Result { - // For SDE, compute log-likelihood using mean predictions across particles - let predictions = self.get_predictions(); - if predictions.is_empty() { - return Ok(0.0); - } - - let log_liks: Result, _> = predictions - .iter() - .filter(|p| p.observation().is_some()) - .map(|p| p.log_likelihood(error_models)) - .collect(); - - log_liks.map(|lls| lls.iter().sum()) - } } impl EquationTypes for SDE { @@ -498,6 +894,7 @@ impl EquationPriv for SDE { tf: f64, ) -> Result<(), PharmsolError> { let ndrugs = self.get_ndrugs(); + let discontinuities = infusion_discontinuities(infusions, ti, tf); state.par_iter_mut().for_each(|particle| { *particle = simulate_sde_event( &self.drift, @@ -509,6 +906,7 @@ impl EquationPriv for SDE { ndrugs, ti, tf, + &discontinuities, ) .inner() .clone(); @@ -519,23 +917,28 @@ impl EquationPriv for SDE { self.nparticles } - fn is_sde(&self) -> bool { - true - } #[inline(always)] fn process_observation( &self, parameters: &[f64], observation: &crate::Observation, - error_models: Option<&AssayErrorModels>, _time: f64, covariates: &Covariates, x: &mut Self::S, - likelihood: &mut Vec, output: &mut Self::P, ) -> Result<(), PharmsolError> { let mut pred = vec![Prediction::default(); self.nparticles]; + let outeq = observation + .outeq_index() + .expect("resolved observations should use numeric output labels"); + if outeq >= self.get_nouteqs() { + return Err(PharmsolError::OuteqOutOfRange { + outeq, + nout: self.get_nouteqs(), + }); + } + let output_label = self.output_label(outeq); pred.par_iter_mut().enumerate().for_each(|(i, p)| { let mut y = V::zeros(self.get_nouteqs(), NalgebraContext::new()); (self.out)( @@ -545,34 +948,10 @@ impl EquationPriv for SDE { covariates, &mut y, ); - let outeq = observation - .outeq_index() - .expect("resolved observations should use numeric output labels"); - *p = observation.to_prediction(y[outeq], x[i].as_slice().to_vec()); + *p = observation.to_prediction(output_label.clone(), y[outeq]); }); - let out = Array2::from_shape_vec((self.nparticles, 1), pred.clone())?; + let out = Array2::from_shape_vec((self.nparticles, 1), pred)?; *output = concatenate(Axis(1), &[output.view(), out.view()]).unwrap(); - //e = y[t] .- x[:,1] - // q = pdf.(Distributions.Normal(0, 0.5), e) - if let Some(em) = error_models { - let mut q: Vec = Vec::with_capacity(self.nparticles); - - pred.iter().for_each(|p| { - let lik = p.log_likelihood(em).map(f64::exp); - match lik { - Ok(l) => q.push(l), - Err(e) => panic!("Error in likelihood calculation: {:?}", e), - } - }); - let sum_q: f64 = q.iter().sum(); - let w: Vec = q.iter().map(|qi| qi / sum_q).collect(); - let i = sysresample(&w); - let a: Vec> = i.iter().map(|&i| x[i].clone()).collect(); - *x = a; - likelihood.push(sum_q / self.nparticles as f64); - // let qq: Vec = i.iter().map(|&i| q[i]).collect(); - // likelihood.push(qq.iter().sum::() / self.nparticles as f64); - } Ok(()) } #[inline(always)] @@ -603,11 +982,9 @@ impl EquationPriv for SDE { parameters: &[f64], event: &crate::Event, next_event: Option<&crate::Event>, - error_models: Option<&AssayErrorModels>, covariates: &Covariates, x: &mut Self::S, infusions: &mut Vec, - likelihood: &mut Vec, output: &mut Self::P, ) -> Result<(), PharmsolError> { match event { @@ -637,11 +1014,9 @@ impl EquationPriv for SDE { self.process_observation( parameters, observation, - error_models, event.time(), covariates, x, - likelihood, output, )?; } @@ -662,116 +1037,19 @@ impl EquationPriv for SDE { } impl Equation for SDE { - fn bound_error_model_cache(&self) -> Option<&BoundErrorModelCache> { - self.error_model_cache.as_ref() - } - - /// Estimates the likelihood of observed data given a model and parameters. - /// - /// # Arguments - /// - /// * `subject` - Subject data containing observations - /// * `parameters` - Parameter vector for the model - /// * `error_model` - Error model to use for likelihood calculations - /// - /// # Returns - /// - /// The log-likelihood of the observed data given the model and parameters. - fn estimate_likelihood( - &self, - subject: &Subject, - parameters: &Parameters, - error_models: &AssayErrorModels, - ) -> Result { - _estimate_likelihood(self, subject, parameters.as_slice(), error_models) - } - - fn estimate_log_likelihood( - &self, - subject: &Subject, - parameters: &Parameters, - error_models: &AssayErrorModels, - ) -> Result { - // For SDE, the particle filter computes likelihood in regular space. - // We compute it directly and then take the log. - let lik = _estimate_likelihood(self, subject, parameters.as_slice(), error_models)?; - - if lik > 0.0 { - Ok(lik.ln()) - } else { - Ok(f64::NEG_INFINITY) - } - } - fn kind() -> EqnKind { EqnKind::SDE } } -#[inline(always)] -fn _estimate_likelihood( - sde: &SDE, - subject: &Subject, - parameters: &[f64], - error_models: &AssayErrorModels, -) -> Result { - if let Some(cache) = &sde.cache { - let key = ( - subject.hash(), - parameters_hash(parameters), - error_models.hash(), - ); - if let Some(cached) = cache.get(&key) { - return Ok(cached); - } - - let ypred = sde.simulate_subject_dense(subject, parameters, Some(error_models))?; - let result = ypred.1.unwrap(); - cache.insert(key, result); - Ok(result) - } else { - let ypred = sde.simulate_subject_dense(subject, parameters, Some(error_models))?; - Ok(ypred.1.unwrap()) - } -} - -/// Performs systematic resampling of particles based on weights. -/// -/// # Arguments -/// -/// * `q` - Vector of particle weights -/// -/// # Returns -/// -/// Vector of indices to use for resampling. -fn sysresample(q: &[f64]) -> Vec { - let mut qc = vec![0.0; q.len()]; - qc[0] = q[0]; - for i in 1..q.len() { - qc[i] = qc[i - 1] + q[i]; - } - let m = q.len(); - let mut rng = rng(); - let u: Vec = (0..m) - .map(|i| (i as f64 + rng.random::()) / m as f64) - .collect(); - let mut i = vec![0; m]; - let mut k = 0; - for j in 0..m { - while qc[k] < u[j] { - k += 1; - } - i[j] = k; - } - i -} - #[cfg(test)] mod tests { use super::*; use crate::simulator::equation::{self, Covariate, Route}; - use crate::SubjectBuilderExt; use crate::{fa, fetch_params, lag}; + use crate::{Subject, SubjectBuilderExt}; + use rand::rngs::StdRng; + use rand::SeedableRng; fn simple_sde() -> SDE { let drift = |x: &V, _p: &V, _t: f64, dx: &mut V, rateiv: &V, _cov: &Covariates| { @@ -815,6 +1093,21 @@ mod tests { .with_nout(1) } + /// Metadata for `route_policy_sde` models that dose via infusion `iv` + /// (input index 0) and observe `cp` (output index 0). + fn route_policy_infusion_metadata() -> equation::ModelMetadata { + equation::metadata::new("route_policy_infusion") + .parameters(["theta"]) + .states(["depot", "central"]) + .outputs(["cp"]) + .route( + Route::infusion("iv") + .to_state("central") + .expect_explicit_input(), + ) + .particles(16) + } + #[test] fn handwritten_sde_metadata_exposes_name_lookup_and_particles() { let sde = simple_sde() @@ -1064,7 +1357,240 @@ mod tests { } #[test] - fn clearing_sde_metadata_preserves_raw_bolus_behavior() { + fn standard_sde_short_infusion_stops_at_boundary() { + let rateiv_drift = |_x: &V, _p: &V, _t: f64, dx: &mut V, rateiv: &V, _cov: &Covariates| { + dx.fill(0.0); + dx[1] = rateiv[0]; + }; + let sde = route_policy_sde(rateiv_drift) + .with_metadata(route_policy_infusion_metadata()) + .expect("metadata should validate"); + let subject = Subject::builder("short-infusion") + .infusion(0.0, 2.5, "iv", 0.025) + .missing_observation(0.025, "cp") + .missing_observation(0.05, "cp") + .build(); + + let predictions = sde + .estimate_predictions(&subject, &crate::parameters::dense([0.0])) + .unwrap(); + + assert!((predictions[[0, 0]].prediction() - 2.5).abs() < 1e-12); + assert!((predictions[[1, 0]].prediction() - 2.5).abs() < 1e-12); + } + + #[test] + fn standard_sde_segments_at_infusion_end_between_events() { + let rateiv_drift = |_x: &V, _p: &V, _t: f64, dx: &mut V, rateiv: &V, _cov: &Covariates| { + dx.fill(0.0); + dx[1] = rateiv[0]; + }; + let sde = route_policy_sde(rateiv_drift) + .with_metadata(route_policy_infusion_metadata()) + .expect("metadata should validate"); + let subject = Subject::builder("segmented-standard") + .infusion(0.0, 3.0, "iv", 0.075) + .missing_observation(0.1, "cp") + .build(); + + let predictions = sde + .estimate_predictions(&subject, &crate::parameters::dense([0.0])) + .unwrap(); + + assert!((predictions[[0, 0]].prediction() - 3.0).abs() < 1e-12); + } + + #[test] + fn standard_sde_infusion_is_inactive_at_end_and_after() { + let rateiv_drift = |_x: &V, _p: &V, _t: f64, dx: &mut V, rateiv: &V, _cov: &Covariates| { + dx.fill(0.0); + dx[1] = rateiv[0]; + }; + let sde = route_policy_sde(rateiv_drift) + .with_metadata(route_policy_infusion_metadata()) + .expect("metadata should validate"); + let subject = Subject::builder("half-open-standard") + .infusion(0.0, 100.0, "iv", 1.0) + .missing_observation(1.0, "cp") + .missing_observation(1.2, "cp") + .build(); + + let predictions = sde + .estimate_predictions(&subject, &crate::parameters::dense([0.0])) + .unwrap(); + + assert!((predictions[[0, 0]].prediction() - 100.0).abs() < 1e-10); + assert!((predictions[[1, 0]].prediction() - 100.0).abs() < 1e-10); + } + + #[test] + fn stateful_sde_infusion_is_inactive_at_end_and_after() { + let rateiv_drift = |_x: &V, _p: &V, _t: f64, dx: &mut V, rateiv: &V, _cov: &Covariates| { + dx.fill(0.0); + dx[1] = rateiv[0]; + }; + let sde = route_policy_sde(rateiv_drift) + .with_metadata(route_policy_infusion_metadata()) + .expect("metadata should validate"); + let subject = Subject::builder("half-open-session") + .infusion(0.0, 100.0, "iv", 1.0) + .missing_observation(1.0, "cp") + .missing_observation(1.2, "cp") + .build(); + let parameters = crate::parameters::dense([0.0]); + let mut rng = StdRng::seed_from_u64(41); + let mut session = sde + .particle_session(&subject, ¶meters, 4, &mut rng) + .unwrap(); + + { + let boundary = session.next_observation().unwrap().unwrap(); + assert_eq!(boundary.time(), 1.0); + assert!(boundary + .predictions() + .iter() + .all(|prediction| (prediction.prediction() - 100.0).abs() < 1e-10)); + } + session.retain_particles().unwrap(); + { + let boundary = session.next_observation().unwrap().unwrap(); + assert_eq!(boundary.time(), 1.2); + assert!(boundary + .predictions() + .iter() + .all(|prediction| (prediction.prediction() - 100.0).abs() < 1e-10)); + } + } + + #[test] + fn stateful_sde_segments_at_infusion_end_between_events() { + let rateiv_drift = |_x: &V, _p: &V, _t: f64, dx: &mut V, rateiv: &V, _cov: &Covariates| { + dx.fill(0.0); + dx[1] = rateiv[0]; + }; + let sde = route_policy_sde(rateiv_drift) + .with_metadata(route_policy_infusion_metadata()) + .expect("metadata should validate"); + let subject = Subject::builder("segmented-session") + .infusion(0.0, 3.0, "iv", 0.075) + .missing_observation(0.1, "cp") + .build(); + let parameters = crate::parameters::dense([0.0]); + let mut rng = StdRng::seed_from_u64(42); + let mut session = sde + .particle_session(&subject, ¶meters, 4, &mut rng) + .unwrap(); + + let boundary = session.next_observation().unwrap().unwrap(); + assert_eq!(boundary.time(), 0.1); + assert!(boundary + .predictions() + .iter() + .all(|prediction| (prediction.prediction() - 3.0).abs() < 1e-12)); + } + + #[test] + fn handwritten_sde_rejects_out_of_range_numeric_event_labels() { + let model = simple_sde() + .with_metadata( + equation::metadata::new("reject") + .states(["central"]) + .outputs(["cp"]) + .routes([ + Route::bolus("iv_bolus") + .to_state("central") + .expect_explicit_input(), + Route::infusion("iv") + .to_state("central") + .expect_explicit_input(), + ]) + .particles(128), + ) + .expect("metadata attachment should validate"); + let parameters = crate::parameters::dense([0.0, 1.0]); + for subject in [ + Subject::builder("bad-bolus").bolus(0.0, 1.0, 1).build(), + Subject::builder("bad-infusion") + .infusion(0.0, 1.0, 1, 1.0) + .build(), + ] { + assert!(matches!( + model.simulate_subject(&subject, ¶meters), + Err(PharmsolError::UnknownInputLabel { .. }) + )); + } + let subject = Subject::builder("bad-output") + .observation(0.0, 0.0, 1) + .build(); + assert!(matches!( + model.simulate_subject(&subject, ¶meters), + Err(PharmsolError::UnknownOutputLabel { .. }) + )); + } + + #[test] + fn particle_session_validates_boundaries_and_ancestor_selection() { + let model = simple_sde() + .with_metadata( + equation::metadata::new("session-validation") + .states(["central"]) + .outputs(["cp"]) + .route( + Route::infusion("iv") + .to_state("central") + .expect_explicit_input(), + ) + .particles(128), + ) + .expect("metadata attachment should validate"); + let subject = Subject::builder("session-validation") + .missing_observation(1.0, "cp") + .missing_observation(1.0, "cp") + .build(); + let parameters = crate::parameters::dense([0.0, 1.0]); + let mut rng = StdRng::seed_from_u64(99); + let mut session = model + .particle_session(&subject, ¶meters, 4, &mut rng) + .unwrap(); + + assert!(matches!( + session.retain_particles(), + Err(SdeSessionError::NoBoundary) + )); + let original = session + .next_observation() + .unwrap() + .unwrap() + .states() + .to_vec(); + assert!(matches!( + session.next_observation(), + Err(SdeSessionError::BoundaryPending) + )); + assert!(matches!( + session.select_ancestors(&[0, 1]), + Err(SdeSessionError::AncestorCount { + expected: 4, + actual: 2 + }) + )); + assert!(matches!( + session.select_ancestors(&[0, 1, 2, 4]), + Err(SdeSessionError::AncestorOutOfRange { + index: 4, + particle_count: 4 + }) + )); + + session.select_ancestors(&[3, 2, 1, 0]).unwrap(); + let selected = session.next_observation().unwrap().unwrap(); + for (actual, expected) in selected.states().iter().zip(original.iter().rev()) { + assert_eq!(actual, expected); + } + } + + #[test] + fn clearing_sde_metadata_makes_label_resolution_fail() { let zero_drift = |_x: &V, _p: &V, _t: f64, dx: &mut V, _rateiv: &V, _cov: &Covariates| { dx.fill(0.0); }; @@ -1086,15 +1612,17 @@ mod tests { .with_nout(1); let subject = Subject::builder("bolus_route") - .bolus(0.0, 100.0, 0) - .missing_observation(0.1, 0) + .bolus(0.0, 100.0, "oral") + .missing_observation(0.1, "cp") .build(); - let predictions = sde - .estimate_predictions(&subject, &crate::parameters::dense([0.0])) - .unwrap(); - + // Changing dimensions clears metadata, and without metadata there is no + // longer a raw numeric fallback: resolution now fails with + // `MissingMetadata` instead of silently simulating. assert!(sde.metadata().is_none()); - assert_eq!(predictions[[0, 0]].prediction(), 0.0); + assert!(matches!( + sde.estimate_predictions(&subject, &crate::parameters::dense([0.0])), + Err(PharmsolError::MissingMetadata) + )); } } diff --git a/src/simulator/likelihood/distributions.rs b/src/simulator/likelihood/distributions.rs deleted file mode 100644 index 95ec9570..00000000 --- a/src/simulator/likelihood/distributions.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! Statistical distribution functions for likelihood calculations. -//! -//! This module provides numerically stable implementations of probability -//! distribution functions used in pharmacometric likelihood calculations. -//! -//! All functions operate in log-space for numerical stability. - -use crate::ErrorModelError; -use statrs::distribution::{ContinuousCDF, Normal}; - -// ln(2π) = ln(2) + ln(π) ≈ 1.8378770664093453 -pub(crate) const LOG_2PI: f64 = 1.8378770664093453_f64; - -/// Log of the probability density function of the normal distribution. -/// -/// This is numerically stable and avoids underflow for extreme values. -/// -/// # Formula -/// ```text -/// log(φ(x; μ, σ)) = -0.5 * ln(2π) - ln(σ) - (x - μ)² / (2σ²) -/// ``` -/// -/// # Parameters -/// - `obs`: Observed value -/// - `pred`: Predicted value (mean) -/// - `sigma`: Standard deviation -/// -/// # Returns -/// The log probability density -#[inline(always)] -pub fn lognormpdf(obs: f64, pred: f64, sigma: f64) -> f64 { - let diff = obs - pred; - -0.5 * LOG_2PI - sigma.ln() - (diff * diff) / (2.0 * sigma * sigma) -} - -/// Log of the cumulative distribution function of the normal distribution. -/// -/// Used for BLOQ (below limit of quantification) observations where the -/// likelihood is the probability of observing a value ≤ LOQ. -/// -/// # Parameters -/// - `obs`: Observed value (typically the LOQ) -/// - `pred`: Predicted value (mean) -/// - `sigma`: Standard deviation -/// -/// # Returns -/// The log of the CDF value, or an error if numerical issues occur -/// -/// # Numerical Stability -/// For extremely small CDF values (z < -37), uses an asymptotic approximation -/// to avoid underflow to zero. -#[inline(always)] -pub fn lognormcdf(obs: f64, pred: f64, sigma: f64) -> Result { - let norm = Normal::new(pred, sigma).map_err(|_| ErrorModelError::NegativeSigma)?; - let cdf = norm.cdf(obs); - if cdf <= 0.0 { - // For extremely small CDF values, use an approximation - // log(Φ(x)) ≈ log(φ(x)) - log(-x) for large negative x - // where x = (obs - pred) / sigma - let z = (obs - pred) / sigma; - if z < -37.0 { - // Below this, cdf is essentially 0, use asymptotic approximation - Ok(lognormpdf(obs, pred, sigma) - z.abs().ln()) - } else { - Err(ErrorModelError::NegativeSigma) // Indicates numerical issue - } - } else { - Ok(cdf.ln()) - } -} - -/// Log of the survival function (1 - CDF) of the normal distribution. -/// -/// Used for ALOQ (above limit of quantification) observations where the -/// likelihood is the probability of observing a value > LOQ. -/// -/// # Parameters -/// - `obs`: Observed value (typically the LOQ) -/// - `pred`: Predicted value (mean) -/// - `sigma`: Standard deviation -/// -/// # Returns -/// The log of the survival function value, or an error if numerical issues occur -/// -/// # Numerical Stability -/// For extremely small survival function values (z > 37), uses an asymptotic -/// approximation to avoid underflow to zero. -#[inline(always)] -pub fn lognormccdf(obs: f64, pred: f64, sigma: f64) -> Result { - let norm = Normal::new(pred, sigma).map_err(|_| ErrorModelError::NegativeSigma)?; - let sf = 1.0 - norm.cdf(obs); - if sf <= 0.0 { - let z = (obs - pred) / sigma; - if z > 37.0 { - // Use asymptotic approximation for upper tail - Ok(lognormpdf(obs, pred, sigma) - z.ln()) - } else { - Err(ErrorModelError::NegativeSigma) - } - } else { - Ok(sf.ln()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_lognormpdf_standard_normal() { - // At mean, log PDF should be -0.5 * ln(2π) - ln(σ) - let log_pdf = lognormpdf(0.0, 0.0, 1.0); - let expected = -0.5 * LOG_2PI; - assert!( - (log_pdf - expected).abs() < 1e-10, - "lognormpdf at mean should be -0.5*ln(2π)" - ); - } - - #[test] - fn test_lognormpdf_matches_exp_pdf() { - let obs = 1.5; - let pred = 1.0; - let sigma = 0.5; - - let log_pdf = lognormpdf(obs, pred, sigma); - let pdf = log_pdf.exp(); - - // Manual calculation - let diff = obs - pred; - let expected_pdf = (1.0 / (sigma * (2.0 * std::f64::consts::PI).sqrt())) - * (-diff * diff / (2.0 * sigma * sigma)).exp(); - - assert!( - (pdf - expected_pdf).abs() < 1e-10, - "exp(lognormpdf) should match manual PDF calculation" - ); - } - - #[test] - fn test_lognormcdf_basic() { - // CDF at mean should be 0.5, so log should be ln(0.5) - let log_cdf = lognormcdf(0.0, 0.0, 1.0).unwrap(); - let expected = 0.5_f64.ln(); - assert!( - (log_cdf - expected).abs() < 1e-10, - "lognormcdf at mean should be ln(0.5)" - ); - } - - #[test] - fn test_lognormccdf_basic() { - // SF at mean should be 0.5, so log should be ln(0.5) - let log_sf = lognormccdf(0.0, 0.0, 1.0).unwrap(); - let expected = 0.5_f64.ln(); - assert!( - (log_sf - expected).abs() < 1e-10, - "lognormccdf at mean should be ln(0.5)" - ); - } - - #[test] - fn test_lognormcdf_extreme() { - // Very far in the tail - should still return finite value - let result = lognormcdf(-40.0, 0.0, 1.0); - assert!(result.is_ok(), "lognormcdf should handle extreme values"); - assert!( - result.unwrap().is_finite(), - "lognormcdf should return finite value" - ); - } - - #[test] - fn test_lognormccdf_extreme() { - // Very far in the upper tail - let result = lognormccdf(40.0, 0.0, 1.0); - assert!(result.is_ok(), "lognormccdf should handle extreme values"); - assert!( - result.unwrap().is_finite(), - "lognormccdf should return finite value" - ); - } -} diff --git a/src/simulator/likelihood/matrix.rs b/src/simulator/likelihood/matrix.rs deleted file mode 100644 index 2ff73273..00000000 --- a/src/simulator/likelihood/matrix.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! Population-level log-likelihood matrix computation. -//! -//! This module provides functions for computing log-likelihood matrices -//! across populations of subjects and parameter support points. - -use ndarray::{Array2, Axis, ShapeBuilder}; -use rayon::prelude::*; - -use crate::data::error_model::AssayErrorModels; -use crate::{Data, Equation, PharmsolError}; - -use super::progress::ProgressTracker; - -/// Calculate the log-likelihood matrix for all subjects and support points. -/// -/// This function computes log-likelihoods directly in log-space, which is numerically -/// more stable than computing likelihoods and then taking logarithms. This is especially -/// important when dealing with many observations or extreme parameter values that could -/// cause the regular likelihood to underflow to zero. -/// -/// `support_points` must already be a dense matrix in model order. If the -/// incoming columns are in an external named order, validate that order once -/// with [`crate::ParameterOrder`] and reorder before calling this function. -/// -/// # Parameters -/// - `equation`: The equation to use for simulation -/// - `subjects`: The subject data -/// - `support_points`: The support points to evaluate (rows = support points, cols = parameters) -/// - `error_models`: The error models to use (observation-based sigma) -/// - `progress`: Whether to display a progress bar during computation -/// -/// # Returns -/// A 2D array of log-likelihoods with shape (n_subjects, n_support_points) -/// -/// # Example -/// ```ignore -/// use ndarray::array; -/// use pharmsol::{ParameterOrder, prelude::simulator::log_likelihood_matrix}; -/// -/// let order = ParameterOrder::with_model(&equation, ["ka", "ke"])?; -/// let support_points_in_source_order = array![[0.1, 0.3], [0.2, 0.4]]; -/// let support_points = order.matrix(support_points_in_source_order)?; -/// -/// let log_liks = log_likelihood_matrix( -/// &equation, -/// &data, -/// &support_points, -/// &error_models, -/// false -/// )?; -/// ``` -pub fn log_likelihood_matrix( - equation: &impl Equation, - subjects: &Data, - support_points: &Array2, - error_models: &AssayErrorModels, - progress: bool, -) -> Result, PharmsolError> { - let n_support_points = support_points.nrows(); - let mut log_psi: Array2 = Array2::default((subjects.len(), n_support_points).f()); - let subject_slice = subjects.subjects_slice(); - let support_point_rows = support_points - .axis_iter(Axis(0)) - .map(|row| row.to_vec()) - .collect::>(); - - let progress_tracker = if progress { - let total = subject_slice.len() * n_support_points; - println!( - "Computing log-likelihood matrix: {} subjects × {} support points...", - subject_slice.len(), - n_support_points - ); - Some(ProgressTracker::new(total)) - } else { - None - }; - - let result: Result<(), PharmsolError> = log_psi - .axis_iter_mut(Axis(0)) - .into_par_iter() - .enumerate() - .try_for_each(|(i, mut row)| { - let subject = &subject_slice[i]; - - for (element, support_point) in row.iter_mut().zip(support_point_rows.iter()) { - *element = equation.estimate_log_likelihood_dense( - subject, - support_point.as_slice(), - error_models, - )?; - if let Some(ref tracker) = progress_tracker { - tracker.inc(); - } - } - - Ok(()) - }); - - if let Some(tracker) = progress_tracker { - tracker.finish(); - } - - result?; - Ok(log_psi) -} - -/// Calculate the log-likelihood matrix (deprecated signature with boolean flags). -/// -/// Deprecated: Use [log_likelihood_matrix] with [LikelihoodMatrixOptions] instead. -/// -/// This function is provided for backward compatibility with the old log_psi API. -#[deprecated( - since = "0.23.0", - note = "Use log_likelihood_matrix() with LikelihoodMatrixOptions instead" -)] -pub fn log_psi( - equation: &impl Equation, - subjects: &Data, - support_points: &Array2, - error_models: &AssayErrorModels, - progress: bool, -) -> Result, PharmsolError> { - log_likelihood_matrix(equation, subjects, support_points, error_models, progress) -} - -/// Calculate the likelihood matrix (deprecated). -/// -/// Deprecated: Use [log_likelihood_matrix] instead. This function exponentiates -/// the log-likelihood matrix, which can cause numerical underflow for many observations -/// or extreme parameter values. -/// -/// This function is provided for backward compatibility with the old psi API. -#[deprecated( - since = "0.23.0", - note = "Use log_likelihood_matrix() instead and exponentiate if needed" -)] -pub fn psi( - equation: &impl Equation, - subjects: &Data, - support_points: &Array2, - error_models: &AssayErrorModels, - progress: bool, -) -> Result, PharmsolError> { - let log_psi_matrix = - log_likelihood_matrix(equation, subjects, support_points, error_models, progress)?; - - // Exponentiate to get likelihoods (may underflow to 0 for extreme values) - Ok(log_psi_matrix.mapv(f64::exp)) -} - -#[cfg(test)] -mod tests { - use super::log_likelihood_matrix; - use crate::data::builder::SubjectBuilderExt; - use crate::data::error_model::{AssayErrorModel, ErrorPoly}; - use crate::{fa, lag, metadata, AssayErrorModels, Data, ModelKind, ParameterOrder, ODE}; - use ndarray::array; - - fn likelihood_named_order_ode() -> ODE { - ODE::new( - |_x, _p, _t, _dx, _b, _rateiv, _cov| {}, - |_p, _t, _cov| lag! {}, - |_p, _t, _cov| fa! {}, - |_p, _t, _cov, x| { - x[0] = 0.0; - }, - |_x, p, _t, _cov, y| { - y[0] = p[0] - p[1]; - }, - ) - .with_nstates(1) - .with_ndrugs(0) - .with_nout(1) - .with_metadata( - metadata::new("likelihood_named_order") - .kind(ModelKind::Ode) - .parameters(["v", "ke"]) - .states(["central"]) - .outputs(["cp"]), - ) - .expect("attach metadata") - } - - fn likelihood_error_models() -> AssayErrorModels { - AssayErrorModels::empty() - .add( - 0, - AssayErrorModel::additive(ErrorPoly::new(0.0, 1.0, 0.0, 0.0), 0.0), - ) - .expect("add error model") - } - - #[test] - fn parameter_order_feeds_likelihood_matrix_once() { - let equation = likelihood_named_order_ode(); - let data = Data::from( - crate::Subject::builder("likelihood-named-order") - .observation(0.0, 9.5, "cp") - .build(), - ); - let error_models = likelihood_error_models(); - let manual_support_points = array![[10.0, 0.5], [20.0, 0.7]]; - let source_order_support_points = array![[0.5, 10.0], [0.7, 20.0]]; - let order = ParameterOrder::with_model(&equation, ["ke", "v"]).unwrap(); - let reordered_support_points = order.matrix(source_order_support_points.clone()).unwrap(); - - assert_eq!(reordered_support_points, manual_support_points); - - let manual = log_likelihood_matrix( - &equation, - &data, - &manual_support_points, - &error_models, - false, - ) - .unwrap(); - let reordered = log_likelihood_matrix( - &equation, - &data, - &reordered_support_points, - &error_models, - false, - ) - .unwrap(); - let unreordered = log_likelihood_matrix( - &equation, - &data, - &source_order_support_points, - &error_models, - false, - ) - .unwrap(); - - assert_eq!(manual, reordered); - assert_ne!(manual, unreordered); - } -} diff --git a/src/simulator/likelihood/mod.rs b/src/simulator/likelihood/mod.rs deleted file mode 100644 index 62ee58b0..00000000 --- a/src/simulator/likelihood/mod.rs +++ /dev/null @@ -1,360 +0,0 @@ -//! Likelihood calculation module for pharmacometric analyses. -//! -//! This module provides functions and types for computing log-likelihoods -//! in pharmacometric population modeling. It supports both: -//! -//! - **Non-parametric algorithms** (NPAG, NPOD): Use [`ErrorModels`] with observation-based sigma -//! - **Parametric algorithms** (SAEM, FOCE): Use [`ResidualErrorModels`] with prediction-based sigma -//! -//! # Module Organization -//! -//! - [`distributions`]: Statistical distribution functions (log-normal PDF, CDF) -//! - [`prediction`]: Single observation-prediction pairs -//! - [`subject`]: Subject-level prediction collections -//! - [`matrix`]: Population-level log-likelihood matrix computation -//! -//! # Key Functions -//! -//! ## For Non-Parametric Algorithms -//! -//! Use [`log_likelihood_matrix`] to compute a matrix of log-likelihoods across -//! all subjects and support points. -//! -//! Validate any external support-point column order once with -//! [`crate::ParameterOrder`] before calling [`log_likelihood_matrix`]. The -//! runtime still consumes a dense model-order matrix: -//! -//! ```ignore -//! use ndarray::array; -//! use pharmsol::{ParameterOrder, prelude::simulator::log_likelihood_matrix}; -//! -//! let order = ParameterOrder::with_model(&equation, ["ka", "ke"])?; -//! let support_points_in_source_order = array![[0.1, 0.3], [0.2, 0.4]]; -//! let support_points = order.matrix(support_points_in_source_order)?; -//! -//! let log_liks = log_likelihood_matrix( -//! &equation, -//! &data, -//! &support_points, -//! &error_models, -//! false, -//! )?; -//! ``` -//! -//! ## For Parametric Algorithms -//! -//! Use [`log_likelihood_batch`] when each subject has individual parameters: -//! -//! ```ignore -//! use ndarray::array; -//! use pharmsol::prelude::simulator::log_likelihood_batch; -//! -//! let params = array![[0.1, 0.3], [0.2, 0.4]]; -//! -//! let log_liks = log_likelihood_batch( -//! &equation, -//! &data, -//! ¶ms, -//! &residual_error_models, -//! )?; -//! ``` -//! -//! # Numerical Stability -//! -//! The deprecated `likelihood()` and `psi()` functions are provided for -//! backward compatibility but should be avoided in new code. - -mod distributions; -mod matrix; -mod prediction; -mod progress; -mod subject; - -// Re-export main types -pub use matrix::log_likelihood_matrix; -#[allow(deprecated)] -pub use matrix::log_psi; -#[allow(deprecated)] -pub use matrix::psi; -pub use prediction::Prediction; -pub use subject::{PopulationPredictions, SubjectPredictions}; - -use ndarray::{Array2, Axis}; -use rayon::prelude::*; - -use crate::{Data, Equation, PharmsolError, Predictions, Subject}; - -/// Compute log-likelihoods for all subjects in parallel, where each subject -/// has its own parameter vector. -/// -/// This function simulates each subject with their individual parameters and -/// computes log-likelihood using prediction-based sigma (appropriate for -/// parametric algorithms like SAEM, FOCE). -/// -/// # Parameters -/// - `equation`: The equation to use for simulation -/// - `subjects`: The subject data (N subjects) -/// - `parameters`: Parameter vectors for each subject (N × P matrix, row i = params for subject i) -/// - `residual_error_models`: The residual error models (prediction-based sigma) -/// -/// # Returns -/// A vector of N log-likelihoods, one per subject. Returns `f64::NEG_INFINITY` -/// for subjects where simulation fails. -/// -/// # Example -/// ```ignore -/// use pharmsol::prelude::simulator::log_likelihood_batch; -/// use pharmsol::{ResidualErrorModel, ResidualErrorModels}; -/// -/// let residual_error = ResidualAssayErrorModels::new() -/// .add(0, ResidualErrorModel::constant(0.5)); -/// -/// let log_liks = log_likelihood_batch( -/// &equation, -/// &data, -/// ¶meters, -/// &residual_error, -/// )?; -/// ``` -pub fn log_likelihood_batch( - equation: &impl Equation, - subjects: &Data, - parameters: &Array2, - residual_error_models: &crate::ResidualErrorModels, -) -> Result, PharmsolError> { - let subject_slice = subjects.subjects_slice(); - let n_subjects = subject_slice.len(); - - if parameters.nrows() != n_subjects { - return Err(PharmsolError::OtherError(format!( - "parameters has {} rows but there are {} subjects", - parameters.nrows(), - n_subjects - ))); - } - - let score_subject = |subject: &Subject, parameter_row: &[f64]| { - let predictions = match equation.estimate_predictions_dense(subject, parameter_row) { - Ok(preds) => preds, - Err(_) => return f64::NEG_INFINITY, - }; - - let obs_pred_pairs = predictions - .get_predictions() - .into_iter() - .filter_map(|pred| { - pred.observation() - .map(|obs| (pred.outeq(), obs, pred.prediction())) - }); - - residual_error_models.total_log_likelihood(obs_pred_pairs) - }; - - let results: Vec = if let Some(flat_parameters) = parameters.as_slice() { - let width = parameters.ncols(); - subject_slice - .par_iter() - .enumerate() - .map(|(i, subject)| { - let start = i * width; - score_subject(subject, &flat_parameters[start..start + width]) - }) - .collect() - } else { - let parameter_rows = parameters - .axis_iter(Axis(0)) - .map(|row| row.to_vec()) - .collect::>(); - - subject_slice - .par_iter() - .enumerate() - .map(|(i, subject)| score_subject(subject, ¶meter_rows[i])) - .collect() - }; - - Ok(results) -} - -/// Compute log-likelihood for a single subject using prediction-based sigma. -/// -/// This is the single-subject equivalent of [`log_likelihood_batch`]. -/// It simulates the model, extracts observation-prediction pairs, and computes -/// the log-likelihood using [`crate::ResidualErrorModels`]. -/// -/// # Parameters -/// - `equation`: The equation to use for simulation -/// - `subject`: The subject data -/// - `params`: Parameter vector for this subject -/// - `residual_error_models`: The residual error models (prediction-based sigma) -/// -/// # Returns -/// The log-likelihood for this subject. Returns `f64::NEG_INFINITY` on simulation error. -/// -/// # Example -/// ```ignore -/// use pharmsol::prelude::simulator::log_likelihood_subject; -/// -/// let log_lik = log_likelihood_subject( -/// &equation, -/// &subject, -/// ¶ms, -/// &residual_error_models, -/// ); -/// ``` -pub fn log_likelihood_subject( - equation: &impl Equation, - subject: &Subject, - params: &crate::Parameters, - residual_error_models: &crate::ResidualErrorModels, -) -> f64 { - // Simulate to get predictions - let predictions = match equation.estimate_predictions_dense(subject, params.as_slice()) { - Ok(preds) => preds, - Err(_) => return f64::NEG_INFINITY, - }; - - // Extract (outeq, observation, prediction) tuples and compute log-likelihood - let obs_pred_pairs = predictions - .get_predictions() - .into_iter() - .filter_map(|pred| { - pred.observation() - .map(|obs| (pred.outeq(), obs, pred.prediction())) - }); - - residual_error_models.total_log_likelihood(obs_pred_pairs) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::data::error_model::{AssayErrorModel, ErrorPoly}; - use crate::data::event::Observation; - use crate::Censor; - - #[test] - fn test_log_likelihood_equals_log_of_likelihood() { - // Create a prediction with an observation - let prediction = Prediction { - time: 1.0, - observation: Some(10.0), - prediction: 10.5, - outeq: 0, - errorpoly: None, - state: vec![10.5], - occasion: 0, - censoring: Censor::None, - }; - - // Create error model with additive error - let error_models = crate::AssayErrorModels::empty() - .add( - 0, - AssayErrorModel::additive(ErrorPoly::new(0.0, 1.0, 0.0, 0.0), 0.0), - ) - .unwrap(); - - #[allow(deprecated)] - let lik = prediction.likelihood(&error_models).unwrap(); - let log_lik = prediction.log_likelihood(&error_models).unwrap(); - - // log_likelihood should equal ln(likelihood) - let expected_log_lik = lik.ln(); - assert!( - (log_lik - expected_log_lik).abs() < 1e-10, - "log_likelihood ({}) should equal ln(likelihood) ({})", - log_lik, - expected_log_lik - ); - } - - #[test] - fn test_subject_predictions_log_likelihood() { - let predictions = vec![ - Prediction { - time: 1.0, - observation: Some(10.0), - prediction: 10.1, - outeq: 0, - errorpoly: None, - state: vec![10.1], - occasion: 0, - censoring: Censor::None, - }, - Prediction { - time: 2.0, - observation: Some(8.0), - prediction: 8.2, - outeq: 0, - errorpoly: None, - state: vec![8.2], - occasion: 0, - censoring: Censor::None, - }, - ]; - - let subject_predictions = SubjectPredictions::from(predictions); - let error_models = crate::AssayErrorModels::empty() - .add( - 0, - AssayErrorModel::additive(ErrorPoly::new(0.0, 1.0, 0.0, 0.0), 0.0), - ) - .unwrap(); - - #[allow(deprecated)] - let lik = subject_predictions.likelihood(&error_models).unwrap(); - let log_lik = subject_predictions.log_likelihood(&error_models).unwrap(); - - // Sum of log likelihoods should equal log of product of likelihoods - let expected_log_lik = lik.ln(); - assert!( - (log_lik - expected_log_lik).abs() < 1e-10, - "Subject log_likelihood ({}) should equal ln(likelihood) ({})", - log_lik, - expected_log_lik - ); - } - - #[test] - fn test_empty_predictions_have_neutral_log_likelihood() { - let preds = SubjectPredictions::default(); - let errors = crate::AssayErrorModels::empty(); - assert_eq!(preds.log_likelihood(&errors).unwrap(), 0.0); // log(1) = 0 - } - - #[test] - fn test_log_likelihood_combines_observations() { - let mut preds = SubjectPredictions::default(); - let obs = Observation::new(0.0, Some(1.0), 0, None, 0, Censor::None); - preds.add_prediction(obs.to_prediction(1.0, vec![])); - - let error_model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 0.0); - let errors = crate::AssayErrorModels::empty() - .add(0, error_model) - .unwrap(); - - let log_lik = preds.log_likelihood(&errors).unwrap(); - assert!(log_lik.is_finite()); - assert!(log_lik <= 0.0); // Log likelihood is always <= 0 - } - - #[test] - fn test_lognormpdf_direct() { - use super::distributions::lognormpdf; - - // Test the helper function directly - let obs = 0.0; - let pred = 0.0; - let sigma = 1.0; - - let log_pdf = lognormpdf(obs, pred, sigma); - - // At mean of standard normal, log PDF = -0.5 * ln(2π) - let expected = -0.5 * distributions::LOG_2PI; - assert!( - (log_pdf - expected).abs() < 1e-12, - "lognormpdf at mean should be -0.5*ln(2π)" - ); - } -} diff --git a/src/simulator/likelihood/prediction.rs b/src/simulator/likelihood/prediction.rs deleted file mode 100644 index b1610f56..00000000 --- a/src/simulator/likelihood/prediction.rs +++ /dev/null @@ -1,306 +0,0 @@ -//! Single-point prediction and likelihood calculation. -//! -//! This module contains the [`Prediction`] struct which holds a single -//! observation-prediction pair along with metadata needed for likelihood -//! calculation. - -use crate::data::error_model::AssayErrorModels; -use crate::data::event::Observation; -use crate::{Censor, ErrorPoly, PharmsolError}; - -use super::distributions::{lognormccdf, lognormcdf, lognormpdf}; - -/// Prediction holds an observation and its prediction at a single time point. -/// -/// This struct contains all information needed to calculate the likelihood -/// contribution of a single observation. -#[derive(Debug, Clone)] -pub struct Prediction { - pub(crate) time: f64, - pub(crate) observation: Option, - pub(crate) prediction: f64, - pub(crate) outeq: usize, - pub(crate) errorpoly: Option, - pub(crate) state: Vec, - pub(crate) occasion: usize, - pub(crate) censoring: Censor, -} - -impl Prediction { - /// Get the time point of this prediction. - pub fn time(&self) -> f64 { - self.time - } - - /// Get the observed value. - pub fn observation(&self) -> Option { - self.observation - } - - /// Get the predicted value. - pub fn prediction(&self) -> f64 { - self.prediction - } - - /// Set the predicted value - pub(crate) fn set_prediction(&mut self, prediction: f64) { - self.prediction = prediction; - } - - /// Get the output equation index. - pub fn outeq(&self) -> usize { - self.outeq - } - - /// Get the error polynomial coefficients, if available. - pub fn errorpoly(&self) -> Option { - self.errorpoly - } - - /// Calculate the raw prediction error (prediction - observation). - pub fn prediction_error(&self) -> Option { - self.observation.map(|obs| self.prediction - obs) - } - - /// Calculate the percentage error as (prediction - observation)/observation * 100. - pub fn percentage_error(&self) -> Option { - self.observation - .map(|obs| ((self.prediction - obs) / obs) * 100.0) - } - - /// Calculate the absolute error |prediction - observation|. - pub fn absolute_error(&self) -> Option { - self.observation.map(|obs| (self.prediction - obs).abs()) - } - - /// Calculate the squared error (prediction - observation)². - pub fn squared_error(&self) -> Option { - self.observation.map(|obs| (self.prediction - obs).powi(2)) - } - - /// Calculate the log-likelihood of this prediction given an error model. - /// - /// This method is numerically stable and handles: - /// - Regular observations: uses log-normal PDF - /// - BLOQ (below limit of quantification): uses log-CDF - /// - ALOQ (above limit of quantification): uses log-survival function - /// - /// # Error Model - /// Uses observation-based sigma from [`AssayErrorModels`], which is appropriate - /// for non-parametric algorithms (NPAG, NPOD). For parametric algorithms - /// (SAEM, FOCE), use [`crate::ResidualErrorModels`] directly. - /// - /// # Parameters - /// - `error_models`: The error models to use for sigma calculation - /// - /// # Returns - /// The log-likelihood value, zero in the case of a missing observation - /// or an error if the likelihood is non-finite. - /// - /// # Example - /// ```ignore - /// let log_lik = prediction.log_likelihood(&error_models)?; - /// ``` - #[inline] - pub fn log_likelihood(&self, error_models: &AssayErrorModels) -> Result { - // Missing observations don't contribute to log-likelihood (log(1) = 0) - let obs = match self.observation { - Some(obs) => obs, - None => return Ok(0.0), - }; - - let sigma = error_models.sigma(self)?; - - let log_lik = match self.censoring { - Censor::None => lognormpdf(obs, self.prediction, sigma), - Censor::BLOQ => lognormcdf(obs, self.prediction, sigma)?, - Censor::ALOQ => lognormccdf(obs, self.prediction, sigma)?, - }; - - if log_lik.is_finite() { - Ok(log_lik) - } else { - Err(PharmsolError::NonFiniteLikelihood(log_lik)) - } - } - - /// Calculate the likelihood of this prediction. - /// - /// **Deprecated**: Use [`log_likelihood`](Self::log_likelihood) instead for - /// better numerical stability. This method is provided for backward - /// compatibility and simply exponentiates the log-likelihood. - /// - /// # Parameters - /// - `error_models`: The error models to use for sigma calculation - /// - /// # Returns - /// The likelihood value (exp of log-likelihood) - #[deprecated( - since = "0.23.0", - note = "Use log_likelihood() instead for better numerical stability" - )] - pub fn likelihood(&self, error_models: &AssayErrorModels) -> Result { - let log_lik = self.log_likelihood(error_models)?; - let lik = log_lik.exp(); - - if lik.is_finite() { - Ok(lik) - } else if lik == 0.0 { - Err(PharmsolError::ZeroLikelihood) - } else { - Err(PharmsolError::NonFiniteLikelihood(lik)) - } - } - - /// Get the state vector at this prediction point - pub fn state(&self) -> &[f64] { - &self.state - } - - /// Get the occasion index - pub fn occasion(&self) -> usize { - self.occasion - } - - /// Get a mutable reference to the occasion index - pub fn mut_occasion(&mut self) -> &mut usize { - &mut self.occasion - } - - /// Get the censoring status - pub fn censoring(&self) -> Censor { - self.censoring - } - - /// Create an [Observation] from this prediction - pub fn to_observation(&self) -> Observation { - Observation::new( - self.time, - self.observation, - self.outeq, - self.errorpoly, - self.occasion, - self.censoring, - ) - } -} - -impl Default for Prediction { - fn default() -> Self { - Self { - time: 0.0, - observation: None, - prediction: 0.0, - outeq: 0, - errorpoly: None, - state: vec![], - occasion: 0, - censoring: Censor::None, - } - } -} - -impl std::fmt::Display for Prediction { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - let obs_str = match self.observation { - Some(obs) => format!("{:.4}", obs), - None => "NA".to_string(), - }; - write!( - f, - "Time: {:.2}\tObs: {:.4}\tPred: {:.4}\tOuteq: {}", - self.time, obs_str, self.prediction, self.outeq - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::data::error_model::{AssayErrorModel, ErrorPoly}; - - fn create_test_prediction(obs: f64, pred: f64) -> Prediction { - Prediction { - time: 1.0, - observation: Some(obs), - prediction: pred, - outeq: 0, - errorpoly: None, - state: vec![pred], - occasion: 0, - censoring: Censor::None, - } - } - - fn create_error_models() -> AssayErrorModels { - AssayErrorModels::empty() - .add( - 0, - AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 0.0), - ) - .unwrap() - } - - #[test] - fn test_log_likelihood_basic() { - let prediction = create_test_prediction(10.0, 10.5); - let error_models = create_error_models(); - - let log_lik = prediction.log_likelihood(&error_models).unwrap(); - assert!(log_lik.is_finite()); - assert!(log_lik < 0.0); // Log likelihood should be negative - } - - #[test] - fn test_log_likelihood_numerical_stability() { - // Test with values that would cause very small likelihood - let prediction = create_test_prediction(10.0, 30.0); // 20 sigma away - let error_models = create_error_models(); - - let log_lik = prediction.log_likelihood(&error_models).unwrap(); - assert!(log_lik.is_finite()); - assert!(log_lik < -100.0); // Should be very negative - } - - #[test] - fn test_log_likelihood_extreme() { - // Test with truly extreme values - let prediction = create_test_prediction(10.0, 50.0); // 40 sigma away - let error_models = create_error_models(); - - let log_lik = prediction.log_likelihood(&error_models).unwrap(); - assert!(log_lik.is_finite()); - assert!( - log_lik < -700.0 && log_lik > -900.0, - "log_lik ({}) should be approximately -800", - log_lik - ); - } - - #[test] - fn test_missing_observation_returns_zero() { - let prediction = Prediction { - time: 1.0, - observation: None, - prediction: 10.0, - ..Default::default() - }; - let error_models = create_error_models(); - - let result = prediction.log_likelihood(&error_models).unwrap(); - assert_eq!( - result, 0.0, - "Missing observation should contribute 0 to log-likelihood" - ); - } - - #[test] - fn test_error_metrics() { - let prediction = create_test_prediction(10.0, 12.0); - - assert_eq!(prediction.prediction_error(), Some(2.0)); - assert_eq!(prediction.absolute_error(), Some(2.0)); - assert_eq!(prediction.squared_error(), Some(4.0)); - assert_eq!(prediction.percentage_error(), Some(20.0)); - } -} diff --git a/src/simulator/likelihood/progress.rs b/src/simulator/likelihood/progress.rs deleted file mode 100644 index 427d8f78..00000000 --- a/src/simulator/likelihood/progress.rs +++ /dev/null @@ -1,69 +0,0 @@ -use std::io::Write; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; -use std::time::Instant; - -pub struct ProgressTracker { - counter: Arc, - total: usize, - start_time: Instant, -} - -impl ProgressTracker { - pub fn new(total: usize) -> Self { - Self { - counter: Arc::new(AtomicUsize::new(0)), - total, - start_time: Instant::now(), - } - } - - pub fn inc(&self) { - let current = self.counter.fetch_add(1, Ordering::Relaxed) + 1; - - if self.total == 0 { - return; - } - - // Print progress every 5% or every 1000 iterations - if current.is_multiple_of(1000) || current.saturating_mul(20).is_multiple_of(self.total) { - let percent = (current * 100) / self.total; - let elapsed = self.start_time.elapsed(); - - let eta_text = if current > 0 { - let estimated_total_duration = elapsed.mul_f64(self.total as f64 / current as f64); - - if let Some(remaining_duration) = estimated_total_duration.checked_sub(elapsed) { - format_duration(remaining_duration) - } else { - "00:00".to_string() - } - } else { - "calculating...".to_string() - }; - - print!( - "\rProgress: {}/{} ({}%) ETA: {}", - current, self.total, percent, eta_text - ); - std::io::stdout().flush().unwrap_or_default(); - } - } - - pub fn finish(&self) { - println!("\nSimulation complete!"); - } -} - -fn format_duration(duration: std::time::Duration) -> String { - let total_seconds = duration.as_secs(); - let hours = total_seconds / 3600; - let minutes = (total_seconds % 3600) / 60; - let secs = total_seconds % 60; - - if hours > 0 { - format!("{:02}h:{:02}m:{:02}s", hours, minutes, secs) - } else { - format!("{:02}m:{:02}s", minutes, secs) - } -} diff --git a/src/simulator/likelihood/subject.rs b/src/simulator/likelihood/subject.rs deleted file mode 100644 index 05c1417f..00000000 --- a/src/simulator/likelihood/subject.rs +++ /dev/null @@ -1,272 +0,0 @@ -//! Subject-level predictions and likelihood calculations. -//! -//! This module contains [`SubjectPredictions`] for holding all predictions -//! for a single subject, and [`PopulationPredictions`] for population-level -//! predictions. - -use ndarray::{Array2, ShapeBuilder}; - -use crate::data::error_model::AssayErrorModels; -use crate::{PharmsolError, Predictions}; - -use super::prediction::Prediction; - -/// Container for predictions associated with a single subject. -/// -/// This struct holds all predictions for a subject along with methods -/// for calculating aggregate likelihood and error metrics. -#[derive(Debug, Clone, Default)] -pub struct SubjectPredictions { - predictions: Vec, -} - -impl Predictions for SubjectPredictions { - fn squared_error(&self) -> f64 { - self.predictions - .iter() - .filter_map(|p| p.observation().map(|obs| (obs - p.prediction()).powi(2))) - .sum() - } - - fn get_predictions(&self) -> Vec { - self.predictions.clone() - } - - fn log_likelihood(&self, error_models: &AssayErrorModels) -> Result { - SubjectPredictions::log_likelihood(self, error_models) - } -} - -impl SubjectPredictions { - /// Calculate the log-likelihood of all predictions given an error model. - /// - /// This sums the log-likelihood of each prediction to get the joint log-likelihood. - /// This is numerically stable and avoids underflow issues that can occur - /// when computing products of small probabilities. - /// - /// # Error Model - /// Uses observation-based sigma from [`AssayErrorModels`], which is appropriate - /// for non-parametric algorithms (NPAG, NPOD). For parametric algorithms - /// (SAEM, FOCE), use [`crate::ResidualErrorModels`] directly. - /// - /// # Parameters - /// - `error_models`: The error models to use for calculating the likelihood - /// - /// # Returns - /// The sum of all individual prediction log-likelihoods. - /// Returns 0.0 for empty prediction sets (log of 1.0). - /// - /// # Example - /// ```ignore - /// let log_lik = subject_predictions.log_likelihood(&error_models)?; - /// ``` - pub fn log_likelihood(&self, error_models: &AssayErrorModels) -> Result { - if self.predictions.is_empty() { - return Ok(0.0); - } - - let mut total = 0.0; - for prediction in self - .predictions - .iter() - .filter(|prediction| prediction.observation().is_some()) - { - total += prediction.log_likelihood(error_models)?; - } - - Ok(total) - } - - /// Calculate the likelihood of all predictions. - /// - /// **Deprecated**: Use [`log_likelihood`](Self::log_likelihood) instead for - /// better numerical stability. This method exponentiates the log-likelihood. - /// - /// # Parameters - /// - `error_models`: The error models to use for calculating the likelihood - /// - /// # Returns - /// The product of all individual prediction likelihoods. - /// Returns 1.0 for empty prediction sets. - #[deprecated( - since = "0.23.0", - note = "Use log_likelihood() instead for better numerical stability" - )] - pub fn likelihood(&self, error_models: &AssayErrorModels) -> Result { - match self.predictions.is_empty() { - true => Ok(1.0), - false => { - let log_lik = self.log_likelihood(error_models)?; - Ok(log_lik.exp()) - } - } - } - - /// Add a new prediction to the collection. - /// - /// # Parameters - /// - `prediction`: The prediction to add - pub fn add_prediction(&mut self, prediction: Prediction) { - self.predictions.push(prediction); - } - - /// Get a reference to the vector of predictions. - pub fn predictions(&self) -> &Vec { - &self.predictions - } - - /// Return a flat vector of prediction values. - pub fn flat_predictions(&self) -> Vec { - self.predictions.iter().map(|p| p.prediction()).collect() - } - - /// Return a flat vector of time points. - pub fn flat_times(&self) -> Vec { - self.predictions.iter().map(|p| p.time()).collect() - } - - /// Return a flat vector of observations. - pub fn flat_observations(&self) -> Vec> { - self.predictions.iter().map(|p| p.observation()).collect() - } -} - -impl From> for SubjectPredictions { - fn from(predictions: Vec) -> Self { - Self { predictions } - } -} - -/// Container for predictions across a population of subjects. -/// -/// This struct holds predictions for multiple subjects organized in a 2D array -/// where rows represent subjects and columns represent support points (or -/// other groupings). -pub struct PopulationPredictions { - /// 2D array of subject predictions - pub subject_predictions: Array2, -} - -impl Default for PopulationPredictions { - fn default() -> Self { - Self { - subject_predictions: Array2::default((0, 0).f()), - } - } -} - -impl From> for PopulationPredictions { - fn from(subject_predictions: Array2) -> Self { - Self { - subject_predictions, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::data::error_model::{AssayErrorModel, ErrorPoly}; - use crate::data::event::Observation; - use crate::Censor; - - fn create_error_models() -> AssayErrorModels { - AssayErrorModels::empty() - .add( - 0, - AssayErrorModel::additive(ErrorPoly::new(0.0, 1.0, 0.0, 0.0), 0.0), - ) - .unwrap() - } - - #[test] - fn test_empty_predictions_log_likelihood() { - let preds = SubjectPredictions::default(); - let errors = create_error_models(); - assert_eq!(preds.log_likelihood(&errors).unwrap(), 0.0); - } - - #[test] - #[allow(deprecated)] - fn test_empty_predictions_likelihood() { - let preds = SubjectPredictions::default(); - let errors = create_error_models(); - assert_eq!(preds.likelihood(&errors).unwrap(), 1.0); - } - - #[test] - fn test_log_likelihood_with_observations() { - let mut preds = SubjectPredictions::default(); - let obs = Observation::new(0.0, Some(1.0), 0, None, 0, Censor::None); - preds.add_prediction(obs.to_prediction(1.0, vec![])); - - let error_model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 0.0); - let errors = AssayErrorModels::empty().add(0, error_model).unwrap(); - - let log_lik = preds.log_likelihood(&errors).unwrap(); - assert!(log_lik.is_finite()); - assert!(log_lik <= 0.0); // Log likelihood should be <= 0 - } - - #[test] - fn test_multiple_observations() { - let predictions = vec![ - Prediction { - time: 1.0, - observation: Some(10.0), - prediction: 10.1, - outeq: 0, - errorpoly: None, - state: vec![10.1], - occasion: 0, - censoring: Censor::None, - }, - Prediction { - time: 2.0, - observation: Some(8.0), - prediction: 8.2, - outeq: 0, - errorpoly: None, - state: vec![8.2], - occasion: 0, - censoring: Censor::None, - }, - ]; - - let subject_predictions = SubjectPredictions::from(predictions); - let error_models = create_error_models(); - - let log_lik = subject_predictions.log_likelihood(&error_models).unwrap(); - assert!(log_lik.is_finite()); - - // Log-likelihood of multiple observations should be sum of individual log-likelihoods - // (more negative than single observation) - } - - #[test] - fn test_flat_vectors() { - let predictions = vec![ - Prediction { - time: 1.0, - observation: Some(10.0), - prediction: 11.0, - ..Default::default() - }, - Prediction { - time: 2.0, - observation: Some(8.0), - prediction: 9.0, - ..Default::default() - }, - ]; - - let subject_predictions = SubjectPredictions::from(predictions); - - assert_eq!(subject_predictions.flat_times(), vec![1.0, 2.0]); - assert_eq!(subject_predictions.flat_predictions(), vec![11.0, 9.0]); - assert_eq!( - subject_predictions.flat_observations(), - vec![Some(10.0), Some(8.0)] - ); - } -} diff --git a/src/simulator/mod.rs b/src/simulator/mod.rs index 058ca125..47433928 100644 --- a/src/simulator/mod.rs +++ b/src/simulator/mod.rs @@ -1,12 +1,9 @@ pub mod cache; pub mod equation; -pub(crate) mod likelihood; +pub mod prediction; use diffsol::{NalgebraMat, NalgebraVec}; -use crate::{ - data::{Covariates, Infusion}, - simulator::likelihood::SubjectPredictions, -}; +use crate::data::{Covariates, Infusion}; use std::collections::HashMap; @@ -235,4 +232,4 @@ impl Default for Neqs { } // Re-export cache types at the simulator level for convenience. -pub use cache::{PredictionCache, SdeLikelihoodCache, DEFAULT_CACHE_SIZE}; +pub use cache::{PredictionCache, DEFAULT_CACHE_SIZE}; diff --git a/src/simulator/prediction/mod.rs b/src/simulator/prediction/mod.rs new file mode 100644 index 00000000..6ab793fd --- /dev/null +++ b/src/simulator/prediction/mod.rs @@ -0,0 +1,110 @@ +//! Prediction data structures produced by simulation. + +mod subject; + +pub use subject::SubjectPredictions; + +use crate::{Censor, ErrorPoly, OutputLabel}; + +/// Prediction holds an observation and its prediction at a single time point. +/// +/// This struct exposes the fields consumers need to inspect a simulated point: +/// the time, the (optional) observed value, the noiseless model prediction, the +/// output label, optional [`ErrorPoly`] data, and the censoring state. +#[derive(Debug, Clone)] +pub struct Prediction { + pub(crate) time: f64, + pub(crate) observation: Option, + pub(crate) prediction: f64, + pub(crate) outeq: OutputLabel, + pub(crate) errorpoly: Option, + pub(crate) censoring: Censor, +} + +impl Prediction { + /// Get the time point of this prediction. + pub fn time(&self) -> f64 { + self.time + } + + /// Get the observed value. + pub fn observation(&self) -> Option { + self.observation + } + + /// Get the predicted value. + pub fn prediction(&self) -> f64 { + self.prediction + } + + /// Set the predicted value + pub(crate) fn set_prediction(&mut self, prediction: f64) { + self.prediction = prediction; + } + + /// Get the output label associated with this prediction. + pub fn output(&self) -> &OutputLabel { + &self.outeq + } + + /// Get the C0-C3 data carried verbatim from the source observation. + pub fn errorpoly(&self) -> Option { + self.errorpoly + } + + /// Get the censoring status + pub fn censoring(&self) -> Censor { + self.censoring + } +} + +impl Default for Prediction { + fn default() -> Self { + Self { + time: 0.0, + observation: None, + prediction: 0.0, + outeq: OutputLabel::default(), + errorpoly: None, + censoring: Censor::None, + } + } +} + +impl std::fmt::Display for Prediction { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let obs_str = match self.observation { + Some(obs) => format!("{:.4}", obs), + None => "NA".to_string(), + }; + write!( + f, + "Time: {:.2}\tObs: {:.4}\tPred: {:.4}\tOuteq: {}", + self.time, obs_str, self.prediction, self.outeq + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prediction_exposes_simulation_fields() { + let errorpoly = Some(ErrorPoly::new(0.1, 0.2, 0.3, 0.4)); + let prediction = Prediction { + time: 1.0, + observation: Some(10.0), + prediction: 12.0, + outeq: OutputLabel::new("cp"), + errorpoly, + censoring: Censor::None, + }; + + assert_eq!(prediction.time(), 1.0); + assert_eq!(prediction.observation(), Some(10.0)); + assert_eq!(prediction.prediction(), 12.0); + assert_eq!(prediction.output().as_str(), "cp"); + assert_eq!(prediction.errorpoly(), errorpoly); + } +} diff --git a/src/simulator/prediction/subject.rs b/src/simulator/prediction/subject.rs new file mode 100644 index 00000000..64f7e516 --- /dev/null +++ b/src/simulator/prediction/subject.rs @@ -0,0 +1,128 @@ +//! Subject-level prediction containers. +//! +//! This module contains [`SubjectPredictions`] for holding all predictions +//! for a single subject. + +use crate::{simulator::prediction::Prediction, Predictions}; + +/// Container for predictions associated with a single subject. +/// +/// This struct holds all predictions for a subject, across every occasion, and +/// supports borrowed visitation of each point. The occasion index that produced +/// each prediction is tracked in parallel to [`SubjectPredictions::predictions`] +/// so it stays discernible without living on the core [`Prediction`] type. +#[derive(Debug, Clone, Default)] +pub struct SubjectPredictions { + id: String, + predictions: Vec, + occasions: Vec, +} + +impl Predictions for SubjectPredictions { + fn get_predictions(&self) -> Vec { + self.predictions.clone() + } + + fn for_each_prediction(&self, mut f: impl FnMut(&Prediction)) { + for prediction in &self.predictions { + f(prediction); + } + } + + fn set_subject_id(&mut self, id: &str) { + self.id = id.to_string(); + } +} + +impl SubjectPredictions { + /// Add a new prediction to the collection. + /// + /// # Parameters + /// - `prediction`: The prediction to add + /// - `occasion`: The occasion index that produced this prediction + pub fn add_prediction(&mut self, prediction: Prediction, occasion: usize) { + self.predictions.push(prediction); + self.occasions.push(occasion); + } + + /// Get the subject identifier these predictions belong to. + pub fn id(&self) -> &str { + &self.id + } + + /// Set the subject identifier these predictions belong to. + pub fn set_id(&mut self, id: impl Into) { + self.id = id.into(); + } + + /// Get a reference to the vector of predictions. + pub fn predictions(&self) -> &Vec { + &self.predictions + } + + /// Get the occasion index for each prediction, parallel to + /// [`SubjectPredictions::predictions`]. + pub fn occasions(&self) -> &Vec { + &self.occasions + } + + /// Return a flat vector of prediction values. + pub fn flat_predictions(&self) -> Vec { + self.predictions.iter().map(|p| p.prediction()).collect() + } + + /// Return a flat vector of time points. + pub fn flat_times(&self) -> Vec { + self.predictions.iter().map(|p| p.time()).collect() + } + + /// Return a flat vector of observations. + pub fn flat_observations(&self) -> Vec> { + self.predictions.iter().map(|p| p.observation()).collect() + } +} + +impl From> for SubjectPredictions { + fn from(predictions: Vec) -> Self { + let occasions = vec![0; predictions.len()]; + Self { + id: String::new(), + predictions, + occasions, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::OutputLabel; + #[test] + fn test_flat_vectors() { + let predictions = vec![ + Prediction { + time: 1.0, + observation: Some(10.0), + prediction: 11.0, + outeq: OutputLabel::new("cp"), + ..Default::default() + }, + Prediction { + time: 2.0, + observation: Some(8.0), + prediction: 9.0, + outeq: OutputLabel::new("cp"), + ..Default::default() + }, + ]; + + let subject_predictions = SubjectPredictions::from(predictions); + + assert_eq!(subject_predictions.flat_times(), vec![1.0, 2.0]); + assert_eq!(subject_predictions.flat_predictions(), vec![11.0, 9.0]); + assert_eq!( + subject_predictions.flat_observations(), + vec![Some(10.0), Some(8.0)] + ); + } +} diff --git a/tests/authoring_parity_corpus.rs b/tests/authoring_parity_corpus.rs index 8877f812..733f2705 100644 --- a/tests/authoring_parity_corpus.rs +++ b/tests/authoring_parity_corpus.rs @@ -1410,9 +1410,14 @@ fn invalid_dsl_infusion_route_properties_fail_explicitly() { #[test] fn ode_runtime_jit_macro_and_handwritten_predictions_agree_on_shared_input_shape() { let runtime_model = - compile_runtime_jit_model(ODE_RUNTIME_SHARED_INPUT_DSL, "shared_input_one_cpt"); - let macro_model = runtime_shared_input_macro_ode(); - let handwritten_model = runtime_shared_input_handwritten_ode(); + match compile_runtime_jit_model(ODE_RUNTIME_SHARED_INPUT_DSL, "shared_input_one_cpt") { + dsl::CompiledRuntimeModel::Ode(model) => { + dsl::CompiledRuntimeModel::Ode(model.with_tolerances(1e-8, 1e-8)) + } + _ => panic!("shared-input ODE fixture should compile as an ODE model"), + }; + let macro_model = runtime_shared_input_macro_ode().with_tolerances(1e-8, 1e-8); + let handwritten_model = runtime_shared_input_handwritten_ode().with_tolerances(1e-8, 1e-8); let macro_metadata = macro_model.metadata().expect("macro ODE metadata exists"); let handwritten_metadata = handwritten_model .metadata() diff --git a/tests/ode_optimizations.rs b/tests/ode_optimizations.rs index 12bda743..39326c26 100644 --- a/tests/ode_optimizations.rs +++ b/tests/ode_optimizations.rs @@ -1096,89 +1096,3 @@ fn time_varying_covariates_work_correctly() { } // ============================================================================= -// LIKELIHOOD TESTS -// ============================================================================= - -#[test] -fn likelihood_calculation_matches_analytical() { - // Verify likelihood calculations are consistent - let subject = Subject::builder("likelihood") - .bolus(0.0, 100.0, "iv_bolus") - .observation(1.0, 1.8, "cp") // Observed value - .observation(2.0, 1.6, "cp") - .observation(4.0, 1.3, "cp") - .observation(8.0, 0.8, "cp") - .build(); - - let analytical = with_one_compartment_analytical_metadata( - equation::Analytical::new( - one_compartment, - |_p, _t, _cov| {}, - |_p, _t, _cov| lag! {}, - |_p, _t, _cov| fa! {}, - |_p, _t, _cov, _x| {}, - |x, p, _t, _cov, y| { - fetch_params!(p, _ke, v); - y[0] = x[0] / v; - }, - ) - .with_nstates(1) - .with_nout(1), - "likelihood_calculation", - ); - - let ode = with_one_compartment_ode_metadata( - equation::ODE::new( - |x, p, _t, dx, b, _rateiv, _cov| { - fetch_params!(p, ke, _v); - dx[0] = -ke * x[0] + b[0]; - }, - |_p, _t, _cov| lag! {}, - |_p, _t, _cov| fa! {}, - |_p, _t, _cov, _x| {}, - |x, p, _t, _cov, y| { - fetch_params!(p, _ke, v); - y[0] = x[0] / v; - }, - ) - .with_nstates(1) - .with_nout(1), - "likelihood_calculation", - ); - - let error_models = AssayErrorModels::default() - .add( - 0, - AssayErrorModel::additive(ErrorPoly::new(0.0, 0.1, 0.0, 0.0), 0.0), - ) - .unwrap(); - - let analytical_params = parameters_for_analytical( - "likelihood_calculation", - &analytical, - &["ke", "v"], - &[0.1, 50.0], - ); - let ode_params = parameters_for_ode("likelihood_calculation", &ode, &["ke", "v"], &[0.1, 50.0]); - - let ll_analytical = analytical - .estimate_log_likelihood(&subject, &analytical_params, &error_models) - .expect("analytical likelihood") - .exp(); - - let ll_ode = ode - .estimate_log_likelihood(&subject, &ode_params, &error_models) - .expect("ode likelihood") - .exp(); - - let ll_diff = (ll_analytical - ll_ode).abs(); - let ll_rel_diff = ll_diff / ll_analytical.abs().max(1e-10); - - assert!( - ll_rel_diff < 0.01, // Within 1% - "Likelihoods should match: analytical={:.6}, ode={:.6}, rel_diff={:.2e}", - ll_analytical, - ll_ode, - ll_rel_diff - ); -} diff --git a/tests/support/runtime_corpus.rs b/tests/support/runtime_corpus.rs index 924432ce..9298565f 100644 --- a/tests/support/runtime_corpus.rs +++ b/tests/support/runtime_corpus.rs @@ -16,7 +16,8 @@ use pharmsol::prelude::{ one_compartment_with_absorption, Equation, Prediction, SubjectPredictions, }; use pharmsol::{ - equation, fa, fetch_cov, fetch_params, lag, Parameters, Subject, SubjectBuilderExt, SDE, + equation, equation::SDE, fa, fetch_cov, fetch_params, lag, Parameters, Subject, + SubjectBuilderExt, }; use tempfile::{tempdir, TempDir}; @@ -421,6 +422,9 @@ enum ExpectedPredictions { fn adjust_runtime_model(case: CorpusCase, model: CompiledRuntimeModel) -> CompiledRuntimeModel { match (case, model) { + (CorpusCase::Ode | CorpusCase::OdeFull, CompiledRuntimeModel::Ode(model)) => { + CompiledRuntimeModel::Ode(model.with_tolerances(1e-8, 1e-8)) + } (CorpusCase::Sde, CompiledRuntimeModel::Sde(model)) => { CompiledRuntimeModel::Sde(model.with_particles(SDE_PARTICLE_COUNT)) } @@ -804,6 +808,7 @@ fn reference_ode_predictions() -> Result> { .with_nstates(2) .with_ndrugs(1) .with_nout(1) + .with_tolerances(1e-8, 1e-8) .with_metadata( equation::metadata::new(CorpusCase::Ode.label()) .parameters(["ka", "cl", "v", "tlag", "f_oral"]) @@ -958,6 +963,7 @@ fn reference_ode_full_predictions() -> Result .with_nstates(3) .with_ndrugs(2) .with_nout(1) + .with_tolerances(1e-8, 1e-8) .with_metadata( equation::metadata::new(CorpusCase::OdeFull.label()) .parameters([ diff --git a/tests/test_pf.rs b/tests/test_pf.rs deleted file mode 100644 index 719aafc3..00000000 --- a/tests/test_pf.rs +++ /dev/null @@ -1,102 +0,0 @@ -use pharmsol::data::error_model::AssayErrorModel; -use pharmsol::*; - -/// Test the particle filter (SDE) likelihood estimation -/// This test runs the particle filter multiple times to assess consistency -#[test] -fn test_particle_filter_likelihood() { - let subject = data::Subject::builder("id1") - .bolus(0.0, 20.0, "dose") - .observation(0.2, 16.6434, "cp") - .observation(0.4, 14.3233, "cp") - .observation(0.6, 9.8468, "cp") - .observation(0.8, 9.4177, "cp") - .observation(1.0, 7.5170, "cp") - .build(); - - let sde = equation::SDE::new( - |x, p, _t, dx, _rateiv, _cov| { - dx[0] = -x[0] * x[1]; // ke *x[0] - dx[1] = -x[1] + p[0]; // mean reverting - }, - |_p, d| { - d[0] = 1.0; - d[1] = 0.01; - }, - |_p, _t, _cov| lag! {}, - |_p, _t, _cov| fa! {}, - |_p, _t, _cov, x| x[1] = 1.0, - |x, _p, _t, _cov, y| { - y[0] = x[0]; - }, - 10000, - ) - .with_nstates(2) - .with_ndrugs(1) - .with_nout(1); - - let sde = sde - .with_metadata( - equation::metadata::new("particle_filter_test") - .kind(equation::ModelKind::Sde) - .parameters(["ke0"]) - .states(["central", "ke_latent"]) - .outputs(["cp"]) - .route( - equation::Route::bolus("dose") - .to_state("central") - .inject_input_to_destination(), - ) - .particles(10000), - ) - .expect("particle filter metadata should validate"); - - let ems = AssayErrorModels::default() - .add( - 0, - AssayErrorModel::additive(ErrorPoly::new(0.5, 0.0, 0.0, 0.0), 0.0), - ) - .unwrap(); - - // Run the particle filter multiple times to get statistics - const NUM_RUNS: usize = 10; - let mut likelihoods = Vec::with_capacity(NUM_RUNS); - - for i in 0..NUM_RUNS { - let parameters = Parameters::with_model(&sde, [("ke0", 1.0)]) - .expect("particle filter parameters should validate"); - let ll = sde - .estimate_log_likelihood(&subject, ¶meters, &ems) - .unwrap() - .exp(); - println!("Run {}: likelihood = {}", i + 1, ll); - likelihoods.push(ll); - } - - // Calculate mean and standard deviation - let mean: f64 = likelihoods.iter().sum::() / NUM_RUNS as f64; - let variance: f64 = - likelihoods.iter().map(|x| (x - mean).powi(2)).sum::() / NUM_RUNS as f64; - let std_dev = variance.sqrt(); - - println!("\n=== Particle Filter Likelihood Statistics ==="); - println!("Number of runs: {}", NUM_RUNS); - println!("Likelihoods: {:?}", likelihoods); - println!("Mean likelihood: {}", mean); - println!("Std deviation: {}", std_dev); - println!( - "Min: {}", - likelihoods.iter().cloned().fold(f64::INFINITY, f64::min) - ); - println!( - "Max: {}", - likelihoods - .iter() - .cloned() - .fold(f64::NEG_INFINITY, f64::max) - ); - - // The likelihood should be a reasonable negative value (log-likelihood) - // This assertion is loose since particle filters have stochastic variation - assert!(mean.is_finite(), "Mean likelihood should be finite"); -}