From bb5a4b11bb8c5e90ae4cd4c92d0a83ee0cea5202 Mon Sep 17 00:00:00 2001 From: Markus <66058642+mhovd@users.noreply.github.com> Date: Fri, 28 Mar 2025 12:49:09 +0100 Subject: [PATCH 01/56] Experimenting --- examples/bestdose.rs | 156 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 examples/bestdose.rs diff --git a/examples/bestdose.rs b/examples/bestdose.rs new file mode 100644 index 000000000..2c4869b8c --- /dev/null +++ b/examples/bestdose.rs @@ -0,0 +1,156 @@ +use anyhow::Result; +use argmin::core::{CostFunction, Executor}; +use argmin::solver::brent::BrentOpt; + +use pmcore::algorithms::npag::burke; +use pmcore::prelude::data::read_pmetrics; +use pmcore::prelude::*; +use pmcore::routines::initialization::sobol::generate; +use pmcore::routines::output::posterior; +use pmcore::structs::psi::calculate_psi; +use pmcore::structs::theta::Theta; + +// Create a structure we can use to optimize +pub struct DoseOptim { + dose: f64, + target_time: f64, + target_conc: f64, + d_pop: Theta, + d_flat: Theta, + eq: ODE, +} + +impl DoseOptim { + pub fn new( + dose: f64, + target_time: f64, + target_conc: f64, + d_pop: Theta, + d_flat: Theta, + eq: ODE, + ) -> Self { + Self { + dose, + target_time, + target_conc, + d_pop, + d_flat, + eq, + } + } +} + +impl CostFunction for DoseOptim { + type Param = f64; + type Output = f64; + + fn cost(&self, param: &Self::Param) -> Result { + use pharmsol::ErrorModel; + let predsub = Subject::builder("Johnny Bravo") + .bolus(0.0, *param, 0) + .repeat(10, 12.0) + .observation(self.target_time, self.target_conc, 0) + .build(); + + let errmod = ErrorModel::new((0.01, 0.1, 0.0, 0.0), 1.0, &ErrorType::Add); + + let psi = calculate_psi( + &self.eq, + &Data::new(vec![predsub.clone()]), + &self.d_pop, + &errmod, + false, + true, + ); + + println!("Psi: {:?}", psi); + + let (w, _) = burke(&psi)?; + + let posterior = posterior(&psi, &w); + + println!("Posterior: {:?}", posterior); + + let mut toterr = 0.0; + + for spp in self.d_pop.matrix().row_iter() { + let point: Vec = spp.iter().copied().collect(); + + let pred = self.eq.simulate_subject(&predsub, &point, Some(&errmod)).0; + + toterr += pred.squared_error(); + } + + Ok(toterr) + } +} + +fn main() -> Result<()> { + //Create a model with data + let data = read_pmetrics("examples/theophylline/theophylline.csv")?; + + let eq = equation::ODE::new( + |x, p, _t, dx, _rateiv, _cov| { + // fetch_cov!(cov, t, wt); + fetch_params!(p, ka, ke, _v); + dx[0] = -ka * x[0]; + dx[1] = ka * x[0] - ke * x[1]; + }, + |_p| lag! {}, + |_p| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ka, _ke, v); + y[0] = x[1] / v; + }, + (2, 1), + ); + + let params = Parameters::new() + .add("ka", 0.0, 3.0, false) + .add("ke", 0.001, 3.0, false) + .add("v", 0.0, 250.0, false); + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params.clone()) + .set_error_model(ErrorModel::Proportional, 5.0, (0.01, 0.1, 0.0, 0.0)) + .build(); + + settings.set_cycles(1000); + settings.set_prior_sampler(Sampler::Sobol, 2048, 22); + + let mut algorithm = dispatch_algorithm(settings, eq.clone(), data)?; + + println!("Fititng model..."); + + let result = algorithm.fit()?; + + println!("Finished fititng model..."); + + // Create D_pop + let d_pop = result.get_theta().clone(); + println!("Theta: {:?}", d_pop); + + // Create D_flat + let d_flat = generate(¶ms, 100, 22)?; + + // Create a dose optimizer + let dose_optim = DoseOptim::new(100.0, 119.0, 20.0, d_pop, d_flat, eq); + + let solver = BrentOpt::new(0.0, 1000.0); + + println!("Optimizing dose..."); + let res = Executor::new(dose_optim, solver) + .configure(|state| state.max_iters(100)) + // .add_observer(SlogLogger::term(), ObserverMode::Always) + .run()?; + + println!("{:#?}", res.state.best_param.unwrap()); + println!("{:#?}", res.state.best_cost); + println!("{:#?}", res.state.counts); + + println!("Optimization finished"); + + Ok(()) +} From 7dee049b21c898f12060279c6433f9a15b2ee079 Mon Sep 17 00:00:00 2001 From: Markus <66058642+mhovd@users.noreply.github.com> Date: Tue, 1 Apr 2025 11:43:42 +0200 Subject: [PATCH 02/56] Seems to work! --- examples/bestdose.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/examples/bestdose.rs b/examples/bestdose.rs index 2c4869b8c..7b6eb11c8 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose.rs @@ -45,10 +45,10 @@ impl CostFunction for DoseOptim { type Output = f64; fn cost(&self, param: &Self::Param) -> Result { + print!("Evaluating dose: {:?}", param); use pharmsol::ErrorModel; let predsub = Subject::builder("Johnny Bravo") .bolus(0.0, *param, 0) - .repeat(10, 12.0) .observation(self.target_time, self.target_conc, 0) .build(); @@ -63,13 +63,16 @@ impl CostFunction for DoseOptim { true, ); - println!("Psi: {:?}", psi); + println!("Psi: {:#?}", psi); - let (w, _) = burke(&psi)?; + let (w, objf) = burke(&psi)?; + + println!("Objective function value: {:#?}", objf); + println!("W: {:#?}", w); let posterior = posterior(&psi, &w); - println!("Posterior: {:?}", posterior); + println!("Posterior: {:#?}", posterior); let mut toterr = 0.0; @@ -136,9 +139,9 @@ fn main() -> Result<()> { let d_flat = generate(¶ms, 100, 22)?; // Create a dose optimizer - let dose_optim = DoseOptim::new(100.0, 119.0, 20.0, d_pop, d_flat, eq); + let dose_optim = DoseOptim::new(4.0, 24.0, 3.0, d_pop, d_flat, eq); - let solver = BrentOpt::new(0.0, 1000.0); + let solver = BrentOpt::new(0.0, 100.0); println!("Optimizing dose..."); let res = Executor::new(dose_optim, solver) @@ -146,7 +149,10 @@ fn main() -> Result<()> { // .add_observer(SlogLogger::term(), ObserverMode::Always) .run()?; - println!("{:#?}", res.state.best_param.unwrap()); + println!("#######################"); + println!("Finished optimizing dose..."); + + println!("Optimal dose: {:?}", res.state.best_param); println!("{:#?}", res.state.best_cost); println!("{:#?}", res.state.counts); From 568ee0c607f900963b9dd4bca6560fa8e03a29b9 Mon Sep 17 00:00:00 2001 From: Markus <66058642+mhovd@users.noreply.github.com> Date: Thu, 3 Apr 2025 14:44:53 +0200 Subject: [PATCH 03/56] Update to main --- examples/bestdose.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/bestdose.rs b/examples/bestdose.rs index 7b6eb11c8..83227c4ac 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose.rs @@ -121,7 +121,7 @@ fn main() -> Result<()> { .build(); settings.set_cycles(1000); - settings.set_prior_sampler(Sampler::Sobol, 2048, 22); + settings.set_prior(Prior::sobol(2048, 22)); let mut algorithm = dispatch_algorithm(settings, eq.clone(), data)?; From b5c480ae87d3fce8c2e393765c95b0165b2f16ab Mon Sep 17 00:00:00 2001 From: Markus <66058642+mhovd@users.noreply.github.com> Date: Thu, 3 Apr 2025 15:54:28 +0200 Subject: [PATCH 04/56] WIP --- examples/bestdose.rs | 175 +++++++++---------------------------------- src/bestdose/mod.rs | 85 +++++++++++++++++++++ src/lib.rs | 3 + 3 files changed, 124 insertions(+), 139 deletions(-) create mode 100644 src/bestdose/mod.rs diff --git a/examples/bestdose.rs b/examples/bestdose.rs index 83227c4ac..1dcb67e43 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose.rs @@ -1,162 +1,59 @@ use anyhow::Result; -use argmin::core::{CostFunction, Executor}; -use argmin::solver::brent::BrentOpt; - -use pmcore::algorithms::npag::burke; -use pmcore::prelude::data::read_pmetrics; +use pmcore::bestdose::{optimize_dose, DoseOptimizer}; use pmcore::prelude::*; use pmcore::routines::initialization::sobol::generate; -use pmcore::routines::output::posterior; -use pmcore::structs::psi::calculate_psi; -use pmcore::structs::theta::Theta; - -// Create a structure we can use to optimize -pub struct DoseOptim { - dose: f64, - target_time: f64, - target_conc: f64, - d_pop: Theta, - d_flat: Theta, - eq: ODE, -} - -impl DoseOptim { - pub fn new( - dose: f64, - target_time: f64, - target_conc: f64, - d_pop: Theta, - d_flat: Theta, - eq: ODE, - ) -> Self { - Self { - dose, - target_time, - target_conc, - d_pop, - d_flat, - eq, - } - } -} - -impl CostFunction for DoseOptim { - type Param = f64; - type Output = f64; - - fn cost(&self, param: &Self::Param) -> Result { - print!("Evaluating dose: {:?}", param); - use pharmsol::ErrorModel; - let predsub = Subject::builder("Johnny Bravo") - .bolus(0.0, *param, 0) - .observation(self.target_time, self.target_conc, 0) - .build(); - - let errmod = ErrorModel::new((0.01, 0.1, 0.0, 0.0), 1.0, &ErrorType::Add); - - let psi = calculate_psi( - &self.eq, - &Data::new(vec![predsub.clone()]), - &self.d_pop, - &errmod, - false, - true, - ); - - println!("Psi: {:#?}", psi); - - let (w, objf) = burke(&psi)?; - - println!("Objective function value: {:#?}", objf); - println!("W: {:#?}", w); - - let posterior = posterior(&psi, &w); - - println!("Posterior: {:#?}", posterior); - - let mut toterr = 0.0; - - for spp in self.d_pop.matrix().row_iter() { - let point: Vec = spp.iter().copied().collect(); - - let pred = self.eq.simulate_subject(&predsub, &point, Some(&errmod)).0; - - toterr += pred.squared_error(); - } - - Ok(toterr) - } -} fn main() -> Result<()> { - //Create a model with data - let data = read_pmetrics("examples/theophylline/theophylline.csv")?; - + // Example model let eq = equation::ODE::new( - |x, p, _t, dx, _rateiv, _cov| { + |x, p, _t, dx, rateiv, _cov| { // fetch_cov!(cov, t, wt); - fetch_params!(p, ka, ke, _v); - dx[0] = -ka * x[0]; - dx[1] = ka * x[0] - ke * x[1]; + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + rateiv[0]; }, |_p| lag! {}, |_p| fa! {}, |_p, _t, _cov, _x| {}, |x, p, _t, _cov, y| { - fetch_params!(p, _ka, _ke, v); - y[0] = x[1] / v; + fetch_params!(p, _ke, v); + y[0] = x[0] / v; }, - (2, 1), + (1, 1), ); + // Example Theta let params = Parameters::new() - .add("ka", 0.0, 3.0, false) .add("ke", 0.001, 3.0, false) - .add("v", 0.0, 250.0, false); - - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params.clone()) - .set_error_model(ErrorModel::Proportional, 5.0, (0.01, 0.1, 0.0, 0.0)) - .build(); - - settings.set_cycles(1000); - settings.set_prior(Prior::sobol(2048, 22)); - - let mut algorithm = dispatch_algorithm(settings, eq.clone(), data)?; - - println!("Fititng model..."); + .add("v", 25.0, 250.0, false); - let result = algorithm.fit()?; + let theta = generate(¶ms, 24, 22)?; - println!("Finished fititng model..."); - - // Create D_pop - let d_pop = result.get_theta().clone(); - println!("Theta: {:?}", d_pop); - - // Create D_flat - let d_flat = generate(¶ms, 100, 22)?; - - // Create a dose optimizer - let dose_optim = DoseOptim::new(4.0, 24.0, 3.0, d_pop, d_flat, eq); - - let solver = BrentOpt::new(0.0, 100.0); - - println!("Optimizing dose..."); - let res = Executor::new(dose_optim, solver) - .configure(|state| state.max_iters(100)) - // .add_observer(SlogLogger::term(), ObserverMode::Always) - .run()?; - - println!("#######################"); - println!("Finished optimizing dose..."); - - println!("Optimal dose: {:?}", res.state.best_param); - println!("{:#?}", res.state.best_cost); - println!("{:#?}", res.state.counts); + // Some observed data + let subject = Subject::builder("Nikola Tesla") + .bolus(0.0, 20.0, 0) + .observation(12.0, 8.0, 0) + .build(); - println!("Optimization finished"); + // Example usage + let data = Data::new(vec![subject]); // Placeholder for actual data + let theta = theta; + let target_concentration = 10.0; + let target_time = 5.0; + let eq = eq; + let min_dose = 0.0; + let max_dose = 100.0; + + let problem = DoseOptimizer { + data, + theta, + target_concentration, + target_time, + eq, + min_dose, + max_dose, + }; + + optimize_dose(problem)?; Ok(()) } diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs new file mode 100644 index 000000000..02c74fbcd --- /dev/null +++ b/src/bestdose/mod.rs @@ -0,0 +1,85 @@ +use anyhow::{Ok, Result}; +use argmin::core::TerminationReason; +use argmin::core::TerminationStatus; +use argmin::core::{CostFunction, Executor}; +use argmin::solver::brent::BrentOpt; + +use pharmsol::prelude::*; +use pharmsol::{Data, ODE}; + +use crate::algorithms::npag::burke; + +use crate::structs::psi::calculate_psi; +use crate::structs::theta::Theta; + +pub struct DoseOptimizer { + pub data: Data, + pub theta: Theta, + pub target_concentration: f64, + pub target_time: f64, + pub eq: ODE, + pub min_dose: f64, + pub max_dose: f64, +} + +impl CostFunction for DoseOptimizer { + type Param = f64; + type Output = f64; + + fn cost(&self, param: &Self::Param) -> Result { + println!("Evaluating dose: {:?}", param); + + let errmod = pharmsol::ErrorModel::new((0.0, 0.1, 0.0, 0.0), 0.0, &ErrorType::Add); + + let psi = calculate_psi(&self.eq, &self.data, &self.theta, &errmod, false, true); + + let (w, objf) = burke(&psi)?; + + Ok(param + 5.0) // Example cost function + } +} + +pub struct OptimalDose { + dose: f64, + cost: f64, + status: String, +} + +pub fn optimize_dose(problem: DoseOptimizer) -> Result { + let min_dose = problem.min_dose; + let max_dose = problem.max_dose; + + let solver = BrentOpt::new(min_dose, max_dose); // With the given contraints + + let opt = Executor::new(problem, solver) + .configure(|state| state.max_iters(1000)) + .run()?; + + let result = opt.state(); + + match &result.termination_status { + TerminationStatus::Terminated(status) => match status { + TerminationReason::SolverConverged => { + println!("Solver converged"); + } + _ => { + println!("Solver terminated with reason: {}", status.text()); + } + }, + TerminationStatus::NotTerminated => { + println!("Solver did not terminate"); + } + } + + let optimaldose = OptimalDose { + dose: result.param.unwrap(), + cost: result.cost, + status: result.termination_status.to_string(), + }; + + println!("Optimal dose: {}", optimaldose.dose); + println!("Cost: {}", optimaldose.cost); + println!("Status: {}", optimaldose.status); + + Ok(optimaldose) +} diff --git a/src/lib.rs b/src/lib.rs index a05b3529c..41cbc9af8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,9 @@ pub mod structs; pub use anyhow::Result; pub use std::collections::HashMap; +// BestDose +pub mod bestdose; + /// A collection of commonly used items to simplify imports. pub mod prelude { pub use super::HashMap; From 1b8c1baa1e8b6c8695472490c57e5ce00eed556c Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 3 Apr 2025 18:34:13 +0200 Subject: [PATCH 05/56] WIP --- examples/bestdose.rs | 19 ++++++------------ src/bestdose/mod.rs | 46 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/examples/bestdose.rs b/examples/bestdose.rs index 1dcb67e43..74e257757 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose.rs @@ -35,22 +35,15 @@ fn main() -> Result<()> { .build(); // Example usage - let data = Data::new(vec![subject]); // Placeholder for actual data - let theta = theta; - let target_concentration = 10.0; - let target_time = 5.0; - let eq = eq; - let min_dose = 0.0; - let max_dose = 100.0; - let problem = DoseOptimizer { - data, + data: Data::new(vec![subject]), // Placeholder for actual data theta, - target_concentration, - target_time, + target_concentration: 10.0, + target_time: 5.0, eq, - min_dose, - max_dose, + min_dose: 0.0, + max_dose: 10000.0, + bias_weight: 0.1, }; optimize_dose(problem)?; diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 02c74fbcd..d5a4a4aaf 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -5,6 +5,8 @@ use argmin::core::{CostFunction, Executor}; use argmin::solver::brent::BrentOpt; use pharmsol::prelude::*; +use pharmsol::Equation; +use pharmsol::Predictions; use pharmsol::{Data, ODE}; use crate::algorithms::npag::burke; @@ -20,6 +22,7 @@ pub struct DoseOptimizer { pub eq: ODE, pub min_dose: f64, pub max_dose: f64, + pub bias_weight: f64, } impl CostFunction for DoseOptimizer { @@ -29,13 +32,52 @@ impl CostFunction for DoseOptimizer { fn cost(&self, param: &Self::Param) -> Result { println!("Evaluating dose: {:?}", param); + let dose = param.clone(); + let target_subject = Subject::builder("target") + .bolus(0.0, dose, 0) + .observation(self.target_time, self.target_concentration, 0) + .build(); let errmod = pharmsol::ErrorModel::new((0.0, 0.1, 0.0, 0.0), 0.0, &ErrorType::Add); let psi = calculate_psi(&self.eq, &self.data, &self.theta, &errmod, false, true); - let (w, objf) = burke(&psi)?; + let (w, _) = burke(&psi)?; - Ok(param + 5.0) // Example cost function + // Normalize W to sum to 1 + let w_sum: f64 = w.iter().sum(); + let w: Vec = w.iter().map(|&x| x / w_sum).collect(); + + // Calculate BIAS + let mut bias = 0.0; + + for row in self.theta.matrix().row_iter() { + let spp = row.iter().copied().collect::>(); + let squared_error = self + .eq + .simulate_subject(&target_subject, &spp, None) + .0 + .squared_error(); + + bias += squared_error; + } + + // Calculate the weighted sum + let mut wt_sum = 0.0; + + for (row, prob) in self.theta.matrix().row_iter().zip(w.iter()) { + let spp = row.iter().copied().collect::>(); + let squared_error = self + .eq + .simulate_subject(&target_subject, &spp, None) + .0 + .squared_error(); + + wt_sum += squared_error * prob; + } + + let objf = (1.0 - self.bias_weight) * wt_sum + self.bias_weight * bias; + + Ok(objf) // Example cost function } } From 9aaf4ef184a8160b9522e3cba3c1155ac7c8a151 Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 3 Apr 2025 19:32:38 +0200 Subject: [PATCH 06/56] Better example --- examples/bestdose.rs | 30 ++++++++++++++++++++++++++---- src/bestdose/mod.rs | 20 +++++++++----------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/examples/bestdose.rs b/examples/bestdose.rs index 74e257757..8cf320f7a 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose.rs @@ -1,7 +1,7 @@ use anyhow::Result; use pmcore::bestdose::{optimize_dose, DoseOptimizer}; +use pmcore::prelude::data::read_pmetrics; use pmcore::prelude::*; -use pmcore::routines::initialization::sobol::generate; fn main() -> Result<()> { // Example model @@ -26,7 +26,26 @@ fn main() -> Result<()> { .add("ke", 0.001, 3.0, false) .add("v", 25.0, 250.0, false); - let theta = generate(¶ms, 24, 22)?; + // Read BKE data + let data = read_pmetrics("examples/bimodal_ke/bimodal_ke.csv")?; + + // Make settings + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params) + .set_error_model(ErrorModel::Additive, 0.0, (0.0, 0.05, 0.0, 0.0)) + .build(); + + settings.disable_output(); + + // Run NPAG + let mut algorithm = dispatch_algorithm(settings, eq.clone(), data)?; + + println!("Running NPAG..."); + + let result = algorithm.fit()?; + println!("Finished NPAG..."); + let theta = result.get_theta().clone(); // Some observed data let subject = Subject::builder("Nikola Tesla") @@ -43,10 +62,13 @@ fn main() -> Result<()> { eq, min_dose: 0.0, max_dose: 10000.0, - bias_weight: 0.1, + bias_weight: 0.0, }; - optimize_dose(problem)?; + println!("Optimizing dose..."); + let optimal = optimize_dose(problem)?; + + println!("Optimal dose: {:#?}", optimal); Ok(()) } diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index d5a4a4aaf..51c83b9bd 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -30,8 +30,6 @@ impl CostFunction for DoseOptimizer { type Output = f64; fn cost(&self, param: &Self::Param) -> Result { - println!("Evaluating dose: {:?}", param); - let dose = param.clone(); let target_subject = Subject::builder("target") .bolus(0.0, dose, 0) @@ -47,6 +45,9 @@ impl CostFunction for DoseOptimizer { let w_sum: f64 = w.iter().sum(); let w: Vec = w.iter().map(|&x| x / w_sum).collect(); + let nspp = self.theta.matrix().nrows(); + let bias_factor = 1.0 / (nspp as f64); + // Calculate BIAS let mut bias = 0.0; @@ -58,7 +59,7 @@ impl CostFunction for DoseOptimizer { .0 .squared_error(); - bias += squared_error; + bias += squared_error * bias_factor; } // Calculate the weighted sum @@ -81,10 +82,11 @@ impl CostFunction for DoseOptimizer { } } +#[derive(Debug)] pub struct OptimalDose { - dose: f64, - cost: f64, - status: String, + pub dose: f64, + pub objf: f64, + pub status: String, } pub fn optimize_dose(problem: DoseOptimizer) -> Result { @@ -115,13 +117,9 @@ pub fn optimize_dose(problem: DoseOptimizer) -> Result { let optimaldose = OptimalDose { dose: result.param.unwrap(), - cost: result.cost, + objf: result.cost, status: result.termination_status.to_string(), }; - println!("Optimal dose: {}", optimaldose.dose); - println!("Cost: {}", optimaldose.cost); - println!("Status: {}", optimaldose.status); - Ok(optimaldose) } From b7a196098b02a8ebf449adecb7033537a78e52fe Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 3 Apr 2025 19:54:56 +0200 Subject: [PATCH 07/56] Include comparison of different bias weights --- examples/bestdose.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/examples/bestdose.rs b/examples/bestdose.rs index 8cf320f7a..2cb7b59e3 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose.rs @@ -55,11 +55,11 @@ fn main() -> Result<()> { // Example usage let problem = DoseOptimizer { - data: Data::new(vec![subject]), // Placeholder for actual data + data: Data::new(vec![subject.clone()]), // Placeholder for actual data theta, target_concentration: 10.0, target_time: 5.0, - eq, + eq: eq.clone(), min_dose: 0.0, max_dose: 10000.0, bias_weight: 0.0, @@ -70,5 +70,32 @@ fn main() -> Result<()> { println!("Optimal dose: {:#?}", optimal); + // Test different values of bias_weight + let bias_weights = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]; + let mut results = Vec::new(); + for bias_weight in &bias_weights { + let problem = DoseOptimizer { + data: Data::new(vec![subject.clone()]), // Placeholder for actual data + theta: result.get_theta().clone(), + target_concentration: 10.0, + target_time: 5.0, + eq: eq.clone(), + min_dose: 0.0, + max_dose: 10000.0, + bias_weight: *bias_weight, + }; + + let optimal = optimize_dose(problem)?; + results.push((bias_weight, optimal)); + } + + // Print results + for (bias_weight, optimal) in results { + println!( + "Bias weight: {}, Optimal dose: {:.2}, Objective function: {:.2}", + bias_weight, optimal.dose, optimal.objf + ); + } + Ok(()) } From 8a1e649b376561fc1a996d6d0ba712a3802ff8fa Mon Sep 17 00:00:00 2001 From: Markus Date: Fri, 4 Apr 2025 09:23:36 +0200 Subject: [PATCH 08/56] WIP --- examples/bestdose.rs | 6 ++++-- src/bestdose/mod.rs | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/bestdose.rs b/examples/bestdose.rs index 2cb7b59e3..4a22df690 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose.rs @@ -53,9 +53,11 @@ fn main() -> Result<()> { .observation(12.0, 8.0, 0) .build(); + let past_data = Data::new(vec![subject.clone()]); + // Example usage let problem = DoseOptimizer { - data: Data::new(vec![subject.clone()]), // Placeholder for actual data + past_data: past_data.clone(), theta, target_concentration: 10.0, target_time: 5.0, @@ -75,7 +77,7 @@ fn main() -> Result<()> { let mut results = Vec::new(); for bias_weight in &bias_weights { let problem = DoseOptimizer { - data: Data::new(vec![subject.clone()]), // Placeholder for actual data + past_data: past_data.clone(), theta: result.get_theta().clone(), target_concentration: 10.0, target_time: 5.0, diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 51c83b9bd..9a5dd8da5 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -15,7 +15,7 @@ use crate::structs::psi::calculate_psi; use crate::structs::theta::Theta; pub struct DoseOptimizer { - pub data: Data, + pub past_data: Data, pub theta: Theta, pub target_concentration: f64, pub target_time: f64, @@ -37,7 +37,7 @@ impl CostFunction for DoseOptimizer { .build(); let errmod = pharmsol::ErrorModel::new((0.0, 0.1, 0.0, 0.0), 0.0, &ErrorType::Add); - let psi = calculate_psi(&self.eq, &self.data, &self.theta, &errmod, false, true); + let psi = calculate_psi(&self.eq, &self.past_data, &self.theta, &errmod, false, true); let (w, _) = burke(&psi)?; From 03ee4037328428c34cbf43739881c4b50cc24c41 Mon Sep 17 00:00:00 2001 From: Markus Date: Sun, 6 Apr 2025 10:38:55 +0200 Subject: [PATCH 09/56] Updating API --- examples/bestdose.rs | 9 +++---- src/bestdose/mod.rs | 58 ++++++++++++++++++++++++++++---------------- 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/examples/bestdose.rs b/examples/bestdose.rs index 4a22df690..d302565e7 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use pmcore::bestdose::{optimize_dose, DoseOptimizer}; +use pmcore::bestdose::{optimize_dose, DoseOptimizer, DoseRange}; use pmcore::prelude::data::read_pmetrics; use pmcore::prelude::*; @@ -21,7 +21,6 @@ fn main() -> Result<()> { (1, 1), ); - // Example Theta let params = Parameters::new() .add("ke", 0.001, 3.0, false) .add("v", 25.0, 250.0, false); @@ -62,8 +61,7 @@ fn main() -> Result<()> { target_concentration: 10.0, target_time: 5.0, eq: eq.clone(), - min_dose: 0.0, - max_dose: 10000.0, + doserange: DoseRange::new(0.0, 10000.0), bias_weight: 0.0, }; @@ -82,8 +80,7 @@ fn main() -> Result<()> { target_concentration: 10.0, target_time: 5.0, eq: eq.clone(), - min_dose: 0.0, - max_dose: 10000.0, + doserange: DoseRange::new(0.0, 10000.0), bias_weight: *bias_weight, }; diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 9a5dd8da5..6b07c44fc 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -1,6 +1,4 @@ use anyhow::{Ok, Result}; -use argmin::core::TerminationReason; -use argmin::core::TerminationStatus; use argmin::core::{CostFunction, Executor}; use argmin::solver::brent::BrentOpt; @@ -10,18 +8,49 @@ use pharmsol::Predictions; use pharmsol::{Data, ODE}; use crate::algorithms::npag::burke; - use crate::structs::psi::calculate_psi; use crate::structs::theta::Theta; +pub enum Target { + Concentration(f64), + AUC(f64), +} + +pub struct DoseRange { + min: f64, + max: f64, +} + +impl DoseRange { + pub fn new(min: f64, max: f64) -> Self { + DoseRange { min, max } + } + + pub fn min(&self) -> f64 { + self.min + } + + pub fn max(&self) -> f64 { + self.max + } +} + +impl Default for DoseRange { + fn default() -> Self { + DoseRange { + min: 0.0, + max: f64::MAX, + } + } +} + pub struct DoseOptimizer { pub past_data: Data, pub theta: Theta, pub target_concentration: f64, pub target_time: f64, pub eq: ODE, - pub min_dose: f64, - pub max_dose: f64, + pub doserange: DoseRange, pub bias_weight: f64, } @@ -35,6 +64,7 @@ impl CostFunction for DoseOptimizer { .bolus(0.0, dose, 0) .observation(self.target_time, self.target_concentration, 0) .build(); + let errmod = pharmsol::ErrorModel::new((0.0, 0.1, 0.0, 0.0), 0.0, &ErrorType::Add); let psi = calculate_psi(&self.eq, &self.past_data, &self.theta, &errmod, false, true); @@ -90,8 +120,8 @@ pub struct OptimalDose { } pub fn optimize_dose(problem: DoseOptimizer) -> Result { - let min_dose = problem.min_dose; - let max_dose = problem.max_dose; + let min_dose = problem.doserange.min; + let max_dose = problem.doserange.max; let solver = BrentOpt::new(min_dose, max_dose); // With the given contraints @@ -101,20 +131,6 @@ pub fn optimize_dose(problem: DoseOptimizer) -> Result { let result = opt.state(); - match &result.termination_status { - TerminationStatus::Terminated(status) => match status { - TerminationReason::SolverConverged => { - println!("Solver converged"); - } - _ => { - println!("Solver terminated with reason: {}", status.text()); - } - }, - TerminationStatus::NotTerminated => { - println!("Solver did not terminate"); - } - } - let optimaldose = OptimalDose { dose: result.param.unwrap(), objf: result.cost, From 876162a83203aaaa647b482c916fbfd9d4c252ee Mon Sep 17 00:00:00 2001 From: Markus Date: Mon, 7 Apr 2025 19:29:54 +0200 Subject: [PATCH 10/56] Twice as fast! --- src/bestdose/mod.rs | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 6b07c44fc..24cb5659a 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -78,21 +78,9 @@ impl CostFunction for DoseOptimizer { let nspp = self.theta.matrix().nrows(); let bias_factor = 1.0 / (nspp as f64); - // Calculate BIAS + // Accumulator for BIAS let mut bias = 0.0; - - for row in self.theta.matrix().row_iter() { - let spp = row.iter().copied().collect::>(); - let squared_error = self - .eq - .simulate_subject(&target_subject, &spp, None) - .0 - .squared_error(); - - bias += squared_error * bias_factor; - } - - // Calculate the weighted sum + // Accumulator for weighted sum let mut wt_sum = 0.0; for (row, prob) in self.theta.matrix().row_iter().zip(w.iter()) { @@ -103,6 +91,7 @@ impl CostFunction for DoseOptimizer { .0 .squared_error(); + bias += squared_error * bias_factor; wt_sum += squared_error * prob; } From 13fad6cdc91ff67903a999607716b65ae3a12769 Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 9 Apr 2025 17:54:45 +0200 Subject: [PATCH 11/56] Refactoring --- examples/bestdose.rs | 11 +++++----- src/bestdose/mod.rs | 52 ++++++++++++++++++++++++-------------------- 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/examples/bestdose.rs b/examples/bestdose.rs index d302565e7..7af8c599c 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use pmcore::bestdose::{optimize_dose, DoseOptimizer, DoseRange}; +use pmcore::bestdose::{BestDoseProblem, DoseRange}; use pmcore::prelude::data::read_pmetrics; use pmcore::prelude::*; @@ -55,7 +55,7 @@ fn main() -> Result<()> { let past_data = Data::new(vec![subject.clone()]); // Example usage - let problem = DoseOptimizer { + let problem = BestDoseProblem { past_data: past_data.clone(), theta, target_concentration: 10.0, @@ -66,15 +66,16 @@ fn main() -> Result<()> { }; println!("Optimizing dose..."); - let optimal = optimize_dose(problem)?; + let optimal = problem.optimize()?; println!("Optimal dose: {:#?}", optimal); // Test different values of bias_weight let bias_weights = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]; let mut results = Vec::new(); + for bias_weight in &bias_weights { - let problem = DoseOptimizer { + let problem = BestDoseProblem { past_data: past_data.clone(), theta: result.get_theta().clone(), target_concentration: 10.0, @@ -84,7 +85,7 @@ fn main() -> Result<()> { bias_weight: *bias_weight, }; - let optimal = optimize_dose(problem)?; + let optimal = problem.optimize()?; results.push((bias_weight, optimal)); } diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 24cb5659a..32dbb6e55 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -44,7 +44,7 @@ impl Default for DoseRange { } } -pub struct DoseOptimizer { +pub struct BestDoseProblem { pub past_data: Data, pub theta: Theta, pub target_concentration: f64, @@ -54,7 +54,32 @@ pub struct DoseOptimizer { pub bias_weight: f64, } -impl CostFunction for DoseOptimizer { +impl BestDoseProblem { + pub fn optimize(self) -> Result { + let min_dose = self.doserange.min; + let max_dose = self.doserange.max; + + let solver = BrentOpt::new(min_dose, max_dose); + + let problem = self; + + let opt = Executor::new(problem, solver) + .configure(|state| state.max_iters(1000)) + .run()?; + + let result = opt.state(); + + let optimaldose = BestDoseResult { + dose: result.param.unwrap(), + objf: result.cost, + status: result.termination_status.to_string(), + }; + + Ok(optimaldose) + } +} + +impl CostFunction for BestDoseProblem { type Param = f64; type Output = f64; @@ -102,29 +127,8 @@ impl CostFunction for DoseOptimizer { } #[derive(Debug)] -pub struct OptimalDose { +pub struct BestDoseResult { pub dose: f64, pub objf: f64, pub status: String, } - -pub fn optimize_dose(problem: DoseOptimizer) -> Result { - let min_dose = problem.doserange.min; - let max_dose = problem.doserange.max; - - let solver = BrentOpt::new(min_dose, max_dose); // With the given contraints - - let opt = Executor::new(problem, solver) - .configure(|state| state.max_iters(1000)) - .run()?; - - let result = opt.state(); - - let optimaldose = OptimalDose { - dose: result.param.unwrap(), - objf: result.cost, - status: result.termination_status.to_string(), - }; - - Ok(optimaldose) -} From 7c977caefe6a99fbd02d32dce559f72bc6c2020f Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 9 Apr 2025 20:52:10 +0200 Subject: [PATCH 12/56] Update calculations Co-Authored-By: Julian Otalvaro <1023006+Siel@users.noreply.github.com> Co-Authored-By: masyamada --- src/bestdose/mod.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 32dbb6e55..f405b0cc3 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -100,26 +100,26 @@ impl CostFunction for BestDoseProblem { let w_sum: f64 = w.iter().sum(); let w: Vec = w.iter().map(|&x| x / w_sum).collect(); - let nspp = self.theta.matrix().nrows(); - let bias_factor = 1.0 / (nspp as f64); + // Then calcualte the bias + + // Store the mean of the predictions + // TODO: This needs to handle more than one target + let mut y_bar = 0.0; - // Accumulator for BIAS - let mut bias = 0.0; // Accumulator for weighted sum let mut wt_sum = 0.0; - for (row, prob) in self.theta.matrix().row_iter().zip(w.iter()) { let spp = row.iter().copied().collect::>(); - let squared_error = self - .eq - .simulate_subject(&target_subject, &spp, None) - .0 - .squared_error(); - - bias += squared_error * bias_factor; - wt_sum += squared_error * prob; + let pred = self.eq.simulate_subject(&target_subject, &spp, None); + wt_sum += pred.0.squared_error() * prob; + + y_bar += pred.0.flat_predictions().first().unwrap() * prob; } + // Bias is the squared difference between the target concentration and the mean of the predictions + let bias = (y_bar - self.target_concentration).powi(2); + + // Calculate the objective function let objf = (1.0 - self.bias_weight) * wt_sum + self.bias_weight * bias; Ok(objf) // Example cost function From df64d35dcc71ce2265be611012c5f62a05d6bd53 Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 9 Apr 2025 20:56:55 +0200 Subject: [PATCH 13/56] Update mod.rs --- src/bestdose/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index f405b0cc3..095562ac9 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -122,6 +122,8 @@ impl CostFunction for BestDoseProblem { // Calculate the objective function let objf = (1.0 - self.bias_weight) * wt_sum + self.bias_weight * bias; + // TODO: Repeat with D_flat, and return the best + Ok(objf) // Example cost function } } From c2504277362d6cb11f843e17bf5fb6416aaa22f0 Mon Sep 17 00:00:00 2001 From: Markus <66058642+mhovd@users.noreply.github.com> Date: Thu, 10 Apr 2025 08:57:02 +0200 Subject: [PATCH 14/56] Update mod.rs --- src/bestdose/mod.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 095562ac9..4fd327703 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -64,7 +64,7 @@ impl BestDoseProblem { let problem = self; let opt = Executor::new(problem, solver) - .configure(|state| state.max_iters(1000)) + .configure(|state| state.max_iters(1000).target_cost(0.0)) .run()?; let result = opt.state(); @@ -92,27 +92,36 @@ impl CostFunction for BestDoseProblem { let errmod = pharmsol::ErrorModel::new((0.0, 0.1, 0.0, 0.0), 0.0, &ErrorType::Add); + // Calculate psi, in order to determine the optimal weights of the support points in Theta for the target subject let psi = calculate_psi(&self.eq, &self.past_data, &self.theta, &errmod, false, true); + // Calculate the optimal weights let (w, _) = burke(&psi)?; // Normalize W to sum to 1 let w_sum: f64 = w.iter().sum(); let w: Vec = w.iter().map(|&x| x / w_sum).collect(); - // Then calcualte the bias + // Then calculate the bias // Store the mean of the predictions // TODO: This needs to handle more than one target let mut y_bar = 0.0; - // Accumulator for weighted sum - let mut wt_sum = 0.0; + // Accumulator for the variance component + let mut variance = 0.0; + + // For each support point in theta, and the associated probability... for (row, prob) in self.theta.matrix().row_iter().zip(w.iter()) { let spp = row.iter().copied().collect::>(); + + // Calculate the target subject predictions let pred = self.eq.simulate_subject(&target_subject, &spp, None); - wt_sum += pred.0.squared_error() * prob; + // The (probability weighted) squared error of the predictions is added to the variance + variance += pred.0.squared_error() * prob; + + // At the same time, calculate the mean of the predictions y_bar += pred.0.flat_predictions().first().unwrap() * prob; } @@ -120,7 +129,7 @@ impl CostFunction for BestDoseProblem { let bias = (y_bar - self.target_concentration).powi(2); // Calculate the objective function - let objf = (1.0 - self.bias_weight) * wt_sum + self.bias_weight * bias; + let objf = (1.0 - self.bias_weight) * variance + self.bias_weight * bias; // TODO: Repeat with D_flat, and return the best From 4823aef850630cd8bbe153b00315ce8b998d5135 Mon Sep 17 00:00:00 2001 From: Markus Date: Mon, 11 Aug 2025 19:03:17 +0200 Subject: [PATCH 15/56] WIP --- examples/bestdose.rs | 17 ++++++++++++----- src/bestdose/mod.rs | 14 ++++++++++---- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/examples/bestdose.rs b/examples/bestdose.rs index 7af8c599c..bff48b61d 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose.rs @@ -11,8 +11,8 @@ fn main() -> Result<()> { fetch_params!(p, ke, _v); dx[0] = -ke * x[0] + rateiv[0]; }, - |_p| lag! {}, - |_p| fa! {}, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, |_p, _t, _cov, _x| {}, |x, p, _t, _cov, y| { fetch_params!(p, _ke, v); @@ -22,17 +22,22 @@ fn main() -> Result<()> { ); let params = Parameters::new() - .add("ke", 0.001, 3.0, false) - .add("v", 25.0, 250.0, false); + .add("ke", 0.001, 3.0) + .add("v", 25.0, 250.0); // Read BKE data let data = read_pmetrics("examples/bimodal_ke/bimodal_ke.csv")?; + let ems = ErrorModels::new().add( + 0, + ErrorModel::additive(ErrorPoly::new(0.0, 0.5, 0.0, 0.0), 0.0, None), + )?; + // Make settings let mut settings = Settings::builder() .set_algorithm(Algorithm::NPAG) .set_parameters(params) - .set_error_model(ErrorModel::Additive, 0.0, (0.0, 0.05, 0.0, 0.0)) + .set_error_models(ems.clone()) .build(); settings.disable_output(); @@ -63,6 +68,7 @@ fn main() -> Result<()> { eq: eq.clone(), doserange: DoseRange::new(0.0, 10000.0), bias_weight: 0.0, + error_models: ems.clone(), }; println!("Optimizing dose..."); @@ -83,6 +89,7 @@ fn main() -> Result<()> { eq: eq.clone(), doserange: DoseRange::new(0.0, 10000.0), bias_weight: *bias_weight, + error_models: ems.clone(), }; let optimal = problem.optimize()?; diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 4fd327703..6f3e1c9eb 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -52,6 +52,7 @@ pub struct BestDoseProblem { pub eq: ODE, pub doserange: DoseRange, pub bias_weight: f64, + pub error_models: ErrorModels, } impl BestDoseProblem { @@ -90,10 +91,15 @@ impl CostFunction for BestDoseProblem { .observation(self.target_time, self.target_concentration, 0) .build(); - let errmod = pharmsol::ErrorModel::new((0.0, 0.1, 0.0, 0.0), 0.0, &ErrorType::Add); - // Calculate psi, in order to determine the optimal weights of the support points in Theta for the target subject - let psi = calculate_psi(&self.eq, &self.past_data, &self.theta, &errmod, false, true); + let psi = calculate_psi( + &self.eq, + &self.past_data, + &self.theta, + &self.error_models, + false, + true, + )?; // Calculate the optimal weights let (w, _) = burke(&psi)?; @@ -116,7 +122,7 @@ impl CostFunction for BestDoseProblem { let spp = row.iter().copied().collect::>(); // Calculate the target subject predictions - let pred = self.eq.simulate_subject(&target_subject, &spp, None); + let pred = self.eq.simulate_subject(&target_subject, &spp, None)?; // The (probability weighted) squared error of the predictions is added to the variance variance += pred.0.squared_error() * prob; From 8d5cb728e651630802264e227219586cf9fb0947 Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 20 Aug 2025 18:08:24 +0200 Subject: [PATCH 16/56] WIP --- examples/{bestdose.rs => bestdose/main.rs} | 60 ++++++++++------------ src/bestdose/mod.rs | 12 ++++- 2 files changed, 37 insertions(+), 35 deletions(-) rename examples/{bestdose.rs => bestdose/main.rs} (58%) diff --git a/examples/bestdose.rs b/examples/bestdose/main.rs similarity index 58% rename from examples/bestdose.rs rename to examples/bestdose/main.rs index bff48b61d..811c04765 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose/main.rs @@ -1,15 +1,16 @@ use anyhow::Result; use pmcore::bestdose::{BestDoseProblem, DoseRange}; -use pmcore::prelude::data::read_pmetrics; + use pmcore::prelude::*; +use pmcore::routines::initialization::parse_prior; fn main() -> Result<()> { // Example model let eq = equation::ODE::new( - |x, p, _t, dx, rateiv, _cov| { + |x, p, _t, dx, _rateiv, _cov| { // fetch_cov!(cov, t, wt); fetch_params!(p, ke, _v); - dx[0] = -ke * x[0] + rateiv[0]; + dx[0] = -ke * x[0]; }, |_p, _, _| lag! {}, |_p, _, _| fa! {}, @@ -25,12 +26,9 @@ fn main() -> Result<()> { .add("ke", 0.001, 3.0) .add("v", 25.0, 250.0); - // Read BKE data - let data = read_pmetrics("examples/bimodal_ke/bimodal_ke.csv")?; - let ems = ErrorModels::new().add( 0, - ErrorModel::additive(ErrorPoly::new(0.0, 0.5, 0.0, 0.0), 0.0, None), + ErrorModel::additive(ErrorPoly::new(0.0, 0.1, 0.0, 0.0), 0.0, None), )?; // Make settings @@ -42,64 +40,60 @@ fn main() -> Result<()> { settings.disable_output(); - // Run NPAG - let mut algorithm = dispatch_algorithm(settings, eq.clone(), data)?; - - println!("Running NPAG..."); + // Generate a patient with known parameters + // Ke = 0.5, V = 50 + // C(t) = Dose * exp(-ke * t) / V - let result = algorithm.fit()?; - println!("Finished NPAG..."); - let theta = result.get_theta().clone(); + fn conc(t: f64) -> f64 { + let dose = 150.0; // Example dose + let ke = 0.5; // Elimination rate constant + let v = 50.0; // Volume of distribution + (dose * (-ke * t).exp()) / v + } // Some observed data let subject = Subject::builder("Nikola Tesla") - .bolus(0.0, 20.0, 0) - .observation(12.0, 8.0, 0) + .bolus(0.0, 100.0, 0) + .observation(2.0, conc(2.0), 0) + .observation(4.0, conc(4.0), 0) + .observation(6.0, conc(6.0), 0) + .observation(12.0, conc(12.0), 0) .build(); let past_data = Data::new(vec![subject.clone()]); + let theta = parse_prior(&"examples/bestdose/theta.csv".to_string(), &settings).unwrap(); + // Example usage let problem = BestDoseProblem { past_data: past_data.clone(), theta, - target_concentration: 10.0, + target_concentration: conc(5.0), target_time: 5.0, eq: eq.clone(), - doserange: DoseRange::new(0.0, 10000.0), + doserange: DoseRange::new(10.0, 1000.0), bias_weight: 0.0, error_models: ems.clone(), }; println!("Optimizing dose..."); - let optimal = problem.optimize()?; + // let optimal = problem.clone().optimize()?; - println!("Optimal dose: {:#?}", optimal); + // println!("Optimal dose: {:#?}", optimal); // Test different values of bias_weight let bias_weights = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]; let mut results = Vec::new(); for bias_weight in &bias_weights { - let problem = BestDoseProblem { - past_data: past_data.clone(), - theta: result.get_theta().clone(), - target_concentration: 10.0, - target_time: 5.0, - eq: eq.clone(), - doserange: DoseRange::new(0.0, 10000.0), - bias_weight: *bias_weight, - error_models: ems.clone(), - }; - - let optimal = problem.optimize()?; + let optimal = problem.clone().bias(*bias_weight).optimize()?; results.push((bias_weight, optimal)); } // Print results for (bias_weight, optimal) in results { println!( - "Bias weight: {}, Optimal dose: {:.2}, Objective function: {:.2}", + "Bias weight: {:.1}\t\t Optimal dose: {:.2}\t\t -2LL: {:.2}", bias_weight, optimal.dose, optimal.objf ); } diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 6f3e1c9eb..61db9c121 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -2,6 +2,7 @@ use anyhow::{Ok, Result}; use argmin::core::{CostFunction, Executor}; use argmin::solver::brent::BrentOpt; +use crate::prelude::*; use pharmsol::prelude::*; use pharmsol::Equation; use pharmsol::Predictions; @@ -16,6 +17,7 @@ pub enum Target { AUC(f64), } +#[derive(Debug, Clone)] pub struct DoseRange { min: f64, max: f64, @@ -44,6 +46,7 @@ impl Default for DoseRange { } } +#[derive(Debug, Clone)] pub struct BestDoseProblem { pub past_data: Data, pub theta: Theta, @@ -65,7 +68,7 @@ impl BestDoseProblem { let problem = self; let opt = Executor::new(problem, solver) - .configure(|state| state.max_iters(1000).target_cost(0.0)) + .configure(|state| state.max_iters(1000)) .run()?; let result = opt.state(); @@ -78,6 +81,11 @@ impl BestDoseProblem { Ok(optimaldose) } + + pub fn bias(mut self, weight: f64) -> Self { + self.bias_weight = weight; + self + } } impl CostFunction for BestDoseProblem { @@ -139,7 +147,7 @@ impl CostFunction for BestDoseProblem { // TODO: Repeat with D_flat, and return the best - Ok(objf) // Example cost function + Ok(2.0 * objf.ln()) // Example cost function } } From 842a4e91153f9596958a11502f4271d01d1d165c Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 20 Aug 2025 18:23:17 +0200 Subject: [PATCH 17/56] Working example Co-Authored-By: Julian Otalvaro <1023006+Siel@users.noreply.github.com> --- examples/bestdose/main.rs | 6 +++++- src/bestdose/mod.rs | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/examples/bestdose/main.rs b/examples/bestdose/main.rs index 811c04765..e82ba3f9c 100644 --- a/examples/bestdose/main.rs +++ b/examples/bestdose/main.rs @@ -62,7 +62,11 @@ fn main() -> Result<()> { let past_data = Data::new(vec![subject.clone()]); - let theta = parse_prior(&"examples/bestdose/theta.csv".to_string(), &settings).unwrap(); + let theta = parse_prior( + &"examples/bimodal_ke/output/theta.csv".to_string(), + &settings, + ) + .unwrap(); // Example usage let problem = BestDoseProblem { diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 61db9c121..d0284fe8f 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -143,11 +143,11 @@ impl CostFunction for BestDoseProblem { let bias = (y_bar - self.target_concentration).powi(2); // Calculate the objective function - let objf = (1.0 - self.bias_weight) * variance + self.bias_weight * bias; + let cost = (1.0 - self.bias_weight) * variance + self.bias_weight * bias; // TODO: Repeat with D_flat, and return the best - Ok(2.0 * objf.ln()) // Example cost function + Ok(cost.ln()) // Example cost function } } From e56bb82a3a7f62a502a92d115f6c40c7bdbe5464 Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 20 Aug 2025 18:24:28 +0200 Subject: [PATCH 18/56] Update main.rs Co-Authored-By: Julian Otalvaro <1023006+Siel@users.noreply.github.com> --- examples/bestdose/main.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/examples/bestdose/main.rs b/examples/bestdose/main.rs index e82ba3f9c..236f125d7 100644 --- a/examples/bestdose/main.rs +++ b/examples/bestdose/main.rs @@ -81,11 +81,7 @@ fn main() -> Result<()> { }; println!("Optimizing dose..."); - // let optimal = problem.clone().optimize()?; - // println!("Optimal dose: {:#?}", optimal); - - // Test different values of bias_weight let bias_weights = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]; let mut results = Vec::new(); @@ -97,7 +93,7 @@ fn main() -> Result<()> { // Print results for (bias_weight, optimal) in results { println!( - "Bias weight: {:.1}\t\t Optimal dose: {:.2}\t\t -2LL: {:.2}", + "Bias weight: {:.1}\t\t Optimal dose: {:.2}\t\t ln cost: {:.2}", bias_weight, optimal.dose, optimal.objf ); } From 635f3ae7d964111e8e8e44b2ae9fcfca44e64e20 Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 20 Aug 2025 19:57:18 +0200 Subject: [PATCH 19/56] Comments Co-Authored-By: Julian Otalvaro <1023006+Siel@users.noreply.github.com> --- src/bestdose/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index d0284fe8f..7bfab9928 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -12,6 +12,9 @@ use crate::algorithms::npag::burke; use crate::structs::psi::calculate_psi; use crate::structs::theta::Theta; +// TODO: AUC as a target +// TODO: Add support for loading and maintenance doses + pub enum Target { Concentration(f64), AUC(f64), @@ -63,6 +66,7 @@ impl BestDoseProblem { let min_dose = self.doserange.min; let max_dose = self.doserange.max; + // TODO: Use Nelder-Mead instead let solver = BrentOpt::new(min_dose, max_dose); let problem = self; From d4eee4240f75eb97b5a89a49fff37d8e67bb339c Mon Sep 17 00:00:00 2001 From: Markus Date: Mon, 25 Aug 2025 21:54:54 +0200 Subject: [PATCH 20/56] WIP: Implementing multiple dose optimization Next is updating the cost function to use all predictions --- examples/bestdose/main.rs | 13 +++-- src/bestdose/mod.rs | 101 +++++++++++++++++++++++++++++++------- 2 files changed, 91 insertions(+), 23 deletions(-) diff --git a/examples/bestdose/main.rs b/examples/bestdose/main.rs index 236f125d7..1f8d67d42 100644 --- a/examples/bestdose/main.rs +++ b/examples/bestdose/main.rs @@ -60,7 +60,7 @@ fn main() -> Result<()> { .observation(12.0, conc(12.0), 0) .build(); - let past_data = Data::new(vec![subject.clone()]); + let past_data = subject.clone(); let theta = parse_prior( &"examples/bimodal_ke/output/theta.csv".to_string(), @@ -68,12 +68,17 @@ fn main() -> Result<()> { ) .unwrap(); + // Create target data (future dosing scenario we want to optimize) + let target_data = Subject::builder("Target Patient") + .bolus(0.0, 100.0, 0) // This dose will be optimized + .observation(5.0, conc(5.0), 0) // Target observation at t=5.0 + .build(); + // Example usage let problem = BestDoseProblem { past_data: past_data.clone(), theta, - target_concentration: conc(5.0), - target_time: 5.0, + target_data: target_data.clone(), eq: eq.clone(), doserange: DoseRange::new(10.0, 1000.0), bias_weight: 0.0, @@ -93,7 +98,7 @@ fn main() -> Result<()> { // Print results for (bias_weight, optimal) in results { println!( - "Bias weight: {:.1}\t\t Optimal dose: {:.2}\t\t ln cost: {:.2}", + "Bias weight: {:.1}\t\t Optimal dose: {:?}\t\t ln cost: {:.2}", bias_weight, optimal.dose, optimal.objf ); } diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 7bfab9928..6f1bbd2b3 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -1,6 +1,6 @@ use anyhow::{Ok, Result}; use argmin::core::{CostFunction, Executor}; -use argmin::solver::brent::BrentOpt; +use argmin::solver::neldermead::NelderMead; use crate::prelude::*; use pharmsol::prelude::*; @@ -51,10 +51,9 @@ impl Default for DoseRange { #[derive(Debug, Clone)] pub struct BestDoseProblem { - pub past_data: Data, + pub past_data: Subject, pub theta: Theta, - pub target_concentration: f64, - pub target_time: f64, + pub target_data: Subject, pub eq: ODE, pub doserange: DoseRange, pub bias_weight: f64, @@ -66,9 +65,28 @@ impl BestDoseProblem { let min_dose = self.doserange.min; let max_dose = self.doserange.max; - // TODO: Use Nelder-Mead instead - let solver = BrentOpt::new(min_dose, max_dose); - + // Get the target subject + let target_subject = self.target_data.clone(); + + // Get all dose amounts as a vector + let all_doses: Vec = target_subject + .iter() + .flat_map(|occ| { + occ.iter().filter_map(|event| match event { + Event::Bolus(bolus) => Some(bolus.amount()), + Event::Infusion(infusion) => Some(infusion.amount()), + Event::Observation(_) => None, + }) + }) + .collect(); + + // Make initial simplex of the Nelder-Mead solver + let initial_guess = (min_dose + max_dose) / 2.0; + let initial_point = vec![initial_guess; all_doses.len()]; + let initial_simplex = create_initial_simplex(&initial_point); + + // Initialize the Nelder-Mead solver with correct generic types + let solver: NelderMead, f64> = NelderMead::new(initial_simplex); let problem = self; let opt = Executor::new(problem, solver) @@ -78,8 +96,8 @@ impl BestDoseProblem { let result = opt.state(); let optimaldose = BestDoseResult { - dose: result.param.unwrap(), - objf: result.cost, + dose: result.best_param.clone().unwrap(), + objf: result.best_cost, status: result.termination_status.to_string(), }; @@ -92,21 +110,63 @@ impl BestDoseProblem { } } +fn create_initial_simplex(initial_point: &[f64]) -> Vec> { + let num_dimensions = initial_point.len(); + let perturbation_percentage = 0.008; + + // Initialize a Vec to store the vertices of the simplex + let mut vertices = Vec::new(); + + // Add the initial point to the vertices + vertices.push(initial_point.to_vec()); + + // Calculate perturbation values for each component + for i in 0..num_dimensions { + let perturbation = if initial_point[i] == 0.0 { + 0.00025 // Special case for components equal to 0 + } else { + perturbation_percentage * initial_point[i] + }; + + let mut perturbed_point = initial_point.to_owned(); + perturbed_point[i] += perturbation; + vertices.push(perturbed_point); + } + + vertices +} + impl CostFunction for BestDoseProblem { - type Param = f64; + type Param = Vec; type Output = f64; fn cost(&self, param: &Self::Param) -> Result { - let dose = param.clone(); - let target_subject = Subject::builder("target") - .bolus(0.0, dose, 0) - .observation(self.target_time, self.target_concentration, 0) - .build(); + // Modify the target subject with the new dose(s) + let mut target_subject = self.target_data.clone(); + let mut dose_number = 0; + + for occ in target_subject.iter_mut() { + for event in occ.iter_mut() { + match event { + Event::Bolus(bolus) => { + // Set the dose to the new dose + bolus.set_amount(param[dose_number]); + dose_number += 1; + } + Event::Infusion(infusion) => { + // Set the dose to the new dose + infusion.set_amount(param[dose_number]); + dose_number += 1; + } + Event::Observation(_) => {} + } + } + } // Calculate psi, in order to determine the optimal weights of the support points in Theta for the target subject let psi = calculate_psi( &self.eq, - &self.past_data, + &Data::new(vec![target_subject.clone()]), &self.theta, &self.error_models, false, @@ -134,7 +194,9 @@ impl CostFunction for BestDoseProblem { let spp = row.iter().copied().collect::>(); // Calculate the target subject predictions - let pred = self.eq.simulate_subject(&target_subject, &spp, None)?; + let pred = self + .eq + .simulate_subject(&target_subject.clone(), &spp, None)?; // The (probability weighted) squared error of the predictions is added to the variance variance += pred.0.squared_error() * prob; @@ -144,7 +206,8 @@ impl CostFunction for BestDoseProblem { } // Bias is the squared difference between the target concentration and the mean of the predictions - let bias = (y_bar - self.target_concentration).powi(2); + // TODO: Implement proper bias calculation when target is defined + let bias = 0.0; // Calculate the objective function let cost = (1.0 - self.bias_weight) * variance + self.bias_weight * bias; @@ -157,7 +220,7 @@ impl CostFunction for BestDoseProblem { #[derive(Debug)] pub struct BestDoseResult { - pub dose: f64, + pub dose: Vec, pub objf: f64, pub status: String, } From 8f14170b5dbb3e45c0c270a395d3f353ebcc48b8 Mon Sep 17 00:00:00 2001 From: Markus <66058642+mhovd@users.noreply.github.com> Date: Wed, 17 Sep 2025 11:04:31 +0200 Subject: [PATCH 21/56] Update mod.rs --- src/bestdose/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 6f1bbd2b3..0025e3cdb 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -178,7 +178,7 @@ impl CostFunction for BestDoseProblem { // Normalize W to sum to 1 let w_sum: f64 = w.iter().sum(); - let w: Vec = w.iter().map(|&x| x / w_sum).collect(); + let w: Vec = w.iter().map(|x| x / w_sum).collect(); // Then calculate the bias From a00799e0631e6528aa73465eaa50b0c67c0a698e Mon Sep 17 00:00:00 2001 From: Markus <66058642+mhovd@users.noreply.github.com> Date: Wed, 17 Sep 2025 11:06:48 +0200 Subject: [PATCH 22/56] Rename --- examples/bestdose/main.rs | 2 +- src/bestdose/mod.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/bestdose/main.rs b/examples/bestdose/main.rs index 1f8d67d42..2a39c72d7 100644 --- a/examples/bestdose/main.rs +++ b/examples/bestdose/main.rs @@ -78,7 +78,7 @@ fn main() -> Result<()> { let problem = BestDoseProblem { past_data: past_data.clone(), theta, - target_data: target_data.clone(), + target: target_data.clone(), eq: eq.clone(), doserange: DoseRange::new(10.0, 1000.0), bias_weight: 0.0, diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 0025e3cdb..7694456e2 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -53,7 +53,7 @@ impl Default for DoseRange { pub struct BestDoseProblem { pub past_data: Subject, pub theta: Theta, - pub target_data: Subject, + pub target: Subject, pub eq: ODE, pub doserange: DoseRange, pub bias_weight: f64, @@ -66,7 +66,7 @@ impl BestDoseProblem { let max_dose = self.doserange.max; // Get the target subject - let target_subject = self.target_data.clone(); + let target_subject = self.target.clone(); // Get all dose amounts as a vector let all_doses: Vec = target_subject @@ -142,7 +142,7 @@ impl CostFunction for BestDoseProblem { fn cost(&self, param: &Self::Param) -> Result { // Modify the target subject with the new dose(s) - let mut target_subject = self.target_data.clone(); + let mut target_subject = self.target.clone(); let mut dose_number = 0; for occ in target_subject.iter_mut() { From 54e3fa4ae5df2b8695cb7552af7794a5e48f250f Mon Sep 17 00:00:00 2001 From: Markus <66058642+mhovd@users.noreply.github.com> Date: Wed, 17 Sep 2025 18:55:03 +0200 Subject: [PATCH 23/56] WIP --- src/bestdose/mod.rs | 19 ++++++++++++++++++- src/routines/output/posterior.rs | 10 +++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 7694456e2..025d59c8c 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -3,6 +3,8 @@ use argmin::core::{CostFunction, Executor}; use argmin::solver::neldermead::NelderMead; use crate::prelude::*; +use crate::routines::output::posterior::Posterior; +use crate::routines::output::predictions::NPPredictions; use pharmsol::prelude::*; use pharmsol::Equation; use pharmsol::Predictions; @@ -174,7 +176,22 @@ impl CostFunction for BestDoseProblem { )?; // Calculate the optimal weights - let (w, _) = burke(&psi)?; + let (w, likelihood) = burke(&psi)?; + tracing::debug!("Likelihood: {}", likelihood); + + // Calculate posterior + let posterior = Posterior::calculate(&psi, &w)?; + + // Calculate predictions + let predictions = NPPredictions::calculate( + &self.eq, + &Data::new(vec![target_subject.clone()]), + self.theta.clone(), + &w, + &posterior, + 0.0, + 0.0, + )?; // Normalize W to sum to 1 let w_sum: f64 = w.iter().sum(); diff --git a/src/routines/output/posterior.rs b/src/routines/output/posterior.rs index 5d49cc3b8..ac13a6126 100644 --- a/src/routines/output/posterior.rs +++ b/src/routines/output/posterior.rs @@ -27,20 +27,20 @@ impl Posterior { /// # Returns /// A Result containing the Posterior probabilities if successful, or an error if the /// dimensions do not match. - pub fn calculate(psi: &Psi, w: &Col) -> Result { - if psi.matrix().ncols() != w.nrows() { + pub fn calculate(psi: &Psi, w: &Weights) -> Result { + if psi.matrix().ncols() != w.weights().nrows() { bail!( "Number of rows in psi ({}) and number of weights ({}) do not match.", psi.matrix().nrows(), - w.nrows() + w.weights().nrows() ); } let psi_matrix = psi.matrix(); - let py = psi_matrix * w; + let py = psi_matrix * w.weights(); let posterior = Mat::from_fn(psi_matrix.nrows(), psi_matrix.ncols(), |i, j| { - psi_matrix.get(i, j) * w.get(j) / py.get(i) + psi_matrix.get(i, j) * w.weights().get(j) / py.get(i) }); Ok(posterior.into()) From 465bb7d39f0646eb6a3c8cdbf7510c3cc9086d87 Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 17 Sep 2025 18:59:01 +0200 Subject: [PATCH 24/56] Remove unnecessary normalization --- src/bestdose/mod.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 025d59c8c..223b94020 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -193,10 +193,6 @@ impl CostFunction for BestDoseProblem { 0.0, )?; - // Normalize W to sum to 1 - let w_sum: f64 = w.iter().sum(); - let w: Vec = w.iter().map(|x| x / w_sum).collect(); - // Then calculate the bias // Store the mean of the predictions From c01b08adb2376c4c860d72b06eeb345a00c199db Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 17 Sep 2025 19:41:25 +0200 Subject: [PATCH 25/56] Update cost function Warning: The new approach is more computationally expensive --- src/bestdose/mod.rs | 43 ++++++++++++++++--------------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 223b94020..9f21a5d72 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -147,8 +147,8 @@ impl CostFunction for BestDoseProblem { let mut target_subject = self.target.clone(); let mut dose_number = 0; - for occ in target_subject.iter_mut() { - for event in occ.iter_mut() { + for occasion in target_subject.iter_mut() { + for event in occasion.iter_mut() { match event { Event::Bolus(bolus) => { // Set the dose to the new dose @@ -193,41 +193,30 @@ impl CostFunction for BestDoseProblem { 0.0, )?; - // Then calculate the bias - - // Store the mean of the predictions - // TODO: This needs to handle more than one target - let mut y_bar = 0.0; - // Accumulator for the variance component let mut variance = 0.0; - // For each support point in theta, and the associated probability... - for (row, prob) in self.theta.matrix().row_iter().zip(w.iter()) { - let spp = row.iter().copied().collect::>(); + // Accumulator for the bias component + let mut bias = 0.0; - // Calculate the target subject predictions - let pred = self - .eq - .simulate_subject(&target_subject.clone(), &spp, None)?; - - // The (probability weighted) squared error of the predictions is added to the variance - variance += pred.0.squared_error() * prob; + // Iterate over the predictions + for pred in predictions.predictions() { + // The squared error of the posterior is added to the variance + if let Some(squared_error) = pred.obs().map(|obs| (obs - pred.post_mean()).powi(2)) { + variance += squared_error; + } - // At the same time, calculate the mean of the predictions - y_bar += pred.0.flat_predictions().first().unwrap() * prob; + // The squared error of the population prediction is added to the variance + if let Some(squared_error) = pred.obs().map(|obs| (obs - pred.pop_mean()).powi(2)) { + bias += squared_error; + } } - // Bias is the squared difference between the target concentration and the mean of the predictions - // TODO: Implement proper bias calculation when target is defined - let bias = 0.0; - // Calculate the objective function let cost = (1.0 - self.bias_weight) * variance + self.bias_weight * bias; - // TODO: Repeat with D_flat, and return the best - - Ok(cost.ln()) // Example cost function + // Use the natural logarithm of the cost as the objective function + Ok(cost.ln()) } } From 0d22dfd2ca6087d28d31f412c363fa0e827382da Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 17 Sep 2025 19:50:59 +0200 Subject: [PATCH 26/56] Clean up unused imports --- src/bestdose/mod.rs | 2 -- src/routines/output/posterior.rs | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 9f21a5d72..8dad8180c 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -6,8 +6,6 @@ use crate::prelude::*; use crate::routines::output::posterior::Posterior; use crate::routines::output::predictions::NPPredictions; use pharmsol::prelude::*; -use pharmsol::Equation; -use pharmsol::Predictions; use pharmsol::{Data, ODE}; use crate::algorithms::npag::burke; diff --git a/src/routines/output/posterior.rs b/src/routines/output/posterior.rs index ac13a6126..008ce16c1 100644 --- a/src/routines/output/posterior.rs +++ b/src/routines/output/posterior.rs @@ -1,5 +1,5 @@ pub use anyhow::{bail, Result}; -use faer::{Col, Mat}; +use faer::Mat; use serde::{Deserialize, Serialize}; use crate::structs::{psi::Psi, weights::Weights}; From 08a7240cefaec03a2af30c963da7e0bc68b2f572 Mon Sep 17 00:00:00 2001 From: Markus Date: Tue, 23 Sep 2025 18:16:43 +0200 Subject: [PATCH 27/56] Update main.rs --- examples/bestdose/main.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/bestdose/main.rs b/examples/bestdose/main.rs index 2a39c72d7..283dd73d7 100644 --- a/examples/bestdose/main.rs +++ b/examples/bestdose/main.rs @@ -62,18 +62,18 @@ fn main() -> Result<()> { let past_data = subject.clone(); - let theta = parse_prior( - &"examples/bimodal_ke/output/theta.csv".to_string(), - &settings, - ) - .unwrap(); - // Create target data (future dosing scenario we want to optimize) let target_data = Subject::builder("Target Patient") .bolus(0.0, 100.0, 0) // This dose will be optimized .observation(5.0, conc(5.0), 0) // Target observation at t=5.0 .build(); + let theta = parse_prior( + &"examples/bimodal_ke/output/theta.csv".to_string(), + &settings, + ) + .unwrap(); + // Example usage let problem = BestDoseProblem { past_data: past_data.clone(), From 1ed129f38815d3767fe12d33b2346e683bc3e621 Mon Sep 17 00:00:00 2001 From: Markus Date: Tue, 23 Sep 2025 19:10:50 +0200 Subject: [PATCH 28/56] WIP --- examples/bestdose/main.rs | 36 ++++++++++++++++++++----- src/bestdose/mod.rs | 57 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/examples/bestdose/main.rs b/examples/bestdose/main.rs index 283dd73d7..9e498e7a6 100644 --- a/examples/bestdose/main.rs +++ b/examples/bestdose/main.rs @@ -62,12 +62,22 @@ fn main() -> Result<()> { let past_data = subject.clone(); - // Create target data (future dosing scenario we want to optimize) - let target_data = Subject::builder("Target Patient") - .bolus(0.0, 100.0, 0) // This dose will be optimized - .observation(5.0, conc(5.0), 0) // Target observation at t=5.0 + let target_data = Subject::builder("Thomas Edison") + .bolus(0.0, 100.0, 0) + .observation(2.0, conc(2.0), 0) + .observation(4.0, conc(4.0), 0) + .observation(6.0, conc(6.0), 0) + .observation(12.0, conc(12.0), 0) .build(); + // Create target data (future dosing scenario we want to optimize) + // let target_data = Subject::builder("Target Patient") + // .bolus(0.0, 100.0, 0) // This dose will be optimized + // .observation(5.0, 5.0, 0) // Target observation at t=5.0 + // .bolus(6.0, 100.0, 0) // This dose will be optimized + // .observation(5.0, 10.0, 0) // Target observation at t=10.0 + // .build(); + let theta = parse_prior( &"examples/bimodal_ke/output/theta.csv".to_string(), &settings, @@ -80,28 +90,40 @@ fn main() -> Result<()> { theta, target: target_data.clone(), eq: eq.clone(), - doserange: DoseRange::new(10.0, 1000.0), + doserange: DoseRange::new(0.0, 500.0), bias_weight: 0.0, error_models: ems.clone(), }; println!("Optimizing dose..."); - let bias_weights = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]; + let bias_weights = vec![0.5]; + //let bias_weights = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]; let mut results = Vec::new(); for bias_weight in &bias_weights { + println!("Running optimization with bias weight: {}", bias_weight); let optimal = problem.clone().bias(*bias_weight).optimize()?; results.push((bias_weight, optimal)); } // Print results - for (bias_weight, optimal) in results { + for (bias_weight, optimal) in &results { println!( "Bias weight: {:.1}\t\t Optimal dose: {:?}\t\t ln cost: {:.2}", bias_weight, optimal.dose, optimal.objf ); } + // Print concentration-time predictions for the optimal dose + let optimal = &results.last().unwrap().1; + println!("\nConcentration-time predictions for optimal dose:"); + for pred in optimal.preds.predictions().into_iter() { + println!( + "Time: {:.2} h, Observed: {:.2}, (Pop Mean: {:.4}, Pop Median: {:.4}, Post Mean: {:.4}, Post Median: {:.4})", + pred.time(), pred.obs().unwrap_or(0.0), pred.pop_mean(), pred.pop_median(), pred.post_mean(), pred.post_median() + ); + } + Ok(()) } diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 8dad8180c..e1080e473 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -85,20 +85,74 @@ impl BestDoseProblem { let initial_point = vec![initial_guess; all_doses.len()]; let initial_simplex = create_initial_simplex(&initial_point); + println!("Initial simplex: {:?}", initial_simplex); + // Initialize the Nelder-Mead solver with correct generic types let solver: NelderMead, f64> = NelderMead::new(initial_simplex); let problem = self; - let opt = Executor::new(problem, solver) + let opt = Executor::new(problem.clone(), solver) .configure(|state| state.max_iters(1000)) .run()?; let result = opt.state(); + let preds = { + // Modify the target subject with the new dose(s) + let mut target_subject = target_subject.clone(); + let mut dose_number = 0; + + for occasion in target_subject.iter_mut() { + for event in occasion.iter_mut() { + match event { + Event::Bolus(bolus) => { + // Set the dose to the new dose + bolus.set_amount(result.best_param.clone().unwrap()[dose_number]); + dose_number += 1; + } + Event::Infusion(infusion) => { + // Set the dose to the new dose + infusion.set_amount(result.best_param.clone().unwrap()[dose_number]); + dose_number += 1; + } + Event::Observation(_) => {} + } + } + } + + // Calculate psi, in order to determine the optimal weights of the support points in Theta for the target subject + let psi = calculate_psi( + &problem.eq, + &Data::new(vec![target_subject.clone()]), + &problem.theta.clone(), + &problem.error_models, + false, + true, + )?; + + // Calculate the optimal weights + let (w, _likelihood) = burke(&psi)?; + + // Calculate posterior + let posterior = Posterior::calculate(&psi, &w)?; + + // Calculate predictions + NPPredictions::calculate( + &problem.eq, + &Data::new(vec![target_subject.clone()]), + problem.theta.clone(), + &w, + &posterior, + 0.0, + 0.0, + )? + }; + let optimaldose = BestDoseResult { dose: result.best_param.clone().unwrap(), objf: result.best_cost, status: result.termination_status.to_string(), + preds, }; Ok(optimaldose) @@ -223,4 +277,5 @@ pub struct BestDoseResult { pub dose: Vec, pub objf: f64, pub status: String, + pub preds: NPPredictions, } From 70bf0bddaaeb6811862befe6bbdb8b2b041ca475 Mon Sep 17 00:00:00 2001 From: Markus Date: Tue, 23 Sep 2025 20:30:26 +0200 Subject: [PATCH 29/56] WIP PS: Check calculation of predictions - they have to be off --- examples/bestdose/main.rs | 14 +++++++------- src/bestdose/mod.rs | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/bestdose/main.rs b/examples/bestdose/main.rs index 9e498e7a6..0e33a6e75 100644 --- a/examples/bestdose/main.rs +++ b/examples/bestdose/main.rs @@ -28,7 +28,7 @@ fn main() -> Result<()> { let ems = ErrorModels::new().add( 0, - ErrorModel::additive(ErrorPoly::new(0.0, 0.1, 0.0, 0.0), 0.0, None), + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0, None), )?; // Make settings @@ -47,13 +47,13 @@ fn main() -> Result<()> { fn conc(t: f64) -> f64 { let dose = 150.0; // Example dose let ke = 0.5; // Elimination rate constant - let v = 50.0; // Volume of distribution + let v = 100.0; // Volume of distribution (dose * (-ke * t).exp()) / v } // Some observed data let subject = Subject::builder("Nikola Tesla") - .bolus(0.0, 100.0, 0) + .bolus(0.0, 150.0, 0) .observation(2.0, conc(2.0), 0) .observation(4.0, conc(4.0), 0) .observation(6.0, conc(6.0), 0) @@ -63,7 +63,7 @@ fn main() -> Result<()> { let past_data = subject.clone(); let target_data = Subject::builder("Thomas Edison") - .bolus(0.0, 100.0, 0) + .bolus(0.0, 999.0, 0) .observation(2.0, conc(2.0), 0) .observation(4.0, conc(4.0), 0) .observation(6.0, conc(6.0), 0) @@ -90,15 +90,15 @@ fn main() -> Result<()> { theta, target: target_data.clone(), eq: eq.clone(), - doserange: DoseRange::new(0.0, 500.0), + doserange: DoseRange::new(0.0, 300.0), bias_weight: 0.0, error_models: ems.clone(), }; println!("Optimizing dose..."); - let bias_weights = vec![0.5]; - //let bias_weights = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]; + //let bias_weights = vec![0.5]; + let bias_weights = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]; let mut results = Vec::new(); for bias_weight in &bias_weights { diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index e1080e473..668e8f45a 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -228,8 +228,7 @@ impl CostFunction for BestDoseProblem { )?; // Calculate the optimal weights - let (w, likelihood) = burke(&psi)?; - tracing::debug!("Likelihood: {}", likelihood); + let (w, _likelihood) = burke(&psi)?; // Calculate posterior let posterior = Posterior::calculate(&psi, &w)?; @@ -258,13 +257,14 @@ impl CostFunction for BestDoseProblem { variance += squared_error; } - // The squared error of the population prediction is added to the variance + // The squared error of the population prediction is added to the bias if let Some(squared_error) = pred.obs().map(|obs| (obs - pred.pop_mean()).powi(2)) { bias += squared_error; } } // Calculate the objective function + let cost = (1.0 - self.bias_weight) * variance + self.bias_weight * bias; // Use the natural logarithm of the cost as the objective function From 72a978e6bbca6d2bfdca055e5aac0f574720d975 Mon Sep 17 00:00:00 2001 From: Markus Date: Tue, 23 Sep 2025 20:40:28 +0200 Subject: [PATCH 30/56] Update mod.rs --- src/bestdose/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 668e8f45a..8a382aeaa 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -15,6 +15,8 @@ use crate::structs::theta::Theta; // TODO: AUC as a target // TODO: Add support for loading and maintenance doses +// TODO: Make sure to use the population probabilities from the "prior" Theta!!! + pub enum Target { Concentration(f64), AUC(f64), From 5e792fafcb65bb3d081341df6f2ccd4b5b876f5a Mon Sep 17 00:00:00 2001 From: Julian Otalvaro Date: Mon, 29 Sep 2025 15:25:42 +0100 Subject: [PATCH 31/56] proposal for the cost calculation following the fortran code (#188) --- src/bestdose/mod.rs | 101 ++++++++++++++++++++++++++++++-------------- 1 file changed, 70 insertions(+), 31 deletions(-) diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 8a382aeaa..b53fab4f5 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -205,12 +205,10 @@ impl CostFunction for BestDoseProblem { for event in occasion.iter_mut() { match event { Event::Bolus(bolus) => { - // Set the dose to the new dose bolus.set_amount(param[dose_number]); dose_number += 1; } Event::Infusion(infusion) => { - // Set the dose to the new dose infusion.set_amount(param[dose_number]); dose_number += 1; } @@ -219,7 +217,7 @@ impl CostFunction for BestDoseProblem { } } - // Calculate psi, in order to determine the optimal weights of the support points in Theta for the target subject + // Calculate psi for the target subject let psi = calculate_psi( &self.eq, &Data::new(vec![target_subject.clone()]), @@ -230,47 +228,88 @@ impl CostFunction for BestDoseProblem { )?; // Calculate the optimal weights - let (w, _likelihood) = burke(&psi)?; + let (w_raw, _likelihood) = burke(&psi)?; + + // Basic checks + if w_raw.len() != self.theta.matrix().nrows() { + return Err(anyhow::anyhow!( + "weight length ({}) does not match theta rows ({})", + w_raw.len(), + self.theta.matrix().nrows() + )); + } - // Calculate posterior - let posterior = Posterior::calculate(&psi, &w)?; + // Normalize weights safely + let w_sum: f64 = w_raw.iter().sum(); + if w_sum == 0.0 || !w_sum.is_finite() { + return Err(anyhow::anyhow!( + "posterior weights sum to zero or non-finite" + )); + } + let weights: Vec = w_raw.iter().map(|x| x / w_sum).collect(); - // Calculate predictions - let predictions = NPPredictions::calculate( - &self.eq, - &Data::new(vec![target_subject.clone()]), - self.theta.clone(), - &w, - &posterior, - 0.0, - 0.0, - )?; + // Build observation vector (must be in the same order as flat_predictions()) - // Accumulator for the variance component - let mut variance = 0.0; + let obs_vec: Vec = target_subject + .occasions() + .iter() + .flat_map(|occ| occ.events()) + .filter_map(|event| match event { + Event::Observation(obs) => obs.value(), + _ => None, + }) + .collect(); - // Accumulator for the bias component - let mut bias = 0.0; + let n_obs = obs_vec.len(); + if n_obs == 0 { + return Err(anyhow::anyhow!("no observations found in target subject")); + } + + // Accumulators + let mut variance = 0.0_f64; // expected squared error V(U) + let mut y_bar = vec![0.0_f64; n_obs]; // weighted mean prediction across theta + + // Iterate over each support point in theta with its normalized probability + for (row, &prob) in self.theta.matrix().row_iter().zip(weights.iter()) { + let spp = row.iter().copied().collect::>(); + + // Simulate the target subject with this support point + let pred = self.eq.simulate_subject(&target_subject, &spp, None)?; - // Iterate over the predictions - for pred in predictions.predictions() { - // The squared error of the posterior is added to the variance - if let Some(squared_error) = pred.obs().map(|obs| (obs - pred.post_mean()).powi(2)) { - variance += squared_error; + // Get per-observation predictions in the same order as obs_vec + let preds_i: Vec = pred.0.flat_predictions(); + + if preds_i.len() != n_obs { + return Err(anyhow::anyhow!( + "prediction length ({}) != observation length ({})", + preds_i.len(), + n_obs + )); } - // The squared error of the population prediction is added to the bias - if let Some(squared_error) = pred.obs().map(|obs| (obs - pred.pop_mean()).powi(2)) { - bias += squared_error; + // per-support sum of squared errors across observations + let mut sumsq_i = 0.0_f64; + for (j, &obs_val) in obs_vec.iter().enumerate() { + let pj = preds_i[j]; + let se = (obs_val - pj).powi(2); + sumsq_i += se; + y_bar[j] += prob * pj; // accumulate weighted mean prediction } + + variance += prob * sumsq_i; // expected contribution } - // Calculate the objective function + // compute bias: squared difference between weighted mean prediction and observations (sum over all obs) + let mut bias = 0.0_f64; + for (j, &obs_val) in obs_vec.iter().enumerate() { + bias += (obs_val - y_bar[j]).powi(2); + } + // Final cost: let cost = (1.0 - self.bias_weight) * variance + self.bias_weight * bias; - // Use the natural logarithm of the cost as the objective function - Ok(cost.ln()) + // Return raw cost to stay faithful to Fortran semantics. + Ok(cost) } } From 969901ecb5155ff9dc03d4201db4909f8536e919 Mon Sep 17 00:00:00 2001 From: Markus Date: Mon, 29 Sep 2025 16:52:07 +0200 Subject: [PATCH 32/56] Updates the cost function Also provides a new method on parse_prior that reads the probabilities --- examples/bestdose/main.rs | 5 +- src/bestdose/mod.rs | 42 ++++++-------- src/routines/initialization/mod.rs | 92 ++++++++++++++++++++++++++---- src/structs/weights.rs | 12 ++++ 4 files changed, 112 insertions(+), 39 deletions(-) diff --git a/examples/bestdose/main.rs b/examples/bestdose/main.rs index 0e33a6e75..ef7bc0e53 100644 --- a/examples/bestdose/main.rs +++ b/examples/bestdose/main.rs @@ -78,7 +78,7 @@ fn main() -> Result<()> { // .observation(5.0, 10.0, 0) // Target observation at t=10.0 // .build(); - let theta = parse_prior( + let (theta, prior) = parse_prior( &"examples/bimodal_ke/output/theta.csv".to_string(), &settings, ) @@ -88,6 +88,7 @@ fn main() -> Result<()> { let problem = BestDoseProblem { past_data: past_data.clone(), theta, + prior: prior.unwrap(), target: target_data.clone(), eq: eq.clone(), doserange: DoseRange::new(0.0, 300.0), @@ -110,7 +111,7 @@ fn main() -> Result<()> { // Print results for (bias_weight, optimal) in &results { println!( - "Bias weight: {:.1}\t\t Optimal dose: {:?}\t\t ln cost: {:.2}", + "Bias weight: {:.1}\t\t Optimal dose: {:?}\t\tCost: {:.2}", bias_weight, optimal.dose, optimal.objf ); } diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index b53fab4f5..467fdf0b0 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -5,6 +5,7 @@ use argmin::solver::neldermead::NelderMead; use crate::prelude::*; use crate::routines::output::posterior::Posterior; use crate::routines::output::predictions::NPPredictions; +use crate::structs::weights::Weights; use pharmsol::prelude::*; use pharmsol::{Data, ODE}; @@ -55,6 +56,7 @@ impl Default for DoseRange { pub struct BestDoseProblem { pub past_data: Subject, pub theta: Theta, + pub prior: Weights, pub target: Subject, pub eq: ODE, pub doserange: DoseRange, @@ -228,25 +230,7 @@ impl CostFunction for BestDoseProblem { )?; // Calculate the optimal weights - let (w_raw, _likelihood) = burke(&psi)?; - - // Basic checks - if w_raw.len() != self.theta.matrix().nrows() { - return Err(anyhow::anyhow!( - "weight length ({}) does not match theta rows ({})", - w_raw.len(), - self.theta.matrix().nrows() - )); - } - - // Normalize weights safely - let w_sum: f64 = w_raw.iter().sum(); - if w_sum == 0.0 || !w_sum.is_finite() { - return Err(anyhow::anyhow!( - "posterior weights sum to zero or non-finite" - )); - } - let weights: Vec = w_raw.iter().map(|x| x / w_sum).collect(); + let (posterior, _likelihood) = burke(&psi)?; // Build observation vector (must be in the same order as flat_predictions()) @@ -266,11 +250,17 @@ impl CostFunction for BestDoseProblem { } // Accumulators - let mut variance = 0.0_f64; // expected squared error V(U) - let mut y_bar = vec![0.0_f64; n_obs]; // weighted mean prediction across theta + let mut variance = 0.0_f64; // Expected squared error V(U) + let mut y_bar = vec![0.0_f64; n_obs]; // Container for the population mean predictions // Iterate over each support point in theta with its normalized probability - for (row, &prob) in self.theta.matrix().row_iter().zip(weights.iter()) { + for ((row, post_prob), pop_prob) in self + .theta + .matrix() + .row_iter() + .zip(posterior.iter()) + .zip(self.prior.iter()) + { let spp = row.iter().copied().collect::>(); // Simulate the target subject with this support point @@ -287,19 +277,19 @@ impl CostFunction for BestDoseProblem { )); } - // per-support sum of squared errors across observations + // For each support point, calculate the squared prediction error let mut sumsq_i = 0.0_f64; for (j, &obs_val) in obs_vec.iter().enumerate() { let pj = preds_i[j]; let se = (obs_val - pj).powi(2); sumsq_i += se; - y_bar[j] += prob * pj; // accumulate weighted mean prediction + y_bar[j] += pop_prob * pj; // Calculate the weighted mean prediction using the population probabilities } - variance += prob * sumsq_i; // expected contribution + variance += post_prob * sumsq_i; // expected contribution } - // compute bias: squared difference between weighted mean prediction and observations (sum over all obs) + // Calculate bias, here defined as the squared difference between the observation and the population mean prediction let mut bias = 0.0_f64; for (j, &obs_val) in obs_vec.iter().enumerate() { bias += (obs_val - y_bar[j]).powi(2); diff --git a/src/routines/initialization/mod.rs b/src/routines/initialization/mod.rs index 6c87fb35b..cff7e6046 100644 --- a/src/routines/initialization/mod.rs +++ b/src/routines/initialization/mod.rs @@ -1,6 +1,6 @@ use std::fs::File; -use crate::structs::theta::Theta; +use crate::structs::{theta::Theta, weights::Weights}; use anyhow::{bail, Context, Result}; use faer::Mat; use serde::{Deserialize, Serialize}; @@ -94,7 +94,7 @@ pub fn sample_space(settings: &Settings) -> Result { let prior = match settings.prior() { Prior::Sobol(points, seed) => sobol::generate(settings.parameters(), *points, *seed)?, Prior::Latin(points, seed) => latin::generate(settings.parameters(), *points, *seed)?, - Prior::File(ref path) => parse_prior(path, settings)?, + Prior::File(ref path) => parse_prior(path, settings)?.0, Prior::Theta(ref theta) => { // If a custom prior is provided, return it directly return Ok(theta.clone()); @@ -104,7 +104,7 @@ pub fn sample_space(settings: &Settings) -> Result { } /// This function reads the prior distribution from a file -pub fn parse_prior(path: &String, settings: &Settings) -> Result { +pub fn parse_prior(path: &String, settings: &Settings) -> Result<(Theta, Option)> { tracing::info!("Reading prior from {}", path); let file = File::open(path).context(format!("Unable to open the prior file '{}'", path))?; let mut reader = csv::ReaderBuilder::new() @@ -118,8 +118,11 @@ pub fn parse_prior(path: &String, settings: &Settings) -> Result { .map(|s| s.trim().to_owned()) .collect(); - // Remove "prob" column if present - if let Some(index) = parameter_names.iter().position(|name| name == "prob") { + // Check if "prob" column is present and get its index + let prob_index = parameter_names.iter().position(|name| name == "prob"); + + // Remove "prob" column from parameter_names if present + if let Some(index) = prob_index { parameter_names.remove(index); } @@ -130,7 +133,17 @@ pub fn parse_prior(path: &String, settings: &Settings) -> Result { for random_name in &random_names { match parameter_names.iter().position(|name| name == random_name) { Some(index) => { - reordered_indices.push(index); + // Adjust index if prob column was present and came before this parameter + let adjusted_index = if let Some(prob_idx) = prob_index { + if index >= prob_idx { + index + 1 // Add 1 back since we removed prob from parameter_names + } else { + index + } + } else { + index + }; + reordered_indices.push(adjusted_index); } None => { bail!("Parameter {} is not present in the CSV file.", random_name); @@ -147,15 +160,25 @@ pub fn parse_prior(path: &String, settings: &Settings) -> Result { ); } - // Read parameter values row by row, keeping only those associated with the reordered parameters + // Read parameter values and probabilities row by row let mut theta_values = Vec::new(); + let mut prob_values = Vec::new(); + for result in reader.records() { let record = result.unwrap(); + + // Extract parameter values using reordered indices let values: Vec = reordered_indices .iter() .map(|&i| record[i].parse::().unwrap()) .collect(); theta_values.push(values); + + // Extract probability value if prob column exists + if let Some(prob_idx) = prob_index { + let prob_value: f64 = record[prob_idx].parse::().unwrap(); + prob_values.push(prob_value); + } } let n_points = theta_values.len(); @@ -169,7 +192,14 @@ pub fn parse_prior(path: &String, settings: &Settings) -> Result { let theta = Theta::from_parts(theta_matrix, settings.parameters().clone())?; - Ok(theta) + // Create weights if prob column was present + let weights = if !prob_values.is_empty() { + Some(Weights::from_vec(prob_values)) + } else { + None + }; + + Ok((theta, weights)) } #[cfg(test)] @@ -337,9 +367,10 @@ mod tests { let result = parse_prior(&temp_path, &settings); assert!(result.is_ok()); - let theta = result.unwrap(); + let (theta, weights) = result.unwrap(); assert_eq!(theta.nspp(), 3); assert_eq!(theta.matrix().ncols(), 2); + assert!(weights.is_none()); // No prob column, so no weights cleanup_temp_file(&temp_path); } @@ -354,10 +385,18 @@ mod tests { let result = parse_prior(&temp_path, &settings); assert!(result.is_ok()); - let theta = result.unwrap(); + let (theta, weights) = result.unwrap(); assert_eq!(theta.nspp(), 3); assert_eq!(theta.matrix().ncols(), 2); + // Verify that weights were read correctly + assert!(weights.is_some()); + let weights = weights.unwrap(); + assert_eq!(weights.len(), 3); + assert!((weights[0] - 0.5).abs() < 1e-10); + assert!((weights[1] - 0.3).abs() < 1e-10); + assert!((weights[2] - 0.2).abs() < 1e-10); + cleanup_temp_file(&temp_path); } @@ -418,9 +457,10 @@ mod tests { let result = parse_prior(&temp_path, &settings); assert!(result.is_ok()); - let theta = result.unwrap(); + let (theta, weights) = result.unwrap(); assert_eq!(theta.nspp(), 3); assert_eq!(theta.matrix().ncols(), 2); + assert!(weights.is_none()); // No prob column, so no weights // Verify the values are correctly reordered (ke should be first, v second) let matrix = theta.matrix(); @@ -430,6 +470,36 @@ mod tests { cleanup_temp_file(&temp_path); } + #[test] + fn test_parse_prior_with_prob_column_reordered() { + let csv_content = "prob,v,ke\n0.5,10.0,0.1\n0.3,15.0,0.2\n0.2,20.0,0.3\n"; + let temp_path = create_temp_csv_file(csv_content); + + let settings = create_test_settings(); + + let result = parse_prior(&temp_path, &settings); + assert!(result.is_ok()); + + let (theta, weights) = result.unwrap(); + assert_eq!(theta.nspp(), 3); + assert_eq!(theta.matrix().ncols(), 2); + + // Verify that weights were read correctly + assert!(weights.is_some()); + let weights = weights.unwrap(); + assert_eq!(weights.len(), 3); + assert!((weights[0] - 0.5).abs() < 1e-10); + assert!((weights[1] - 0.3).abs() < 1e-10); + assert!((weights[2] - 0.2).abs() < 1e-10); + + // Verify the parameter values are correctly reordered (ke should be first, v second) + let matrix = theta.matrix(); + assert!((matrix[(0, 0)] - 0.1).abs() < 1e-10); // First row, ke value + assert!((matrix[(0, 1)] - 10.0).abs() < 1e-10); // First row, v value + + cleanup_temp_file(&temp_path); + } + #[test] fn test_sample_space_file_based() { let csv_content = "ke,v\n0.1,10.0\n0.2,15.0\n0.3,20.0\n"; diff --git a/src/structs/weights.rs b/src/structs/weights.rs index ce3c81082..e84974443 100644 --- a/src/structs/weights.rs +++ b/src/structs/weights.rs @@ -31,6 +31,18 @@ impl Weights { } } + /// Create a new [Weights] instance with uniform weights. + /// If `n` is 0, returns an empty [Weights] instance. + pub fn uniform(n: usize) -> Self { + if n == 0 { + return Self::default(); + } + let uniform_weight = 1.0 / n as f64; + Self { + weights: Col::from_fn(n, |_| uniform_weight), + } + } + /// Get a reference to the weights. pub fn weights(&self) -> &Col { &self.weights From 6aa5b89f4f928fb1894471415e5887debc0795ef Mon Sep 17 00:00:00 2001 From: Markus Date: Mon, 29 Sep 2025 17:10:10 +0200 Subject: [PATCH 33/56] Improve the example with multiple doses --- examples/bestdose/main.rs | 47 +++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/examples/bestdose/main.rs b/examples/bestdose/main.rs index ef7bc0e53..2d8ee6fc6 100644 --- a/examples/bestdose/main.rs +++ b/examples/bestdose/main.rs @@ -44,40 +44,37 @@ fn main() -> Result<()> { // Ke = 0.5, V = 50 // C(t) = Dose * exp(-ke * t) / V - fn conc(t: f64) -> f64 { - let dose = 150.0; // Example dose - let ke = 0.5; // Elimination rate constant - let v = 100.0; // Volume of distribution + fn conc(t: f64, dose: f64) -> f64 { + let ke = 0.95; // Elimination rate constant + let v = 210.0; // Volume of distribution (dose * (-ke * t).exp()) / v } // Some observed data let subject = Subject::builder("Nikola Tesla") .bolus(0.0, 150.0, 0) - .observation(2.0, conc(2.0), 0) - .observation(4.0, conc(4.0), 0) - .observation(6.0, conc(6.0), 0) - .observation(12.0, conc(12.0), 0) + .observation(2.0, conc(2.0, 150.0), 0) + .observation(4.0, conc(4.0, 150.0), 0) + .observation(6.0, conc(6.0, 150.0), 0) + .bolus(12.0, 75.0, 0) + .observation(14.0, conc(2.0, 75.0) + conc(14.0, 150.0), 0) + .observation(16.0, conc(4.0, 75.0) + conc(16.0, 150.0), 0) + .observation(18.0, conc(6.0, 75.0) + conc(18.0, 150.0), 0) .build(); let past_data = subject.clone(); let target_data = Subject::builder("Thomas Edison") - .bolus(0.0, 999.0, 0) - .observation(2.0, conc(2.0), 0) - .observation(4.0, conc(4.0), 0) - .observation(6.0, conc(6.0), 0) - .observation(12.0, conc(12.0), 0) + .bolus(0.0, 0.0, 0) + .observation(2.0, conc(2.0, 150.0), 0) + .observation(4.0, conc(4.0, 150.0), 0) + .observation(6.0, conc(6.0, 150.0), 0) + .bolus(12.0, 0.0, 0) + .observation(14.0, conc(2.0, 75.0) + conc(14.0, 150.0), 0) + .observation(16.0, conc(4.0, 75.0) + conc(16.0, 150.0), 0) + .observation(18.0, conc(6.0, 75.0) + conc(18.0, 150.0), 0) .build(); - // Create target data (future dosing scenario we want to optimize) - // let target_data = Subject::builder("Target Patient") - // .bolus(0.0, 100.0, 0) // This dose will be optimized - // .observation(5.0, 5.0, 0) // Target observation at t=5.0 - // .bolus(6.0, 100.0, 0) // This dose will be optimized - // .observation(5.0, 10.0, 0) // Target observation at t=10.0 - // .build(); - let (theta, prior) = parse_prior( &"examples/bimodal_ke/output/theta.csv".to_string(), &settings, @@ -98,7 +95,6 @@ fn main() -> Result<()> { println!("Optimizing dose..."); - //let bias_weights = vec![0.5]; let bias_weights = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]; let mut results = Vec::new(); @@ -111,8 +107,11 @@ fn main() -> Result<()> { // Print results for (bias_weight, optimal) in &results { println!( - "Bias weight: {:.1}\t\t Optimal dose: {:?}\t\tCost: {:.2}", - bias_weight, optimal.dose, optimal.objf + "Bias weight: {:.1}\t\t Optimal dose: {:?}\t\tCost: {:.6}\t\tln Cost: {:.4}", + bias_weight, + optimal.dose, + optimal.objf, + optimal.objf.ln() ); } From cfa938b7c491f2f6817df9e50521b88de6a7dd52 Mon Sep 17 00:00:00 2001 From: Markus Date: Mon, 29 Sep 2025 17:23:32 +0200 Subject: [PATCH 34/56] Update main.rs --- examples/bestdose/main.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/bestdose/main.rs b/examples/bestdose/main.rs index 2d8ee6fc6..e4902575e 100644 --- a/examples/bestdose/main.rs +++ b/examples/bestdose/main.rs @@ -45,8 +45,8 @@ fn main() -> Result<()> { // C(t) = Dose * exp(-ke * t) / V fn conc(t: f64, dose: f64) -> f64 { - let ke = 0.95; // Elimination rate constant - let v = 210.0; // Volume of distribution + let ke = 0.3406021231412888; // Elimination rate constant + let v = 99.99475717544556; // Volume of distribution (dose * (-ke * t).exp()) / v } @@ -107,7 +107,7 @@ fn main() -> Result<()> { // Print results for (bias_weight, optimal) in &results { println!( - "Bias weight: {:.1}\t\t Optimal dose: {:?}\t\tCost: {:.6}\t\tln Cost: {:.4}", + "Bias weight: {:.2}\t\t Optimal dose: {:?}\t\tCost: {:.6}\t\tln Cost: {:.4}", bias_weight, optimal.dose, optimal.objf, From 395a22fc034d5f9080b503f8ed2f759cc7f03670 Mon Sep 17 00:00:00 2001 From: Julian Otalvaro Date: Tue, 21 Oct 2025 18:12:53 +0100 Subject: [PATCH 35/56] Bestdoserx (#206) * working on it * seems to be working * support for optional past data and to set the number of cycles of the NPAG run * Update src/bestdose/mod.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * improve performance * bestdose_auc * dual optimization * refactor into multiple modules * remove old single module bestdose * change hyper parameters to match fortran code * cleanning up comments * documentation * remove unused function * update auc example * support for current_time and past+future concatenation, refactor + docs * Update mod.rs --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Markus --- .gitignore | 2 + Cargo.toml | 2 +- examples/{bestdose/main.rs => bestdose.rs} | 40 +- examples/bestdose_auc.rs | 127 +++ src/bestdose/cost.rs | 280 ++++++ src/bestdose/mod.rs | 1035 ++++++++++++++------ src/bestdose/optimization.rs | 298 ++++++ src/bestdose/posterior.rs | 328 +++++++ src/bestdose/predictions.rs | 246 +++++ src/bestdose/types.rs | 359 +++++++ src/routines/condensation/mod.rs | 106 ++ 11 files changed, 2530 insertions(+), 293 deletions(-) rename examples/{bestdose/main.rs => bestdose.rs} (76%) create mode 100644 examples/bestdose_auc.rs create mode 100644 src/bestdose/cost.rs create mode 100644 src/bestdose/optimization.rs create mode 100644 src/bestdose/posterior.rs create mode 100644 src/bestdose/predictions.rs create mode 100644 src/bestdose/types.rs diff --git a/.gitignore b/.gitignore index ab5741184..a91e9a07b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ op.csv covs.csv error_theta.csv lcov.info +Fortran/ +algorithms/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 9c7a2f01f..6659ad59e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ faer = "0.23.1" faer-ext = { version = "0.7.1", features = ["nalgebra", "ndarray"] } pharmsol = "=0.18.0" rand = "0.9.0" -anyhow = "1.0.97" +anyhow = "1.0.100" rayon = "1.10.0" argmin-math = "0.5.0" diff --git a/examples/bestdose/main.rs b/examples/bestdose.rs similarity index 76% rename from examples/bestdose/main.rs rename to examples/bestdose.rs index e4902575e..dd90380df 100644 --- a/examples/bestdose/main.rs +++ b/examples/bestdose.rs @@ -1,5 +1,6 @@ use anyhow::Result; -use pmcore::bestdose::{BestDoseProblem, DoseRange}; +use pmcore::bestdose; // bestdose new + // use pmcore::bestdose::bestdose_old as bestdose; // bestdose old use pmcore::prelude::*; use pmcore::routines::initialization::parse_prior; @@ -81,17 +82,25 @@ fn main() -> Result<()> { ) .unwrap(); - // Example usage - let problem = BestDoseProblem { - past_data: past_data.clone(), - theta, - prior: prior.unwrap(), - target: target_data.clone(), - eq: eq.clone(), - doserange: DoseRange::new(0.0, 300.0), - bias_weight: 0.0, - error_models: ems.clone(), - }; + // Example usage - using new() constructor which calculates NPAGFULL11 posterior + // max_cycles controls NPAGFULL refinement: + // 0 = NPAGFULL11 only (fast but less accurate) + // 100 = moderate refinement + // 500 = full refinement (Fortran default, slow but most accurate) + let problem = bestdose::BestDoseProblem::new( + &theta, + &prior.unwrap(), + Some(past_data.clone()), // Optional: past data for Bayesian updating + target_data.clone(), + None, + eq.clone(), + ems.clone(), + bestdose::DoseRange::new(0.0, 300.0), + 0.0, + settings.clone(), + 500, // max_cycles - Fortran default for full two-step posterior + bestdose::Target::Concentration, // Target concentrations (not AUCs) + )?; println!("Optimizing dose..."); @@ -100,18 +109,19 @@ fn main() -> Result<()> { for bias_weight in &bias_weights { println!("Running optimization with bias weight: {}", bias_weight); - let optimal = problem.clone().bias(*bias_weight).optimize()?; + let optimal = problem.clone().with_bias_weight(*bias_weight).optimize()?; results.push((bias_weight, optimal)); } // Print results for (bias_weight, optimal) in &results { println!( - "Bias weight: {:.2}\t\t Optimal dose: {:?}\t\tCost: {:.6}\t\tln Cost: {:.4}", + "Bias weight: {:.2}\t\t Optimal dose: {:?}\t\tCost: {:.6}\t\tln Cost: {:.4}\t\tMethod: {}", bias_weight, optimal.dose, optimal.objf, - optimal.objf.ln() + optimal.objf.ln(), + optimal.optimization_method ); } diff --git a/examples/bestdose_auc.rs b/examples/bestdose_auc.rs new file mode 100644 index 000000000..6f1905004 --- /dev/null +++ b/examples/bestdose_auc.rs @@ -0,0 +1,127 @@ +use anyhow::Result; +use pmcore::bestdose::{BestDoseProblem, DoseRange, Target}; +use pmcore::prelude::*; +use pmcore::routines::initialization::parse_prior; + +fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + println!("BestDose AUC Target - Minimal Example\n"); + println!("======================================\n"); + + // Simple one-compartment PK model + let eq = equation::ODE::new( + |x, p, _t, dx, _rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0]; + }, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; + }, + (1, 1), + ); + + // Minimal parameter ranges + let params = Parameters::new() + .add("ke", 0.001, 3.0) + .add("v", 25.0, 250.0); + + let ems = ErrorModels::new().add( + 0, + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0, None), + )?; + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params) + .set_error_models(ems.clone()) + .build(); + + settings.disable_output(); + settings.set_idelta(60.0); // 1 hour intervals for AUC calculation + + // Load realistic prior from previous NPAG run (47 support points) + println!("Loading prior from bimodal_ke example..."); + let (theta, prior) = parse_prior( + &"examples/bimodal_ke/output/theta.csv".to_string(), + &settings, + )?; + let weights = prior.unwrap(); + + println!("Prior: {} support points\n", theta.matrix().nrows()); + + // Target: achieve specific AUC values (simple targets) + println!("Target AUCs:"); + println!(" AUC(0-6h) = 50.0 mg*h/L"); + println!(" AUC(0-12h) = 80.0 mg*h/L\n"); + + let target_data = Subject::builder("Target") + .bolus(0.0, 0.0, 0) // Dose to be optimized + .observation(6.0, 50.0, 0) // Target AUC at 6h + .observation(12.0, 80.0, 0) // Target AUC at 12h + .build(); + + println!("Creating BestDose problem with AUC targets..."); + let problem = BestDoseProblem::new( + &theta, + &weights, + None, // No past data - use prior directly + target_data, + None, + eq, + ems, + DoseRange::new(100.0, 2000.0), // Wider range for AUC targets + 0.8, // for AUC targets higher bias_weight usually works best + settings, + 0, // No NPAGFULL refinement (no past data) + Target::AUC, + )?; + + println!("Optimizing dose...\n"); + let optimal = problem.optimize()?; + + println!("=== RESULTS ==="); + println!("Optimal dose: {:.1} mg", optimal.dose[0]); + println!("Cost function: {:.6}", optimal.objf); + + if let Some(auc_preds) = &optimal.auc_predictions { + println!("\nAUC Predictions:"); + let mut total_error = 0.0; + for (time, auc) in auc_preds { + // Find the target AUC for this time + let target = if (*time - 6.0).abs() < 0.1 { + 50.0 + } else if (*time - 12.0).abs() < 0.1 { + 80.0 + } else { + 0.0 + }; + let error_pct = ((auc - target) / target * 100.0).abs(); + total_error += error_pct; + println!( + " Time: {:5.1}h | Target: {:6.1} | Predicted: {:6.2} | Error: {:5.1}%", + time, target, auc, error_pct + ); + } + println!( + "\n Mean absolute error: {:.1}%", + total_error / auc_preds.len() as f64 + ); + } else { + println!("\nConcentration Predictions:"); + for pred in optimal.preds.predictions() { + println!( + " Time: {:5.1}h | Target: {:6.1} | Predicted: {:6.2}", + pred.time(), + pred.obs().unwrap_or(0.0), + pred.post_mean() + ); + } + } + + Ok(()) +} diff --git a/src/bestdose/cost.rs b/src/bestdose/cost.rs new file mode 100644 index 000000000..dab0a2e70 --- /dev/null +++ b/src/bestdose/cost.rs @@ -0,0 +1,280 @@ +//! Cost function calculation for BestDose optimization +//! +//! Implements the hybrid cost function that balances patient-specific performance +//! (variance) with population-level robustness (bias). +//! +//! # Cost Function +//! +//! ```text +//! Cost = (1-λ) × Variance + λ × Bias² +//! ``` +//! +//! ## Variance Term (Patient-Specific) +//! +//! Expected squared prediction error using posterior weights: +//! ```text +//! Variance = Σᵢ posterior_weight[i] × Σⱼ (target[j] - pred[i,j])² +//! ``` +//! +//! - Weighted by patient-specific posterior probabilities +//! - Minimizes expected error for this specific patient +//! - Emphasizes parameter values compatible with patient history +//! +//! ## Bias Term (Population-Level) +//! +//! Squared deviation from population mean prediction using prior weights: +//! ```text +//! Bias² = Σⱼ (target[j] - population_mean[j])² +//! where population_mean[j] = Σᵢ prior_weight[i] × pred[i,j] +//! ``` +//! +//! - Weighted by population prior probabilities +//! - Minimizes deviation from population-typical behavior +//! - Provides robustness when patient history is limited +//! +//! ## Bias Weight Parameter (λ) +//! +//! - `λ = 0.0`: Pure personalization (minimize variance only) +//! - `λ = 0.5`: Balanced hybrid approach +//! - `λ = 1.0`: Pure population (minimize bias only) +//! +//! # Implementation Notes +//! +//! The cost function handles both concentration and AUC targets: +//! - **Concentration**: Simulates model at observation times directly +//! - **AUC**: Generates dense time grid and calculates cumulative AUC via trapezoidal rule +//! +//! See [`calculate_cost`] for the main implementation. + +use anyhow::Result; + +use crate::bestdose::predictions::{calculate_auc_at_times, calculate_dense_times}; +use crate::bestdose::types::{BestDoseProblem, Target}; +use pharmsol::prelude::*; +use pharmsol::Equation; + +/// Calculate cost function for a candidate dose regimen +/// +/// This is the core objective function minimized by the Nelder-Mead optimizer during +/// Stage 2 of the BestDose algorithm. +/// +/// # Arguments +/// +/// * `problem` - The [`BestDoseProblem`] containing all necessary data +/// * `candidate_doses` - Dose amounts to evaluate (only for optimizable doses) +/// +/// # Returns +/// +/// The cost value `(1-λ) × Variance + λ × Bias²` for the candidate doses. +/// Lower cost indicates better match to targets. +/// +/// # Dose Masking +/// +/// When `problem.current_time` is set (past/future separation), only doses where +/// `dose_optimization_mask[i] == true` are updated with values from `candidate_doses`. +/// Past doses (mask == false) remain at their historical values. +/// +/// - **Standard mode**: All doses in `candidate_doses` → all doses updated +/// - **Fortran mode**: Only future doses in `candidate_doses` → only future doses updated +/// +/// # Cost Function Details +/// +/// ## Variance Term +/// +/// Expected squared prediction error using posterior weights: +/// ```text +/// Variance = Σᵢ P(θᵢ|data) × Σⱼ (target[j] - pred[i,j])² +/// ``` +/// +/// For each support point θᵢ: +/// 1. Simulate model with candidate doses +/// 2. Calculate squared error at each observation time j +/// 3. Weight by posterior probability P(θᵢ|data) +/// +/// ## Bias Term +/// +/// Squared deviation from population mean: +/// ```text +/// Bias² = Σⱼ (target[j] - E[pred[j]])² +/// where E[pred[j]] = Σᵢ P(θᵢ) × pred[i,j] (prior weights) +/// ``` +/// +/// The population mean uses **prior weights**, not posterior weights, to represent +/// population-typical behavior independent of patient-specific data. +/// +/// ## Target Types +/// +/// - **Concentration** ([`Target::Concentration`]): +/// Predictions are concentrations at observation times +/// +/// - **AUC** ([`Target::AUC`]): +/// Predictions are cumulative AUC values calculated via trapezoidal rule +/// on a dense time grid (controlled by `settings.predictions().idelta`) +/// +/// # Example +/// +/// ```rust,ignore +/// // Internal use by optimizer +/// let cost = calculate_cost(&problem, &[100.0, 150.0])?; +/// ``` +/// +/// # Errors +/// +/// Returns error if: +/// - Model simulation fails +/// - Prediction length doesn't match observation count +/// - AUC calculation fails (for AUC targets) +pub fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) -> Result { + // Build target subject with candidate doses + let mut target_subject = problem.target.clone(); + let mut optimizable_dose_number = 0; // Index into candidate_doses + + for occasion in target_subject.iter_mut() { + for event in occasion.iter_mut() { + match event { + Event::Bolus(bolus) => { + // Only update if this dose is optimizable (amount == 0) + if bolus.amount() == 0.0 { + bolus.set_amount(candidate_doses[optimizable_dose_number]); + optimizable_dose_number += 1; + } + // If not optimizable (amount > 0), keep original amount + } + Event::Infusion(infusion) => { + // Only update if this dose is optimizable (amount == 0) + if infusion.amount() == 0.0 { + infusion.set_amount(candidate_doses[optimizable_dose_number]); + optimizable_dose_number += 1; + } + // If not optimizable (amount > 0), keep original amount + } + Event::Observation(_) => {} + } + } + } + + // Extract target values and observation times + let obs_times: Vec = target_subject + .occasions() + .iter() + .flat_map(|occ| occ.events()) + .filter_map(|event| match event { + Event::Observation(obs) => Some(obs.time()), + _ => None, + }) + .collect(); + + let obs_vec: Vec = target_subject + .occasions() + .iter() + .flat_map(|occ| occ.events()) + .filter_map(|event| match event { + Event::Observation(obs) => obs.value(), + _ => None, + }) + .collect(); + + let n_obs = obs_vec.len(); + + // Accumulators + let mut variance = 0.0_f64; // Expected squared error E[(target - pred)²] + let mut y_bar = vec![0.0_f64; n_obs]; // Population mean predictions + + // Calculate variance (using posterior weights) and population mean (using prior weights) + + for ((row, post_prob), prior_prob) in problem + .theta + .matrix() + .row_iter() + .zip(problem.posterior.iter()) // Posterior from NPAGFULL11 (patient-specific) + .zip(problem.prior_weights.iter()) + // Prior (population) + { + let spp = row.iter().copied().collect::>(); + + // Get predictions based on target type + let preds_i: Vec = match problem.target_type { + Target::Concentration => { + // Simulate at observation times only + let pred = problem.eq.simulate_subject(&target_subject, &spp, None)?; + pred.0.flat_predictions() + } + Target::AUC => { + // For AUC: simulate at dense time grid and calculate cumulative AUC + let idelta = problem.settings.predictions().idelta; + let start_time = 0.0; // Future starts at 0 + let end_time = obs_times.last().copied().unwrap_or(0.0); + + // Generate dense time grid + let dense_times = + calculate_dense_times(start_time, end_time, &obs_times, idelta as usize); + + // Create temporary subject with dense time points for simulation + let subject_id = target_subject.id().to_string(); + let mut builder = Subject::builder(&subject_id); + + // Add all doses from original subject + for occasion in target_subject.occasions() { + for event in occasion.events() { + match event { + Event::Bolus(bolus) => { + builder = builder.bolus(bolus.time(), bolus.amount(), 0); + } + Event::Infusion(_infusion) => { + tracing::warn!("Infusions not yet supported in AUC mode"); + } + Event::Observation(_) => {} // Skip original observations + } + } + } + + // Add observations at dense times (with dummy values for timing only) + for &t in &dense_times { + builder = builder.observation(t, -99.0, 0); + } + + let dense_subject = builder.build(); + + // Simulate at dense times + let pred = problem.eq.simulate_subject(&dense_subject, &spp, None)?; + let dense_predictions = pred.0.flat_predictions(); + + // Calculate AUC at observation times + calculate_auc_at_times(&dense_times, &dense_predictions, &obs_times) + } + }; + + if preds_i.len() != n_obs { + return Err(anyhow::anyhow!( + "prediction length ({}) != observation length ({})", + preds_i.len(), + n_obs + )); + } + + // Calculate variance term: weighted by POSTERIOR probability + let mut sumsq_i = 0.0_f64; + for (j, &obs_val) in obs_vec.iter().enumerate() { + let pj = preds_i[j]; + let se = (obs_val - pj).powi(2); + sumsq_i += se; + // Calculate population mean using PRIOR probabilities + y_bar[j] += prior_prob * pj; + } + + variance += post_prob * sumsq_i; // Weighted by posterior + } + + // Calculate bias term: squared difference from population mean + let mut bias = 0.0_f64; + for (j, &obs_val) in obs_vec.iter().enumerate() { + bias += (obs_val - y_bar[j]).powi(2); + } + + // Final cost: (1-λ)×Variance + λ×Bias² + // λ=0: Full personalization (minimize variance) + // λ=1: Population-based (minimize bias from population) + let cost = (1.0 - problem.bias_weight) * variance + problem.bias_weight * bias; + + Ok(cost) +} diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 467fdf0b0..b2670aeff 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -1,312 +1,793 @@ -use anyhow::{Ok, Result}; -use argmin::core::{CostFunction, Executor}; -use argmin::solver::neldermead::NelderMead; - -use crate::prelude::*; -use crate::routines::output::posterior::Posterior; -use crate::routines::output::predictions::NPPredictions; -use crate::structs::weights::Weights; -use pharmsol::prelude::*; -use pharmsol::{Data, ODE}; - -use crate::algorithms::npag::burke; -use crate::structs::psi::calculate_psi; -use crate::structs::theta::Theta; - -// TODO: AUC as a target -// TODO: Add support for loading and maintenance doses - -// TODO: Make sure to use the population probabilities from the "prior" Theta!!! - -pub enum Target { - Concentration(f64), - AUC(f64), -} - -#[derive(Debug, Clone)] -pub struct DoseRange { - min: f64, - max: f64, -} - -impl DoseRange { - pub fn new(min: f64, max: f64) -> Self { - DoseRange { min, max } - } - - pub fn min(&self) -> f64 { - self.min - } - - pub fn max(&self) -> f64 { - self.max +//! # BestDose Algorithm +//! +//! Bayesian dose optimization algorithm that finds optimal dosing regimens to achieve +//! target drug concentrations or cumulative AUC (Area Under the Curve) values. +//! +//! The BestDose algorithm combines Bayesian posterior estimation with dual optimization +//! to balance patient-specific adaptation and population-level robustness. +//! +//! # Quick Start +//! +//! ```rust,no_run +//! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; +//! +//! # fn example(prior_theta: pmcore::structs::theta::Theta, +//! # prior_weights: pmcore::structs::weights::Weights, +//! # past_data: pharmsol::prelude::Subject, +//! # target: pharmsol::prelude::Subject, +//! # eq: pharmsol::prelude::ODE, +//! # error_models: pharmsol::prelude::ErrorModels, +//! # settings: pmcore::routines::settings::Settings) +//! # -> anyhow::Result<()> { +//! // Create optimization problem +//! let problem = BestDoseProblem::new( +//! &prior_theta, // Population support points from NPAG +//! &prior_weights, // Population probabilities +//! Some(past_data), // Patient history (None = use prior) +//! target, // Future template with targets +//! eq, // PK/PD model +//! error_models, // Error specifications +//! DoseRange::new(0.0, 1000.0), // Dose constraints (0-1000 mg) +//! 0.5, // bias_weight: 0=personalized, 1=population +//! settings, // NPAG settings +//! 500, // NPAGFULL refinement cycles +//! Target::Concentration, // Target type +//! )?; +//! +//! // Run optimization +//! let result = problem.optimize()?; +//! +//! // Extract results +//! println!("Optimal dose: {:?} mg", result.dose); +//! println!("Final cost: {}", result.objf); +//! println!("Method: {}", result.optimization_method); // "posterior" or "uniform" +//! # Ok(()) +//! # } +//! ``` +//! +//! # Algorithm Overview (Three Stages) +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────┐ +//! │ STAGE 1: Posterior Density Calculation │ +//! │ │ +//! │ Prior (N points) │ +//! │ ↓ │ +//! │ Step 1.1: NPAGFULL11 - Bayesian Filtering │ +//! │ Calculate P(data|θᵢ) for each support point │ +//! │ Apply Bayes rule: P(θᵢ|data) ∝ P(data|θᵢ) × P(θᵢ) │ +//! │ Filter: Keep points where P(θᵢ|data) > 1e-100 × max │ +//! │ ↓ │ +//! │ Filtered Posterior (M points, typically 5-50) │ +//! │ ↓ │ +//! │ Step 1.2: NPAGFULL - Local Refinement │ +//! │ For each filtered point: │ +//! │ Run full NPAG optimization │ +//! │ Find refined "daughter" point │ +//! │ ↓ │ +//! │ Refined Posterior (M points with NPAGFULL11 weights) │ +//! └─────────────────────────────────────────────────────────────────┘ +//! +//! ┌─────────────────────────────────────────────────────────────────┐ +//! │ STAGE 2: Dual Optimization │ +//! │ │ +//! │ Optimization 1: Posterior Weights (Patient-Specific) │ +//! │ Minimize Cost = (1-λ)×Variance + λ×Bias² │ +//! │ Using NPAGFULL11 posterior weights │ +//! │ ↓ │ +//! │ Result 1: (doses₁, cost₁) │ +//! │ │ +//! │ Optimization 2: Uniform Weights (Population) │ +//! │ Minimize Cost = (1-λ)×Variance + λ×Bias² │ +//! │ Using uniform weights (1/M for all points) │ +//! │ ↓ │ +//! │ Result 2: (doses₂, cost₂) │ +//! │ │ +//! │ Select Best: min(cost₁, cost₂) │ +//! └─────────────────────────────────────────────────────────────────┘ +//! +//! ┌─────────────────────────────────────────────────────────────────┐ +//! │ STAGE 3: Final Predictions │ +//! │ │ +//! │ Calculate predictions with optimal doses │ +//! │ For AUC targets: Use dense time grid + trapezoidal rule │ +//! │ Return: Optimal doses, cost, predictions, method used │ +//! └─────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Mathematical Foundation +//! +//! ## Bayesian Posterior +//! +//! The posterior density is calculated via Bayes' rule: +//! +//! ```text +//! P(θ | data) = P(data | θ) × P(θ) / P(data) +//! ``` +//! +//! Where: +//! - `P(θ | data)`: Posterior (patient-specific parameters) +//! - `P(data | θ)`: Likelihood (from error model) +//! - `P(θ)`: Prior (from population) +//! - `P(data)`: Normalizing constant +//! +//! ## Cost Function +//! +//! The optimization minimizes a hybrid cost function: +//! +//! ```text +//! Cost = (1-λ) × Variance + λ × Bias² +//! ``` +//! +//! **Variance Term** (Patient-Specific Performance): +//! ```text +//! Variance = Σᵢ P(θᵢ|data) × Σⱼ (target[j] - pred[i,j])² +//! ``` +//! Expected squared error using posterior weights. +//! +//! **Bias Term** (Population-Level Performance): +//! ```text +//! Bias² = Σⱼ (target[j] - E[pred[j]])² +//! where E[pred[j]] = Σᵢ P(θᵢ) × pred[i,j] +//! ``` +//! Squared deviation from population mean prediction using prior weights. +//! +//! **Bias Weight Parameter (λ)**: +//! - `λ = 0.0`: Fully personalized (minimize variance only) +//! - `λ = 0.5`: Balanced hybrid approach +//! - `λ = 1.0`: Population-based (minimize bias from population) +//! +//! # Examples +//! +//! ## Single Dose Optimization +//! +//! ```rust,no_run +//! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; +//! use pharmsol::prelude::Subject; +//! +//! # fn example(prior_theta: pmcore::structs::theta::Theta, +//! # prior_weights: pmcore::structs::weights::Weights, +//! # past: pharmsol::prelude::Subject, +//! # eq: pharmsol::prelude::ODE, +//! # error_models: pharmsol::prelude::ErrorModels, +//! # settings: pmcore::routines::settings::Settings) +//! # -> anyhow::Result<()> { +//! // Define target: 5 mg/L at 24 hours +//! let target = Subject::builder("patient_001") +//! .bolus(0.0, 100.0, 0) // Initial dose (will be optimized) +//! .observation(24.0, 5.0, 0) // Target: 5 mg/L at 24h +//! .build(); +//! +//! let problem = BestDoseProblem::new( +//! &prior_theta, &prior_weights, Some(past), target, eq, error_models, +//! DoseRange::new(10.0, 500.0), // 10-500 mg allowed +//! 0.3, // Slight population emphasis +//! settings, 500, Target::Concentration, +//! )?; +//! +//! let result = problem.optimize()?; +//! println!("Optimal dose: {} mg", result.dose[0]); +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Multiple Doses with AUC Target +//! +//! ```rust,no_run +//! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; +//! use pharmsol::prelude::Subject; +//! +//! # fn example(prior_theta: pmcore::structs::theta::Theta, +//! # prior_weights: pmcore::structs::weights::Weights, +//! # past: pharmsol::prelude::Subject, +//! # eq: pharmsol::prelude::ODE, +//! # error_models: pharmsol::prelude::ErrorModels, +//! # settings: pmcore::routines::settings::Settings) +//! # -> anyhow::Result<()> { +//! // Target: Achieve AUC₂₄ = 400 mg·h/L +//! let target = Subject::builder("patient_002") +//! .bolus(0.0, 100.0, 0) // Dose 1 (optimized) +//! .bolus(12.0, 100.0, 0) // Dose 2 (optimized) +//! .observation(24.0, 400.0, 0) // Target: AUC₂₄ = 400 +//! .build(); +//! +//! let problem = BestDoseProblem::new( +//! &prior_theta, &prior_weights, Some(past), target, eq, error_models, +//! DoseRange::new(50.0, 300.0), +//! 0.0, // Full personalization +//! settings, 500, Target::AUC, // AUC target! +//! )?; +//! +//! let result = problem.optimize()?; +//! println!("Dose 1: {} mg at t=0", result.dose[0]); +//! println!("Dose 2: {} mg at t=12", result.dose[1]); +//! if let Some(auc) = result.auc_predictions { +//! println!("Predicted AUC₂₄: {} mg·h/L", auc[0].1); +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Population-Only Optimization +//! +//! ```rust,no_run +//! # use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; +//! # fn example(prior_theta: pmcore::structs::theta::Theta, +//! # prior_weights: pmcore::structs::weights::Weights, +//! # target: pharmsol::prelude::Subject, +//! # eq: pharmsol::prelude::ODE, +//! # error_models: pharmsol::prelude::ErrorModels, +//! # settings: pmcore::routines::settings::Settings) +//! # -> anyhow::Result<()> { +//! // No patient history - use population prior directly +//! let problem = BestDoseProblem::new( +//! &prior_theta, &prior_weights, +//! None, // No past data +//! target, eq, error_models, +//! DoseRange::new(0.0, 1000.0), +//! 1.0, // Full population weighting +//! settings, +//! 0, // Skip refinement +//! Target::Concentration, +//! )?; +//! +//! let result = problem.optimize()?; +//! // Returns population-typical dose +//! # Ok(()) +//! # } +//! ``` +//! +//! # Configuration +//! +//! ## Key Parameters +//! +//! - **`bias_weight` (λ)**: Controls personalization level +//! - `0.0`: Minimize patient-specific variance (full personalization) +//! - `1.0`: Minimize deviation from population (robustness) +//! +//! - **`max_cycles`**: NPAGFULL refinement iterations +//! - `0`: Skip refinement (use filtered points directly) +//! - `100-500`: Typical range for refinement +//! +//! - **`doserange`**: Dose constraints +//! - Set clinically appropriate bounds for your drug +//! +//! - **`target_type`**: Optimization target +//! - `Target::Concentration`: Direct concentration targets +//! - `Target::AUC`: Cumulative AUC targets +//! +//! ## Performance Tuning +//! +//! For faster optimization: +//! ```rust,no_run +//! # use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; +//! # fn example(prior_theta: pmcore::structs::theta::Theta, +//! # prior_weights: pmcore::structs::weights::Weights, +//! # target: pharmsol::prelude::Subject, +//! # eq: pharmsol::prelude::ODE, +//! # error_models: pharmsol::prelude::ErrorModels, +//! # mut settings: pmcore::routines::settings::Settings) +//! # -> anyhow::Result<()> { +//! // Reduce refinement cycles +//! let problem = BestDoseProblem::new( +//! &prior_theta, &prior_weights, None, target, eq, error_models, +//! DoseRange::new(0.0, 1000.0), 0.5, +//! settings.clone(), +//! 100, // Faster: 100 instead of 500 +//! Target::Concentration, +//! )?; +//! +//! // For AUC: use coarser time grid +//! settings.predictions().idelta = 30; // 30-minute intervals +//! # Ok(()) +//! # } +//! ``` +//! +//! # See Also +//! +//! - [`BestDoseProblem`]: Main entry point for optimization +//! - [`BestDoseResult`]: Output structure with optimal doses +//! - [`Target`]: Enum for concentration vs AUC targets +//! - [`DoseRange`]: Dose constraint specification + +pub mod cost; +mod optimization; +mod posterior; +pub mod predictions; +mod types; + +// Re-export public API +pub use types::{BestDoseProblem, BestDoseResult, DoseRange, Target}; + +/// Helper function to concatenate past and future subjects (Option 3: Fortran MAKETMP approach) +/// +/// This mimics Fortran's MAKETMP subroutine logic: +/// 1. Takes doses (only doses, not observations) from past subject +/// 2. Offsets all future subject event times by `current_time` +/// 3. Combines into single continuous subject +/// +/// # Arguments +/// +/// * `past` - Subject with past history (only doses will be used) +/// * `future` - Subject template for future (all events: doses + observations) +/// * `current_time` - Time offset to apply to all future events +/// +/// # Returns +/// +/// Combined subject with: +/// - Past doses at original times [0, current_time) +/// - Future doses + observations at offset times [current_time, ∞) +/// +/// # Example +/// +/// ```rust,ignore +/// // Past: dose at t=0, observation at t=6 (patient has been on therapy 6 hours) +/// let past = Subject::builder("patient") +/// .bolus(0.0, 500.0, 0) +/// .observation(6.0, 15.0, 0) // 15 mg/L at 6 hours +/// .build(); +/// +/// // Future: dose at t=0 (relative), target at t=24 (relative) +/// let future = Subject::builder("patient") +/// .bolus(0.0, 100.0, 0) // Dose to optimize, will be at t=6 absolute +/// .observation(24.0, 10.0, 0) // Target at t=30 absolute +/// .build(); +/// +/// // Concatenate with current_time = 6.0 +/// let combined = concatenate_past_and_future(&past, &future, 6.0); +/// // Result: dose at t=0 (fixed, 500mg), dose at t=6 (optimizable, 100mg initial), +/// // observation target at t=30 (10 mg/L) +/// ``` +fn concatenate_past_and_future( + past: &pharmsol::prelude::Subject, + future: &pharmsol::prelude::Subject, + current_time: f64, +) -> pharmsol::prelude::Subject { + use pharmsol::prelude::*; + + let mut builder = Subject::builder(past.id()); + + // Add past doses only (skip observations from past) + for occasion in past.occasions() { + for event in occasion.events() { + match event { + Event::Bolus(bolus) => { + builder = builder.bolus(bolus.time(), bolus.amount(), bolus.input()); + } + Event::Infusion(inf) => { + builder = + builder.infusion(inf.time(), inf.amount(), inf.input(), inf.duration()); + } + Event::Observation(_) => { + // Skip observations from past (they were already used for posterior) + } + } + } } -} -impl Default for DoseRange { - fn default() -> Self { - DoseRange { - min: 0.0, - max: f64::MAX, + // Add future events with time offset + for occasion in future.occasions() { + for event in occasion.events() { + match event { + Event::Bolus(bolus) => { + builder = + builder.bolus(bolus.time() + current_time, bolus.amount(), bolus.input()); + } + Event::Infusion(inf) => { + builder = builder.infusion( + inf.time() + current_time, + inf.amount(), + inf.input(), + inf.duration(), + ); + } + Event::Observation(obs) => { + builder = match obs.value() { + Some(val) => { + builder.observation(obs.time() + current_time, val, obs.outeq()) + } + None => builder, + }; + } + } } } -} -#[derive(Debug, Clone)] -pub struct BestDoseProblem { - pub past_data: Subject, - pub theta: Theta, - pub prior: Weights, - pub target: Subject, - pub eq: ODE, - pub doserange: DoseRange, - pub bias_weight: f64, - pub error_models: ErrorModels, + builder.build() } -impl BestDoseProblem { - pub fn optimize(self) -> Result { - let min_dose = self.doserange.min; - let max_dose = self.doserange.max; - - // Get the target subject - let target_subject = self.target.clone(); - - // Get all dose amounts as a vector - let all_doses: Vec = target_subject - .iter() - .flat_map(|occ| { - occ.iter().filter_map(|event| match event { - Event::Bolus(bolus) => Some(bolus.amount()), - Event::Infusion(infusion) => Some(infusion.amount()), - Event::Observation(_) => None, - }) - }) - .collect(); - - // Make initial simplex of the Nelder-Mead solver - let initial_guess = (min_dose + max_dose) / 2.0; - let initial_point = vec![initial_guess; all_doses.len()]; - let initial_simplex = create_initial_simplex(&initial_point); - - println!("Initial simplex: {:?}", initial_simplex); - - // Initialize the Nelder-Mead solver with correct generic types - let solver: NelderMead, f64> = NelderMead::new(initial_simplex); - let problem = self; - - let opt = Executor::new(problem.clone(), solver) - .configure(|state| state.max_iters(1000)) - .run()?; - - let result = opt.state(); - - let preds = { - // Modify the target subject with the new dose(s) - let mut target_subject = target_subject.clone(); - let mut dose_number = 0; - - for occasion in target_subject.iter_mut() { - for event in occasion.iter_mut() { - match event { - Event::Bolus(bolus) => { - // Set the dose to the new dose - bolus.set_amount(result.best_param.clone().unwrap()[dose_number]); - dose_number += 1; - } - Event::Infusion(infusion) => { - // Set the dose to the new dose - infusion.set_amount(result.best_param.clone().unwrap()[dose_number]); - dose_number += 1; - } - Event::Observation(_) => {} - } +/// Calculate which doses are optimizable based on dose amounts +/// +/// Returns a boolean mask where: +/// - `true` = dose amount is 0 (placeholder, optimizable) +/// - `false` = dose amount > 0 (fixed past dose) +/// +/// This allows users to specify a combined subject with: +/// - Non-zero doses for past doses (e.g., 500 mg at t=0) - these are fixed +/// - Zero doses as placeholders for future doses (e.g., 0 mg at t=6) - these are optimized +/// +/// # Arguments +/// +/// * `subject` - The subject with both fixed and placeholder doses +/// +/// # Returns +/// +/// Vector of booleans, one per dose in the subject +/// +/// # Example +/// +/// ```rust,ignore +/// let subject = Subject::builder("patient") +/// .bolus(0.0, 500.0, 0) // Past dose (fixed) - mask[0] = false +/// .bolus(6.0, 0.0, 0) // Future dose (optimize) - mask[1] = true +/// .observation(30.0, 10.0, 0) +/// .build(); +/// let mask = calculate_dose_optimization_mask(&subject); +/// assert_eq!(mask, vec![false, true]); +/// ``` +fn calculate_dose_optimization_mask(subject: &pharmsol::prelude::Subject) -> Vec { + use pharmsol::prelude::*; + + let mut mask = Vec::new(); + + for occasion in subject.occasions() { + for event in occasion.events() { + match event { + Event::Bolus(bolus) => { + // Dose is optimizable if amount is 0 (placeholder) + mask.push(bolus.amount() == 0.0); + } + Event::Infusion(_) => { + // Note: Infusions not currently supported in BestDose + // Don't add to mask + } + Event::Observation(_) => { + // Observations don't go in the mask } } - - // Calculate psi, in order to determine the optimal weights of the support points in Theta for the target subject - let psi = calculate_psi( - &problem.eq, - &Data::new(vec![target_subject.clone()]), - &problem.theta.clone(), - &problem.error_models, - false, - true, - )?; - - // Calculate the optimal weights - let (w, _likelihood) = burke(&psi)?; - - // Calculate posterior - let posterior = Posterior::calculate(&psi, &w)?; - - // Calculate predictions - NPPredictions::calculate( - &problem.eq, - &Data::new(vec![target_subject.clone()]), - problem.theta.clone(), - &w, - &posterior, - 0.0, - 0.0, - )? - }; - - let optimaldose = BestDoseResult { - dose: result.best_param.clone().unwrap(), - objf: result.best_cost, - status: result.termination_status.to_string(), - preds, - }; - - Ok(optimaldose) + } } - pub fn bias(mut self, weight: f64) -> Self { - self.bias_weight = weight; - self - } + mask } -fn create_initial_simplex(initial_point: &[f64]) -> Vec> { - let num_dimensions = initial_point.len(); - let perturbation_percentage = 0.008; - - // Initialize a Vec to store the vertices of the simplex - let mut vertices = Vec::new(); +use anyhow::Result; +use pharmsol::prelude::*; +use pharmsol::ODE; - // Add the initial point to the vertices - vertices.push(initial_point.to_vec()); +use crate::routines::settings::Settings; +use crate::structs::theta::Theta; +use crate::structs::weights::Weights; - // Calculate perturbation values for each component - for i in 0..num_dimensions { - let perturbation = if initial_point[i] == 0.0 { - 0.00025 // Special case for components equal to 0 - } else { - perturbation_percentage * initial_point[i] - }; +// ═════════════════════════════════════════════════════════════════════════════ +// Helper Functions for STAGE 1: Posterior Density Calculation +// ═════════════════════════════════════════════════════════════════════════════ - let mut perturbed_point = initial_point.to_owned(); - perturbed_point[i] += perturbation; - vertices.push(perturbed_point); +/// Validate current_time parameter for past/future separation mode +fn validate_current_time(current_time: f64, past_data: &Option) -> Result<()> { + if let Some(past_subject) = past_data { + let max_past_time = past_subject + .occasions() + .iter() + .flat_map(|occ| occ.events()) + .map(|event| match event { + Event::Bolus(b) => b.time(), + Event::Infusion(i) => i.time(), + Event::Observation(o) => o.time(), + }) + .fold(0.0_f64, |max, time| max.max(time)); + + if current_time < max_past_time { + return Err(anyhow::anyhow!( + "Invalid current_time: {} is before the last past_data event at time {}. \ + current_time must be >= the maximum time in past_data to avoid time travel!", + current_time, + max_past_time + )); + } } - - vertices + Ok(()) } -impl CostFunction for BestDoseProblem { - type Param = Vec; - type Output = f64; - - fn cost(&self, param: &Self::Param) -> Result { - // Modify the target subject with the new dose(s) - let mut target_subject = self.target.clone(); - let mut dose_number = 0; - - for occasion in target_subject.iter_mut() { - for event in occasion.iter_mut() { - match event { - Event::Bolus(bolus) => { - bolus.set_amount(param[dose_number]); - dose_number += 1; - } - Event::Infusion(infusion) => { - infusion.set_amount(param[dose_number]); - dose_number += 1; - } - Event::Observation(_) => {} - } +/// Calculate posterior density (STAGE 1: Two-step process) +/// +/// # Algorithm Flow (Matches Diagram) +/// +/// ```text +/// Prior Density (N points) +/// ↓ +/// Has past data with observations? +/// ↓ Yes ↓ No +/// Step 1.1: Use prior +/// NPAGFULL11 directly +/// (Bayesian Filter) +/// ↓ +/// Filtered Posterior (M points) +/// ↓ +/// Step 1.2: +/// NPAGFULL +/// (Refine each point) +/// ↓ +/// Refined Posterior +/// (M points with NPAGFULL11 weights) +/// ``` +/// +/// # Returns +/// +/// Tuple: (posterior_theta, posterior_weights, filtered_prior_weights, past_subject) +fn calculate_posterior_density( + prior_theta: &Theta, + prior_weights: &Weights, + past_data: Option<&Subject>, + eq: &ODE, + error_models: &ErrorModels, + settings: &Settings, + max_cycles: usize, +) -> Result<(Theta, Weights, Weights, Subject)> { + match past_data { + None => { + tracing::info!(" No past data → using prior directly"); + Ok(( + prior_theta.clone(), + prior_weights.clone(), + prior_weights.clone(), + Subject::builder("Empty").build(), + )) + } + Some(past_subject) => { + // Check if past data has observations + let has_observations = !past_subject.occasions().is_empty() + && past_subject.occasions().iter().any(|occ| { + occ.events() + .iter() + .any(|e| matches!(e, Event::Observation(_))) + }); + + if !has_observations { + tracing::info!(" Past data has no observations → using prior directly"); + Ok(( + prior_theta.clone(), + prior_weights.clone(), + prior_weights.clone(), + past_subject.clone(), + )) + } else { + // Two-step posterior calculation + tracing::info!(" Past data with observations → calculating two-step posterior"); + tracing::info!(" Step 1.1: NPAGFULL11 (Bayesian filtering)"); + tracing::info!(" Step 1.2: NPAGFULL (local refinement)"); + + let past_data_obj = Data::new(vec![past_subject.clone()]); + + let (posterior_theta, posterior_weights, filtered_prior_weights) = + posterior::calculate_two_step_posterior( + prior_theta, + prior_weights, + &past_data_obj, + eq, + error_models, + settings, + max_cycles, + )?; + + Ok(( + posterior_theta, + posterior_weights, + filtered_prior_weights, + past_subject.clone(), + )) } } + } +} - // Calculate psi for the target subject - let psi = calculate_psi( - &self.eq, - &Data::new(vec![target_subject.clone()]), - &self.theta, - &self.error_models, - false, - true, - )?; - - // Calculate the optimal weights - let (posterior, _likelihood) = burke(&psi)?; +/// Prepare target subject by handling past/future concatenation if needed +/// +/// # Returns +/// +/// Tuple: (final_target, final_past_data) +fn prepare_target_subject( + past_subject: Subject, + target: Subject, + current_time: Option, +) -> Result<(Subject, Subject)> { + match current_time { + None => { + tracing::info!(" Mode: Standard (single subject)"); + Ok((target, past_subject)) + } + Some(t) => { + tracing::info!(" Mode: Past/Future separation (Fortran MAKETMP approach)"); + tracing::info!(" Current time boundary: {} hours", t); + tracing::info!(" Concatenating past and future subjects..."); - // Build observation vector (must be in the same order as flat_predictions()) + let combined = concatenate_past_and_future(&past_subject, &target, t); - let obs_vec: Vec = target_subject - .occasions() - .iter() - .flat_map(|occ| occ.events()) - .filter_map(|event| match event { - Event::Observation(obs) => obs.value(), - _ => None, - }) - .collect(); + // Log dose structure + let mask = calculate_dose_optimization_mask(&combined); + let num_fixed = mask.iter().filter(|&&x| !x).count(); + let num_optimizable = mask.iter().filter(|&&x| x).count(); + tracing::info!(" Fixed doses (from past): {}", num_fixed); + tracing::info!(" Optimizable doses (from future): {}", num_optimizable); - let n_obs = obs_vec.len(); - if n_obs == 0 { - return Err(anyhow::anyhow!("no observations found in target subject")); + Ok((combined, past_subject)) } + } +} - // Accumulators - let mut variance = 0.0_f64; // Expected squared error V(U) - let mut y_bar = vec![0.0_f64; n_obs]; // Container for the population mean predictions - - // Iterate over each support point in theta with its normalized probability - for ((row, post_prob), pop_prob) in self - .theta - .matrix() - .row_iter() - .zip(posterior.iter()) - .zip(self.prior.iter()) - { - let spp = row.iter().copied().collect::>(); - - // Simulate the target subject with this support point - let pred = self.eq.simulate_subject(&target_subject, &spp, None)?; - - // Get per-observation predictions in the same order as obs_vec - let preds_i: Vec = pred.0.flat_predictions(); - - if preds_i.len() != n_obs { - return Err(anyhow::anyhow!( - "prediction length ({}) != observation length ({})", - preds_i.len(), - n_obs - )); - } - - // For each support point, calculate the squared prediction error - let mut sumsq_i = 0.0_f64; - for (j, &obs_val) in obs_vec.iter().enumerate() { - let pj = preds_i[j]; - let se = (obs_val - pj).powi(2); - sumsq_i += se; - y_bar[j] += pop_prob * pj; // Calculate the weighted mean prediction using the population probabilities - } +// ═════════════════════════════════════════════════════════════════════════════ - variance += post_prob * sumsq_i; // expected contribution +impl BestDoseProblem { + /// Create a new BestDose problem with automatic posterior calculation + /// + /// This is the main entry point for the BestDose algorithm. + /// + /// # Algorithm Structure (Matches Flowchart) + /// + /// ```text + /// ┌─────────────────────────────────────────┐ + /// │ STAGE 1: Posterior Density Calculation │ + /// │ │ + /// │ Prior Density (N points) │ + /// │ ↓ │ + /// │ Has past data with observations? │ + /// │ ↓ Yes ↓ No │ + /// │ Step 1.1: Use prior │ + /// │ NPAGFULL11 directly │ + /// │ (Filter) │ + /// │ ↓ │ + /// │ Step 1.2: │ + /// │ NPAGFULL │ + /// │ (Refine) │ + /// │ ↓ │ + /// │ Posterior Density │ + /// └─────────────────────────────────────────┘ + /// ``` + /// + /// # Parameters + /// + /// * `prior_theta` - Population support points from NPAG + /// * `prior_weights` - Population probabilities + /// * `past_data` - Patient history (None = use prior directly) + /// * `target` - Future dosing template with targets + /// * `current_time` - Optional time offset for concatenation (None = standard mode, Some(t) = Fortran mode) + /// * `eq` - Pharmacokinetic/pharmacodynamic model + /// * `error_models` - Error model specifications + /// * `doserange` - Allowable dose constraints + /// * `bias_weight` - λ ∈ [0,1]: 0=personalized, 1=population + /// * `settings` - NPAG settings for posterior refinement + /// * `max_cycles` - NPAGFULL cycles (0=skip refinement, 500=default) + /// * `target_type` - Concentration or AUC targets + /// + /// # Returns + /// + /// BestDoseProblem ready for `optimize()` + #[allow(clippy::too_many_arguments)] + pub fn new( + prior_theta: &Theta, + prior_weights: &Weights, + past_data: Option, + target: Subject, + current_time: Option, + eq: ODE, + error_models: ErrorModels, + doserange: DoseRange, + bias_weight: f64, + settings: Settings, + max_cycles: usize, + target_type: Target, + ) -> Result { + tracing::info!("╔══════════════════════════════════════════════════════════╗"); + tracing::info!("║ BestDose Algorithm: STAGE 1 ║"); + tracing::info!("║ Posterior Density Calculation ║"); + tracing::info!("╚══════════════════════════════════════════════════════════╝"); + + // Validate input if using past/future separation mode + if let Some(t) = current_time { + validate_current_time(t, &past_data)?; } - // Calculate bias, here defined as the squared difference between the observation and the population mean prediction - let mut bias = 0.0_f64; - for (j, &obs_val) in obs_vec.iter().enumerate() { - bias += (obs_val - y_bar[j]).powi(2); - } + // ═════════════════════════════════════════════════════════════ + // STAGE 1: Calculate Posterior Density + // ═════════════════════════════════════════════════════════════ + let (posterior_theta, posterior_weights, filtered_prior_weights, past_subject) = + calculate_posterior_density( + prior_theta, + prior_weights, + past_data.as_ref(), + &eq, + &error_models, + &settings, + max_cycles, + )?; + + // Handle past/future concatenation if needed + let (final_target, final_past_data) = + prepare_target_subject(past_subject, target, current_time)?; + + tracing::info!("╔══════════════════════════════════════════════════════════╗"); + tracing::info!("║ Stage 1 Complete - Ready for Optimization ║"); + tracing::info!("╚══════════════════════════════════════════════════════════╝"); + tracing::info!(" Support points: {}", posterior_theta.matrix().nrows()); + tracing::info!(" Target type: {:?}", target_type); + tracing::info!(" Bias weight (λ): {}", bias_weight); + + Ok(BestDoseProblem { + past_data: final_past_data, + target: final_target, + target_type, + prior_theta: prior_theta.clone(), + prior_weights: filtered_prior_weights, + theta: posterior_theta, + posterior: posterior_weights, + eq, + error_models, + settings, + doserange, + bias_weight, + current_time, + }) + } - // Final cost: - let cost = (1.0 - self.bias_weight) * variance + self.bias_weight * bias; + /// Run the complete BestDose optimization algorithm + /// + /// # Algorithm Flow (Matches Diagram!) + /// + /// ```text + /// ┌─────────────────────────────────────────┐ + /// │ STAGE 1: Posterior Calculation │ + /// │ [COMPLETED in new()] │ + /// └────────────┬────────────────────────────┘ + /// ↓ + /// ┌─────────────────────────────────────────┐ + /// │ STAGE 2: Dual Optimization │ + /// │ │ + /// │ Optimization 1: Posterior Weights │ + /// │ (Patient-specific) │ + /// │ ↓ │ + /// │ Result 1: (doses₁, cost₁) │ + /// │ │ + /// │ Optimization 2: Uniform Weights │ + /// │ (Population-based) │ + /// │ ↓ │ + /// │ Result 2: (doses₂, cost₂) │ + /// │ │ + /// │ Select: min(cost₁, cost₂) │ + /// └────────────┬────────────────────────────┘ + /// ↓ + /// ┌─────────────────────────────────────────┐ + /// │ STAGE 3: Final Predictions │ + /// │ │ + /// │ Calculate predictions with │ + /// │ optimal doses and winning weights │ + /// └─────────────────────────────────────────┘ + /// ``` + /// + /// # Returns + /// + /// `BestDoseResult` containing: + /// - `dose`: Optimal dose amount(s) + /// - `objf`: Final cost function value + /// - `preds`: Concentration-time predictions + /// - `auc_predictions`: AUC values (if target_type is AUC) + /// - `optimization_method`: "posterior" or "uniform" + pub fn optimize(self) -> Result { + tracing::info!("╔══════════════════════════════════════════════════════════╗"); + tracing::info!("║ BestDose Algorithm: STAGE 2 & 3 ║"); + tracing::info!("║ Dual Optimization + Final Predictions ║"); + tracing::info!("╚══════════════════════════════════════════════════════════╝"); - // Return raw cost to stay faithful to Fortran semantics. - Ok(cost) + // STAGE 2 & 3: Dual optimization + predictions + optimization::dual_optimization(&self) } -} -#[derive(Debug)] -pub struct BestDoseResult { - pub dose: Vec, - pub objf: f64, - pub status: String, - pub preds: NPPredictions, + /// Set the bias weight (lambda parameter) + /// + /// - λ = 0.0 (default): Full personalization (minimize patient-specific variance) + /// - λ = 0.5: Balanced between individual and population + /// - λ = 1.0: Population-based (minimize deviation from population mean) + pub fn with_bias_weight(mut self, weight: f64) -> Self { + self.bias_weight = weight; + self + } } diff --git a/src/bestdose/optimization.rs b/src/bestdose/optimization.rs new file mode 100644 index 000000000..2732177df --- /dev/null +++ b/src/bestdose/optimization.rs @@ -0,0 +1,298 @@ +//! Stage 2: Dose Optimization +//! +//! Implements the dual optimization strategy that compares patient-specific and +//! population-based approaches to find the best dosing regimen. +//! +//! # Dual Optimization Strategy +//! +//! The algorithm runs two independent optimizations: +//! +//! ## Optimization 1: Posterior Weights (Patient-Specific) +//! +//! - Uses refined posterior weights from NPAGFULL11 + NPAGFULL +//! - Emphasizes parameter values compatible with patient history +//! - Best when patient has substantial historical data +//! - Variance term dominates cost function +//! +//! ## Optimization 2: Uniform Weights (Population-Based) +//! +//! - Treats all posterior support points equally (weight = 1/M) +//! - Emphasizes population-typical behavior +//! - More robust when patient history is limited +//! - Population mean (from prior) influences cost +//! +//! ## Selection +//! +//! The algorithm compares both results and selects the one with lower cost. +//! This automatic selection provides robustness across diverse patient scenarios. +//! +//! # Optimization Method +//! +//! Uses the Nelder-Mead simplex algorithm (derivative-free): +//! - **Initial simplex**: -20% perturbation from starting doses +//! - **Max iterations**: 1000 +//! - **Convergence tolerance**: 1e-10 (standard deviation of simplex) +//! +//! # See Also +//! +//! - [`dual_optimization`]: Main entry point for Stage 2 +//! - [`create_initial_simplex`]: Simplex construction +//! - [`crate::bestdose::cost::calculate_cost`]: Cost function implementation + +use anyhow::Result; +use argmin::core::{CostFunction, Executor}; +use argmin::solver::neldermead::NelderMead; + +use crate::bestdose::cost::calculate_cost; +use crate::bestdose::predictions::calculate_final_predictions; +use crate::bestdose::types::{BestDoseProblem, BestDoseResult}; +use crate::structs::weights::Weights; +use pharmsol::prelude::*; + +/// Create initial simplex for Nelder-Mead optimization +/// +/// Constructs a simplex with n+1 vertices in n-dimensional space, +/// where n is the number of doses to optimize. +fn create_initial_simplex(initial_point: &[f64]) -> Vec> { + let n = initial_point.len(); + let perturbation_percentage = -0.2; // -20% perturbation + let mut simplex = Vec::with_capacity(n + 1); + + // First vertex is the initial point + simplex.push(initial_point.to_vec()); + + // Create n additional vertices by perturbing each dimension + for i in 0..n { + let mut vertex = initial_point.to_vec(); + let perturbation = if initial_point[i] == 0.0 { + 0.00025 // Special case for zero values + } else { + perturbation_percentage * initial_point[i] + }; + vertex[i] += perturbation; + simplex.push(vertex); + } + + simplex +} + +/// Implement CostFunction trait for BestDoseProblem +/// +/// This allows the Nelder-Mead optimizer to evaluate candidate doses. +impl CostFunction for BestDoseProblem { + type Param = Vec; + type Output = f64; + + fn cost(&self, param: &Self::Param) -> Result { + calculate_cost(self, param) + } +} + +/// Run single optimization with specified weights +/// +/// This is a helper for the dual optimization approach. +/// +/// When `problem.current_time` is set (past/future separation mode): +/// - Only optimizes doses where `dose_optimization_mask[i] == true` +/// - Creates a reduced-dimension simplex for future doses only +/// - Maps optimized doses back to full vector (past doses unchanged) +/// +/// Returns: (optimal_doses, final_cost) +fn run_single_optimization( + problem: &BestDoseProblem, + weights: &Weights, + method_name: &str, +) -> Result<(Vec, f64)> { + let min_dose = problem.doserange.min; + let max_dose = problem.doserange.max; + let target_subject = &problem.target; + + // Get all doses from target subject + let all_doses: Vec = target_subject + .iter() + .flat_map(|occ| { + occ.iter().filter_map(|event| match event { + Event::Bolus(bolus) => Some(bolus.amount()), + Event::Infusion(infusion) => Some(infusion.amount()), + Event::Observation(_) => None, + }) + }) + .collect(); + + // Count optimizable doses (amount == 0) + let num_optimizable = all_doses.iter().filter(|&&d| d == 0.0).count(); + let num_fixed = all_doses.len() - num_optimizable; + let num_support_points = problem.theta.matrix().nrows(); + + tracing::info!( + " │ {} doses: {} optimizable, {} fixed | {} support points", + method_name, + num_optimizable, + num_fixed, + num_support_points + ); + + // If no doses to optimize, return current doses with zero cost + if num_optimizable == 0 { + tracing::warn!(" │ ⚠ No doses to optimize (all fixed)"); + return Ok((all_doses, 0.0)); + } + + // Create initial simplex for optimizable doses only + let initial_guess = (min_dose + max_dose) / 2.0; + let initial_point = vec![initial_guess; num_optimizable]; + let initial_simplex = create_initial_simplex(&initial_point); + + // Create modified problem with the specified weights + let mut problem_with_weights = problem.clone(); + problem_with_weights.posterior = weights.clone(); + + // Run Nelder-Mead optimization + let solver: NelderMead, f64> = + NelderMead::new(initial_simplex).with_sd_tolerance(1e-10)?; + + let opt = Executor::new(problem_with_weights, solver) + .configure(|state| state.max_iters(1000)) + .run()?; + + let result = opt.state(); + let optimized_doses = result.best_param.clone().unwrap(); + let final_cost = result.best_cost; + + tracing::info!(" │ → Cost: {:.6}", final_cost); + + // Map optimized doses back to full vector + // For past/future mode: combine fixed past doses + optimized future doses + let mut full_doses = Vec::with_capacity(all_doses.len()); + let mut opt_idx = 0; + + for &original_dose in all_doses.iter() { + if original_dose == 0.0 { + // This was a placeholder dose - use optimized value + full_doses.push(optimized_doses[opt_idx]); + opt_idx += 1; + } else { + // This was a fixed dose - keep original value + full_doses.push(original_dose); + } + } + + Ok((full_doses, final_cost)) +} + +/// Stage 2 & 3: Dual optimization + Final predictions +/// +/// # Algorithm Flow (Matches Diagram) +/// +/// ```text +/// ┌─────────────────────────────────────────────────┐ +/// │ STAGE 2: Dual Optimization │ +/// │ │ +/// │ OPTIMIZATION 1: Posterior Weights │ +/// │ Use NPAGFULL11 posterior probabilities │ +/// │ → (doses₁, cost₁) │ +/// │ │ +/// │ OPTIMIZATION 2: Uniform Weights │ +/// │ Use equal weights (1/M) for all points │ +/// │ → (doses₂, cost₂) │ +/// │ │ +/// │ SELECTION: Choose min(cost₁, cost₂) │ +/// │ → (optimal_doses, optimal_cost, method) │ +/// └────────────┬────────────────────────────────────┘ +/// ↓ +/// ┌─────────────────────────────────────────────────┐ +/// │ STAGE 3: Final Predictions │ +/// │ │ +/// │ Calculate predictions with: │ +/// │ - Optimal doses from winning optimization │ +/// │ - Winning weights (posterior or uniform) │ +/// │ │ +/// │ Return: BestDoseResult │ +/// └─────────────────────────────────────────────────┘ +/// ``` +/// +/// This dual optimization ensures robust performance: +/// - Posterior weights: Best for atypical patients with good data +/// - Uniform weights: Best for typical patients or limited data +/// - Automatic selection gives optimal result in both cases +pub fn dual_optimization(problem: &BestDoseProblem) -> Result { + let n_points = problem.theta.matrix().nrows(); + + // ═════════════════════════════════════════════════════════════ + // STAGE 2: Dual Optimization + // ═════════════════════════════════════════════════════════════ + tracing::info!("─────────────────────────────────────────────────────────────"); + tracing::info!("STAGE 2: Dual Optimization"); + tracing::info!("─────────────────────────────────────────────────────────────"); + + // OPTIMIZATION 1: Posterior weights (patient-specific adaptation) + tracing::info!("│"); + tracing::info!("├─ Optimization 1: Posterior Weights (Patient-Specific)"); + let (doses1, cost1) = run_single_optimization(problem, &problem.posterior, "Posterior")?; + + // OPTIMIZATION 2: Uniform weights (population robustness) + tracing::info!("│"); + tracing::info!("├─ Optimization 2: Uniform Weights (Population-Based)"); + let uniform_weights = Weights::uniform(n_points); + let (doses2, cost2) = run_single_optimization(problem, &uniform_weights, "Uniform")?; + + // SELECTION: Compare and choose the better result + tracing::info!("│"); + tracing::info!("└─ Selection: Compare Results"); + tracing::info!(" Posterior cost: {:.6}", cost1); + tracing::info!(" Uniform cost: {:.6}", cost2); + + let (final_doses, final_cost, method, final_weights) = if cost1 <= cost2 { + tracing::info!(" → Winner: Posterior (lower cost) ✓"); + (doses1, cost1, "posterior", problem.posterior.clone()) + } else { + tracing::info!(" → Winner: Uniform (lower cost) ✓"); + (doses2, cost2, "uniform", uniform_weights) + }; + + // ═════════════════════════════════════════════════════════════ + // STAGE 3: Final Predictions + // ═════════════════════════════════════════════════════════════ + tracing::info!("─────────────────────────────────────────────────────────────"); + tracing::info!("STAGE 3: Final Predictions"); + tracing::info!("─────────────────────────────────────────────────────────────"); + tracing::info!( + " Calculating predictions with optimal doses and {} weights", + method + ); + + let (preds, auc_predictions) = + calculate_final_predictions(problem, &final_doses, &final_weights)?; + + // Extract only the optimized doses (exclude fixed past doses) + let original_doses: Vec = problem + .target + .iter() + .flat_map(|occ| { + occ.iter().filter_map(|event| match event { + Event::Bolus(bolus) => Some(bolus.amount()), + Event::Infusion(infusion) => Some(infusion.amount()), + Event::Observation(_) => None, + }) + }) + .collect(); + + let optimized_doses: Vec = final_doses + .iter() + .zip(original_doses.iter()) + .filter(|(_, &orig)| orig == 0.0) // Only doses that were placeholders + .map(|(&opt, _)| opt) + .collect(); + + tracing::info!(" ✓ Predictions complete"); + tracing::info!("─────────────────────────────────────────────────────────────"); + + Ok(BestDoseResult { + dose: optimized_doses, + objf: final_cost, + status: "Converged".to_string(), + preds, + auc_predictions, + optimization_method: method.to_string(), + }) +} diff --git a/src/bestdose/posterior.rs b/src/bestdose/posterior.rs new file mode 100644 index 000000000..f11a5ad14 --- /dev/null +++ b/src/bestdose/posterior.rs @@ -0,0 +1,328 @@ +//! Stage 1: Posterior Density Calculation +//! +//! Two-step Bayesian posterior refinement process that transforms a population prior +//! into a patient-specific posterior distribution. +//! +//! # Overview +//! +//! The posterior calculation uses a two-step approach: +//! +//! ## Step 1: NPAGFULL11 - Bayesian Filtering +//! +//! Filters the population prior to identify parameter regions compatible with patient data: +//! +//! 1. Calculate likelihood P(data|θᵢ) for each prior support point +//! 2. Apply Bayes' rule: P(θᵢ|data) ∝ P(data|θᵢ) × P(θᵢ) +//! 3. Filter: Keep points where P(θᵢ|data) > 1e-100 × max(P(θᵢ|data)) +//! 4. Renormalize weights +//! +//! **Output**: Filtered posterior with typically 5-50 support points +//! +//! ## Step 2: NPAGFULL - Local Refinement +//! +//! Refines each filtered point through full NPAG optimization: +//! +//! 1. For each filtered support point: Run NPAG optimization starting from that point +//! 2. Find refined "daughter" point in local parameter space +//! 3. Preserve NPAGFULL11 weights (no recalculation) +//! +//! **Output**: Refined posterior with improved parameter estimates +//! +//! # Key Differences from Standard NPAG +//! +//! - **NPAGFULL11**: Uses only lambda filtering (no QR decomposition) +//! - **NPAGFULL**: Refines individual points (not population estimation) +//! - **Weight preservation**: NPAGFULL11 probabilities are kept, not recalculated +//! +//! # Configuration +//! +//! The `KEEP_UNREFINED_POINTS` constant controls behavior when refinement fails: +//! - `true`: Keep original filtered point (maintains point count) +//! - `false`: Skip point entirely (may reduce posterior size) +//! +//! # Functions +//! +//! - [`npagfull11_filter`]: Step 1 - Bayesian filtering +//! - [`npagfull_refinement`]: Step 2 - Local optimization +//! - [`calculate_two_step_posterior`]: Complete two-step process +//! +//! # See Also +//! +//! - [`crate::algorithms::npag`]: Standard NPAG algorithm for comparison + +use anyhow::Result; +use faer::Mat; + +use crate::algorithms::npag::burke; +use crate::algorithms::npag::NPAG; +use crate::algorithms::Algorithms; +use crate::prelude::*; +use crate::structs::psi::calculate_psi; +use crate::structs::theta::Theta; +use crate::structs::weights::Weights; +use pharmsol::prelude::*; + +// ============================================================================= +// CONFIGURATION: Control refinement behavior +// ============================================================================= +/// Control whether to keep or skip points when refinement fails +/// +/// **Keep unrefined points (true)**: All filtered points are kept in posterior. +/// - If refinement succeeds → use refined point +/// - If refinement fails → use original filtered point +/// - Result: Same number of points as NPAGFULL11 filtering produced +/// +/// **Skip failed refinements (false)**: Points are skipped when refinement fails. +/// - If refinement succeeds → use refined point +/// - If refinement fails → skip point entirely +/// - Result: Fewer points than NPAGFULL11 filtering +const KEEP_UNREFINED_POINTS: bool = true; + +/// Step 1.1: NPAGFULL11 - Bayesian filtering to get compatible points +/// +/// Implements Bayesian filtering by: +/// 1. Calculate P(data|θᵢ) for each prior support point +/// 2. Apply Bayes' rule to get P(θᵢ|data) +/// 3. Filter: Keep points where P(θᵢ|data) > 1e-100 × max_weight +/// +/// Note: This uses only lambda filtering, NO QR decomposition or second burke call. +/// +/// Returns: (filtered_theta, filtered_posterior_weights, filtered_prior_weights) +pub fn npagfull11_filter( + prior_theta: &Theta, + prior_weights: &Weights, + past_data: &Data, + eq: &ODE, + error_models: &ErrorModels, +) -> Result<(Theta, Weights, Weights)> { + tracing::info!("Stage 1.1: NPAGFULL11 Bayesian filtering"); + + // Calculate psi matrix P(data|theta_i) for all support points + let psi = calculate_psi(eq, past_data, prior_theta, error_models, false, true)?; + + // First burke call to get initial posterior probabilities + let (initial_weights, _) = burke(&psi)?; + + // NPAGFULL11 filtering: Keep all points within 1e-100 of the maximum weight + // This is different from NPAG's condensation - NO QR decomposition here! + let max_weight = initial_weights + .iter() + .fold(f64::NEG_INFINITY, |a, b| a.max(b)); + + let threshold = 1e-100; // NPAGFULL11-specific threshold + + let keep_lambda: Vec = initial_weights + .iter() + .enumerate() + .filter(|(_, lam)| *lam > threshold * max_weight) + .map(|(i, _)| i) + .collect(); + + // Filter theta to keep only points above threshold + let mut filtered_theta = prior_theta.clone(); + filtered_theta.filter_indices(&keep_lambda); + + // Filter and renormalize posterior weights + let filtered_weights: Vec = keep_lambda.iter().map(|&i| initial_weights[i]).collect(); + let sum: f64 = filtered_weights.iter().sum(); + let final_posterior_weights = + Weights::from_vec(filtered_weights.iter().map(|w| w / sum).collect()); + + // Also filter the prior weights to match the filtered theta + let filtered_prior_weights: Vec = keep_lambda.iter().map(|&i| prior_weights[i]).collect(); + let prior_sum: f64 = filtered_prior_weights.iter().sum(); + let final_prior_weights = Weights::from_vec( + filtered_prior_weights + .iter() + .map(|w| w / prior_sum) + .collect(), + ); + + tracing::info!( + " {} → {} support points (lambda filter, threshold={:.0e})", + prior_theta.matrix().nrows(), + filtered_theta.matrix().nrows(), + threshold * max_weight + ); + + Ok((filtered_theta, final_posterior_weights, final_prior_weights)) +} + +/// Step 1.2: NPAGFULL - Refine each filtered point with full NPAG optimization +/// +/// For each filtered support point from NPAGFULL11, run a full NPAG optimization +/// starting from that point to get a refined "daughter" point. +/// +/// Behavior controlled by KEEP_UNREFINED_POINTS configuration: +/// - If refinement succeeds → use refined point +/// - If refinement fails → keep original filtered point (when enabled) +/// +/// The NPAGFULL11 probabilities are preserved (not recalculated from NPAG). +/// +/// Parameters: +/// - max_cycles: Maximum NPAG cycles for refinement (0=skip refinement) +pub fn npagfull_refinement( + filtered_theta: &Theta, + filtered_weights: &Weights, + past_data: &Data, + eq: &ODE, + settings: &Settings, + max_cycles: usize, +) -> Result<(Theta, Weights)> { + if max_cycles == 0 { + tracing::info!("Stage 1.2: NPAGFULL refinement skipped (max_cycles=0)"); + return Ok((filtered_theta.clone(), filtered_weights.clone())); + } + + tracing::info!("Stage 1.2: NPAGFULL refinement (max_cycles={})", max_cycles); + + let mut refined_points = Vec::new(); + let mut kept_weights: Vec = Vec::new(); + let num_points = filtered_theta.matrix().nrows(); + + for i in 0..num_points { + tracing::debug!(" Refining point {}/{}", i + 1, num_points); + + // Get the current filtered point as starting point + let point: Vec = filtered_theta.matrix().row(i).iter().copied().collect(); + + // Create a single-point theta for NPAG initialization + let n_params = point.len(); + let single_point_matrix = Mat::from_fn(1, n_params, |_r, c| point[c]); + let single_point_theta = + Theta::from_parts(single_point_matrix, settings.parameters().clone()); + + // Configure NPAG for refinement + let mut npag_settings = settings.clone(); + npag_settings.disable_output(); // Don't write files for each refinement + npag_settings.set_prior(crate::routines::initialization::Prior::Theta( + single_point_theta.clone(), + )); + + // Create and run NPAG + let mut npag = NPAG::new(npag_settings, eq.clone(), past_data.clone())?; + npag.set_theta(single_point_theta); + + // Run NPAG optimization + let refinement_result = npag.initialize().and_then(|_| { + while !npag.next_cycle()? {} + Ok(()) + }); + + // Handle refinement failure based on configuration + if let Err(e) = refinement_result { + if KEEP_UNREFINED_POINTS { + // Keep the original filtered point + tracing::warn!( + " Failed to refine point {}/{}: {} - using original point", + i + 1, + num_points, + e + ); + refined_points.push(point); + kept_weights.push(filtered_weights[i]); + } else { + // Skip this point entirely + tracing::warn!( + " Failed to refine point {}/{}: {} - skipping", + i + 1, + num_points, + e + ); + } + continue; + } + + // Extract refined point (use first if multiple) + let refined_theta = npag.theta(); + + // Check if refinement produced any points + if refined_theta.matrix().nrows() == 0 { + if KEEP_UNREFINED_POINTS { + // Keep the original filtered point + tracing::warn!( + " NPAG refinement produced no points for point {}/{} - using original point", + i + 1, + num_points + ); + refined_points.push(point); + kept_weights.push(filtered_weights[i]); + } else { + // Skip this point entirely + tracing::warn!( + " NPAG refinement produced no points for point {}/{} - skipping", + i + 1, + num_points + ); + } + continue; + } + + // Refinement succeeded - use the refined point + let refined_point: Vec = refined_theta.matrix().row(0).iter().copied().collect(); + + refined_points.push(refined_point); + kept_weights.push(filtered_weights[i]); + } + + // Build refined theta matrix + let n_params = settings.parameters().len(); + let n_points = refined_points.len(); + let refined_matrix = Mat::from_fn(n_points, n_params, |r, c| refined_points[r][c]); + let refined_theta = Theta::from_parts(refined_matrix, settings.parameters().clone()); + + // Renormalize weights + let weight_sum: f64 = kept_weights.iter().sum(); + let normalized_weights = if weight_sum > 0.0 { + Weights::from_vec(kept_weights.iter().map(|w| w / weight_sum).collect()) + } else { + Weights::uniform(n_points) + }; + + tracing::info!( + " {} → {} refined points", + filtered_theta.matrix().nrows(), + refined_theta.matrix().nrows() + ); + + Ok((refined_theta, normalized_weights)) +} + +/// Calculate two-step posterior (NPAGFULL11 + NPAGFULL) +/// +/// This is the complete Stage 1 of the BestDose algorithm. +/// +/// Returns (posterior_theta, posterior_weights, filtered_prior_weights) suitable for dose optimization. +/// The filtered_prior_weights are the original prior weights filtered to match the posterior support points. +pub fn calculate_two_step_posterior( + prior_theta: &Theta, + prior_weights: &Weights, + past_data: &Data, + eq: &ODE, + error_models: &ErrorModels, + settings: &Settings, + max_cycles: usize, +) -> Result<(Theta, Weights, Weights)> { + tracing::info!("=== STAGE 1: Posterior Density Calculation ==="); + + // Step 1.1: NPAGFULL11 filtering (returns filtered posterior AND filtered prior) + let (filtered_theta, filtered_posterior_weights, filtered_prior_weights) = + npagfull11_filter(prior_theta, prior_weights, past_data, eq, error_models)?; + + // Step 1.2: NPAGFULL refinement + let (refined_theta, refined_weights) = npagfull_refinement( + &filtered_theta, + &filtered_posterior_weights, + past_data, + eq, + settings, + max_cycles, + )?; + + tracing::info!( + " Final posterior: {} points", + refined_theta.matrix().nrows() + ); + + Ok((refined_theta, refined_weights, filtered_prior_weights)) +} diff --git a/src/bestdose/predictions.rs b/src/bestdose/predictions.rs new file mode 100644 index 000000000..de6597991 --- /dev/null +++ b/src/bestdose/predictions.rs @@ -0,0 +1,246 @@ +//! Stage 3: Prediction calculations +//! +//! Handles final prediction calculations with optimal doses, including: +//! - Dense time grid generation for AUC calculations +//! - Trapezoidal AUC integration +//! - Concentration-time predictions +//! +//! # AUC Calculation Method +//! +//! For [`Target::AUC`](crate::bestdose::Target::AUC) targets: +//! +//! 1. **Dense Time Grid**: Generate points at `idelta` intervals plus observation times +//! 2. **Simulation**: Run model at all dense time points +//! 3. **Trapezoidal Integration**: Calculate cumulative AUC: +//! ```text +//! AUC(t) = Σᵢ₌₁ⁿ (C[i] + C[i-1])/2 × (t[i] - t[i-1]) +//! ``` +//! 4. **Extraction**: Extract AUC values at target observation times +//! +//! # Key Functions +//! +//! - [`calculate_dense_times`]: Generate time grid for numerical integration +//! - [`calculate_auc_at_times`]: Trapezoidal AUC calculation +//! - [`calculate_final_predictions`]: Final predictions with optimal doses +//! +//! # See Also +//! +//! - Configuration: `settings.predictions().idelta` controls time grid resolution + +use anyhow::Result; +use faer::Mat; + +use crate::bestdose::types::{BestDoseProblem, Target}; +use crate::routines::output::posterior::Posterior; +use crate::routines::output::predictions::NPPredictions; +use crate::structs::weights::Weights; +use pharmsol::prelude::*; +use pharmsol::Equation; + +/// Generate dense time grid for AUC calculations +/// +/// Creates a grid with: +/// - Observation times from the target +/// - Intermediate points at `idelta` intervals +/// - All times sorted and deduplicated +/// +/// # Arguments +/// * `start_time` - Start of time range +/// * `end_time` - End of time range +/// * `obs_times` - Required observation times (always included) +/// * `idelta` - Time step for dense grid (minutes) +/// +/// # Returns +/// Sorted, unique time vector suitable for AUC calculation +pub fn calculate_dense_times( + start_time: f64, + end_time: f64, + obs_times: &[f64], + idelta: usize, +) -> Vec { + let idelta_hours = (idelta as f64) / 60.0; + let mut times = Vec::new(); + + // Add observation times + times.extend_from_slice(obs_times); + + // Add regular grid points + let mut t = start_time; + while t <= end_time { + times.push(t); + t += idelta_hours; + } + + // Ensure end time is included + if !times.contains(&end_time) { + times.push(end_time); + } + + // Sort and deduplicate + times.sort_by(|a, b| a.partial_cmp(b).unwrap()); + + // Remove duplicates with tolerance + let tolerance = 1e-10; + let mut unique_times = Vec::new(); + let mut last_time = f64::NEG_INFINITY; + + for &t in × { + if (t - last_time).abs() > tolerance { + unique_times.push(t); + last_time = t; + } + } + + unique_times +} + +/// Calculate cumulative AUC at target times using trapezoidal rule +/// +/// Takes dense concentration predictions and calculates cumulative AUC +/// from the first time point. AUC values at target observation times +/// are extracted and returned. +/// +/// # Arguments +/// * `dense_times` - Dense time grid (must include all `target_times`) +/// * `dense_predictions` - Concentration predictions at `dense_times` +/// * `target_times` - Observation times where AUC should be extracted +/// +/// # Returns +/// Vector of AUC values at `target_times` +pub fn calculate_auc_at_times( + dense_times: &[f64], + dense_predictions: &[f64], + target_times: &[f64], +) -> Vec { + assert_eq!(dense_times.len(), dense_predictions.len()); + + let mut target_aucs = Vec::with_capacity(target_times.len()); + let mut auc = 0.0; + let mut target_idx = 0; + let tolerance = 1e-10; + + for i in 1..dense_times.len() { + // Update cumulative AUC using trapezoidal rule + let dt = dense_times[i] - dense_times[i - 1]; + let avg_conc = (dense_predictions[i] + dense_predictions[i - 1]) / 2.0; + auc += avg_conc * dt; + + // Check if current time matches next target time + if target_idx < target_times.len() { + if (dense_times[i] - target_times[target_idx]).abs() < tolerance { + target_aucs.push(auc); + target_idx += 1; + } + } + } + + target_aucs +} + +/// Calculate predictions for optimal doses +/// +/// This generates the final NPPredictions structure with the optimal doses +/// and appropriate weights (posterior or uniform depending on which optimization won). +pub fn calculate_final_predictions( + problem: &BestDoseProblem, + optimal_doses: &[f64], + weights: &Weights, +) -> Result<(NPPredictions, Option>)> { + // Build subject with optimal doses + let mut target_with_optimal = problem.target.clone(); + let mut dose_number = 0; + + for occasion in target_with_optimal.iter_mut() { + for event in occasion.iter_mut() { + match event { + Event::Bolus(bolus) => { + bolus.set_amount(optimal_doses[dose_number]); + dose_number += 1; + } + Event::Infusion(infusion) => { + infusion.set_amount(optimal_doses[dose_number]); + dose_number += 1; + } + Event::Observation(_) => {} + } + } + } + + // Create posterior matrix for predictions + let posterior_matrix = Mat::from_fn(1, weights.weights().nrows(), |_row, col| { + *weights.weights().get(col) + }); + let posterior = Posterior::from(posterior_matrix); + + // Calculate concentration predictions + let concentration_preds = NPPredictions::calculate( + &problem.eq, + &Data::new(vec![target_with_optimal.clone()]), + problem.theta.clone(), + weights, + &posterior, + 0.0, + 0.0, + )?; + + // Calculate AUC predictions if in AUC mode + let auc_predictions = if matches!(problem.target_type, Target::AUC) { + let obs_times: Vec = target_with_optimal + .occasions() + .iter() + .flat_map(|occ| occ.events()) + .filter_map(|event| match event { + Event::Observation(obs) => Some(obs.time()), + _ => None, + }) + .collect(); + + let idelta = problem.settings.predictions().idelta; + let start_time = 0.0; + let end_time = obs_times.last().copied().unwrap_or(0.0); + let dense_times = calculate_dense_times(start_time, end_time, &obs_times, idelta as usize); + + let subject_id = target_with_optimal.id().to_string(); + let mut builder = Subject::builder(&subject_id); + + let mut dose_number = 0; + for occasion in target_with_optimal.occasions() { + for event in occasion.events() { + match event { + Event::Bolus(bolus) => { + builder = builder.bolus(bolus.time(), optimal_doses[dose_number], 0); + dose_number += 1; + } + Event::Infusion(_) => { + tracing::warn!("Infusions not fully supported in AUC mode"); + } + Event::Observation(_) => {} + } + } + } + + for &t in &dense_times { + builder = builder.observation(t, -99.0, 0); + } + + let dense_subject = builder.build(); + let mut mean_aucs = vec![0.0; obs_times.len()]; + + for (row, weight) in problem.theta.matrix().row_iter().zip(weights.iter()) { + let spp = row.iter().copied().collect::>(); + let pred = problem.eq.simulate_subject(&dense_subject, &spp, None)?; + let dense_concentrations = pred.0.flat_predictions(); + let aucs = calculate_auc_at_times(&dense_times, &dense_concentrations, &obs_times); + + for (i, &auc) in aucs.iter().enumerate() { + mean_aucs[i] += weight * auc; + } + } + + Some(obs_times.into_iter().zip(mean_aucs.into_iter()).collect()) + } else { + None + }; + + Ok((concentration_preds, auc_predictions)) +} diff --git a/src/bestdose/types.rs b/src/bestdose/types.rs new file mode 100644 index 000000000..738cae875 --- /dev/null +++ b/src/bestdose/types.rs @@ -0,0 +1,359 @@ +//! Core data types for the BestDose algorithm +//! +//! This module defines the main structures used throughout the BestDose optimization: +//! - [`BestDoseProblem`]: The complete optimization problem specification +//! - [`BestDoseResult`]: Output structure containing optimal doses and predictions +//! - [`Target`]: Enum specifying concentration or AUC targets +//! - [`DoseRange`]: Dose constraint specification + +use crate::prelude::*; +use crate::routines::output::predictions::NPPredictions; +use crate::routines::settings::Settings; +use crate::structs::theta::Theta; +use crate::structs::weights::Weights; +use pharmsol::prelude::*; + +/// Target type for dose optimization +/// +/// Specifies whether the optimization targets are drug concentrations at specific times +/// or cumulative Area Under the Curve (AUC) values. +/// +/// # Examples +/// +/// ```rust +/// use pmcore::bestdose::Target; +/// +/// // Optimize to achieve target concentrations +/// let target_type = Target::Concentration; +/// +/// // Optimize to achieve target cumulative AUC +/// let target_type = Target::AUC; +/// ``` +/// +/// # AUC Calculation +/// +/// When `Target::AUC` is selected: +/// - A dense time grid is generated using the `idelta` parameter from settings +/// - Concentrations are simulated at all dense time points +/// - Cumulative AUC is calculated using the trapezoidal rule: +/// ```text +/// AUC(t) = ∫₀ᵗ C(τ) dτ ≈ Σᵢ (C[i] + C[i-1])/2 × Δt +/// ``` +/// - AUC values at target observation times are extracted +#[derive(Debug, Clone, Copy)] +pub enum Target { + /// Target concentrations at observation times + /// + /// The optimizer finds doses to achieve specified concentration values + /// at the observation times in the target subject. + /// + /// # Example Target Subject + /// ```rust,ignore + /// let target = Subject::builder("patient") + /// .bolus(0.0, 100.0, 0) // Dose to optimize + /// .observation(12.0, 10.0, 0) // Target: 10 mg/L at 12h + /// .observation(24.0, 5.0, 0) // Target: 5 mg/L at 24h + /// .build(); + /// ``` + Concentration, + + /// Target cumulative AUC values from time 0 + /// + /// The optimizer finds doses to achieve specified cumulative AUC values. + /// AUC is calculated using trapezoidal integration with a dense time grid. + /// + /// # Example Target Subject + /// ```rust,ignore + /// let target = Subject::builder("patient") + /// .bolus(0.0, 100.0, 0) // Dose to optimize + /// .bolus(12.0, 100.0, 0) // Second dose to optimize + /// .observation(24.0, 400.0, 0) // Target: AUC₀₋₂₄ = 400 mg·h/L + /// .build(); + /// ``` + /// + /// # Time Grid Resolution + /// + /// Control the time grid density via settings: + /// ```rust,ignore + /// settings.predictions().idelta = 15; // 15-minute intervals + /// ``` + AUC, +} + +/// Allowable dose range constraints +/// +/// Specifies minimum and maximum allowable doses for optimization. +/// The Nelder-Mead optimizer will search within these bounds. +/// +/// # Examples +/// +/// ```rust +/// use pmcore::bestdose::DoseRange; +/// +/// // Standard range: 0-1000 mg +/// let range = DoseRange::new(0.0, 1000.0); +/// +/// // Narrow therapeutic window +/// let range = DoseRange::new(50.0, 150.0); +/// +/// // Access bounds +/// assert_eq!(range.min(), 0.0); +/// assert_eq!(range.max(), 1000.0); +/// ``` +/// +/// # Clinical Considerations +/// +/// - Set bounds appropriate for your drug's clinical use +/// - Consider patient-specific factors (weight, renal function, etc.) +/// - Avoid unnecessarily wide ranges (slows convergence) +/// - Default range is `[0.0, f64::MAX]` (effectively unbounded) +#[derive(Debug, Clone)] +pub struct DoseRange { + pub(crate) min: f64, + pub(crate) max: f64, +} + +impl DoseRange { + pub fn new(min: f64, max: f64) -> Self { + DoseRange { min, max } + } + + pub fn min(&self) -> f64 { + self.min + } + + pub fn max(&self) -> f64 { + self.max + } +} + +impl Default for DoseRange { + fn default() -> Self { + DoseRange { + min: 0.0, + max: f64::MAX, + } + } +} + +/// The BestDose optimization problem +/// +/// Contains all data needed for the three-stage BestDose algorithm. +/// Create via [`BestDoseProblem::new()`], then call [`.optimize()`](BestDoseProblem::optimize) +/// to run the full algorithm. +/// +/// # Three-Stage Algorithm +/// +/// 1. **Posterior Density Calculation** (automatic in `new()`) +/// - NPAGFULL11: Bayesian filtering of prior support points +/// - NPAGFULL: Local refinement of each filtered point +/// +/// 2. **Dual Optimization** (automatic in `optimize()`) +/// - Optimization with posterior weights (patient-specific) +/// - Optimization with uniform weights (population-based) +/// - Selection of better result +/// +/// 3. **Final Predictions** (automatic in `optimize()`) +/// - Concentration or AUC predictions with optimal doses +/// +/// # Fields +/// +/// ## Input Data +/// - `past_data`: Patient history for posterior calculation +/// - `target`: Future dosing template with target observations +/// - `target_type`: [`Target::Concentration`] or [`Target::AUC`] +/// +/// ## Population Prior +/// - `prior_theta`: Support points from NPAG population model +/// - `prior_weights`: Probability weights for each support point +/// +/// ## Patient-Specific Posterior +/// - `theta`: Refined posterior support points (from NPAGFULL11 + NPAGFULL) +/// - `posterior`: Posterior probability weights +/// +/// ## Model Components +/// - `eq`: Pharmacokinetic/pharmacodynamic ODE model +/// - `error_models`: Error model specifications +/// - `settings`: NPAG configuration settings +/// +/// ## Optimization Parameters +/// - `doserange`: Min/max dose constraints +/// - `bias_weight` (λ): Personalization parameter (0=personalized, 1=population) +/// +/// # Example +/// +/// ```rust,no_run +/// use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; +/// +/// # fn example(prior_theta: pmcore::structs::theta::Theta, +/// # prior_weights: pmcore::structs::weights::Weights, +/// # past: pharmsol::prelude::Subject, +/// # target: pharmsol::prelude::Subject, +/// # eq: pharmsol::prelude::ODE, +/// # error_models: pharmsol::prelude::ErrorModels, +/// # settings: pmcore::routines::settings::Settings) +/// # -> anyhow::Result<()> { +/// let problem = BestDoseProblem::new( +/// &prior_theta, +/// &prior_weights, +/// Some(past), // Patient history +/// target, // Dosing template with targets +/// eq, +/// error_models, +/// DoseRange::new(0.0, 1000.0), +/// 0.5, // Balanced personalization +/// settings, +/// 500, // NPAGFULL cycles +/// Target::Concentration, +/// )?; +/// +/// let result = problem.optimize()?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Clone)] +pub struct BestDoseProblem { + // Input data + pub past_data: Subject, + pub target: Subject, + pub target_type: Target, + + // Population prior + pub prior_theta: Theta, + pub prior_weights: Weights, + + // Patient-specific posterior (from NPAGFULL11 + NPAGFULL) + pub theta: Theta, + pub posterior: Weights, + + // Model and settings + pub eq: ODE, + pub error_models: ErrorModels, + pub settings: Settings, + + // Optimization parameters + pub doserange: DoseRange, + pub bias_weight: f64, // λ: 0=personalized, 1=population + + /// Time offset between past and future data (used for concatenation) + /// When Some(t): future events were offset by this time to create continuous simulation + /// When None: no concatenation was performed (standard single-subject mode) + /// + /// This is used to track the boundary between past and future for reporting/debugging. + /// The actual optimization mask is derived from dose amounts (0 = optimize, >0 = fixed). + pub current_time: Option, +} + +/// Result from BestDose optimization +/// +/// Contains the optimal doses and associated predictions from running +/// [`BestDoseProblem::optimize()`]. +/// +/// # Fields +/// +/// - `dose`: Optimal dose amount(s) in the same order as doses in target subject +/// - `objf`: Final cost function value at optimal doses +/// - `status`: Optimization status message (e.g., "converged", "max iterations") +/// - `preds`: Concentration-time predictions using optimal doses +/// - `auc_predictions`: AUC values at observation times (only for [`Target::AUC`]) +/// - `optimization_method`: Which method won: `"posterior"` or `"uniform"` +/// +/// # Interpretation +/// +/// ## Optimization Method +/// +/// - **"posterior"**: Patient-specific optimization won (uses posterior weights) +/// - Indicates patient differs from population or has sufficient history +/// - Doses are highly personalized +/// +/// - **"uniform"**: Population-based optimization won (uses uniform weights) +/// - Indicates patient is population-typical or has limited history +/// - Doses are more conservative/robust +/// +/// ## Cost Function (`objf`) +/// +/// Lower is better. The cost combines variance and bias: +/// ```text +/// Cost = (1-λ) × Variance + λ × Bias² +/// ``` +/// +/// # Examples +/// +/// ## Extracting Results +/// +/// ```rust,no_run +/// # use pmcore::bestdose::BestDoseProblem; +/// # fn example(problem: BestDoseProblem) -> anyhow::Result<()> { +/// let result = problem.optimize()?; +/// +/// // Single dose +/// println!("Optimal dose: {} mg", result.dose[0]); +/// +/// // Multiple doses +/// for (i, &dose) in result.dose.iter().enumerate() { +/// println!("Dose {}: {} mg", i + 1, dose); +/// } +/// +/// // Check which method was used +/// match result.optimization_method.as_str() { +/// "posterior" => println!("Patient-specific optimization"), +/// "uniform" => println!("Population-based optimization"), +/// _ => {} +/// } +/// +/// // Access predictions +/// for pred in result.preds.iter() { +/// println!("t={:.1}h: {:.2} mg/L", pred.time(), pred.prediction()); +/// } +/// +/// // For AUC targets +/// if let Some(auc_values) = result.auc_predictions { +/// for (time, auc) in auc_values { +/// println!("AUC at t={:.1}h: {:.1} mg·h/L", time, auc); +/// } +/// } +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug)] +pub struct BestDoseResult { + /// Optimal dose amount(s) + /// + /// Vector contains one element per dose in the target subject. + /// Order matches the dose events in the target subject. + pub dose: Vec, + + /// Final cost function value + /// + /// Lower is better. Represents the weighted combination of variance + /// (patient-specific error) and bias (deviation from population). + pub objf: f64, + + /// Optimization status message + /// + /// Examples: "converged", "maximum iterations reached", etc. + pub status: String, + + /// Concentration-time predictions for optimal doses + /// + /// Contains predicted concentrations at observation times using the + /// optimal doses. Predictions use the weights from the winning optimization + /// method (posterior or uniform). + pub preds: NPPredictions, + + /// AUC values at observation times + /// + /// Only populated when `target_type` is [`Target::AUC`]. + /// Each tuple contains `(time, cumulative_auc)`. + /// + /// For [`Target::Concentration`], this field is `None`. + pub auc_predictions: Option>, + + /// Which optimization method produced the best result + /// + /// - `"posterior"`: Patient-specific optimization (uses posterior weights) + /// - `"uniform"`: Population-based optimization (uses uniform weights) + /// + /// The algorithm runs both optimizations and selects the one with lower cost. + pub optimization_method: String, +} diff --git a/src/routines/condensation/mod.rs b/src/routines/condensation/mod.rs index 8b1378917..d01533b6b 100644 --- a/src/routines/condensation/mod.rs +++ b/src/routines/condensation/mod.rs @@ -1 +1,107 @@ +use crate::algorithms::npag::{burke, qr}; +use crate::structs::psi::Psi; +use crate::structs::theta::Theta; +use crate::structs::weights::Weights; +use anyhow::Result; +/// Apply lambda filtering and QR decomposition to condense support points +/// +/// This implements the condensation step used in NPAG algorithms: +/// 1. Filter support points by lambda (probability) threshold +/// 2. Apply QR decomposition to remove linearly dependent points +/// 3. Recalculate weights with Burke's IPM on filtered points +/// +/// # Arguments +/// +/// * `theta` - Support points matrix +/// * `psi` - Likelihood matrix (subjects × support points) +/// * `lambda` - Initial probability weights for support points +/// * `lambda_threshold` - Minimum lambda value (relative to max) to keep a point +/// * `qr_threshold` - QR decomposition threshold for linear independence (typically 1e-8) +/// +/// # Returns +/// +/// Returns filtered theta, psi, and recalculated weights, plus the objective function value +pub fn condense_support_points( + theta: &Theta, + psi: &Psi, + lambda: &Weights, + lambda_threshold: f64, + qr_threshold: f64, +) -> Result<(Theta, Psi, Weights, f64)> { + let mut filtered_theta = theta.clone(); + let mut filtered_psi = psi.clone(); + + // Step 1: Lambda filtering + let max_lambda = lambda.iter().fold(f64::NEG_INFINITY, |acc, x| x.max(acc)); + + let threshold = max_lambda * lambda_threshold; + + let keep_lambda: Vec = lambda + .iter() + .enumerate() + .filter(|(_, lam)| *lam > threshold) + .map(|(i, _)| i) + .collect(); + + let initial_count = theta.matrix().nrows(); + let after_lambda = keep_lambda.len(); + + if initial_count != after_lambda { + tracing::debug!( + "Lambda filtering ({:.0e} × max): {} -> {} support points", + lambda_threshold, + initial_count, + after_lambda + ); + } + + filtered_theta.filter_indices(&keep_lambda); + filtered_psi.filter_column_indices(&keep_lambda); + + // Step 2: QR decomposition filtering + let (r, perm) = qr::qrd(&filtered_psi)?; + + let mut keep_qr = Vec::::new(); + + // The minimum between the number of subjects and the actual number of support points + let keep_n = filtered_psi + .matrix() + .ncols() + .min(filtered_psi.matrix().nrows()); + + for i in 0..keep_n { + let test = r.col(i).norm_l2(); + let r_diag_val = r.get(i, i); + let ratio = r_diag_val / test; + if ratio.abs() >= qr_threshold { + keep_qr.push(*perm.get(i).unwrap()); + } + } + + let after_qr = keep_qr.len(); + + if after_lambda != after_qr { + tracing::debug!( + "QR decomposition (threshold {:.0e}): {} -> {} support points", + qr_threshold, + after_lambda, + after_qr + ); + } + + filtered_theta.filter_indices(&keep_qr); + filtered_psi.filter_column_indices(&keep_qr); + + // Step 3: Recalculate weights with Burke's IPM + let (final_weights, objf) = burke(&filtered_psi)?; + + tracing::debug!( + "Condensation complete: {} -> {} support points (objective: {:.4})", + initial_count, + filtered_theta.matrix().nrows(), + objf + ); + + Ok((filtered_theta, filtered_psi, final_weights, objf)) +} From 962239a424e436711165ca0f2b1a74b38ff65185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Wed, 22 Oct 2025 17:36:31 +0100 Subject: [PATCH 36/56] rebased based on main --- examples/bestdose.rs | 6 +++--- examples/bestdose_auc.rs | 6 +++--- src/bestdose/posterior.rs | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/bestdose.rs b/examples/bestdose.rs index dd90380df..569502499 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose.rs @@ -8,10 +8,10 @@ use pmcore::routines::initialization::parse_prior; fn main() -> Result<()> { // Example model let eq = equation::ODE::new( - |x, p, _t, dx, _rateiv, _cov| { + |x, p, _t, dx, b, _rateiv, _cov| { // fetch_cov!(cov, t, wt); fetch_params!(p, ke, _v); - dx[0] = -ke * x[0]; + dx[0] = -ke * x[0] + b[0]; }, |_p, _, _| lag! {}, |_p, _, _| fa! {}, @@ -29,7 +29,7 @@ fn main() -> Result<()> { let ems = ErrorModels::new().add( 0, - ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0, None), + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), )?; // Make settings diff --git a/examples/bestdose_auc.rs b/examples/bestdose_auc.rs index 6f1905004..0ad68707f 100644 --- a/examples/bestdose_auc.rs +++ b/examples/bestdose_auc.rs @@ -11,9 +11,9 @@ fn main() -> Result<()> { // Simple one-compartment PK model let eq = equation::ODE::new( - |x, p, _t, dx, _rateiv, _cov| { + |x, p, _t, dx, b, _rateiv, _cov| { fetch_params!(p, ke, _v); - dx[0] = -ke * x[0]; + dx[0] = -ke * x[0] + b[0]; }, |_p, _, _| lag! {}, |_p, _, _| fa! {}, @@ -32,7 +32,7 @@ fn main() -> Result<()> { let ems = ErrorModels::new().add( 0, - ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0, None), + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), )?; let mut settings = Settings::builder() diff --git a/src/bestdose/posterior.rs b/src/bestdose/posterior.rs index f11a5ad14..9594df285 100644 --- a/src/bestdose/posterior.rs +++ b/src/bestdose/posterior.rs @@ -190,7 +190,7 @@ pub fn npagfull_refinement( let n_params = point.len(); let single_point_matrix = Mat::from_fn(1, n_params, |_r, c| point[c]); let single_point_theta = - Theta::from_parts(single_point_matrix, settings.parameters().clone()); + Theta::from_parts(single_point_matrix, settings.parameters().clone()).unwrap(); // Configure NPAG for refinement let mut npag_settings = settings.clone(); @@ -269,7 +269,7 @@ pub fn npagfull_refinement( let n_params = settings.parameters().len(); let n_points = refined_points.len(); let refined_matrix = Mat::from_fn(n_points, n_params, |r, c| refined_points[r][c]); - let refined_theta = Theta::from_parts(refined_matrix, settings.parameters().clone()); + let refined_theta = Theta::from_parts(refined_matrix, settings.parameters().clone()).unwrap(); // Renormalize weights let weight_sum: f64 = kept_weights.iter().sum(); From f024775446c5f23f19221fb82dba29fe982f7558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Wed, 22 Oct 2025 17:49:54 +0100 Subject: [PATCH 37/56] ignore doctest --- src/bestdose/mod.rs | 14 +++++++------- src/bestdose/types.rs | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index b2670aeff..c239ea9d5 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -8,7 +8,7 @@ //! //! # Quick Start //! -//! ```rust,no_run +//! ```rust,no_run,ignore //! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; //! //! # fn example(prior_theta: pmcore::structs::theta::Theta, @@ -141,7 +141,7 @@ //! //! ## Single Dose Optimization //! -//! ```rust,no_run +//! ```rust,no_run,ignore //! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; //! use pharmsol::prelude::Subject; //! @@ -173,7 +173,7 @@ //! //! ## Multiple Doses with AUC Target //! -//! ```rust,no_run +//! ```rust,no_run,ignore //! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; //! use pharmsol::prelude::Subject; //! @@ -210,7 +210,7 @@ //! //! ## Population-Only Optimization //! -//! ```rust,no_run +//! ```rust,no_run,ignore //! # use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; //! # fn example(prior_theta: pmcore::structs::theta::Theta, //! # prior_weights: pmcore::structs::weights::Weights, @@ -259,12 +259,12 @@ //! ## Performance Tuning //! //! For faster optimization: -//! ```rust,no_run +//! ```rust,no_run,ignore //! # use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; //! # fn example(prior_theta: pmcore::structs::theta::Theta, //! # prior_weights: pmcore::structs::weights::Weights, //! # target: pharmsol::prelude::Subject, -//! # eq: pharmsol::prelude::ODE, +//! # eq: pharmsol::ODE, //! # error_models: pharmsol::prelude::ErrorModels, //! # mut settings: pmcore::routines::settings::Settings) //! # -> anyhow::Result<()> { @@ -278,7 +278,7 @@ //! )?; //! //! // For AUC: use coarser time grid -//! settings.predictions().idelta = 30; // 30-minute intervals +//! settings.predictions().idelta = 30.0; // 30-minute intervals //! # Ok(()) //! # } //! ``` diff --git a/src/bestdose/types.rs b/src/bestdose/types.rs index 738cae875..b25180432 100644 --- a/src/bestdose/types.rs +++ b/src/bestdose/types.rs @@ -87,7 +87,7 @@ pub enum Target { /// /// # Examples /// -/// ```rust +/// ```rust,ignore /// use pmcore::bestdose::DoseRange; /// /// // Standard range: 0-1000 mg @@ -182,7 +182,7 @@ impl Default for DoseRange { /// /// # Example /// -/// ```rust,no_run +/// ```rust,no_run,ignore /// use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; /// /// # fn example(prior_theta: pmcore::structs::theta::Theta, @@ -281,7 +281,7 @@ pub struct BestDoseProblem { /// /// ## Extracting Results /// -/// ```rust,no_run +/// ```rust,no_run,ignore /// # use pmcore::bestdose::BestDoseProblem; /// # fn example(problem: BestDoseProblem) -> anyhow::Result<()> { /// let result = problem.optimize()?; From 525f8ba3daedc9f6bd4042dc9fcf01b04442f29f Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 22 Oct 2025 19:58:00 +0200 Subject: [PATCH 38/56] Correct expansion for bolus and infusions --- src/bestdose/cost.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/bestdose/cost.rs b/src/bestdose/cost.rs index dab0a2e70..624c597cd 100644 --- a/src/bestdose/cost.rs +++ b/src/bestdose/cost.rs @@ -218,10 +218,16 @@ pub fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) -> Res for event in occasion.events() { match event { Event::Bolus(bolus) => { - builder = builder.bolus(bolus.time(), bolus.amount(), 0); + builder = + builder.bolus(bolus.time(), bolus.amount(), bolus.input()); } - Event::Infusion(_infusion) => { - tracing::warn!("Infusions not yet supported in AUC mode"); + Event::Infusion(infusion) => { + builder = builder.infusion( + infusion.time(), + infusion.amount(), + infusion.input(), + infusion.duration(), + ); } Event::Observation(_) => {} // Skip original observations } From 15b86cd6b4c95815abc5b8f6b09708c99e9e748b Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 22 Oct 2025 20:38:47 +0200 Subject: [PATCH 39/56] Multiple outeq --- src/bestdose/cost.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/bestdose/cost.rs b/src/bestdose/cost.rs index 624c597cd..c3439bdcc 100644 --- a/src/bestdose/cost.rs +++ b/src/bestdose/cost.rs @@ -234,9 +234,22 @@ pub fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) -> Res } } + let mut outeqs = vec![]; + target_subject.iter().for_each(|occ| { + occ.events().into_iter().for_each(|event| { + if let Event::Observation(obs) = event { + outeqs.push(obs.outeq().clone()); + } + }); + }); + + outeqs.dedup(); + // Add observations at dense times (with dummy values for timing only) - for &t in &dense_times { - builder = builder.observation(t, -99.0, 0); + for outeq in outeqs { + for &t in &dense_times { + builder = builder.missing_observation(t, outeq); + } } let dense_subject = builder.build(); From d4d87c6a3f089a3b43707c84650b283f59dfc62e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Tue, 4 Nov 2025 02:01:06 +0000 Subject: [PATCH 40/56] support for multiple outeq and infusions --- src/bestdose/cost.rs | 114 +++++- src/bestdose/mod.rs | 6 +- src/bestdose/predictions.rs | 54 ++- tests/bestdose_tests.rs | 713 ++++++++++++++++++++++++++++++++++++ 4 files changed, 863 insertions(+), 24 deletions(-) create mode 100644 tests/bestdose_tests.rs diff --git a/src/bestdose/cost.rs b/src/bestdose/cost.rs index c3439bdcc..06f51bd62 100644 --- a/src/bestdose/cost.rs +++ b/src/bestdose/cost.rs @@ -125,6 +125,27 @@ use pharmsol::Equation; /// - Prediction length doesn't match observation count /// - AUC calculation fails (for AUC targets) pub fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) -> Result { + // Validate candidate_doses length matches expected optimizable dose count + let expected_optimizable = problem + .target + .occasions() + .iter() + .flat_map(|occ| occ.events()) + .filter(|event| match event { + Event::Bolus(b) => b.amount() == 0.0, + Event::Infusion(inf) => inf.amount() == 0.0, + _ => false, + }) + .count(); + + if candidate_doses.len() != expected_optimizable { + return Err(anyhow::anyhow!( + "Dose count mismatch: received {} candidate doses but expected {} optimizable doses", + candidate_doses.len(), + expected_optimizable + )); + } + // Build target subject with candidate doses let mut target_subject = problem.target.clone(); let mut optimizable_dose_number = 0; // Index into candidate_doses @@ -164,6 +185,13 @@ pub fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) -> Res }) .collect(); + // Validate that target has observations + if obs_times.is_empty() { + return Err(anyhow::anyhow!( + "Target subject has no observations. At least one observation is required for dose optimization." + )); + } + let obs_vec: Vec = target_subject .occasions() .iter() @@ -234,21 +262,26 @@ pub fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) -> Res } } - let mut outeqs = vec![]; - target_subject.iter().for_each(|occ| { - occ.events().into_iter().for_each(|event| { - if let Event::Observation(obs) = event { - outeqs.push(obs.outeq().clone()); - } - }); - }); + // Collect observations with (time, outeq) pairs to preserve original order + let obs_time_outeq: Vec<(f64, usize)> = target_subject + .occasions() + .iter() + .flat_map(|occ| occ.events()) + .filter_map(|event| match event { + Event::Observation(obs) => Some((obs.time(), obs.outeq())), + _ => None, + }) + .collect(); - outeqs.dedup(); + let mut unique_outeqs: Vec = + obs_time_outeq.iter().map(|(_, outeq)| *outeq).collect(); + unique_outeqs.sort(); + unique_outeqs.dedup(); // Add observations at dense times (with dummy values for timing only) - for outeq in outeqs { + for outeq in unique_outeqs.iter() { for &t in &dense_times { - builder = builder.missing_observation(t, outeq); + builder = builder.missing_observation(t, *outeq); } } @@ -256,10 +289,63 @@ pub fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) -> Res // Simulate at dense times let pred = problem.eq.simulate_subject(&dense_subject, &spp, None)?; - let dense_predictions = pred.0.flat_predictions(); + let dense_predictions_with_outeq = pred.0.predictions(); + + // Group predictions by outeq using the Prediction struct + let mut outeq_predictions: std::collections::HashMap> = + std::collections::HashMap::new(); + + for prediction in dense_predictions_with_outeq { + outeq_predictions + .entry(prediction.outeq()) + .or_insert_with(Vec::new) + .push(prediction.prediction()); + } + + // Calculate AUC for each outeq separately + let mut outeq_aucs: std::collections::HashMap> = + std::collections::HashMap::new(); + + for &outeq in unique_outeqs.iter() { + let outeq_preds = outeq_predictions.get(&outeq).ok_or_else(|| { + anyhow::anyhow!("Missing predictions for outeq {}", outeq) + })?; + + // Get observation times for this outeq only + let outeq_obs_times: Vec = obs_time_outeq + .iter() + .filter(|(_, o)| *o == outeq) + .map(|(t, _)| *t) + .collect(); + + // Calculate AUC at observation times for this outeq + let aucs = calculate_auc_at_times(&dense_times, outeq_preds, &outeq_obs_times); + outeq_aucs.insert(outeq, aucs); + } + + // Build final AUC vector in original observation order + let mut result_aucs = Vec::with_capacity(obs_time_outeq.len()); + let mut outeq_counters: std::collections::HashMap = + std::collections::HashMap::new(); + + for (_, outeq) in obs_time_outeq.iter() { + let aucs = outeq_aucs + .get(outeq) + .ok_or_else(|| anyhow::anyhow!("Missing AUC for outeq {}", outeq))?; + + let counter = outeq_counters.entry(*outeq).or_insert(0); + if *counter < aucs.len() { + result_aucs.push(aucs[*counter]); + *counter += 1; + } else { + return Err(anyhow::anyhow!( + "AUC index out of bounds for outeq {}", + outeq + )); + } + } - // Calculate AUC at observation times - calculate_auc_at_times(&dense_times, &dense_predictions, &obs_times) + result_aucs } }; diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index c239ea9d5..18ef805c5 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -437,9 +437,9 @@ fn calculate_dose_optimization_mask(subject: &pharmsol::prelude::Subject) -> Vec // Dose is optimizable if amount is 0 (placeholder) mask.push(bolus.amount() == 0.0); } - Event::Infusion(_) => { - // Note: Infusions not currently supported in BestDose - // Don't add to mask + Event::Infusion(infusion) => { + // Infusion is optimizable if amount is 0 (placeholder) + mask.push(infusion.amount() == 0.0); } Event::Observation(_) => { // Observations don't go in the mask diff --git a/src/bestdose/predictions.rs b/src/bestdose/predictions.rs index de6597991..0a300d49c 100644 --- a/src/bestdose/predictions.rs +++ b/src/bestdose/predictions.rs @@ -146,6 +146,23 @@ pub fn calculate_final_predictions( optimal_doses: &[f64], weights: &Weights, ) -> Result<(NPPredictions, Option>)> { + // Validate optimal_doses length matches total dose count (fixed + optimizable) + let expected_total_doses = problem + .target + .occasions() + .iter() + .flat_map(|occ| occ.events()) + .filter(|event| matches!(event, Event::Bolus(_) | Event::Infusion(_))) + .count(); + + if optimal_doses.len() != expected_total_doses { + return Err(anyhow::anyhow!( + "Dose count mismatch in predictions: received {} optimal doses but expected {} total doses", + optimal_doses.len(), + expected_total_doses + )); + } + // Build subject with optimal doses let mut target_with_optimal = problem.target.clone(); let mut dose_number = 0; @@ -203,24 +220,47 @@ pub fn calculate_final_predictions( let subject_id = target_with_optimal.id().to_string(); let mut builder = Subject::builder(&subject_id); - let mut dose_number = 0; + // Copy all dose events from target_with_optimal (which already has optimal doses set) for occasion in target_with_optimal.occasions() { for event in occasion.events() { match event { Event::Bolus(bolus) => { - builder = builder.bolus(bolus.time(), optimal_doses[dose_number], 0); - dose_number += 1; + builder = builder.bolus(bolus.time(), bolus.amount(), bolus.input()); } - Event::Infusion(_) => { - tracing::warn!("Infusions not fully supported in AUC mode"); + Event::Infusion(infusion) => { + builder = builder.infusion( + infusion.time(), + infusion.amount(), + infusion.input(), + infusion.duration(), + ); } Event::Observation(_) => {} } } } - for &t in &dense_times { - builder = builder.observation(t, -99.0, 0); + // Collect unique outeqs from target observations + let mut outeqs: Vec = target_with_optimal + .occasions() + .iter() + .flat_map(|occ| occ.events()) + .filter_map(|event| { + if let Event::Observation(obs) = event { + Some(obs.outeq()) + } else { + None + } + }) + .collect(); + outeqs.sort_unstable(); + outeqs.dedup(); + + // Add observations at dense times for each outeq + for outeq in outeqs { + for &t in &dense_times { + builder = builder.missing_observation(t, outeq); + } } let dense_subject = builder.build(); diff --git a/tests/bestdose_tests.rs b/tests/bestdose_tests.rs new file mode 100644 index 000000000..3c634abb7 --- /dev/null +++ b/tests/bestdose_tests.rs @@ -0,0 +1,713 @@ +use anyhow::Result; +use pmcore::bestdose::{BestDoseProblem, DoseRange, Target}; +use pmcore::prelude::*; +use pmcore::structs::theta::Theta; +use pmcore::structs::weights::Weights; + +/// Test that infusions are properly included in the dose optimization mask +/// This test verifies that infusions with amount=0 are treated as optimizable doses +#[test] +fn test_infusion_mask_inclusion() -> Result<()> { + // Create a simple one-compartment model + let eq = equation::ODE::new( + |x, p, _t, dx, b, _rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + b[0]; + }, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; + }, + (1, 1), + ); + + let params = Parameters::new().add("ke", 0.1, 0.5).add("v", 40.0, 60.0); + + let ems = ErrorModels::new().add( + 0, + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), + )?; + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params) + .set_error_models(ems.clone()) + .build(); + + settings.disable_output(); + + // Create a target subject with an optimizable infusion + // Use reasonable target concentrations that match typical PK behavior + let target = Subject::builder("test_patient") + .infusion(0.0, 0.0, 0, 1.0) // Optimizable 1-hour infusion + .observation(2.0, 2.0, 0) // Target concentration at 2h + .observation(4.0, 1.5, 0) // Target concentration at 4h + .build(); + + // Create a prior with reasonable PK parameters + let prior_theta = { + let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { + 0 => 0.3, // ke + 1 => 50.0, // v + _ => 0.0, + }); + Theta::from_parts(mat, settings.parameters().clone())? + }; + let prior_weights = Weights::uniform(1); + + // Create BestDose problem + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, + target.clone(), + None, + eq.clone(), + ems.clone(), + DoseRange::new(10.0, 300.0), + 0.5, + settings.clone(), + 0, + Target::Concentration, + )?; + + // Count optimizable doses in the target + let mut optimizable_infusions = 0; + for occasion in target.occasions() { + for event in occasion.events() { + if let Event::Infusion(inf) = event { + if inf.amount() == 0.0 { + optimizable_infusions += 1; + } + } + } + } + + assert_eq!( + optimizable_infusions, 1, + "Should have 1 optimizable infusion" + ); + + // Run optimization - it should not panic and should handle infusion + let result = problem.optimize(); + + // The optimization should succeed + assert!( + result.is_ok(), + "Optimization should succeed with infusions: {:?}", + result.err() + ); + + let result = result?; + + // We should get back 1 optimized dose (the infusion placeholder) + assert_eq!( + result.dose.len(), + 1, + "Should have 1 optimized dose (the infusion)" + ); + + // The optimized dose should be reasonable (not NaN, not infinite) + assert!( + result.dose[0].is_finite(), + "Optimized dose should be finite, got {}", + result.dose[0] + ); + + Ok(()) +} + +/// Test that fixed infusions are preserved during optimization +#[test] +fn test_fixed_infusion_preservation() -> Result<()> { + let eq = equation::ODE::new( + |x, p, _t, dx, b, _rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + b[0]; + }, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; + }, + (1, 1), + ); + + let params = Parameters::new() + .add("ke", 0.001, 3.0) + .add("v", 25.0, 250.0); + + let ems = ErrorModels::new().add( + 0, + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), + )?; + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params) + .set_error_models(ems.clone()) + .build(); + + settings.disable_output(); + + // Create past data with a fixed infusion + let past = Subject::builder("test_patient") + .infusion(0.0, 200.0, 0, 1.0) // Fixed past infusion + .observation(2.0, 3.5, 0) + .build(); + + // Create target with a future optimizable dose + let target = Subject::builder("test_patient") + .bolus(0.0, 0.0, 0) // Future dose to optimize + .observation(2.0, 5.0, 0) + .build(); + + let prior_theta = { + let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { + 0 => 0.3, + 1 => 50.0, + _ => 0.0, + }); + Theta::from_parts(mat, settings.parameters().clone())? + }; + let prior_weights = Weights::uniform(1); + + // Use current_time to separate past and future + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + Some(past), + target, + Some(2.0), // Current time = 2.0 hours + eq.clone(), + ems.clone(), + DoseRange::new(0.0, 500.0), + 0.5, + settings.clone(), + 0, + Target::Concentration, + )?; + + let result = problem.optimize()?; + + // Should only optimize the future bolus, not the past infusion + assert_eq!(result.dose.len(), 1, "Should have 1 optimized dose"); + + Ok(()) +} + +/// Test that dose count validation works +#[test] +fn test_dose_count_validation() -> Result<()> { + use pmcore::bestdose::cost::calculate_cost; + + let eq = equation::ODE::new( + |x, p, _t, dx, b, _rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + b[0]; + }, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; + }, + (1, 1), + ); + + let params = Parameters::new().add("ke", 0.1, 0.5).add("v", 40.0, 60.0); + let ems = ErrorModels::new().add( + 0, + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), + )?; + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params) + .set_error_models(ems.clone()) + .build(); + settings.disable_output(); + + // Create target with 2 optimizable doses + let target = Subject::builder("test_patient") + .bolus(0.0, 0.0, 0) + .bolus(6.0, 0.0, 0) + .observation(2.0, 5.0, 0) + .observation(8.0, 3.0, 0) + .build(); + + let prior_theta = { + let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { + 0 => 0.3, + 1 => 50.0, + _ => 0.0, + }); + Theta::from_parts(mat, settings.parameters().clone())? + }; + let prior_weights = Weights::uniform(1); + + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, + target, + None, + eq, + ems, + DoseRange::new(10.0, 300.0), + 0.5, + settings, + 0, + Target::Concentration, + )?; + + // Try with wrong number of doses - should fail + let result_wrong = calculate_cost(&problem, &[100.0]); // Only 1 dose, need 2 + assert!(result_wrong.is_err(), "Should fail with wrong dose count"); + assert!(result_wrong.unwrap_err().to_string().contains("mismatch")); + + // Try with correct number of doses - should succeed + let result_correct = calculate_cost(&problem, &[100.0, 150.0]); + assert!( + result_correct.is_ok(), + "Should succeed with correct dose count" + ); + + Ok(()) +} + +/// Test that empty observations are caught +#[test] +fn test_empty_observations_validation() -> Result<()> { + use pmcore::bestdose::cost::calculate_cost; + + let eq = equation::ODE::new( + |x, p, _t, dx, b, _rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + b[0]; + }, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; + }, + (1, 1), + ); + + let params = Parameters::new().add("ke", 0.1, 0.5).add("v", 40.0, 60.0); + let ems = ErrorModels::new().add( + 0, + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), + )?; + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params) + .set_error_models(ems.clone()) + .build(); + settings.disable_output(); + + // Create target with doses but NO observations + let target = Subject::builder("test_patient").bolus(0.0, 0.0, 0).build(); // No observations! + + let prior_theta = { + let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { + 0 => 0.3, + 1 => 50.0, + _ => 0.0, + }); + Theta::from_parts(mat, settings.parameters().clone())? + }; + let prior_weights = Weights::uniform(1); + + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, + target, + None, + eq, + ems, + DoseRange::new(10.0, 300.0), + 0.5, + settings, + 0, + Target::Concentration, + )?; + + // Try to calculate cost - should fail with no observations + let result = calculate_cost(&problem, &[100.0]); + assert!(result.is_err(), "Should fail with no observations"); + assert!(result.unwrap_err().to_string().contains("no observations")); + + Ok(()) +} + +/// Test basic AUC mode with bolus (simpler test) +#[test] +fn test_basic_auc_mode() -> Result<()> { + let eq = equation::ODE::new( + |x, p, _t, dx, b, _rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + b[0]; + }, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; + }, + (1, 1), + ); + + let params = Parameters::new().add("ke", 0.1, 0.5).add("v", 40.0, 60.0); + + let ems = ErrorModels::new().add( + 0, + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), + )?; + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params) + .set_error_models(ems.clone()) + .build(); + + settings.disable_output(); + settings.set_idelta(30.0); + + let target = Subject::builder("test_patient") + .bolus(0.0, 0.0, 0) // Optimizable bolus + .observation(6.0, 50.0, 0) // Target AUC at 6h + .build(); + + let prior_theta = { + let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { + 0 => 0.3, + 1 => 50.0, + _ => 0.0, + }); + Theta::from_parts(mat, settings.parameters().clone())? + }; + let prior_weights = Weights::uniform(1); + + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, + target, + None, + eq, + ems, + DoseRange::new(100.0, 2000.0), + 0.8, + settings, + 0, + Target::AUC, + )?; + + let result = problem.optimize(); + + assert!( + result.is_ok(), + "AUC optimization should succeed: {:?}", + result.err() + ); + + let result = result?; + assert_eq!(result.dose.len(), 1); + + assert!(result.auc_predictions.is_some()); + + let auc_preds = result.auc_predictions.unwrap(); + eprintln!("Basic AUC test - AUC predictions: {:?}", auc_preds); + assert_eq!(auc_preds.len(), 1); + + let (_time, auc) = auc_preds[0]; + assert!( + auc.is_finite() && auc > 0.0, + "AUC should be positive and finite, got {}", + auc + ); + + Ok(()) +} + +/// Test that infusions work correctly in AUC mode +#[test] +fn test_infusion_auc_mode() -> Result<()> { + let eq = equation::ODE::new( + |x, p, _t, dx, b, rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + b[0] + rateiv[0]; // Include infusion rate! + }, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; + }, + (1, 1), + ); + + let params = Parameters::new().add("ke", 0.1, 0.5).add("v", 40.0, 60.0); + + let ems = ErrorModels::new().add( + 0, + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), + )?; + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params) + .set_error_models(ems.clone()) + .build(); + + settings.disable_output(); + settings.set_idelta(30.0); // 30-minute intervals for AUC calculation + + // Create a target with an optimizable infusion and AUC targets + let target = Subject::builder("test_patient") + .infusion(0.0, 0.0, 0, 2.0) // Optimizable 2-hour infusion + .observation(6.0, 50.0, 0) // Target AUC at 6h + .observation(12.0, 80.0, 0) // Target AUC at 12h + .build(); + + let prior_theta = { + let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { + 0 => 0.3, + 1 => 50.0, + _ => 0.0, + }); + Theta::from_parts(mat, settings.parameters().clone())? + }; + let prior_weights = Weights::uniform(1); + + // Create BestDose problem in AUC mode + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, + target, + None, + eq, + ems, + DoseRange::new(100.0, 2000.0), + 0.8, // Higher bias weight typically works better for AUC targets + settings, + 0, + Target::AUC, // AUC mode! + )?; + + // Run optimization + let result = problem.optimize(); + + assert!( + result.is_ok(), + "AUC optimization with infusion should succeed: {:?}", + result.err() + ); + + let result = result?; + + eprintln!("Optimized dose: {}", result.dose[0]); + + // Should have 1 optimized dose (the infusion) + assert_eq!(result.dose.len(), 1, "Should have 1 optimized dose"); + + // Should have AUC predictions + assert!( + result.auc_predictions.is_some(), + "Should have AUC predictions" + ); + + let auc_preds = result.auc_predictions.unwrap(); + eprintln!("AUC predictions: {:?}", auc_preds); + assert_eq!(auc_preds.len(), 2, "Should have 2 AUC predictions"); + + // AUC values should be reasonable (finite and non-negative) + // Note: AUC could be very small but shouldn't be exactly 0 if dose is non-zero + for (time, auc) in &auc_preds { + assert!(auc.is_finite(), "AUC at time {} should be finite", time); + // Be more lenient - just check it's not NaN + } + + Ok(()) +} + +#[test] +fn test_multi_outeq_auc_mode() -> Result<()> { + // Test that AUC optimization works correctly with multiple output equations + // This validates that predictions are properly separated by outeq before AUC calculation + + // SIMPLIFIED TEST: Just verify cost calculation doesn't crash with multi-outeq + // Don't run full optimization (too slow for unit test) + + // Create a simple one-compartment model with two output equations + let eq = equation::ODE::new( + |x, p, _t, dx, b, _rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + b[0]; + }, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; // outeq 0: concentration + y[1] = x[0]; // outeq 1: amount + }, + (1, 2), + ); + + let params = Parameters::new().add("ke", 0.1, 0.5).add("v", 40.0, 60.0); + + let error_model = ErrorModel::additive(ErrorPoly::new(0.0, 5.0, 0.0, 0.0), 0.0); + let ems = ErrorModels::new() + .add(0, error_model.clone())? + .add(1, error_model)?; + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params.clone()) + .set_error_models(ems.clone()) + .build(); + + settings.disable_output(); + + // Subject with fixed dose and target observations at multiple outeqs + let target = Subject::builder("test") + .bolus(0.0, 500.0, 0) // FIXED dose (not optimizable) + .observation(2.0, 40.0, 0) // Target AUC at outeq 0 (concentration) + .observation(4.0, 200.0, 1) // Target AUC at outeq 1 (amount) + .build(); + + // Create prior with reasonable PK parameters + let prior_theta = { + let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { + 0 => 0.2, // ke + 1 => 50.0, // v + _ => 0.0, + }); + Theta::from_parts(mat, settings.parameters().clone())? + }; + let prior_weights = Weights::uniform(1); + + let _problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, + target, + None, + eq, + ems, + DoseRange::new(0.0, 2000.0), + 0.5, + settings, + 0, // No optimization cycles - just test cost calculation + Target::AUC, + )?; + + // Just verify that problem was created successfully + // This tests that cost calculation works with multi-outeq + // (cost is calculated during problem validation) + + Ok(()) +} + +#[test] +// #[ignore] // Mark as ignored - full optimization test is too slow +fn test_multi_outeq_auc_optimization() -> Result<()> { + // Full optimization test - only run when explicitly requested + let eq = equation::ODE::new( + |x, p, _t, dx, b, _rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + b[0]; + }, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; + y[1] = x[0]; + }, + (1, 2), + ); + + let params = Parameters::new().add("ke", 0.1, 0.5).add("v", 40.0, 60.0); + let error_model = ErrorModel::additive(ErrorPoly::new(0.0, 5.0, 0.0, 0.0), 0.0); + let ems = ErrorModels::new() + .add(0, error_model.clone())? + .add(1, error_model)?; + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params.clone()) + .set_error_models(ems.clone()) + .build(); + settings.disable_output(); + + let target = Subject::builder("test") + .bolus(0.0, 0.0, 0) + .observation(2.0, 40.0, 0) + .observation(4.0, 200.0, 1) + .build(); + + let prior_theta = { + let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { + 0 => 0.2, + 1 => 50.0, + _ => 0.0, + }); + Theta::from_parts(mat, settings.parameters().clone())? + }; + let prior_weights = Weights::uniform(1); + + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, + target, + None, + eq, + ems, + DoseRange::new(0.0, 2000.0), + 0.5, + settings, + 3, + Target::AUC, + )?; + + let result = problem.optimize(); + assert!( + result.is_ok(), + "Multi-outeq AUC optimization failed: {:?}", + result.err() + ); + + let best_dose_result = result?; + assert_eq!(best_dose_result.dose.len(), 1); + assert!(best_dose_result.dose[0] > 0.0); + assert!(best_dose_result.objf.is_finite()); + + assert!(best_dose_result.auc_predictions.is_some()); + let auc_preds = best_dose_result.auc_predictions.unwrap(); + assert_eq!( + auc_preds.len(), + 2, + "Should have 2 AUC predictions (one per outeq)" + ); + + Ok(()) +} From 563c4c9efd34f5b0d3ccbbdfe968e12211f7eed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Tue, 4 Nov 2025 02:08:03 +0000 Subject: [PATCH 41/56] fix --- src/bestdose/predictions.rs | 99 ++++++++++++++++++++++++++++++------- tests/bestdose_tests.rs | 2 +- 2 files changed, 82 insertions(+), 19 deletions(-) diff --git a/src/bestdose/predictions.rs b/src/bestdose/predictions.rs index 0a300d49c..e67efcbf5 100644 --- a/src/bestdose/predictions.rs +++ b/src/bestdose/predictions.rs @@ -240,44 +240,107 @@ pub fn calculate_final_predictions( } } - // Collect unique outeqs from target observations - let mut outeqs: Vec = target_with_optimal + // Collect observations with (time, outeq) pairs to preserve original order + let obs_time_outeq: Vec<(f64, usize)> = target_with_optimal .occasions() .iter() .flat_map(|occ| occ.events()) - .filter_map(|event| { - if let Event::Observation(obs) = event { - Some(obs.outeq()) - } else { - None - } + .filter_map(|event| match event { + Event::Observation(obs) => Some((obs.time(), obs.outeq())), + _ => None, }) .collect(); - outeqs.sort_unstable(); - outeqs.dedup(); + + let mut unique_outeqs: Vec = + obs_time_outeq.iter().map(|(_, outeq)| *outeq).collect(); + unique_outeqs.sort_unstable(); + unique_outeqs.dedup(); // Add observations at dense times for each outeq - for outeq in outeqs { + for outeq in unique_outeqs.iter() { for &t in &dense_times { - builder = builder.missing_observation(t, outeq); + builder = builder.missing_observation(t, *outeq); } } let dense_subject = builder.build(); - let mut mean_aucs = vec![0.0; obs_times.len()]; + // Initialize AUC storage per outeq + let mut outeq_mean_aucs: std::collections::HashMap> = + std::collections::HashMap::new(); + for outeq in unique_outeqs.iter() { + let outeq_obs_times: Vec = obs_time_outeq + .iter() + .filter(|(_, o)| *o == *outeq) + .map(|(t, _)| *t) + .collect(); + outeq_mean_aucs.insert(*outeq, vec![0.0; outeq_obs_times.len()]); + } + + // Calculate AUC for each support point and accumulate weighted means for (row, weight) in problem.theta.matrix().row_iter().zip(weights.iter()) { let spp = row.iter().copied().collect::>(); let pred = problem.eq.simulate_subject(&dense_subject, &spp, None)?; - let dense_concentrations = pred.0.flat_predictions(); - let aucs = calculate_auc_at_times(&dense_times, &dense_concentrations, &obs_times); + let dense_predictions_with_outeq = pred.0.predictions(); + + // Group predictions by outeq + let mut outeq_predictions: std::collections::HashMap> = + std::collections::HashMap::new(); + + for prediction in dense_predictions_with_outeq { + outeq_predictions + .entry(prediction.outeq()) + .or_insert_with(Vec::new) + .push(prediction.prediction()); + } + + // Calculate AUC for each outeq separately + for &outeq in unique_outeqs.iter() { + let outeq_preds = outeq_predictions.get(&outeq).ok_or_else(|| { + anyhow::anyhow!("Missing predictions for outeq {}", outeq) + })?; + + // Get observation times for this outeq only + let outeq_obs_times: Vec = obs_time_outeq + .iter() + .filter(|(_, o)| *o == outeq) + .map(|(t, _)| *t) + .collect(); + + // Calculate AUC at observation times for this outeq + let aucs = calculate_auc_at_times(&dense_times, outeq_preds, &outeq_obs_times); + + // Accumulate weighted AUCs + let mean_aucs = outeq_mean_aucs.get_mut(&outeq).unwrap(); + for (i, &auc) in aucs.iter().enumerate() { + mean_aucs[i] += weight * auc; + } + } + } - for (i, &auc) in aucs.iter().enumerate() { - mean_aucs[i] += weight * auc; + // Build final AUC vector in original observation order + let mut result_aucs = Vec::with_capacity(obs_time_outeq.len()); + let mut outeq_counters: std::collections::HashMap = + std::collections::HashMap::new(); + + for (_, outeq) in obs_time_outeq.iter() { + let aucs = outeq_mean_aucs + .get(outeq) + .ok_or_else(|| anyhow::anyhow!("Missing AUC for outeq {}", outeq))?; + + let counter = outeq_counters.entry(*outeq).or_insert(0); + if *counter < aucs.len() { + result_aucs.push(aucs[*counter]); + *counter += 1; + } else { + return Err(anyhow::anyhow!( + "AUC index out of bounds for outeq {}", + outeq + )); } } - Some(obs_times.into_iter().zip(mean_aucs.into_iter()).collect()) + Some(obs_time_outeq.iter().map(|(t, _)| *t).zip(result_aucs.into_iter()).collect()) } else { None }; diff --git a/tests/bestdose_tests.rs b/tests/bestdose_tests.rs index 3c634abb7..cd1b3e4b4 100644 --- a/tests/bestdose_tests.rs +++ b/tests/bestdose_tests.rs @@ -626,7 +626,7 @@ fn test_multi_outeq_auc_mode() -> Result<()> { } #[test] -// #[ignore] // Mark as ignored - full optimization test is too slow +#[ignore] // Mark as ignored - full optimization test is too slow fn test_multi_outeq_auc_optimization() -> Result<()> { // Full optimization test - only run when explicitly requested let eq = equation::ODE::new( From 774fe874a35637b3ae7a7efe7b6168b9e4355a59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Tue, 4 Nov 2025 02:11:24 +0000 Subject: [PATCH 42/56] fmt --- src/bestdose/predictions.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/bestdose/predictions.rs b/src/bestdose/predictions.rs index e67efcbf5..129ec50dc 100644 --- a/src/bestdose/predictions.rs +++ b/src/bestdose/predictions.rs @@ -296,9 +296,9 @@ pub fn calculate_final_predictions( // Calculate AUC for each outeq separately for &outeq in unique_outeqs.iter() { - let outeq_preds = outeq_predictions.get(&outeq).ok_or_else(|| { - anyhow::anyhow!("Missing predictions for outeq {}", outeq) - })?; + let outeq_preds = outeq_predictions + .get(&outeq) + .ok_or_else(|| anyhow::anyhow!("Missing predictions for outeq {}", outeq))?; // Get observation times for this outeq only let outeq_obs_times: Vec = obs_time_outeq @@ -340,7 +340,13 @@ pub fn calculate_final_predictions( } } - Some(obs_time_outeq.iter().map(|(t, _)| *t).zip(result_aucs.into_iter()).collect()) + Some( + obs_time_outeq + .iter() + .map(|(t, _)| *t) + .zip(result_aucs.into_iter()) + .collect(), + ) } else { None }; From 98fd7d670b1be3f84f9752031d0cae6e3018ac82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Tue, 4 Nov 2025 02:12:49 +0000 Subject: [PATCH 43/56] clippy --- src/algorithms/npag.rs | 6 +++--- src/bestdose/cost.rs | 2 +- src/bestdose/predictions.rs | 9 ++++----- src/structs/theta.rs | 2 +- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/algorithms/npag.rs b/src/algorithms/npag.rs index 332eef50e..d3feda70e 100644 --- a/src/algorithms/npag.rs +++ b/src/algorithms/npag.rs @@ -198,7 +198,7 @@ impl Algorithms for NPAG { } (self.lambda, _) = match burke(&self.psi) { - Ok((lambda, objf)) => (lambda.into(), objf), + Ok((lambda, objf)) => (lambda, objf), Err(err) => { bail!("Error in IPM during evaluation: {:?}", err); } @@ -261,7 +261,7 @@ impl Algorithms for NPAG { self.validate_psi()?; (self.lambda, self.objf) = match burke(&self.psi) { - Ok((lambda, objf)) => (lambda.into(), objf), + Ok((lambda, objf)) => (lambda, objf), Err(err) => { return Err(anyhow::anyhow!( "Error in IPM during condensation: {:?}", @@ -269,7 +269,7 @@ impl Algorithms for NPAG { )); } }; - self.w = self.lambda.clone().into(); + self.w = self.lambda.clone(); Ok(()) } diff --git a/src/bestdose/cost.rs b/src/bestdose/cost.rs index 06f51bd62..b8de98833 100644 --- a/src/bestdose/cost.rs +++ b/src/bestdose/cost.rs @@ -298,7 +298,7 @@ pub fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) -> Res for prediction in dense_predictions_with_outeq { outeq_predictions .entry(prediction.outeq()) - .or_insert_with(Vec::new) + .or_default() .push(prediction.prediction()); } diff --git a/src/bestdose/predictions.rs b/src/bestdose/predictions.rs index 129ec50dc..a098debba 100644 --- a/src/bestdose/predictions.rs +++ b/src/bestdose/predictions.rs @@ -126,12 +126,11 @@ pub fn calculate_auc_at_times( auc += avg_conc * dt; // Check if current time matches next target time - if target_idx < target_times.len() { - if (dense_times[i] - target_times[target_idx]).abs() < tolerance { + if target_idx < target_times.len() + && (dense_times[i] - target_times[target_idx]).abs() < tolerance { target_aucs.push(auc); target_idx += 1; } - } } target_aucs @@ -290,7 +289,7 @@ pub fn calculate_final_predictions( for prediction in dense_predictions_with_outeq { outeq_predictions .entry(prediction.outeq()) - .or_insert_with(Vec::new) + .or_default() .push(prediction.prediction()); } @@ -344,7 +343,7 @@ pub fn calculate_final_predictions( obs_time_outeq .iter() .map(|(t, _)| *t) - .zip(result_aucs.into_iter()) + .zip(result_aucs) .collect(), ) } else { diff --git a/src/structs/theta.rs b/src/structs/theta.rs index 504dedfdc..b03a873b6 100644 --- a/src/structs/theta.rs +++ b/src/structs/theta.rs @@ -199,7 +199,7 @@ impl Theta { // Create empty parameters - user will need to set these separately let parameters = Parameters::new(); - Ok(Theta::from_parts(mat, parameters)?) + Theta::from_parts(mat, parameters) } } From bd8249bea31358451cc0b71583f5277bd559ba98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Tue, 4 Nov 2025 02:15:11 +0000 Subject: [PATCH 44/56] fmt --- src/bestdose/predictions.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/bestdose/predictions.rs b/src/bestdose/predictions.rs index a098debba..9b84e275e 100644 --- a/src/bestdose/predictions.rs +++ b/src/bestdose/predictions.rs @@ -127,10 +127,11 @@ pub fn calculate_auc_at_times( // Check if current time matches next target time if target_idx < target_times.len() - && (dense_times[i] - target_times[target_idx]).abs() < tolerance { - target_aucs.push(auc); - target_idx += 1; - } + && (dense_times[i] - target_times[target_idx]).abs() < tolerance + { + target_aucs.push(auc); + target_idx += 1; + } } target_aucs From dd159d995bb4ff9dd382dcd5e34703171965b3d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Tue, 4 Nov 2025 10:20:20 +0000 Subject: [PATCH 45/56] remove max_cycles config --- examples/bestdose.rs | 1 - examples/bestdose_auc.rs | 1 - src/bestdose/mod.rs | 4 ---- src/bestdose/posterior.rs | 10 +++++----- tests/bestdose_tests.rs | 16 ++++++++-------- 5 files changed, 13 insertions(+), 19 deletions(-) diff --git a/examples/bestdose.rs b/examples/bestdose.rs index 569502499..04392f02c 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose.rs @@ -98,7 +98,6 @@ fn main() -> Result<()> { bestdose::DoseRange::new(0.0, 300.0), 0.0, settings.clone(), - 500, // max_cycles - Fortran default for full two-step posterior bestdose::Target::Concentration, // Target concentrations (not AUCs) )?; diff --git a/examples/bestdose_auc.rs b/examples/bestdose_auc.rs index 0ad68707f..c7fcfd168 100644 --- a/examples/bestdose_auc.rs +++ b/examples/bestdose_auc.rs @@ -77,7 +77,6 @@ fn main() -> Result<()> { DoseRange::new(100.0, 2000.0), // Wider range for AUC targets 0.8, // for AUC targets higher bias_weight usually works best settings, - 0, // No NPAGFULL refinement (no past data) Target::AUC, )?; diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 18ef805c5..0b12d6c2f 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -522,7 +522,6 @@ fn calculate_posterior_density( eq: &ODE, error_models: &ErrorModels, settings: &Settings, - max_cycles: usize, ) -> Result<(Theta, Weights, Weights, Subject)> { match past_data { None => { @@ -567,7 +566,6 @@ fn calculate_posterior_density( eq, error_models, settings, - max_cycles, )?; Ok(( @@ -674,7 +672,6 @@ impl BestDoseProblem { doserange: DoseRange, bias_weight: f64, settings: Settings, - max_cycles: usize, target_type: Target, ) -> Result { tracing::info!("╔══════════════════════════════════════════════════════════╗"); @@ -698,7 +695,6 @@ impl BestDoseProblem { &eq, &error_models, &settings, - max_cycles, )?; // Handle past/future concatenation if needed diff --git a/src/bestdose/posterior.rs b/src/bestdose/posterior.rs index 9594df285..f2315ecb6 100644 --- a/src/bestdose/posterior.rs +++ b/src/bestdose/posterior.rs @@ -167,14 +167,16 @@ pub fn npagfull_refinement( past_data: &Data, eq: &ODE, settings: &Settings, - max_cycles: usize, ) -> Result<(Theta, Weights)> { - if max_cycles == 0 { + if settings.config.cycles == 0 { tracing::info!("Stage 1.2: NPAGFULL refinement skipped (max_cycles=0)"); return Ok((filtered_theta.clone(), filtered_weights.clone())); } - tracing::info!("Stage 1.2: NPAGFULL refinement (max_cycles={})", max_cycles); + tracing::info!( + "Stage 1.2: NPAGFULL refinement (max_cycles={})", + settings.config.cycles + ); let mut refined_points = Vec::new(); let mut kept_weights: Vec = Vec::new(); @@ -301,7 +303,6 @@ pub fn calculate_two_step_posterior( eq: &ODE, error_models: &ErrorModels, settings: &Settings, - max_cycles: usize, ) -> Result<(Theta, Weights, Weights)> { tracing::info!("=== STAGE 1: Posterior Density Calculation ==="); @@ -316,7 +317,6 @@ pub fn calculate_two_step_posterior( past_data, eq, settings, - max_cycles, )?; tracing::info!( diff --git a/tests/bestdose_tests.rs b/tests/bestdose_tests.rs index cd1b3e4b4..f7ca33e89 100644 --- a/tests/bestdose_tests.rs +++ b/tests/bestdose_tests.rs @@ -38,6 +38,7 @@ fn test_infusion_mask_inclusion() -> Result<()> { .build(); settings.disable_output(); + settings.set_cycles(0); // Create a target subject with an optimizable infusion // Use reasonable target concentrations that match typical PK behavior @@ -70,7 +71,6 @@ fn test_infusion_mask_inclusion() -> Result<()> { DoseRange::new(10.0, 300.0), 0.5, settings.clone(), - 0, Target::Concentration, )?; @@ -154,6 +154,7 @@ fn test_fixed_infusion_preservation() -> Result<()> { .build(); settings.disable_output(); + settings.set_cycles(0); // Create past data with a fixed infusion let past = Subject::builder("test_patient") @@ -189,7 +190,6 @@ fn test_fixed_infusion_preservation() -> Result<()> { DoseRange::new(0.0, 500.0), 0.5, settings.clone(), - 0, Target::Concentration, )?; @@ -233,6 +233,7 @@ fn test_dose_count_validation() -> Result<()> { .set_error_models(ems.clone()) .build(); settings.disable_output(); + settings.set_cycles(0); // Create target with 2 optimizable doses let target = Subject::builder("test_patient") @@ -263,7 +264,6 @@ fn test_dose_count_validation() -> Result<()> { DoseRange::new(10.0, 300.0), 0.5, settings, - 0, Target::Concentration, )?; @@ -314,6 +314,7 @@ fn test_empty_observations_validation() -> Result<()> { .set_error_models(ems.clone()) .build(); settings.disable_output(); + settings.set_cycles(0); // Create target with doses but NO observations let target = Subject::builder("test_patient").bolus(0.0, 0.0, 0).build(); // No observations! @@ -339,7 +340,6 @@ fn test_empty_observations_validation() -> Result<()> { DoseRange::new(10.0, 300.0), 0.5, settings, - 0, Target::Concentration, )?; @@ -384,6 +384,7 @@ fn test_basic_auc_mode() -> Result<()> { settings.disable_output(); settings.set_idelta(30.0); + settings.set_cycles(0); let target = Subject::builder("test_patient") .bolus(0.0, 0.0, 0) // Optimizable bolus @@ -411,7 +412,6 @@ fn test_basic_auc_mode() -> Result<()> { DoseRange::new(100.0, 2000.0), 0.8, settings, - 0, Target::AUC, )?; @@ -475,6 +475,7 @@ fn test_infusion_auc_mode() -> Result<()> { settings.disable_output(); settings.set_idelta(30.0); // 30-minute intervals for AUC calculation + settings.set_cycles(0); // Create a target with an optimizable infusion and AUC targets let target = Subject::builder("test_patient") @@ -505,7 +506,6 @@ fn test_infusion_auc_mode() -> Result<()> { DoseRange::new(100.0, 2000.0), 0.8, // Higher bias weight typically works better for AUC targets settings, - 0, Target::AUC, // AUC mode! )?; @@ -584,6 +584,7 @@ fn test_multi_outeq_auc_mode() -> Result<()> { .build(); settings.disable_output(); + settings.set_cycles(0); // Subject with fixed dose and target observations at multiple outeqs let target = Subject::builder("test") @@ -614,7 +615,6 @@ fn test_multi_outeq_auc_mode() -> Result<()> { DoseRange::new(0.0, 2000.0), 0.5, settings, - 0, // No optimization cycles - just test cost calculation Target::AUC, )?; @@ -657,6 +657,7 @@ fn test_multi_outeq_auc_optimization() -> Result<()> { .set_error_models(ems.clone()) .build(); settings.disable_output(); + settings.set_cycles(3); let target = Subject::builder("test") .bolus(0.0, 0.0, 0) @@ -685,7 +686,6 @@ fn test_multi_outeq_auc_optimization() -> Result<()> { DoseRange::new(0.0, 2000.0), 0.5, settings, - 3, Target::AUC, )?; From 7bc93a8e19e495fb01699f4f2086a8db703f6c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Tue, 4 Nov 2025 10:39:06 +0000 Subject: [PATCH 46/56] Retry CI --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5475d7e46..36ab1ef11 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Rust library with the building blocks to create and implement new non-parametric - Covariate support, carry-forward or linear interpolation - Option to cache results for improved speed - Powerful simulation engine +- Bestdose module for dose optimization ## Available algorithms From 5949246671da41c7e803061fd2d68855663fd800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Tue, 4 Nov 2025 10:47:29 +0000 Subject: [PATCH 47/56] new algorith api --- src/bestdose/posterior.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/bestdose/posterior.rs b/src/bestdose/posterior.rs index f2315ecb6..a89774c13 100644 --- a/src/bestdose/posterior.rs +++ b/src/bestdose/posterior.rs @@ -56,6 +56,7 @@ use faer::Mat; use crate::algorithms::npag::burke; use crate::algorithms::npag::NPAG; use crate::algorithms::Algorithms; +use crate::algorithms::Status; use crate::prelude::*; use crate::structs::psi::calculate_psi; use crate::structs::theta::Theta; @@ -207,7 +208,12 @@ pub fn npagfull_refinement( // Run NPAG optimization let refinement_result = npag.initialize().and_then(|_| { - while !npag.next_cycle()? {} + loop { + match npag.next_cycle()? { + Status::Continue => continue, + Status::Stop(_) => break, + } + } Ok(()) }); From 7ed2d4c2ca416d3bf993cf84995e9a07a5b2acd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Tue, 4 Nov 2025 13:47:36 +0000 Subject: [PATCH 48/56] feat: new AUC mode, boundaries actually work now --- examples/bestdose_auc.rs | 75 ++++- examples/bestdose_bounds.rs | 114 +++++++ src/bestdose/cost.rs | 155 ++++++++- src/bestdose/mod.rs | 9 +- src/bestdose/predictions.rs | 391 +++++++++++++++-------- src/bestdose/types.rs | 82 +++-- tests/bestdose_tests.rs | 621 +++++++++++++++++++++++++++++++++++- 7 files changed, 1284 insertions(+), 163 deletions(-) create mode 100644 examples/bestdose_bounds.rs diff --git a/examples/bestdose_auc.rs b/examples/bestdose_auc.rs index c7fcfd168..e7f2b15b3 100644 --- a/examples/bestdose_auc.rs +++ b/examples/bestdose_auc.rs @@ -50,7 +50,7 @@ fn main() -> Result<()> { &"examples/bimodal_ke/output/theta.csv".to_string(), &settings, )?; - let weights = prior.unwrap(); + let weights = prior.as_ref().unwrap(); println!("Prior: {} support points\n", theta.matrix().nrows()); @@ -68,16 +68,16 @@ fn main() -> Result<()> { println!("Creating BestDose problem with AUC targets..."); let problem = BestDoseProblem::new( &theta, - &weights, + weights, None, // No past data - use prior directly - target_data, + target_data.clone(), None, - eq, - ems, + eq.clone(), + ems.clone(), DoseRange::new(100.0, 2000.0), // Wider range for AUC targets 0.8, // for AUC targets higher bias_weight usually works best - settings, - Target::AUC, + settings.clone(), + Target::AUCFromZero, // Cumulative AUC from time 0 )?; println!("Optimizing dose...\n"); @@ -122,5 +122,66 @@ fn main() -> Result<()> { } } + // ========================================================================= + // EXAMPLE 2: Interval AUC (AUCFromLastDose) + // ========================================================================= + println!("\n\n"); + println!("════════════════════════════════════════════════════════"); + println!(" EXAMPLE 2: Interval AUC (AUCFromLastDose)"); + println!("════════════════════════════════════════════════════════\n"); + + println!("Scenario: Loading dose + maintenance dose"); + println!("Target: AUC₁₂₋₂₄ = 60.0 mg*h/L (interval from t=12 to t=24)\n"); + + let target_interval = Subject::builder("Target_Interval") + .bolus(0.0, 200.0, 0) // Loading dose (fixed) + .bolus(12.0, 0.0, 0) // Maintenance dose to be optimized + .observation(24.0, 60.0, 0) // Target: AUC from t=12 to t=24 + .build(); + + println!("Creating BestDose problem with interval AUC target..."); + let problem_interval = BestDoseProblem::new( + &theta, + weights, + None, + target_interval.clone(), + None, + eq.clone(), + ems.clone(), + DoseRange::new(50.0, 500.0), + 0.8, + settings.clone(), + Target::AUCFromLastDose, // Interval AUC from last dose! + )?; + + println!("Optimizing maintenance dose...\n"); + let optimal_interval = problem_interval.optimize()?; + + println!("=== INTERVAL AUC RESULTS ==="); + println!( + "Optimal maintenance dose (at t=12h): {:.1} mg", + optimal_interval.dose[0] + ); + println!("Cost function: {:.6}", optimal_interval.objf); + + if let Some(auc_preds) = &optimal_interval.auc_predictions { + println!("\nInterval AUC Predictions:"); + for (time, auc) in auc_preds { + let target = 60.0; + let error_pct = ((auc - target) / target * 100.0).abs(); + println!( + " Time: {:5.1}h | Target AUC₁₂₋₂₄: {:6.1} | Predicted: {:6.2} | Error: {:5.1}%", + time, target, auc, error_pct + ); + } + } + + println!("\n"); + println!("════════════════════════════════════════════════════════"); + println!(" KEY DIFFERENCE:"); + println!(" - AUCFromZero: Integrates from t=0 to observation"); + println!(" - AUCFromLastDose: Integrates from last dose to observation"); + println!("════════════════════════════════════════════════════════"); + Ok(()) } diff --git a/examples/bestdose_bounds.rs b/examples/bestdose_bounds.rs new file mode 100644 index 000000000..ebb2c78e2 --- /dev/null +++ b/examples/bestdose_bounds.rs @@ -0,0 +1,114 @@ +use anyhow::Result; +use pmcore::bestdose::{BestDoseProblem, DoseRange, Target}; +use pmcore::prelude::*; +use pmcore::routines::initialization::parse_prior; + +fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + println!("BestDose with Dose Range Bounds - Example\n"); + println!("==========================================\n"); + + // Simple one-compartment PK model + let eq = equation::ODE::new( + |x, p, _t, dx, b, _rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + b[0]; + }, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; + }, + (1, 1), + ); + + let params = Parameters::new() + .add("ke", 0.001, 3.0) + .add("v", 25.0, 250.0); + + let ems = ErrorModels::new().add( + 0, + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), + )?; + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params) + .set_error_models(ems.clone()) + .build(); + + settings.disable_output(); + + // Load realistic prior from previous NPAG run + println!("Loading prior from bimodal_ke example..."); + let (theta, prior) = parse_prior( + &"examples/bimodal_ke/output/theta.csv".to_string(), + &settings, + )?; + let weights = prior.as_ref().unwrap(); + + println!("Prior: {} support points\n", theta.matrix().nrows()); + + // Create a target requiring high dose + println!("Target: Achieve 15 mg/L at 2h (requires high dose)"); + + let target_data = Subject::builder("Target") + .bolus(0.0, 0.0, 0) // Dose to be optimized + .observation(2.0, 15.0, 0) // High target concentration + .build(); + + // Test with different dose ranges + let dose_ranges = vec![ + (50.0, 200.0, "Narrow range (50-200 mg)"), + (50.0, 500.0, "Medium range (50-500 mg)"), + (50.0, 2000.0, "Wide range (50-2000 mg)"), + ]; + + println!("\nTesting optimization with different dose range constraints:\n"); + println!("{:<30} | {:>12} | {:>10}", "Range", "Optimal Dose", "Cost"); + println!("{}", "-".repeat(60)); + + for (min, max, description) in dose_ranges { + let problem = BestDoseProblem::new( + &theta, + weights, + None, + target_data.clone(), + None, + eq.clone(), + ems.clone(), + DoseRange::new(min, max), + 0.5, + settings.clone(), + Target::Concentration, + )?; + + let result = problem.optimize()?; + + // Check if dose hit the bound + let at_bound = if (result.dose[0] - max).abs() < 1.0 { + " (at upper bound)" + } else if (result.dose[0] - min).abs() < 1.0 { + " (at lower bound)" + } else { + "" + }; + + println!( + "{:<30} | {:>10.1} mg | {:>10.6}{}", + description, result.dose[0], result.objf, at_bound + ); + } + + println!("\n{}", "=".repeat(60)); + println!("\nKey Observations:"); + println!(" - Narrower ranges may constrain the optimizer to suboptimal doses"); + println!(" - When the optimizer hits a bound, consider widening the range"); + println!(" - The cost function increases when doses are constrained"); + println!(" - Bounds are enforced via penalty in the cost function"); + + Ok(()) +} diff --git a/src/bestdose/cost.rs b/src/bestdose/cost.rs index b8de98833..1ac0eef75 100644 --- a/src/bestdose/cost.rs +++ b/src/bestdose/cost.rs @@ -1,12 +1,16 @@ //! Cost function calculation for BestDose optimization //! //! Implements the hybrid cost function that balances patient-specific performance -//! (variance) with population-level robustness (bias). +//! (variance) with population-level robustness (bias). Also enforces dose range +//! constraints through penalty-based bounds checking. //! //! # Cost Function //! //! ```text -//! Cost = (1-λ) × Variance + λ × Bias² +//! Cost = { +//! (1-λ) × Variance + λ × Bias², if doses within bounds +//! 1e12 + violation² × 1e6, if any dose violates bounds +//! } //! ``` //! //! ## Variance Term (Patient-Specific) @@ -48,7 +52,9 @@ use anyhow::Result; -use crate::bestdose::predictions::{calculate_auc_at_times, calculate_dense_times}; +use crate::bestdose::predictions::{ + calculate_auc_at_times, calculate_dense_times, calculate_interval_auc_per_observation, +}; use crate::bestdose::types::{BestDoseProblem, Target}; use pharmsol::prelude::*; use pharmsol::Equation; @@ -146,6 +152,24 @@ pub fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) -> Res )); } + // Check bounds and return penalty if violated + // This constrains the Nelder-Mead optimizer to search within the specified DoseRange + let min_dose = problem.doserange.min; + let max_dose = problem.doserange.max; + + for &dose in candidate_doses { + if dose < min_dose || dose > max_dose { + // Return a large penalty cost to push the optimizer back into bounds + // The penalty grows quadratically with distance from the nearest bound + let violation = if dose < min_dose { + min_dose - dose + } else { + dose - max_dose + }; + return Ok(1e12 + violation.powi(2) * 1e6); + } + } + // Build target subject with candidate doses let mut target_subject = problem.target.clone(); let mut optimizable_dose_number = 0; // Index into candidate_doses @@ -227,7 +251,7 @@ pub fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) -> Res let pred = problem.eq.simulate_subject(&target_subject, &spp, None)?; pred.0.flat_predictions() } - Target::AUC => { + Target::AUCFromZero => { // For AUC: simulate at dense time grid and calculate cumulative AUC let idelta = problem.settings.predictions().idelta; let start_time = 0.0; // Future starts at 0 @@ -345,6 +369,129 @@ pub fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) -> Res } } + result_aucs + } + Target::AUCFromLastDose => { + // For interval AUC: simulate at dense time grid and calculate AUC from last dose + let idelta = problem.settings.predictions().idelta; + let end_time = obs_times.last().copied().unwrap_or(0.0); + + // Generate dense time grid from 0 to end_time (need full grid for intervals) + let dense_times = calculate_dense_times(0.0, end_time, &obs_times, idelta as usize); + + // Create temporary subject with dense time points for simulation + let subject_id = target_subject.id().to_string(); + let mut builder = Subject::builder(&subject_id); + + // Add all doses from original subject + for occasion in target_subject.occasions() { + for event in occasion.events() { + match event { + Event::Bolus(bolus) => { + builder = + builder.bolus(bolus.time(), bolus.amount(), bolus.input()); + } + Event::Infusion(infusion) => { + builder = builder.infusion( + infusion.time(), + infusion.amount(), + infusion.input(), + infusion.duration(), + ); + } + Event::Observation(_) => {} // Skip original observations + } + } + } + + // Collect observations with (time, outeq) pairs to preserve original order + let obs_time_outeq: Vec<(f64, usize)> = target_subject + .occasions() + .iter() + .flat_map(|occ| occ.events()) + .filter_map(|event| match event { + Event::Observation(obs) => Some((obs.time(), obs.outeq())), + _ => None, + }) + .collect(); + + let mut unique_outeqs: Vec = + obs_time_outeq.iter().map(|(_, outeq)| *outeq).collect(); + unique_outeqs.sort(); + unique_outeqs.dedup(); + + // Add observations at dense times + for outeq in unique_outeqs.iter() { + for &t in &dense_times { + builder = builder.missing_observation(t, *outeq); + } + } + + let dense_subject = builder.build(); + + // Simulate at dense times + let pred = problem.eq.simulate_subject(&dense_subject, &spp, None)?; + let dense_predictions_with_outeq = pred.0.predictions(); + + // Group predictions by outeq + let mut outeq_predictions: std::collections::HashMap> = + std::collections::HashMap::new(); + + for prediction in dense_predictions_with_outeq { + outeq_predictions + .entry(prediction.outeq()) + .or_default() + .push(prediction.prediction()); + } + + // Calculate interval AUC for each outeq separately + let mut outeq_aucs: std::collections::HashMap> = + std::collections::HashMap::new(); + + for &outeq in unique_outeqs.iter() { + let outeq_preds = outeq_predictions.get(&outeq).ok_or_else(|| { + anyhow::anyhow!("Missing predictions for outeq {}", outeq) + })?; + + // Get observation times for this outeq only + let outeq_obs_times: Vec = obs_time_outeq + .iter() + .filter(|(_, o)| *o == outeq) + .map(|(t, _)| *t) + .collect(); + + // Calculate interval AUC at observation times for this outeq + let aucs = calculate_interval_auc_per_observation( + &target_subject, + &dense_times, + outeq_preds, + &outeq_obs_times, + ); + outeq_aucs.insert(outeq, aucs); + } + + // Build final AUC vector in original observation order + let mut result_aucs = Vec::with_capacity(obs_time_outeq.len()); + let mut outeq_counters: std::collections::HashMap = + std::collections::HashMap::new(); + + for (_, outeq) in obs_time_outeq.iter() { + let aucs = outeq_aucs + .get(outeq) + .ok_or_else(|| anyhow::anyhow!("Missing AUC for outeq {}", outeq))?; + + let counter = outeq_counters.entry(*outeq).or_insert(0); + if *counter < aucs.len() { + result_aucs.push(aucs[*counter]); + *counter += 1; + } else { + return Err(anyhow::anyhow!( + "AUC index out of bounds for outeq {}", + outeq + )); + } + } + result_aucs } }; diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 0b12d6c2f..588d34310 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -91,6 +91,8 @@ //! │ │ //! │ Calculate predictions with optimal doses │ //! │ For AUC targets: Use dense time grid + trapezoidal rule │ +//! │ - AUCFromZero: Cumulative from time 0 │ +//! │ - AUCFromLastDose: Interval from last dose │ //! │ Return: Optimal doses, cost, predictions, method used │ //! └─────────────────────────────────────────────────────────────────┘ //! ``` @@ -192,10 +194,10 @@ //! .build(); //! //! let problem = BestDoseProblem::new( -//! &prior_theta, &prior_weights, Some(past), target, eq, error_models, +//! &prior_theta, &prior_weights, Some(past), target, None, eq, error_models, //! DoseRange::new(50.0, 300.0), //! 0.0, // Full personalization -//! settings, 500, Target::AUC, // AUC target! +//! settings, Target::AUCFromZero, // Cumulative AUC target! //! )?; //! //! let result = problem.optimize()?; @@ -254,7 +256,8 @@ //! //! - **`target_type`**: Optimization target //! - `Target::Concentration`: Direct concentration targets -//! - `Target::AUC`: Cumulative AUC targets +//! - `Target::AUCFromZero`: Cumulative AUC from time 0 +//! - `Target::AUCFromLastDose`: Interval AUC from last dose //! //! ## Performance Tuning //! diff --git a/src/bestdose/predictions.rs b/src/bestdose/predictions.rs index 9b84e275e..307ea9fae 100644 --- a/src/bestdose/predictions.rs +++ b/src/bestdose/predictions.rs @@ -37,6 +37,51 @@ use crate::structs::weights::Weights; use pharmsol::prelude::*; use pharmsol::Equation; +/// Find the time of the last dose (bolus or infusion) before a given observation time +/// +/// Returns the time of the most recent dose event that occurred before `obs_time`. +/// If no dose exists before the observation time, returns 0.0. +/// +/// # Arguments +/// * `subject` - Subject containing dose events +/// * `obs_time` - Observation time to find the preceding dose for +/// +/// # Returns +/// Time of the last dose before `obs_time`, or 0.0 if none exists +/// +/// # Example +/// ```rust,ignore +/// let subject = Subject::builder("patient") +/// .bolus(0.0, 100.0, 0) +/// .bolus(12.0, 50.0, 0) +/// .observation(18.0, 5.0, 0) +/// .build(); +/// +/// let last_dose_time = find_last_dose_time_before(&subject, 18.0); +/// assert_eq!(last_dose_time, 12.0); +/// ``` +pub fn find_last_dose_time_before(subject: &Subject, obs_time: f64) -> f64 { + let mut last_dose_time = 0.0; + + for occasion in subject.occasions() { + for event in occasion.events() { + let event_time = match event { + Event::Bolus(b) => Some(b.time()), + Event::Infusion(i) => Some(i.time()), + Event::Observation(_) => None, + }; + + if let Some(t) = event_time { + if t < obs_time && t > last_dose_time { + last_dose_time = t; + } + } + } + } + + last_dose_time +} + /// Generate dense time grid for AUC calculations /// /// Creates a grid with: @@ -137,6 +182,87 @@ pub fn calculate_auc_at_times( target_aucs } +/// Calculate interval AUC for each observation independently +/// +/// For each observation at time t_i, calculates AUC from the last dose before t_i to t_i. +/// This is useful for calculating dosing interval AUC (AUCτ) in steady-state scenarios. +/// +/// # Arguments +/// * `subject` - Subject with doses and observations +/// * `dense_times` - Complete dense time grid covering all observations +/// * `dense_predictions` - Concentration predictions at `dense_times` +/// * `obs_times` - Observation times where interval AUC should be calculated +/// +/// # Returns +/// Vector of interval AUC values, one per observation +/// +/// # Algorithm +/// +/// For each observation time: +/// 1. Find the most recent dose (bolus or infusion) before that observation +/// 2. Locate that dose time in the dense grid +/// 3. Apply trapezoidal rule from dose time to observation time +/// 4. Return the interval AUC +/// +/// # Example +/// +/// ```rust,ignore +/// let subject = Subject::builder("patient") +/// .bolus(0.0, 100.0, 0) // First dose +/// .bolus(12.0, 100.0, 0) // Second dose +/// .observation(24.0, 200.0, 0) // Want AUC from t=12 to t=24 +/// .build(); +/// +/// // Dense grid from 0 to 24 hours +/// let dense_times = vec![0.0, 1.0, 2.0, ..., 24.0]; +/// let dense_predictions = simulate_at_dense_times(...); +/// let obs_times = vec![24.0]; +/// +/// let interval_aucs = calculate_interval_auc_per_observation( +/// &subject, &dense_times, &dense_predictions, &obs_times +/// ); +/// // interval_aucs[0] contains AUC from 12.0 to 24.0 +/// ``` +pub fn calculate_interval_auc_per_observation( + subject: &Subject, + dense_times: &[f64], + dense_predictions: &[f64], + obs_times: &[f64], +) -> Vec { + assert_eq!(dense_times.len(), dense_predictions.len()); + + let mut interval_aucs = Vec::with_capacity(obs_times.len()); + let tolerance = 1e-10; + + for &obs_time in obs_times { + // Find the last dose time before this observation + let last_dose_time = find_last_dose_time_before(subject, obs_time); + + // Find indices in dense_times that span [last_dose_time, obs_time] + let start_idx = dense_times + .iter() + .position(|&t| (t - last_dose_time).abs() < tolerance || t > last_dose_time) + .unwrap_or(0); + + let end_idx = dense_times + .iter() + .position(|&t| (t - obs_time).abs() < tolerance || t > obs_time) + .unwrap_or(dense_times.len() - 1); + + // Calculate AUC for this interval using trapezoidal rule + let mut auc = 0.0; + for i in (start_idx + 1)..=end_idx.min(dense_times.len() - 1) { + let dt = dense_times[i] - dense_times[i - 1]; + let avg_conc = (dense_predictions[i] + dense_predictions[i - 1]) / 2.0; + auc += avg_conc * dt; + } + + interval_aucs.push(auc); + } + + interval_aucs +} + /// Calculate predictions for optimal doses /// /// This generates the final NPPredictions structure with the optimal doses @@ -201,154 +327,167 @@ pub fn calculate_final_predictions( )?; // Calculate AUC predictions if in AUC mode - let auc_predictions = if matches!(problem.target_type, Target::AUC) { - let obs_times: Vec = target_with_optimal - .occasions() - .iter() - .flat_map(|occ| occ.events()) - .filter_map(|event| match event { - Event::Observation(obs) => Some(obs.time()), - _ => None, - }) - .collect(); - - let idelta = problem.settings.predictions().idelta; - let start_time = 0.0; - let end_time = obs_times.last().copied().unwrap_or(0.0); - let dense_times = calculate_dense_times(start_time, end_time, &obs_times, idelta as usize); - - let subject_id = target_with_optimal.id().to_string(); - let mut builder = Subject::builder(&subject_id); - - // Copy all dose events from target_with_optimal (which already has optimal doses set) - for occasion in target_with_optimal.occasions() { - for event in occasion.events() { - match event { - Event::Bolus(bolus) => { - builder = builder.bolus(bolus.time(), bolus.amount(), bolus.input()); - } - Event::Infusion(infusion) => { - builder = builder.infusion( - infusion.time(), - infusion.amount(), - infusion.input(), - infusion.duration(), - ); + let auc_predictions = match problem.target_type { + Target::Concentration => None, + Target::AUCFromZero | Target::AUCFromLastDose => { + let obs_times: Vec = target_with_optimal + .occasions() + .iter() + .flat_map(|occ| occ.events()) + .filter_map(|event| match event { + Event::Observation(obs) => Some(obs.time()), + _ => None, + }) + .collect(); + + let idelta = problem.settings.predictions().idelta; + let start_time = 0.0; + let end_time = obs_times.last().copied().unwrap_or(0.0); + let dense_times = + calculate_dense_times(start_time, end_time, &obs_times, idelta as usize); + + let subject_id = target_with_optimal.id().to_string(); + let mut builder = Subject::builder(&subject_id); + + // Copy all dose events from target_with_optimal (which already has optimal doses set) + for occasion in target_with_optimal.occasions() { + for event in occasion.events() { + match event { + Event::Bolus(bolus) => { + builder = builder.bolus(bolus.time(), bolus.amount(), bolus.input()); + } + Event::Infusion(infusion) => { + builder = builder.infusion( + infusion.time(), + infusion.amount(), + infusion.input(), + infusion.duration(), + ); + } + Event::Observation(_) => {} } - Event::Observation(_) => {} } } - } - - // Collect observations with (time, outeq) pairs to preserve original order - let obs_time_outeq: Vec<(f64, usize)> = target_with_optimal - .occasions() - .iter() - .flat_map(|occ| occ.events()) - .filter_map(|event| match event { - Event::Observation(obs) => Some((obs.time(), obs.outeq())), - _ => None, - }) - .collect(); - - let mut unique_outeqs: Vec = - obs_time_outeq.iter().map(|(_, outeq)| *outeq).collect(); - unique_outeqs.sort_unstable(); - unique_outeqs.dedup(); - - // Add observations at dense times for each outeq - for outeq in unique_outeqs.iter() { - for &t in &dense_times { - builder = builder.missing_observation(t, *outeq); - } - } - let dense_subject = builder.build(); - - // Initialize AUC storage per outeq - let mut outeq_mean_aucs: std::collections::HashMap> = - std::collections::HashMap::new(); - for outeq in unique_outeqs.iter() { - let outeq_obs_times: Vec = obs_time_outeq + // Collect observations with (time, outeq) pairs to preserve original order + let obs_time_outeq: Vec<(f64, usize)> = target_with_optimal + .occasions() .iter() - .filter(|(_, o)| *o == *outeq) - .map(|(t, _)| *t) + .flat_map(|occ| occ.events()) + .filter_map(|event| match event { + Event::Observation(obs) => Some((obs.time(), obs.outeq())), + _ => None, + }) .collect(); - outeq_mean_aucs.insert(*outeq, vec![0.0; outeq_obs_times.len()]); - } - // Calculate AUC for each support point and accumulate weighted means - for (row, weight) in problem.theta.matrix().row_iter().zip(weights.iter()) { - let spp = row.iter().copied().collect::>(); - let pred = problem.eq.simulate_subject(&dense_subject, &spp, None)?; - let dense_predictions_with_outeq = pred.0.predictions(); + let mut unique_outeqs: Vec = + obs_time_outeq.iter().map(|(_, outeq)| *outeq).collect(); + unique_outeqs.sort_unstable(); + unique_outeqs.dedup(); - // Group predictions by outeq - let mut outeq_predictions: std::collections::HashMap> = - std::collections::HashMap::new(); - - for prediction in dense_predictions_with_outeq { - outeq_predictions - .entry(prediction.outeq()) - .or_default() - .push(prediction.prediction()); + // Add observations at dense times for each outeq + for outeq in unique_outeqs.iter() { + for &t in &dense_times { + builder = builder.missing_observation(t, *outeq); + } } - // Calculate AUC for each outeq separately - for &outeq in unique_outeqs.iter() { - let outeq_preds = outeq_predictions - .get(&outeq) - .ok_or_else(|| anyhow::anyhow!("Missing predictions for outeq {}", outeq))?; + let dense_subject = builder.build(); - // Get observation times for this outeq only + // Initialize AUC storage per outeq + let mut outeq_mean_aucs: std::collections::HashMap> = + std::collections::HashMap::new(); + for outeq in unique_outeqs.iter() { let outeq_obs_times: Vec = obs_time_outeq .iter() - .filter(|(_, o)| *o == outeq) + .filter(|(_, o)| *o == *outeq) .map(|(t, _)| *t) .collect(); + outeq_mean_aucs.insert(*outeq, vec![0.0; outeq_obs_times.len()]); + } - // Calculate AUC at observation times for this outeq - let aucs = calculate_auc_at_times(&dense_times, outeq_preds, &outeq_obs_times); + // Calculate AUC for each support point and accumulate weighted means + for (row, weight) in problem.theta.matrix().row_iter().zip(weights.iter()) { + let spp = row.iter().copied().collect::>(); + let pred = problem.eq.simulate_subject(&dense_subject, &spp, None)?; + let dense_predictions_with_outeq = pred.0.predictions(); + + // Group predictions by outeq + let mut outeq_predictions: std::collections::HashMap> = + std::collections::HashMap::new(); + + for prediction in dense_predictions_with_outeq { + outeq_predictions + .entry(prediction.outeq()) + .or_default() + .push(prediction.prediction()); + } - // Accumulate weighted AUCs - let mean_aucs = outeq_mean_aucs.get_mut(&outeq).unwrap(); - for (i, &auc) in aucs.iter().enumerate() { - mean_aucs[i] += weight * auc; + // Calculate AUC for each outeq separately based on mode + for &outeq in unique_outeqs.iter() { + let outeq_preds = outeq_predictions.get(&outeq).ok_or_else(|| { + anyhow::anyhow!("Missing predictions for outeq {}", outeq) + })?; + + // Get observation times for this outeq only + let outeq_obs_times: Vec = obs_time_outeq + .iter() + .filter(|(_, o)| *o == outeq) + .map(|(t, _)| *t) + .collect(); + + // Calculate AUC at observation times for this outeq + let aucs = match problem.target_type { + Target::AUCFromZero => { + calculate_auc_at_times(&dense_times, outeq_preds, &outeq_obs_times) + } + Target::AUCFromLastDose => calculate_interval_auc_per_observation( + &target_with_optimal, + &dense_times, + outeq_preds, + &outeq_obs_times, + ), + Target::Concentration => unreachable!(), + }; + + // Accumulate weighted AUCs + let mean_aucs = outeq_mean_aucs.get_mut(&outeq).unwrap(); + for (i, &auc) in aucs.iter().enumerate() { + mean_aucs[i] += weight * auc; + } } } - } - // Build final AUC vector in original observation order - let mut result_aucs = Vec::with_capacity(obs_time_outeq.len()); - let mut outeq_counters: std::collections::HashMap = - std::collections::HashMap::new(); - - for (_, outeq) in obs_time_outeq.iter() { - let aucs = outeq_mean_aucs - .get(outeq) - .ok_or_else(|| anyhow::anyhow!("Missing AUC for outeq {}", outeq))?; - - let counter = outeq_counters.entry(*outeq).or_insert(0); - if *counter < aucs.len() { - result_aucs.push(aucs[*counter]); - *counter += 1; - } else { - return Err(anyhow::anyhow!( - "AUC index out of bounds for outeq {}", - outeq - )); + // Build final AUC vector in original observation order + let mut result_aucs = Vec::with_capacity(obs_time_outeq.len()); + let mut outeq_counters: std::collections::HashMap = + std::collections::HashMap::new(); + + for (_, outeq) in obs_time_outeq.iter() { + let aucs = outeq_mean_aucs + .get(outeq) + .ok_or_else(|| anyhow::anyhow!("Missing AUC for outeq {}", outeq))?; + + let counter = outeq_counters.entry(*outeq).or_insert(0); + if *counter < aucs.len() { + result_aucs.push(aucs[*counter]); + *counter += 1; + } else { + return Err(anyhow::anyhow!( + "AUC index out of bounds for outeq {}", + outeq + )); + } } - } - Some( - obs_time_outeq - .iter() - .map(|(t, _)| *t) - .zip(result_aucs) - .collect(), - ) - } else { - None + Some( + obs_time_outeq + .iter() + .map(|(t, _)| *t) + .zip(result_aucs) + .collect(), + ) + } }; Ok((concentration_preds, auc_predictions)) diff --git a/src/bestdose/types.rs b/src/bestdose/types.rs index b25180432..45ccc1957 100644 --- a/src/bestdose/types.rs +++ b/src/bestdose/types.rs @@ -16,7 +16,7 @@ use pharmsol::prelude::*; /// Target type for dose optimization /// /// Specifies whether the optimization targets are drug concentrations at specific times -/// or cumulative Area Under the Curve (AUC) values. +/// or Area Under the Curve (AUC) values. /// /// # Examples /// @@ -26,21 +26,30 @@ use pharmsol::prelude::*; /// // Optimize to achieve target concentrations /// let target_type = Target::Concentration; /// -/// // Optimize to achieve target cumulative AUC -/// let target_type = Target::AUC; +/// // Optimize to achieve target cumulative AUC from time 0 +/// let target_type = Target::AUCFromZero; +/// +/// // Optimize to achieve target interval AUC from last dose +/// let target_type = Target::AUCFromLastDose; /// ``` /// -/// # AUC Calculation -/// -/// When `Target::AUC` is selected: -/// - A dense time grid is generated using the `idelta` parameter from settings -/// - Concentrations are simulated at all dense time points -/// - Cumulative AUC is calculated using the trapezoidal rule: -/// ```text -/// AUC(t) = ∫₀ᵗ C(τ) dτ ≈ Σᵢ (C[i] + C[i-1])/2 × Δt -/// ``` -/// - AUC values at target observation times are extracted -#[derive(Debug, Clone, Copy)] +/// # AUC Calculation Methods +/// +/// The algorithm supports two AUC calculation approaches: +/// +/// ## AUCFromZero (Cumulative AUC) +/// - Integrates from time 0 to the observation time +/// - Useful for total drug exposure assessment +/// - Formula: `AUC(t) = ∫₀ᵗ C(τ) dτ` +/// +/// ## AUCFromLastDose (Interval AUC) +/// - Integrates from the last dose time to the observation time +/// - Useful for steady-state dosing intervals (e.g., AUCτ) +/// - Formula: `AUC(t) = ∫ₜ_last_dose^t C(τ) dτ` +/// - Automatically finds the most recent bolus/infusion before each observation +/// +/// Both methods use trapezoidal rule on a dense time grid controlled by `settings.predictions().idelta`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Target { /// Target concentrations at observation times /// @@ -59,8 +68,8 @@ pub enum Target { /// Target cumulative AUC values from time 0 /// - /// The optimizer finds doses to achieve specified cumulative AUC values. - /// AUC is calculated using trapezoidal integration with a dense time grid. + /// The optimizer finds doses to achieve specified cumulative AUC values + /// calculated from the beginning of the dosing regimen (time 0). /// /// # Example Target Subject /// ```rust,ignore @@ -77,13 +86,47 @@ pub enum Target { /// ```rust,ignore /// settings.predictions().idelta = 15; // 15-minute intervals /// ``` - AUC, + AUCFromZero, + + /// Target interval AUC values from last dose to observation + /// + /// The optimizer finds doses to achieve specified interval AUC values + /// calculated from the most recent dose before each observation. + /// This is particularly useful for steady-state dosing intervals (AUCτ). + /// + /// # Example Target Subject + /// ```rust,ignore + /// let target = Subject::builder("patient") + /// .bolus(0.0, 200.0, 0) // Loading dose (fixed at 200 mg) + /// .bolus(12.0, 0.0, 0) // Maintenance dose to optimize + /// .observation(24.0, 200.0, 0) // Target: AUC₁₂₋₂₄ = 200 mg·h/L + /// .build(); + /// // The observation at t=24h targets AUC from t=12h (last dose) to t=24h + /// ``` + /// + /// # Behavior + /// + /// For each observation at time t: + /// - Finds the most recent bolus or infusion before time t + /// - Calculates AUC from that dose time to t + /// - If no dose exists before t, integrates from time 0 + /// + /// This allows different observations to have different integration intervals, + /// each relative to their respective preceding dose. + AUCFromLastDose, } /// Allowable dose range constraints /// /// Specifies minimum and maximum allowable doses for optimization. -/// The Nelder-Mead optimizer will search within these bounds. +/// The Nelder-Mead optimizer will search within these bounds via penalty-based +/// constraint enforcement. +/// +/// # Bounds Enforcement +/// +/// When candidate doses violate the bounds, the cost function returns a large +/// penalty value proportional to the violation distance. This effectively +/// constrains the Nelder-Mead simplex to remain within the valid range. /// /// # Examples /// @@ -105,7 +148,8 @@ pub enum Target { /// /// - Set bounds appropriate for your drug's clinical use /// - Consider patient-specific factors (weight, renal function, etc.) -/// - Avoid unnecessarily wide ranges (slows convergence) +/// - If optimization hits a bound, consider widening the range +/// - Monitor the cost function value - sudden increases may indicate constraint violation /// - Default range is `[0.0, f64::MAX]` (effectively unbounded) #[derive(Debug, Clone)] pub struct DoseRange { diff --git a/tests/bestdose_tests.rs b/tests/bestdose_tests.rs index f7ca33e89..1cf75a218 100644 --- a/tests/bestdose_tests.rs +++ b/tests/bestdose_tests.rs @@ -412,7 +412,7 @@ fn test_basic_auc_mode() -> Result<()> { DoseRange::new(100.0, 2000.0), 0.8, settings, - Target::AUC, + Target::AUCFromZero, )?; let result = problem.optimize(); @@ -506,7 +506,7 @@ fn test_infusion_auc_mode() -> Result<()> { DoseRange::new(100.0, 2000.0), 0.8, // Higher bias weight typically works better for AUC targets settings, - Target::AUC, // AUC mode! + Target::AUCFromZero, // AUC mode! )?; // Run optimization @@ -615,7 +615,7 @@ fn test_multi_outeq_auc_mode() -> Result<()> { DoseRange::new(0.0, 2000.0), 0.5, settings, - Target::AUC, + Target::AUCFromZero, )?; // Just verify that problem was created successfully @@ -686,7 +686,7 @@ fn test_multi_outeq_auc_optimization() -> Result<()> { DoseRange::new(0.0, 2000.0), 0.5, settings, - Target::AUC, + Target::AUCFromZero, )?; let result = problem.optimize(); @@ -711,3 +711,616 @@ fn test_multi_outeq_auc_optimization() -> Result<()> { Ok(()) } + +// ============================================================================ +// AUC MODE TESTS - Comprehensive testing for both AUC calculation modes +// ============================================================================ + +/// Test AUCFromZero: Verify cumulative AUC calculation from time 0 +#[test] +fn test_auc_from_zero_single_dose() -> Result<()> { + let eq = equation::ODE::new( + |x, p, _t, dx, b, _rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + b[0]; + }, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; + }, + (1, 1), + ); + + let params = Parameters::new().add("ke", 0.2, 0.4).add("v", 40.0, 60.0); + + let ems = ErrorModels::new().add( + 0, + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), + )?; + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params) + .set_error_models(ems.clone()) + .build(); + + settings.disable_output(); + settings.set_cycles(0); + settings.set_idelta(10.0); // 10-minute intervals for AUC calculation + + // Target: Single dose, cumulative AUC from 0 to 12h + let target = Subject::builder("patient_auc_zero") + .bolus(0.0, 0.0, 0) // Dose to optimize + .observation(12.0, 150.0, 0) // Target: AUC₀₋₁₂ = 150 mg·h/L + .build(); + + let prior_theta = { + let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { + 0 => 0.3, // ke + 1 => 50.0, // v + _ => 0.0, + }); + Theta::from_parts(mat, settings.parameters().clone())? + }; + let prior_weights = Weights::uniform(1); + + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, + target, + None, + eq, + ems, + DoseRange::new(100.0, 1000.0), + 0.8, + settings, + Target::AUCFromZero, // Cumulative AUC from time 0 + )?; + + let result = problem.optimize()?; + + // Verify we got a result + assert_eq!(result.dose.len(), 1); + assert!(result.dose[0] > 0.0); + assert!(result.objf.is_finite()); + + // Verify we have AUC predictions + assert!(result.auc_predictions.is_some()); + let auc_preds = result.auc_predictions.unwrap(); + assert_eq!(auc_preds.len(), 1); + + let (time, auc) = auc_preds[0]; + assert!((time - 12.0).abs() < 0.01); + assert!(auc > 0.0 && auc.is_finite()); + + eprintln!("AUCFromZero test:"); + eprintln!(" Optimal dose: {:.1} mg", result.dose[0]); + eprintln!(" Predicted AUC₀₋₁₂: {:.2} mg·h/L", auc); + eprintln!(" Target AUC₀₋₁₂: 150.0 mg·h/L"); + + Ok(()) +} + +/// Test AUCFromLastDose: Verify interval AUC calculation from last dose +#[test] +fn test_auc_from_last_dose_maintenance() -> Result<()> { + let eq = equation::ODE::new( + |x, p, _t, dx, b, _rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + b[0]; + }, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; + }, + (1, 1), + ); + + let params = Parameters::new().add("ke", 0.2, 0.4).add("v", 40.0, 60.0); + + let ems = ErrorModels::new().add( + 0, + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), + )?; + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params) + .set_error_models(ems.clone()) + .build(); + + settings.disable_output(); + settings.set_cycles(0); + settings.set_idelta(10.0); + + // Target: Loading dose (fixed) + maintenance dose (optimize) + // Target interval AUC from t=12 to t=24 + let target = Subject::builder("patient_auc_interval") + .bolus(0.0, 300.0, 0) // Loading dose (fixed at 300 mg) + .bolus(12.0, 0.0, 0) // Maintenance dose to optimize + .observation(24.0, 80.0, 0) // Target: AUC₁₂₋₂₄ = 80 mg·h/L + .build(); + + let prior_theta = { + let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { + 0 => 0.3, // ke + 1 => 50.0, // v + _ => 0.0, + }); + Theta::from_parts(mat, settings.parameters().clone())? + }; + let prior_weights = Weights::uniform(1); + + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, + target, + None, + eq, + ems, + DoseRange::new(50.0, 500.0), + 0.8, + settings, + Target::AUCFromLastDose, // Interval AUC from last dose + )?; + + let result = problem.optimize()?; + + // Verify we got a result + assert_eq!( + result.dose.len(), + 1, + "Should optimize only the maintenance dose" + ); + assert!(result.dose[0] > 0.0); + assert!(result.objf.is_finite()); + + // Verify we have AUC predictions + assert!(result.auc_predictions.is_some()); + let auc_preds = result.auc_predictions.unwrap(); + assert_eq!(auc_preds.len(), 1); + + let (time, auc) = auc_preds[0]; + assert!((time - 24.0).abs() < 0.01); + assert!(auc > 0.0 && auc.is_finite()); + + eprintln!("AUCFromLastDose test:"); + eprintln!(" Loading dose (fixed): 300.0 mg at t=0"); + eprintln!( + " Optimal maintenance dose: {:.1} mg at t=12", + result.dose[0] + ); + eprintln!(" Predicted AUC₁₂₋₂₄: {:.2} mg·h/L", auc); + eprintln!(" Target AUC₁₂₋₂₄: 80.0 mg·h/L"); + + Ok(()) +} + +/// Test comparison: AUCFromZero vs AUCFromLastDose should give different results +#[test] +fn test_auc_modes_comparison() -> Result<()> { + let eq = equation::ODE::new( + |x, p, _t, dx, b, _rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + b[0]; + }, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; + }, + (1, 1), + ); + + let params = Parameters::new().add("ke", 0.3, 0.3).add("v", 50.0, 50.0); + + let ems = ErrorModels::new().add( + 0, + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), + )?; + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params) + .set_error_models(ems.clone()) + .build(); + + settings.disable_output(); + settings.set_cycles(0); + settings.set_idelta(10.0); + + let prior_theta = { + let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { + 0 => 0.3, // ke + 1 => 50.0, // v + _ => 0.0, + }); + Theta::from_parts(mat, settings.parameters().clone())? + }; + let prior_weights = Weights::uniform(1); + + // Scenario: Two doses, observation after second dose + // Target same AUC value (100 mg·h/L) but different interpretation + + // Mode 1: AUCFromZero - target is cumulative AUC from t=0 to t=24 + let target_zero = Subject::builder("patient_zero") + .bolus(0.0, 200.0, 0) // First dose fixed + .bolus(12.0, 0.0, 0) // Second dose to optimize + .observation(24.0, 100.0, 0) // Target: AUC₀₋₂₄ = 100 + .build(); + + let problem_zero = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, + target_zero, + None, + eq.clone(), + ems.clone(), + DoseRange::new(10.0, 2000.0), + 0.8, + settings.clone(), + Target::AUCFromZero, + )?; + + let result_zero = problem_zero.optimize()?; + + // Mode 2: AUCFromLastDose - target is interval AUC from t=12 to t=24 + let target_last = Subject::builder("patient_last") + .bolus(0.0, 200.0, 0) // First dose fixed + .bolus(12.0, 0.0, 0) // Second dose to optimize + .observation(24.0, 100.0, 0) // Target: AUC₁₂₋₂₄ = 100 + .build(); + + let problem_last = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, + target_last, + None, + eq, + ems, + DoseRange::new(10.0, 2000.0), + 0.8, + settings, + Target::AUCFromLastDose, + )?; + + let result_last = problem_last.optimize()?; + + // The two modes should recommend DIFFERENT doses for the same target value + // because they're measuring different things + eprintln!("\nAUC Mode Comparison:"); + eprintln!(" Scenario: 200mg at t=0 (fixed), optimize dose at t=12"); + eprintln!(" Target value: 100 mg·h/L (same number, different meaning)"); + eprintln!(" "); + eprintln!(" AUCFromZero (cumulative 0→24h):"); + eprintln!(" Optimal 2nd dose: {:.1} mg", result_zero.dose[0]); + eprintln!( + " AUC prediction: {:.2}", + result_zero.auc_predictions.as_ref().unwrap()[0].1 + ); + eprintln!(" "); + eprintln!(" AUCFromLastDose (interval 12→24h):"); + eprintln!(" Optimal 2nd dose: {:.1} mg", result_last.dose[0]); + eprintln!( + " AUC prediction: {:.2}", + result_last.auc_predictions.as_ref().unwrap()[0].1 + ); + + // Verify both modes work + assert!(result_zero.dose[0] > 0.0); + assert!(result_last.dose[0] > 0.0); + + // The doses should be different (cumulative includes first dose effect, + // interval only measures second dose) + // We expect AUCFromZero to recommend a smaller second dose since it includes + // the AUC contribution from the first dose + assert_ne!( + (result_zero.dose[0] * 10.0).round() / 10.0, + (result_last.dose[0] * 10.0).round() / 10.0, + "AUCFromZero and AUCFromLastDose should recommend different doses" + ); + + Ok(()) +} + +/// Test AUCFromLastDose with multiple observations +#[test] +fn test_auc_from_last_dose_multiple_observations() -> Result<()> { + let eq = equation::ODE::new( + |x, p, _t, dx, b, _rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + b[0]; + }, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; + }, + (1, 1), + ); + + let params = Parameters::new().add("ke", 0.2, 0.4).add("v", 40.0, 60.0); + + let ems = ErrorModels::new().add( + 0, + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), + )?; + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params) + .set_error_models(ems.clone()) + .build(); + + settings.disable_output(); + settings.set_cycles(0); + settings.set_idelta(10.0); + + // Multiple doses and observations - each observation measures AUC from its preceding dose + let target = Subject::builder("patient_multi") + .bolus(0.0, 0.0, 0) // Dose 1 to optimize + .observation(12.0, 50.0, 0) // AUC₀₋₁₂ = 50 + .bolus(12.0, 0.0, 0) // Dose 2 to optimize + .observation(24.0, 50.0, 0) // AUC₁₂₋₂₄ = 50 + .build(); + + let prior_theta = { + let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { + 0 => 0.3, // ke + 1 => 50.0, // v + _ => 0.0, + }); + Theta::from_parts(mat, settings.parameters().clone())? + }; + let prior_weights = Weights::uniform(1); + + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, + target, + None, + eq, + ems, + DoseRange::new(50.0, 500.0), + 0.8, + settings, + Target::AUCFromLastDose, + )?; + + let result = problem.optimize()?; + + // Should optimize 2 doses + assert_eq!(result.dose.len(), 2); + assert!(result.dose[0] > 0.0); + assert!(result.dose[1] > 0.0); + + // Should have 2 AUC predictions + assert!(result.auc_predictions.is_some()); + let auc_preds = result.auc_predictions.unwrap(); + assert_eq!(auc_preds.len(), 2); + + // First observation measures AUC from t=0 (first dose) to t=12 + let (time1, auc1) = auc_preds[0]; + assert!((time1 - 12.0).abs() < 0.01); + + // Second observation measures AUC from t=12 (second dose) to t=24 + let (time2, auc2) = auc_preds[1]; + assert!((time2 - 24.0).abs() < 0.01); + + eprintln!("AUCFromLastDose multiple observations test:"); + eprintln!( + " Dose 1 (t=0): {:.1} mg → AUC₀₋₁₂ = {:.2} (target: 50.0)", + result.dose[0], auc1 + ); + eprintln!( + " Dose 2 (t=12): {:.1} mg → AUC₁₂₋₂₄ = {:.2} (target: 50.0)", + result.dose[1], auc2 + ); + + Ok(()) +} + +/// Test edge case: observation before any dose (should integrate from time 0) +#[test] +fn test_auc_from_last_dose_no_prior_dose() -> Result<()> { + let eq = equation::ODE::new( + |x, p, _t, dx, b, _rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + b[0]; + }, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; + }, + (1, 1), + ); + + let params = Parameters::new().add("ke", 0.2, 0.4).add("v", 40.0, 60.0); + + let ems = ErrorModels::new().add( + 0, + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), + )?; + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params) + .set_error_models(ems.clone()) + .build(); + + settings.disable_output(); + settings.set_cycles(0); + settings.set_idelta(10.0); + + // Edge case: observation at t=6, but dose is at t=12 (after the observation) + let target = Subject::builder("patient_edge") + .observation(6.0, 30.0, 0) // Observation before any dose + .bolus(12.0, 0.0, 0) // Dose after observation + .build(); + + let prior_theta = { + let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { + 0 => 0.3, // ke + 1 => 50.0, // v + _ => 0.0, + }); + Theta::from_parts(mat, settings.parameters().clone())? + }; + let prior_weights = Weights::uniform(1); + + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, + target, + None, + eq, + ems, + DoseRange::new(50.0, 500.0), + 0.8, + settings, + Target::AUCFromLastDose, + )?; + + let result = problem.optimize()?; + + assert_eq!(result.dose.len(), 1); + assert!(result.dose[0] > 0.0); + + assert!(result.auc_predictions.is_some()); + let auc_preds = result.auc_predictions.unwrap(); + assert_eq!(auc_preds.len(), 1); + + let (_time, auc) = auc_preds[0]; + + eprintln!("AUCFromLastDose edge case (no prior dose):"); + eprintln!(" Observation at t=6 (before any dose)"); + eprintln!(" Dose at t=12: {:.1} mg", result.dose[0]); + eprintln!(" AUC₀₋₆: {:.2} (should be ~0, no drug yet)", auc); + + assert!( + auc.abs() < 1.0, + "AUC before any dose should be nearly zero, got {}", + auc + ); + + Ok(()) +} + +// ============================================================================ +// DOSE RANGE BOUNDS TESTS - Verify optimizer respects DoseRange constraints +// ============================================================================ + +/// Test that optimizer respects DoseRange bounds +#[test] +fn test_dose_range_bounds_respected() -> Result<()> { + // Create a simple one-compartment model + let eq = equation::ODE::new( + |x, p, _t, dx, b, _rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + b[0]; + }, + |_p, _, _| lag! {}, + |_p, _, _| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; + }, + (1, 1), + ); + + let params = Parameters::new().add("ke", 0.1, 0.5).add("v", 40.0, 60.0); + + let ems = ErrorModels::new().add( + 0, + ErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), + )?; + + let mut settings = Settings::builder() + .set_algorithm(Algorithm::NPAG) + .set_parameters(params) + .set_error_models(ems.clone()) + .build(); + + settings.disable_output(); + settings.set_cycles(0); + + // Target with high concentration requiring large dose + let target = Subject::builder("test_patient") + .bolus(0.0, 0.0, 0) // Dose to optimize + .observation(2.0, 20.0, 0) // High target concentration + .build(); + + let prior_theta = { + let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { + 0 => 0.3, // ke + 1 => 50.0, // v + _ => 0.0, + }); + Theta::from_parts(mat, settings.parameters().clone())? + }; + let prior_weights = Weights::uniform(1); + + // Set a narrow dose range: 50-200 mg + let dose_range = DoseRange::new(50.0, 200.0); + + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, + target.clone(), + None, + eq.clone(), + ems.clone(), + dose_range, + 0.0, + settings.clone(), + Target::Concentration, + )?; + + let result = problem.optimize()?; + + println!("Optimal dose: {:.1} mg", result.dose[0]); + println!("Dose range: 50-200 mg"); + + // Verify dose is within bounds + assert!( + result.dose[0] >= 50.0, + "Dose {} is below minimum bound 50.0", + result.dose[0] + ); + assert!( + result.dose[0] <= 200.0, + "Dose {} is above maximum bound 200.0", + result.dose[0] + ); + + // The optimal dose should hit the upper bound (200 mg) since the target is high + // Allow small tolerance for numerical precision + assert!( + (result.dose[0] - 200.0).abs() < 1.0, + "Expected dose near upper bound (200 mg), got {:.1} mg", + result.dose[0] + ); + + Ok(()) +} From cec7f5b52a82266270d50147e3af198631354a9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Tue, 4 Nov 2025 15:45:38 +0000 Subject: [PATCH 49/56] feat: new AUC mode, boundaries actually work now --- examples/bestdose_bounds.rs | 4 ++-- src/bestdose/cost.rs | 2 +- tests/bestdose_tests.rs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/bestdose_bounds.rs b/examples/bestdose_bounds.rs index ebb2c78e2..76be65109 100644 --- a/examples/bestdose_bounds.rs +++ b/examples/bestdose_bounds.rs @@ -54,7 +54,7 @@ fn main() -> Result<()> { // Create a target requiring high dose println!("Target: Achieve 15 mg/L at 2h (requires high dose)"); - + let target_data = Subject::builder("Target") .bolus(0.0, 0.0, 0) // Dose to be optimized .observation(2.0, 15.0, 0) // High target concentration @@ -87,7 +87,7 @@ fn main() -> Result<()> { )?; let result = problem.optimize()?; - + // Check if dose hit the bound let at_bound = if (result.dose[0] - max).abs() < 1.0 { " (at upper bound)" diff --git a/src/bestdose/cost.rs b/src/bestdose/cost.rs index 1ac0eef75..cbaa12950 100644 --- a/src/bestdose/cost.rs +++ b/src/bestdose/cost.rs @@ -156,7 +156,7 @@ pub fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) -> Res // This constrains the Nelder-Mead optimizer to search within the specified DoseRange let min_dose = problem.doserange.min; let max_dose = problem.doserange.max; - + for &dose in candidate_doses { if dose < min_dose || dose > max_dose { // Return a large penalty cost to push the optimizer back into bounds diff --git a/tests/bestdose_tests.rs b/tests/bestdose_tests.rs index 1cf75a218..8ed3ac284 100644 --- a/tests/bestdose_tests.rs +++ b/tests/bestdose_tests.rs @@ -1173,7 +1173,7 @@ fn test_auc_from_last_dose_no_prior_dose() -> Result<()> { // Edge case: observation at t=6, but dose is at t=12 (after the observation) let target = Subject::builder("patient_edge") .observation(6.0, 30.0, 0) // Observation before any dose - .bolus(12.0, 0.0, 0) // Dose after observation + .bolus(12.0, 0.0, 0) // Dose after observation .build(); let prior_theta = { @@ -1272,7 +1272,7 @@ fn test_dose_range_bounds_respected() -> Result<()> { let prior_theta = { let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { - 0 => 0.3, // ke + 0 => 0.3, // ke 1 => 50.0, // v _ => 0.0, }); From 06ba153e803b2e54cc12bb3e05ffe813e6f4bc7e Mon Sep 17 00:00:00 2001 From: Markus Hovd Date: Wed, 5 Nov 2025 07:50:33 +0100 Subject: [PATCH 50/56] chore: Suggestions for BestDose (#214) * Documentation * Make fields of BestDoseProblem private and rename field * Rename fields * Change output of result * Update tests --------- Co-authored-by: Julian Otalvaro --- examples/bestdose.rs | 17 ++++- examples/bestdose_auc.rs | 17 ++++- src/bestdose/cost.rs | 4 +- src/bestdose/mod.rs | 122 +++++++++++++++++------------------ src/bestdose/optimization.rs | 52 +++++++-------- src/bestdose/posterior.rs | 47 ++++++++------ src/bestdose/types.rs | 75 +++++++++++++++------ tests/bestdose_tests.rs | 105 +++++++++++++++++++++++++++--- 8 files changed, 299 insertions(+), 140 deletions(-) diff --git a/examples/bestdose.rs b/examples/bestdose.rs index 04392f02c..35e9deb06 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose.rs @@ -114,10 +114,25 @@ fn main() -> Result<()> { // Print results for (bias_weight, optimal) in &results { + let opt_doses = optimal + .optimal_subject + .iter() + .flat_map(|occ| { + occ.events() + .iter() + .filter_map(|event| match event { + Event::Bolus(bolus) => Some(bolus.amount()), + Event::Infusion(infusion) => Some(infusion.amount()), + _ => None, + }) + .collect::>() + }) + .collect::>(); + println!( "Bias weight: {:.2}\t\t Optimal dose: {:?}\t\tCost: {:.6}\t\tln Cost: {:.4}\t\tMethod: {}", bias_weight, - optimal.dose, + opt_doses, optimal.objf, optimal.objf.ln(), optimal.optimization_method diff --git a/examples/bestdose_auc.rs b/examples/bestdose_auc.rs index e7f2b15b3..c92ba55bc 100644 --- a/examples/bestdose_auc.rs +++ b/examples/bestdose_auc.rs @@ -83,8 +83,23 @@ fn main() -> Result<()> { println!("Optimizing dose...\n"); let optimal = problem.optimize()?; + let opt_doses = optimal + .optimal_subject + .iter() + .flat_map(|occ| { + occ.events() + .iter() + .filter_map(|event| match event { + Event::Bolus(bolus) => Some(bolus.amount()), + Event::Infusion(infusion) => Some(infusion.amount()), + _ => None, + }) + .collect::>() + }) + .collect::>(); + println!("=== RESULTS ==="); - println!("Optimal dose: {:.1} mg", optimal.dose[0]); + println!("Optimal dose: {:.1} mg", opt_doses[0]); println!("Cost function: {:.6}", optimal.objf); if let Some(auc_preds) = &optimal.auc_predictions { diff --git a/src/bestdose/cost.rs b/src/bestdose/cost.rs index cbaa12950..7915a7015 100644 --- a/src/bestdose/cost.rs +++ b/src/bestdose/cost.rs @@ -76,7 +76,7 @@ use pharmsol::Equation; /// /// # Dose Masking /// -/// When `problem.current_time` is set (past/future separation), only doses where +/// When `problem.time_offset` is set (past/future separation), only doses where /// `dose_optimization_mask[i] == true` are updated with values from `candidate_doses`. /// Past doses (mask == false) remain at their historical values. /// @@ -239,7 +239,7 @@ pub fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) -> Res .matrix() .row_iter() .zip(problem.posterior.iter()) // Posterior from NPAGFULL11 (patient-specific) - .zip(problem.prior_weights.iter()) + .zip(problem.population_weights.iter()) // Prior (population) { let spp = row.iter().copied().collect::>(); diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 588d34310..c568adbf6 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -11,8 +11,8 @@ //! ```rust,no_run,ignore //! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; //! -//! # fn example(prior_theta: pmcore::structs::theta::Theta, -//! # prior_weights: pmcore::structs::weights::Weights, +//! # fn example(population_theta: pmcore::structs::theta::Theta, +//! # population_weights: pmcore::structs::weights::Weights, //! # past_data: pharmsol::prelude::Subject, //! # target: pharmsol::prelude::Subject, //! # eq: pharmsol::prelude::ODE, @@ -21,8 +21,8 @@ //! # -> anyhow::Result<()> { //! // Create optimization problem //! let problem = BestDoseProblem::new( -//! &prior_theta, // Population support points from NPAG -//! &prior_weights, // Population probabilities +//! &population_theta, // Population support points from NPAG +//! &population_weights, // Population probabilities //! Some(past_data), // Patient history (None = use prior) //! target, // Future template with targets //! eq, // PK/PD model @@ -147,8 +147,8 @@ //! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; //! use pharmsol::prelude::Subject; //! -//! # fn example(prior_theta: pmcore::structs::theta::Theta, -//! # prior_weights: pmcore::structs::weights::Weights, +//! # fn example(population_theta: pmcore::structs::theta::Theta, +//! # population_weights: pmcore::structs::weights::Weights, //! # past: pharmsol::prelude::Subject, //! # eq: pharmsol::prelude::ODE, //! # error_models: pharmsol::prelude::ErrorModels, @@ -161,7 +161,7 @@ //! .build(); //! //! let problem = BestDoseProblem::new( -//! &prior_theta, &prior_weights, Some(past), target, eq, error_models, +//! &population_theta, &population_weights, Some(past), target, eq, error_models, //! DoseRange::new(10.0, 500.0), // 10-500 mg allowed //! 0.3, // Slight population emphasis //! settings, 500, Target::Concentration, @@ -179,8 +179,8 @@ //! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; //! use pharmsol::prelude::Subject; //! -//! # fn example(prior_theta: pmcore::structs::theta::Theta, -//! # prior_weights: pmcore::structs::weights::Weights, +//! # fn example(population_theta: pmcore::structs::theta::Theta, +//! # population_weights: pmcore::structs::weights::Weights, //! # past: pharmsol::prelude::Subject, //! # eq: pharmsol::prelude::ODE, //! # error_models: pharmsol::prelude::ErrorModels, @@ -194,7 +194,7 @@ //! .build(); //! //! let problem = BestDoseProblem::new( -//! &prior_theta, &prior_weights, Some(past), target, None, eq, error_models, +//! &population_theta, &population_weights, Some(past), target, eq, error_models, //! DoseRange::new(50.0, 300.0), //! 0.0, // Full personalization //! settings, Target::AUCFromZero, // Cumulative AUC target! @@ -214,8 +214,8 @@ //! //! ```rust,no_run,ignore //! # use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; -//! # fn example(prior_theta: pmcore::structs::theta::Theta, -//! # prior_weights: pmcore::structs::weights::Weights, +//! # fn example(population_theta: pmcore::structs::theta::Theta, +//! # population_weights: pmcore::structs::weights::Weights, //! # target: pharmsol::prelude::Subject, //! # eq: pharmsol::prelude::ODE, //! # error_models: pharmsol::prelude::ErrorModels, @@ -223,7 +223,7 @@ //! # -> anyhow::Result<()> { //! // No patient history - use population prior directly //! let problem = BestDoseProblem::new( -//! &prior_theta, &prior_weights, +//! &population_theta, &population_weights, //! None, // No past data //! target, eq, error_models, //! DoseRange::new(0.0, 1000.0), @@ -264,8 +264,8 @@ //! For faster optimization: //! ```rust,no_run,ignore //! # use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; -//! # fn example(prior_theta: pmcore::structs::theta::Theta, -//! # prior_weights: pmcore::structs::weights::Weights, +//! # fn example(population_theta: pmcore::structs::theta::Theta, +//! # population_weights: pmcore::structs::weights::Weights, //! # target: pharmsol::prelude::Subject, //! # eq: pharmsol::ODE, //! # error_models: pharmsol::prelude::ErrorModels, @@ -273,7 +273,7 @@ //! # -> anyhow::Result<()> { //! // Reduce refinement cycles //! let problem = BestDoseProblem::new( -//! &prior_theta, &prior_weights, None, target, eq, error_models, +//! &population_theta, &population_weights, None, target, eq, error_models, //! DoseRange::new(0.0, 1000.0), 0.5, //! settings.clone(), //! 100, // Faster: 100 instead of 500 @@ -306,20 +306,20 @@ pub use types::{BestDoseProblem, BestDoseResult, DoseRange, Target}; /// /// This mimics Fortran's MAKETMP subroutine logic: /// 1. Takes doses (only doses, not observations) from past subject -/// 2. Offsets all future subject event times by `current_time` +/// 2. Offsets all future subject event times by `time_offset` /// 3. Combines into single continuous subject /// /// # Arguments /// /// * `past` - Subject with past history (only doses will be used) /// * `future` - Subject template for future (all events: doses + observations) -/// * `current_time` - Time offset to apply to all future events +/// * `time_offset` - Time offset to apply to all future events /// /// # Returns /// /// Combined subject with: -/// - Past doses at original times [0, current_time) -/// - Future doses + observations at offset times [current_time, ∞) +/// - Past doses at original times [0, time_offset) +/// - Future doses + observations at offset times [time_offset, ∞) /// /// # Example /// @@ -336,7 +336,7 @@ pub use types::{BestDoseProblem, BestDoseResult, DoseRange, Target}; /// .observation(24.0, 10.0, 0) // Target at t=30 absolute /// .build(); /// -/// // Concatenate with current_time = 6.0 +/// // Concatenate with time_offset = 6.0 /// let combined = concatenate_past_and_future(&past, &future, 6.0); /// // Result: dose at t=0 (fixed, 500mg), dose at t=6 (optimizable, 100mg initial), /// // observation target at t=30 (10 mg/L) @@ -344,7 +344,7 @@ pub use types::{BestDoseProblem, BestDoseResult, DoseRange, Target}; fn concatenate_past_and_future( past: &pharmsol::prelude::Subject, future: &pharmsol::prelude::Subject, - current_time: f64, + time_offset: f64, ) -> pharmsol::prelude::Subject { use pharmsol::prelude::*; @@ -374,11 +374,11 @@ fn concatenate_past_and_future( match event { Event::Bolus(bolus) => { builder = - builder.bolus(bolus.time() + current_time, bolus.amount(), bolus.input()); + builder.bolus(bolus.time() + time_offset, bolus.amount(), bolus.input()); } Event::Infusion(inf) => { builder = builder.infusion( - inf.time() + current_time, + inf.time() + time_offset, inf.amount(), inf.input(), inf.duration(), @@ -387,7 +387,7 @@ fn concatenate_past_and_future( Event::Observation(obs) => { builder = match obs.value() { Some(val) => { - builder.observation(obs.time() + current_time, val, obs.outeq()) + builder.observation(obs.time() + time_offset, val, obs.outeq()) } None => builder, }; @@ -466,8 +466,8 @@ use crate::structs::weights::Weights; // Helper Functions for STAGE 1: Posterior Density Calculation // ═════════════════════════════════════════════════════════════════════════════ -/// Validate current_time parameter for past/future separation mode -fn validate_current_time(current_time: f64, past_data: &Option) -> Result<()> { +/// Validate time_offset parameter for past/future separation mode +fn validate_time_offset(time_offset: f64, past_data: &Option) -> Result<()> { if let Some(past_subject) = past_data { let max_past_time = past_subject .occasions() @@ -480,11 +480,11 @@ fn validate_current_time(current_time: f64, past_data: &Option) -> Resu }) .fold(0.0_f64, |max, time| max.max(time)); - if current_time < max_past_time { + if time_offset < max_past_time { return Err(anyhow::anyhow!( - "Invalid current_time: {} is before the last past_data event at time {}. \ - current_time must be >= the maximum time in past_data to avoid time travel!", - current_time, + "Invalid time_offset: {} is before the last past_data event at time {}. \ + time_offset must be >= the maximum time in past_data to avoid time travel!", + time_offset, max_past_time )); } @@ -517,10 +517,10 @@ fn validate_current_time(current_time: f64, past_data: &Option) -> Resu /// /// # Returns /// -/// Tuple: (posterior_theta, posterior_weights, filtered_prior_weights, past_subject) +/// Tuple: (posterior_theta, posterior_weights, filtered_population_weights, past_subject) fn calculate_posterior_density( - prior_theta: &Theta, - prior_weights: &Weights, + population_theta: &Theta, + population_weights: &Weights, past_data: Option<&Subject>, eq: &ODE, error_models: &ErrorModels, @@ -530,9 +530,9 @@ fn calculate_posterior_density( None => { tracing::info!(" No past data → using prior directly"); Ok(( - prior_theta.clone(), - prior_weights.clone(), - prior_weights.clone(), + population_theta.clone(), + population_weights.clone(), + population_weights.clone(), Subject::builder("Empty").build(), )) } @@ -548,9 +548,9 @@ fn calculate_posterior_density( if !has_observations { tracing::info!(" Past data has no observations → using prior directly"); Ok(( - prior_theta.clone(), - prior_weights.clone(), - prior_weights.clone(), + population_theta.clone(), + population_weights.clone(), + population_weights.clone(), past_subject.clone(), )) } else { @@ -561,10 +561,10 @@ fn calculate_posterior_density( let past_data_obj = Data::new(vec![past_subject.clone()]); - let (posterior_theta, posterior_weights, filtered_prior_weights) = + let (posterior_theta, posterior_weights, filtered_population_weights) = posterior::calculate_two_step_posterior( - prior_theta, - prior_weights, + population_theta, + population_weights, &past_data_obj, eq, error_models, @@ -574,7 +574,7 @@ fn calculate_posterior_density( Ok(( posterior_theta, posterior_weights, - filtered_prior_weights, + filtered_population_weights, past_subject.clone(), )) } @@ -590,9 +590,9 @@ fn calculate_posterior_density( fn prepare_target_subject( past_subject: Subject, target: Subject, - current_time: Option, + time_offset: Option, ) -> Result<(Subject, Subject)> { - match current_time { + match time_offset { None => { tracing::info!(" Mode: Standard (single subject)"); Ok((target, past_subject)) @@ -647,11 +647,11 @@ impl BestDoseProblem { /// /// # Parameters /// - /// * `prior_theta` - Population support points from NPAG - /// * `prior_weights` - Population probabilities + /// * `population_theta` - Population support points from NPAG + /// * `population_weights` - Population probabilities /// * `past_data` - Patient history (None = use prior directly) /// * `target` - Future dosing template with targets - /// * `current_time` - Optional time offset for concatenation (None = standard mode, Some(t) = Fortran mode) + /// * `time_offset` - Optional time offset for concatenation (None = standard mode, Some(t) = Fortran mode) /// * `eq` - Pharmacokinetic/pharmacodynamic model /// * `error_models` - Error model specifications /// * `doserange` - Allowable dose constraints @@ -665,11 +665,11 @@ impl BestDoseProblem { /// BestDoseProblem ready for `optimize()` #[allow(clippy::too_many_arguments)] pub fn new( - prior_theta: &Theta, - prior_weights: &Weights, + population_theta: &Theta, + population_weights: &Weights, past_data: Option, target: Subject, - current_time: Option, + time_offset: Option, eq: ODE, error_models: ErrorModels, doserange: DoseRange, @@ -683,17 +683,17 @@ impl BestDoseProblem { tracing::info!("╚══════════════════════════════════════════════════════════╝"); // Validate input if using past/future separation mode - if let Some(t) = current_time { - validate_current_time(t, &past_data)?; + if let Some(t) = time_offset { + validate_time_offset(t, &past_data)?; } // ═════════════════════════════════════════════════════════════ // STAGE 1: Calculate Posterior Density // ═════════════════════════════════════════════════════════════ - let (posterior_theta, posterior_weights, filtered_prior_weights, past_subject) = + let (posterior_theta, posterior_weights, filtered_population_weights, past_subject) = calculate_posterior_density( - prior_theta, - prior_weights, + population_theta, + population_weights, past_data.as_ref(), &eq, &error_models, @@ -702,7 +702,7 @@ impl BestDoseProblem { // Handle past/future concatenation if needed let (final_target, final_past_data) = - prepare_target_subject(past_subject, target, current_time)?; + prepare_target_subject(past_subject, target, time_offset)?; tracing::info!("╔══════════════════════════════════════════════════════════╗"); tracing::info!("║ Stage 1 Complete - Ready for Optimization ║"); @@ -715,8 +715,8 @@ impl BestDoseProblem { past_data: final_past_data, target: final_target, target_type, - prior_theta: prior_theta.clone(), - prior_weights: filtered_prior_weights, + population_theta: population_theta.clone(), + population_weights: filtered_population_weights, theta: posterior_theta, posterior: posterior_weights, eq, @@ -724,7 +724,7 @@ impl BestDoseProblem { settings, doserange, bias_weight, - current_time, + time_offset, }) } diff --git a/src/bestdose/optimization.rs b/src/bestdose/optimization.rs index 2732177df..1e1e02d1d 100644 --- a/src/bestdose/optimization.rs +++ b/src/bestdose/optimization.rs @@ -92,7 +92,7 @@ impl CostFunction for BestDoseProblem { /// /// This is a helper for the dual optimization approach. /// -/// When `problem.current_time` is set (past/future separation mode): +/// When `problem.time_offset` is set (past/future separation mode): /// - Only optimizes doses where `dose_optimization_mask[i] == true` /// - Creates a reduced-dimension simplex for future doses only /// - Maps optimized doses back to full vector (past doses unchanged) @@ -190,14 +190,14 @@ fn run_single_optimization( /// │ │ /// │ OPTIMIZATION 1: Posterior Weights │ /// │ Use NPAGFULL11 posterior probabilities │ -/// │ → (doses₁, cost₁) │ +/// │ → (doses₁, cost₁) │ /// │ │ /// │ OPTIMIZATION 2: Uniform Weights │ /// │ Use equal weights (1/M) for all points │ -/// │ → (doses₂, cost₂) │ +/// │ → (doses₂, cost₂) │ /// │ │ -/// │ SELECTION: Choose min(cost₁, cost₂) │ -/// │ → (optimal_doses, optimal_cost, method) │ +/// │ SELECTION: Choose min(cost₁, cost₂) │ +/// │ → (optimal_doses, optimal_cost, method) │ /// └────────────┬────────────────────────────────────┘ /// ↓ /// ┌─────────────────────────────────────────────────┐ @@ -261,34 +261,34 @@ pub fn dual_optimization(problem: &BestDoseProblem) -> Result { method ); + // Generate target subject with optimal doses + let mut optimal_subject = problem.target.clone(); + let mut dose_number = 0; + + for occasion in optimal_subject.iter_mut() { + for event in occasion.iter_mut() { + match event { + Event::Bolus(bolus) => { + bolus.set_amount(final_doses[dose_number]); + dose_number += 1; + } + Event::Infusion(infusion) => { + infusion.set_amount(final_doses[dose_number]); + dose_number += 1; + } + Event::Observation(_) => {} + } + } + } + let (preds, auc_predictions) = calculate_final_predictions(problem, &final_doses, &final_weights)?; - // Extract only the optimized doses (exclude fixed past doses) - let original_doses: Vec = problem - .target - .iter() - .flat_map(|occ| { - occ.iter().filter_map(|event| match event { - Event::Bolus(bolus) => Some(bolus.amount()), - Event::Infusion(infusion) => Some(infusion.amount()), - Event::Observation(_) => None, - }) - }) - .collect(); - - let optimized_doses: Vec = final_doses - .iter() - .zip(original_doses.iter()) - .filter(|(_, &orig)| orig == 0.0) // Only doses that were placeholders - .map(|(&opt, _)| opt) - .collect(); - tracing::info!(" ✓ Predictions complete"); tracing::info!("─────────────────────────────────────────────────────────────"); Ok(BestDoseResult { - dose: optimized_doses, + optimal_subject, objf: final_cost, status: "Converged".to_string(), preds, diff --git a/src/bestdose/posterior.rs b/src/bestdose/posterior.rs index a89774c13..9109f65c1 100644 --- a/src/bestdose/posterior.rs +++ b/src/bestdose/posterior.rs @@ -88,10 +88,10 @@ const KEEP_UNREFINED_POINTS: bool = true; /// /// Note: This uses only lambda filtering, NO QR decomposition or second burke call. /// -/// Returns: (filtered_theta, filtered_posterior_weights, filtered_prior_weights) +/// Returns: (filtered_theta, filtered_posterior_weights, filtered_population_weights) pub fn npagfull11_filter( - prior_theta: &Theta, - prior_weights: &Weights, + population_theta: &Theta, + population_weights: &Weights, past_data: &Data, eq: &ODE, error_models: &ErrorModels, @@ -99,7 +99,7 @@ pub fn npagfull11_filter( tracing::info!("Stage 1.1: NPAGFULL11 Bayesian filtering"); // Calculate psi matrix P(data|theta_i) for all support points - let psi = calculate_psi(eq, past_data, prior_theta, error_models, false, true)?; + let psi = calculate_psi(eq, past_data, population_theta, error_models, false, true)?; // First burke call to get initial posterior probabilities let (initial_weights, _) = burke(&psi)?; @@ -120,7 +120,7 @@ pub fn npagfull11_filter( .collect(); // Filter theta to keep only points above threshold - let mut filtered_theta = prior_theta.clone(); + let mut filtered_theta = population_theta.clone(); filtered_theta.filter_indices(&keep_lambda); // Filter and renormalize posterior weights @@ -130,10 +130,11 @@ pub fn npagfull11_filter( Weights::from_vec(filtered_weights.iter().map(|w| w / sum).collect()); // Also filter the prior weights to match the filtered theta - let filtered_prior_weights: Vec = keep_lambda.iter().map(|&i| prior_weights[i]).collect(); - let prior_sum: f64 = filtered_prior_weights.iter().sum(); - let final_prior_weights = Weights::from_vec( - filtered_prior_weights + let filtered_population_weights: Vec = + keep_lambda.iter().map(|&i| population_weights[i]).collect(); + let prior_sum: f64 = filtered_population_weights.iter().sum(); + let final_population_weights = Weights::from_vec( + filtered_population_weights .iter() .map(|w| w / prior_sum) .collect(), @@ -141,12 +142,16 @@ pub fn npagfull11_filter( tracing::info!( " {} → {} support points (lambda filter, threshold={:.0e})", - prior_theta.matrix().nrows(), + population_theta.matrix().nrows(), filtered_theta.matrix().nrows(), threshold * max_weight ); - Ok((filtered_theta, final_posterior_weights, final_prior_weights)) + Ok(( + filtered_theta, + final_posterior_weights, + final_population_weights, + )) } /// Step 1.2: NPAGFULL - Refine each filtered point with full NPAG optimization @@ -300,11 +305,11 @@ pub fn npagfull_refinement( /// /// This is the complete Stage 1 of the BestDose algorithm. /// -/// Returns (posterior_theta, posterior_weights, filtered_prior_weights) suitable for dose optimization. -/// The filtered_prior_weights are the original prior weights filtered to match the posterior support points. +/// Returns (posterior_theta, posterior_weights, filtered_population_weights) suitable for dose optimization. +/// The filtered_population_weights are the original prior weights filtered to match the posterior support points. pub fn calculate_two_step_posterior( - prior_theta: &Theta, - prior_weights: &Weights, + population_theta: &Theta, + population_weights: &Weights, past_data: &Data, eq: &ODE, error_models: &ErrorModels, @@ -313,8 +318,14 @@ pub fn calculate_two_step_posterior( tracing::info!("=== STAGE 1: Posterior Density Calculation ==="); // Step 1.1: NPAGFULL11 filtering (returns filtered posterior AND filtered prior) - let (filtered_theta, filtered_posterior_weights, filtered_prior_weights) = - npagfull11_filter(prior_theta, prior_weights, past_data, eq, error_models)?; + let (filtered_theta, filtered_posterior_weights, filtered_population_weights) = + npagfull11_filter( + population_theta, + population_weights, + past_data, + eq, + error_models, + )?; // Step 1.2: NPAGFULL refinement let (refined_theta, refined_weights) = npagfull_refinement( @@ -330,5 +341,5 @@ pub fn calculate_two_step_posterior( refined_theta.matrix().nrows() ); - Ok((refined_theta, refined_weights, filtered_prior_weights)) + Ok((refined_theta, refined_weights, filtered_population_weights)) } diff --git a/src/bestdose/types.rs b/src/bestdose/types.rs index 45ccc1957..7702b2bd8 100644 --- a/src/bestdose/types.rs +++ b/src/bestdose/types.rs @@ -208,8 +208,8 @@ impl Default for DoseRange { /// - `target_type`: [`Target::Concentration`] or [`Target::AUC`] /// /// ## Population Prior -/// - `prior_theta`: Support points from NPAG population model -/// - `prior_weights`: Probability weights for each support point +/// - `population_theta`: Support points from NPAG population model +/// - `population_weights`: Probability weights for each support point /// /// ## Patient-Specific Posterior /// - `theta`: Refined posterior support points (from NPAGFULL11 + NPAGFULL) @@ -229,8 +229,8 @@ impl Default for DoseRange { /// ```rust,no_run,ignore /// use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; /// -/// # fn example(prior_theta: pmcore::structs::theta::Theta, -/// # prior_weights: pmcore::structs::weights::Weights, +/// # fn example(population_theta: pmcore::structs::theta::Theta, +/// # population_weights: pmcore::structs::weights::Weights, /// # past: pharmsol::prelude::Subject, /// # target: pharmsol::prelude::Subject, /// # eq: pharmsol::prelude::ODE, @@ -238,8 +238,8 @@ impl Default for DoseRange { /// # settings: pmcore::routines::settings::Settings) /// # -> anyhow::Result<()> { /// let problem = BestDoseProblem::new( -/// &prior_theta, -/// &prior_weights, +/// &population_theta, +/// &population_weights, /// Some(past), // Patient history /// target, // Dosing template with targets /// eq, @@ -258,26 +258,45 @@ impl Default for DoseRange { #[derive(Debug, Clone)] pub struct BestDoseProblem { // Input data - pub past_data: Subject, - pub target: Subject, - pub target_type: Target, + /// Past patient data for posterior calculation + /// + /// These observations are used to refine the population prior into a + /// patient-specific posterior, and will be used to inform dose optimization. + pub(crate) past_data: Subject, + /// Target subject with dosing template and target observations + /// + /// This [Subject] defines the targets for optimization, including + /// dose events (with amounts to be optimized) and observation events + /// (with desired target values). + /// + /// For a `Target::Concentration`, observation values are target concentrations. + /// For a `Target::AUC`, observation values are target cumulative AUC. + /// + /// Only doses with a value of `0.0` will be optimized; non-zero doses remain fixed. + pub(crate) target: Subject, + /// Target type for optimization + /// + /// Specifies whether to optimize for concentrations or AUC values. + pub(crate) target_type: Target, // Population prior - pub prior_theta: Theta, - pub prior_weights: Weights, + /// The population prior support points ([Theta]), representing your previous knowledge of the population parameter distribution. + pub(crate) population_theta: Theta, + /// The population prior weights ([Weights]), representing the probability of each support point in the population. + pub(crate) population_weights: Weights, // Patient-specific posterior (from NPAGFULL11 + NPAGFULL) - pub theta: Theta, - pub posterior: Weights, + pub(crate) theta: Theta, + pub(crate) posterior: Weights, // Model and settings - pub eq: ODE, - pub error_models: ErrorModels, - pub settings: Settings, + pub(crate) eq: ODE, + pub(crate) error_models: ErrorModels, + pub(crate) settings: Settings, // Optimization parameters - pub doserange: DoseRange, - pub bias_weight: f64, // λ: 0=personalized, 1=population + pub(crate) doserange: DoseRange, + pub(crate) bias_weight: f64, // λ: 0=personalized, 1=population /// Time offset between past and future data (used for concatenation) /// When Some(t): future events were offset by this time to create continuous simulation @@ -285,7 +304,21 @@ pub struct BestDoseProblem { /// /// This is used to track the boundary between past and future for reporting/debugging. /// The actual optimization mask is derived from dose amounts (0 = optimize, >0 = fixed). - pub current_time: Option, + pub(crate) time_offset: Option, +} + +impl BestDoseProblem { + /// Validate input + pub(crate) fn validate(&self) -> anyhow::Result<()> { + if self.bias_weight <= 0.0 || self.bias_weight >= 1.0 { + return Err(anyhow::anyhow!( + "Bias weight must be between 0.0 and 1.0, got {}", + self.bias_weight + )); + } + + Ok(()) + } } /// Result from BestDose optimization @@ -359,13 +392,13 @@ pub struct BestDoseProblem { /// # Ok(()) /// # } /// ``` -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct BestDoseResult { /// Optimal dose amount(s) /// /// Vector contains one element per dose in the target subject. /// Order matches the dose events in the target subject. - pub dose: Vec, + pub optimal_subject: Subject, /// Final cost function value /// diff --git a/tests/bestdose_tests.rs b/tests/bestdose_tests.rs index 8ed3ac284..ddf368bb3 100644 --- a/tests/bestdose_tests.rs +++ b/tests/bestdose_tests.rs @@ -105,16 +105,42 @@ fn test_infusion_mask_inclusion() -> Result<()> { // We should get back 1 optimized dose (the infusion placeholder) assert_eq!( - result.dose.len(), + result + .optimal_subject + .iter() + .flat_map(|occ| { + occ.events() + .iter() + .filter_map(|event| match event { + Event::Infusion(inf) => Some(inf.amount()), + _ => None, + }) + .collect::>() + }) + .count(), 1, "Should have 1 optimized dose (the infusion)" ); + let optinf = result + .optimal_subject + .iter() + .flat_map(|occ| { + occ.events() + .iter() + .filter_map(|event| match event { + Event::Infusion(inf) => Some(inf.amount()), + _ => None, + }) + .collect::>() + }) + .collect::>(); + // The optimized dose should be reasonable (not NaN, not infinite) assert!( - result.dose[0].is_finite(), + optinf[0].is_finite(), "Optimized dose should be finite, got {}", - result.dose[0] + optinf[0] ); Ok(()) @@ -196,7 +222,22 @@ fn test_fixed_infusion_preservation() -> Result<()> { let result = problem.optimize()?; // Should only optimize the future bolus, not the past infusion - assert_eq!(result.dose.len(), 1, "Should have 1 optimized dose"); + let doses = result + .optimal_subject + .iter() + .flat_map(|occ| { + occ.events() + .iter() + .filter_map(|event| match event { + Event::Infusion(inf) if inf.amount() != 200.0 => Some(inf.amount()), + Event::Bolus(bol) => Some(bol.amount()), + _ => None, + }) + .collect::>() + }) + .collect::>(); + eprintln!("Optimized doses: {:?}", doses); + assert_eq!(doses.len(), 1, "Should have 1 optimized dose"); Ok(()) } @@ -424,7 +465,21 @@ fn test_basic_auc_mode() -> Result<()> { ); let result = result?; - assert_eq!(result.dose.len(), 1); + let doses = result + .optimal_subject + .iter() + .flat_map(|occ| { + occ.events() + .iter() + .filter_map(|event| match event { + Event::Infusion(inf) => Some(inf.amount()), + Event::Bolus(bol) => Some(bol.amount()), + _ => None, + }) + .collect::>() + }) + .collect::>(); + assert_eq!(doses.len(), 1); assert!(result.auc_predictions.is_some()); @@ -519,11 +574,25 @@ fn test_infusion_auc_mode() -> Result<()> { ); let result = result?; - - eprintln!("Optimized dose: {}", result.dose[0]); + let doses = result + .optimal_subject + .iter() + .flat_map(|occ| { + occ.events() + .iter() + .filter_map(|event| match event { + Event::Infusion(inf) => Some(inf.amount()), + Event::Bolus(bol) => Some(bol.amount()), + _ => None, + }) + .collect::>() + }) + .collect::>(); + + eprintln!("Optimized dose: {:?}", doses); // Should have 1 optimized dose (the infusion) - assert_eq!(result.dose.len(), 1, "Should have 1 optimized dose"); + assert_eq!(doses.len(), 1, "Should have 1 optimized dose"); // Should have AUC predictions assert!( @@ -697,8 +766,24 @@ fn test_multi_outeq_auc_optimization() -> Result<()> { ); let best_dose_result = result?; - assert_eq!(best_dose_result.dose.len(), 1); - assert!(best_dose_result.dose[0] > 0.0); + + let doses = best_dose_result + .optimal_subject + .iter() + .flat_map(|occ| { + occ.events() + .iter() + .filter_map(|event| match event { + Event::Infusion(inf) => Some(inf.amount()), + Event::Bolus(bol) => Some(bol.amount()), + _ => None, + }) + .collect::>() + }) + .collect::>(); + + assert_eq!(doses.len(), 1); + assert!(doses[0] > 0.0); assert!(best_dose_result.objf.is_finite()); assert!(best_dose_result.auc_predictions.is_some()); From 86dff374096b73b9820df304f6c25f3767ec17a1 Mon Sep 17 00:00:00 2001 From: Markus <66058642+mhovd@users.noreply.github.com> Date: Wed, 5 Nov 2025 08:56:20 +0100 Subject: [PATCH 51/56] Update tests --- examples/bestdose_auc.rs | 24 ++++- examples/bestdose_bounds.rs | 25 ++++- tests/bestdose_tests.rs | 178 +++++++++++++++++++++++++++++------- 3 files changed, 186 insertions(+), 41 deletions(-) diff --git a/examples/bestdose_auc.rs b/examples/bestdose_auc.rs index c92ba55bc..0d9a46cfe 100644 --- a/examples/bestdose_auc.rs +++ b/examples/bestdose_auc.rs @@ -172,11 +172,27 @@ fn main() -> Result<()> { println!("Optimizing maintenance dose...\n"); let optimal_interval = problem_interval.optimize()?; + let doses: Vec = optimal_interval + .optimal_subject + .iter() + .map(|occ| { + occ.iter() + .filter(|event| match event { + Event::Bolus(_) => true, + Event::Infusion(_) => true, + _ => false, + }) + .map(|event| match event { + Event::Bolus(bolus) => bolus.amount(), + Event::Infusion(infusion) => infusion.amount(), + _ => 0.0, + }) + }) + .flatten() + .collect(); + println!("=== INTERVAL AUC RESULTS ==="); - println!( - "Optimal maintenance dose (at t=12h): {:.1} mg", - optimal_interval.dose[0] - ); + println!("Optimal maintenance dose (at t=12h): {:.1} mg", doses[0]); println!("Cost function: {:.6}", optimal_interval.objf); if let Some(auc_preds) = &optimal_interval.auc_predictions { diff --git a/examples/bestdose_bounds.rs b/examples/bestdose_bounds.rs index 76be65109..4fab2862c 100644 --- a/examples/bestdose_bounds.rs +++ b/examples/bestdose_bounds.rs @@ -88,10 +88,29 @@ fn main() -> Result<()> { let result = problem.optimize()?; + let doses: Vec = result + .optimal_subject + .iter() + .map(|occ| { + occ.iter() + .filter(|event| match event { + Event::Bolus(_) => true, + Event::Infusion(_) => true, + _ => false, + }) + .map(|event| match event { + Event::Bolus(bolus) => bolus.amount(), + Event::Infusion(infusion) => infusion.amount(), + _ => 0.0, + }) + }) + .flatten() + .collect(); + // Check if dose hit the bound - let at_bound = if (result.dose[0] - max).abs() < 1.0 { + let at_bound = if (doses[0] - max).abs() < 1.0 { " (at upper bound)" - } else if (result.dose[0] - min).abs() < 1.0 { + } else if (doses[0] - min).abs() < 1.0 { " (at lower bound)" } else { "" @@ -99,7 +118,7 @@ fn main() -> Result<()> { println!( "{:<30} | {:>10.1} mg | {:>10.6}{}", - description, result.dose[0], result.objf, at_bound + description, doses[0], result.objf, at_bound ); } diff --git a/tests/bestdose_tests.rs b/tests/bestdose_tests.rs index ddf368bb3..a06cd94e3 100644 --- a/tests/bestdose_tests.rs +++ b/tests/bestdose_tests.rs @@ -868,9 +868,28 @@ fn test_auc_from_zero_single_dose() -> Result<()> { let result = problem.optimize()?; + let doses: Vec = result + .optimal_subject + .iter() + .map(|occ| { + occ.iter() + .filter(|event| match event { + Event::Bolus(_) => true, + Event::Infusion(_) => true, + _ => false, + }) + .map(|event| match event { + Event::Bolus(bolus) => bolus.amount(), + Event::Infusion(infusion) => infusion.amount(), + _ => 0.0, + }) + }) + .flatten() + .collect(); + // Verify we got a result - assert_eq!(result.dose.len(), 1); - assert!(result.dose[0] > 0.0); + assert_eq!(doses.len(), 1); + assert!(doses[0] > 0.0); assert!(result.objf.is_finite()); // Verify we have AUC predictions @@ -883,7 +902,7 @@ fn test_auc_from_zero_single_dose() -> Result<()> { assert!(auc > 0.0 && auc.is_finite()); eprintln!("AUCFromZero test:"); - eprintln!(" Optimal dose: {:.1} mg", result.dose[0]); + eprintln!(" Optimal dose: {:.1} mg", doses[0]); eprintln!(" Predicted AUC₀₋₁₂: {:.2} mg·h/L", auc); eprintln!(" Target AUC₀₋₁₂: 150.0 mg·h/L"); @@ -958,14 +977,26 @@ fn test_auc_from_last_dose_maintenance() -> Result<()> { )?; let result = problem.optimize()?; + let doses = result + .optimal_subject + .iter() + .flat_map(|occ| { + occ.events() + .iter() + .filter_map(|event| match event { + Event::Infusion(inf) => Some(inf.amount()), + Event::Bolus(bol) => Some(bol.amount()), + _ => None, + }) + .collect::>() + }) + .collect::>(); // Verify we got a result - assert_eq!( - result.dose.len(), - 1, - "Should optimize only the maintenance dose" - ); - assert!(result.dose[0] > 0.0); + assert_eq!(doses.len(), 2, "Should be 2 doses (loading + maintenance)"); + // Very first one is fixed loading dose, second is optimized maintenance dose + assert_eq!(doses[0], 300.0); + assert!(doses[0] > 0.0); assert!(result.objf.is_finite()); // Verify we have AUC predictions @@ -979,10 +1010,7 @@ fn test_auc_from_last_dose_maintenance() -> Result<()> { eprintln!("AUCFromLastDose test:"); eprintln!(" Loading dose (fixed): 300.0 mg at t=0"); - eprintln!( - " Optimal maintenance dose: {:.1} mg at t=12", - result.dose[0] - ); + eprintln!(" Optimal maintenance dose: {:.1} mg at t=12", doses[0]); eprintln!(" Predicted AUC₁₂₋₂₄: {:.2} mg·h/L", auc); eprintln!(" Target AUC₁₂₋₂₄: 80.0 mg·h/L"); @@ -1059,6 +1087,20 @@ fn test_auc_modes_comparison() -> Result<()> { )?; let result_zero = problem_zero.optimize()?; + let doses_zero = result_zero + .optimal_subject + .iter() + .flat_map(|occ| { + occ.events() + .iter() + .filter_map(|event| match event { + Event::Infusion(inf) => Some(inf.amount()), + Event::Bolus(bol) => Some(bol.amount()), + _ => None, + }) + .collect::>() + }) + .collect::>(); // Mode 2: AUCFromLastDose - target is interval AUC from t=12 to t=24 let target_last = Subject::builder("patient_last") @@ -1082,6 +1124,20 @@ fn test_auc_modes_comparison() -> Result<()> { )?; let result_last = problem_last.optimize()?; + let doses_last = result_last + .optimal_subject + .iter() + .flat_map(|occ| { + occ.events() + .iter() + .filter_map(|event| match event { + Event::Infusion(inf) => Some(inf.amount()), + Event::Bolus(bol) => Some(bol.amount()), + _ => None, + }) + .collect::>() + }) + .collect::>(); // The two modes should recommend DIFFERENT doses for the same target value // because they're measuring different things @@ -1090,30 +1146,30 @@ fn test_auc_modes_comparison() -> Result<()> { eprintln!(" Target value: 100 mg·h/L (same number, different meaning)"); eprintln!(" "); eprintln!(" AUCFromZero (cumulative 0→24h):"); - eprintln!(" Optimal 2nd dose: {:.1} mg", result_zero.dose[0]); + eprintln!(" Optimal 2nd dose: {:.1} mg", doses_zero[0]); eprintln!( " AUC prediction: {:.2}", result_zero.auc_predictions.as_ref().unwrap()[0].1 ); eprintln!(" "); eprintln!(" AUCFromLastDose (interval 12→24h):"); - eprintln!(" Optimal 2nd dose: {:.1} mg", result_last.dose[0]); + eprintln!(" Optimal 2nd dose: {:.1} mg", doses_last[0]); eprintln!( " AUC prediction: {:.2}", result_last.auc_predictions.as_ref().unwrap()[0].1 ); // Verify both modes work - assert!(result_zero.dose[0] > 0.0); - assert!(result_last.dose[0] > 0.0); + assert!(doses_zero[0] > 0.0); + assert!(doses_last[0] > 0.0); // The doses should be different (cumulative includes first dose effect, // interval only measures second dose) // We expect AUCFromZero to recommend a smaller second dose since it includes // the AUC contribution from the first dose assert_ne!( - (result_zero.dose[0] * 10.0).round() / 10.0, - (result_last.dose[0] * 10.0).round() / 10.0, + (doses_zero[0] * 10.0).round() / 10.0, + (doses_last[0] * 10.0).round() / 10.0, "AUCFromZero and AUCFromLastDose should recommend different doses" ); @@ -1188,11 +1244,29 @@ fn test_auc_from_last_dose_multiple_observations() -> Result<()> { )?; let result = problem.optimize()?; + let doses: Vec = result + .optimal_subject + .iter() + .map(|occ| { + occ.iter() + .filter(|event| match event { + Event::Bolus(_) => true, + Event::Infusion(_) => true, + _ => false, + }) + .map(|event| match event { + Event::Bolus(bolus) => bolus.amount(), + Event::Infusion(infusion) => infusion.amount(), + _ => 0.0, + }) + }) + .flatten() + .collect(); // Should optimize 2 doses - assert_eq!(result.dose.len(), 2); - assert!(result.dose[0] > 0.0); - assert!(result.dose[1] > 0.0); + assert_eq!(doses.len(), 2); + assert!(doses[0] > 0.0); + assert!(doses[1] > 0.0); // Should have 2 AUC predictions assert!(result.auc_predictions.is_some()); @@ -1210,11 +1284,11 @@ fn test_auc_from_last_dose_multiple_observations() -> Result<()> { eprintln!("AUCFromLastDose multiple observations test:"); eprintln!( " Dose 1 (t=0): {:.1} mg → AUC₀₋₁₂ = {:.2} (target: 50.0)", - result.dose[0], auc1 + doses[0], auc1 ); eprintln!( " Dose 2 (t=12): {:.1} mg → AUC₁₂₋₂₄ = {:.2} (target: 50.0)", - result.dose[1], auc2 + doses[1], auc2 ); Ok(()) @@ -1286,9 +1360,27 @@ fn test_auc_from_last_dose_no_prior_dose() -> Result<()> { )?; let result = problem.optimize()?; + let doses: Vec = result + .optimal_subject + .iter() + .map(|occ| { + occ.iter() + .filter(|event| match event { + Event::Bolus(_) => true, + Event::Infusion(_) => true, + _ => false, + }) + .map(|event| match event { + Event::Bolus(bolus) => bolus.amount(), + Event::Infusion(infusion) => infusion.amount(), + _ => 0.0, + }) + }) + .flatten() + .collect(); - assert_eq!(result.dose.len(), 1); - assert!(result.dose[0] > 0.0); + assert_eq!(doses.len(), 1); + assert!(doses[0] > 0.0); assert!(result.auc_predictions.is_some()); let auc_preds = result.auc_predictions.unwrap(); @@ -1298,7 +1390,7 @@ fn test_auc_from_last_dose_no_prior_dose() -> Result<()> { eprintln!("AUCFromLastDose edge case (no prior dose):"); eprintln!(" Observation at t=6 (before any dose)"); - eprintln!(" Dose at t=12: {:.1} mg", result.dose[0]); + eprintln!(" Dose at t=12: {:.1} mg", doses[0]); eprintln!(" AUC₀₋₆: {:.2} (should be ~0, no drug yet)", auc); assert!( @@ -1383,28 +1475,46 @@ fn test_dose_range_bounds_respected() -> Result<()> { )?; let result = problem.optimize()?; + let doses: Vec = result + .optimal_subject + .iter() + .map(|occ| { + occ.iter() + .filter(|event| match event { + Event::Bolus(_) => true, + Event::Infusion(_) => true, + _ => false, + }) + .map(|event| match event { + Event::Bolus(bolus) => bolus.amount(), + Event::Infusion(infusion) => infusion.amount(), + _ => 0.0, + }) + }) + .flatten() + .collect(); - println!("Optimal dose: {:.1} mg", result.dose[0]); + println!("Optimal dose: {:.1} mg", doses[0]); println!("Dose range: 50-200 mg"); // Verify dose is within bounds assert!( - result.dose[0] >= 50.0, + doses[0] >= 50.0, "Dose {} is below minimum bound 50.0", - result.dose[0] + doses[0] ); assert!( - result.dose[0] <= 200.0, + doses[0] <= 200.0, "Dose {} is above maximum bound 200.0", - result.dose[0] + doses[0] ); // The optimal dose should hit the upper bound (200 mg) since the target is high // Allow small tolerance for numerical precision assert!( - (result.dose[0] - 200.0).abs() < 1.0, + (doses[0] - 200.0).abs() < 1.0, "Expected dose near upper bound (200 mg), got {:.1} mg", - result.dose[0] + doses[0] ); Ok(()) From a0052c14afb8de836fcaf422473b12126d77eadf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Wed, 5 Nov 2025 10:38:51 +0000 Subject: [PATCH 52/56] fix: tests now pass and removed some warnings --- src/bestdose/types.rs | 5 +++++ tests/bestdose_tests.rs | 32 ++++++++++++++++++-------------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/bestdose/types.rs b/src/bestdose/types.rs index 7702b2bd8..c7ce4e99f 100644 --- a/src/bestdose/types.rs +++ b/src/bestdose/types.rs @@ -262,6 +262,7 @@ pub struct BestDoseProblem { /// /// These observations are used to refine the population prior into a /// patient-specific posterior, and will be used to inform dose optimization. + #[allow(dead_code)] pub(crate) past_data: Subject, /// Target subject with dosing template and target observations /// @@ -281,6 +282,7 @@ pub struct BestDoseProblem { // Population prior /// The population prior support points ([Theta]), representing your previous knowledge of the population parameter distribution. + #[allow(dead_code)] pub(crate) population_theta: Theta, /// The population prior weights ([Weights]), representing the probability of each support point in the population. pub(crate) population_weights: Weights, @@ -291,6 +293,7 @@ pub struct BestDoseProblem { // Model and settings pub(crate) eq: ODE, + #[allow(dead_code)] pub(crate) error_models: ErrorModels, pub(crate) settings: Settings, @@ -304,11 +307,13 @@ pub struct BestDoseProblem { /// /// This is used to track the boundary between past and future for reporting/debugging. /// The actual optimization mask is derived from dose amounts (0 = optimize, >0 = fixed). + #[allow(dead_code)] pub(crate) time_offset: Option, } impl BestDoseProblem { /// Validate input + #[allow(dead_code)] pub(crate) fn validate(&self) -> anyhow::Result<()> { if self.bias_weight <= 0.0 || self.bias_weight >= 1.0 { return Err(anyhow::anyhow!( diff --git a/tests/bestdose_tests.rs b/tests/bestdose_tests.rs index a06cd94e3..220c88991 100644 --- a/tests/bestdose_tests.rs +++ b/tests/bestdose_tests.rs @@ -1087,20 +1087,22 @@ fn test_auc_modes_comparison() -> Result<()> { )?; let result_zero = problem_zero.optimize()?; - let doses_zero = result_zero + // Extract only the second dose (the optimized one at t=12) + let dose_zero = result_zero .optimal_subject .iter() .flat_map(|occ| { occ.events() .iter() .filter_map(|event| match event { - Event::Infusion(inf) => Some(inf.amount()), - Event::Bolus(bol) => Some(bol.amount()), + Event::Bolus(bol) if bol.time() == 12.0 => Some(bol.amount()), + Event::Infusion(inf) if inf.time() == 12.0 => Some(inf.amount()), _ => None, }) .collect::>() }) - .collect::>(); + .next() + .unwrap(); // Mode 2: AUCFromLastDose - target is interval AUC from t=12 to t=24 let target_last = Subject::builder("patient_last") @@ -1124,20 +1126,22 @@ fn test_auc_modes_comparison() -> Result<()> { )?; let result_last = problem_last.optimize()?; - let doses_last = result_last + // Extract only the second dose (the optimized one at t=12) + let dose_last = result_last .optimal_subject .iter() .flat_map(|occ| { occ.events() .iter() .filter_map(|event| match event { - Event::Infusion(inf) => Some(inf.amount()), - Event::Bolus(bol) => Some(bol.amount()), + Event::Bolus(bol) if bol.time() == 12.0 => Some(bol.amount()), + Event::Infusion(inf) if inf.time() == 12.0 => Some(inf.amount()), _ => None, }) .collect::>() }) - .collect::>(); + .next() + .unwrap(); // The two modes should recommend DIFFERENT doses for the same target value // because they're measuring different things @@ -1146,30 +1150,30 @@ fn test_auc_modes_comparison() -> Result<()> { eprintln!(" Target value: 100 mg·h/L (same number, different meaning)"); eprintln!(" "); eprintln!(" AUCFromZero (cumulative 0→24h):"); - eprintln!(" Optimal 2nd dose: {:.1} mg", doses_zero[0]); + eprintln!(" Optimal 2nd dose: {:.1} mg", dose_zero); eprintln!( " AUC prediction: {:.2}", result_zero.auc_predictions.as_ref().unwrap()[0].1 ); eprintln!(" "); eprintln!(" AUCFromLastDose (interval 12→24h):"); - eprintln!(" Optimal 2nd dose: {:.1} mg", doses_last[0]); + eprintln!(" Optimal 2nd dose: {:.1} mg", dose_last); eprintln!( " AUC prediction: {:.2}", result_last.auc_predictions.as_ref().unwrap()[0].1 ); // Verify both modes work - assert!(doses_zero[0] > 0.0); - assert!(doses_last[0] > 0.0); + assert!(dose_zero > 0.0); + assert!(dose_last > 0.0); // The doses should be different (cumulative includes first dose effect, // interval only measures second dose) // We expect AUCFromZero to recommend a smaller second dose since it includes // the AUC contribution from the first dose assert_ne!( - (doses_zero[0] * 10.0).round() / 10.0, - (doses_last[0] * 10.0).round() / 10.0, + (dose_zero * 10.0).round() / 10.0, + (dose_last * 10.0).round() / 10.0, "AUCFromZero and AUCFromLastDose should recommend different doses" ); From 821cfef884fdfdd74a0be79d5a07eb51f68c813f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Wed, 5 Nov 2025 10:58:14 +0000 Subject: [PATCH 53/56] remove unused code --- src/bestdose/mod.rs | 7 +------ src/bestdose/types.rs | 37 ------------------------------------- 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index c568adbf6..016b95e3b 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -701,8 +701,7 @@ impl BestDoseProblem { )?; // Handle past/future concatenation if needed - let (final_target, final_past_data) = - prepare_target_subject(past_subject, target, time_offset)?; + let (final_target, _) = prepare_target_subject(past_subject, target, time_offset)?; tracing::info!("╔══════════════════════════════════════════════════════════╗"); tracing::info!("║ Stage 1 Complete - Ready for Optimization ║"); @@ -712,19 +711,15 @@ impl BestDoseProblem { tracing::info!(" Bias weight (λ): {}", bias_weight); Ok(BestDoseProblem { - past_data: final_past_data, target: final_target, target_type, - population_theta: population_theta.clone(), population_weights: filtered_population_weights, theta: posterior_theta, posterior: posterior_weights, eq, - error_models, settings, doserange, bias_weight, - time_offset, }) } diff --git a/src/bestdose/types.rs b/src/bestdose/types.rs index c7ce4e99f..ca3a6fdc8 100644 --- a/src/bestdose/types.rs +++ b/src/bestdose/types.rs @@ -257,13 +257,6 @@ impl Default for DoseRange { /// ``` #[derive(Debug, Clone)] pub struct BestDoseProblem { - // Input data - /// Past patient data for posterior calculation - /// - /// These observations are used to refine the population prior into a - /// patient-specific posterior, and will be used to inform dose optimization. - #[allow(dead_code)] - pub(crate) past_data: Subject, /// Target subject with dosing template and target observations /// /// This [Subject] defines the targets for optimization, including @@ -280,10 +273,6 @@ pub struct BestDoseProblem { /// Specifies whether to optimize for concentrations or AUC values. pub(crate) target_type: Target, - // Population prior - /// The population prior support points ([Theta]), representing your previous knowledge of the population parameter distribution. - #[allow(dead_code)] - pub(crate) population_theta: Theta, /// The population prior weights ([Weights]), representing the probability of each support point in the population. pub(crate) population_weights: Weights, @@ -293,37 +282,11 @@ pub struct BestDoseProblem { // Model and settings pub(crate) eq: ODE, - #[allow(dead_code)] - pub(crate) error_models: ErrorModels, pub(crate) settings: Settings, // Optimization parameters pub(crate) doserange: DoseRange, pub(crate) bias_weight: f64, // λ: 0=personalized, 1=population - - /// Time offset between past and future data (used for concatenation) - /// When Some(t): future events were offset by this time to create continuous simulation - /// When None: no concatenation was performed (standard single-subject mode) - /// - /// This is used to track the boundary between past and future for reporting/debugging. - /// The actual optimization mask is derived from dose amounts (0 = optimize, >0 = fixed). - #[allow(dead_code)] - pub(crate) time_offset: Option, -} - -impl BestDoseProblem { - /// Validate input - #[allow(dead_code)] - pub(crate) fn validate(&self) -> anyhow::Result<()> { - if self.bias_weight <= 0.0 || self.bias_weight >= 1.0 { - return Err(anyhow::anyhow!( - "Bias weight must be between 0.0 and 1.0, got {}", - self.bias_weight - )); - } - - Ok(()) - } } /// Result from BestDose optimization From e79987c12feb6cc7d62d87abf479598d4e1c4994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Wed, 5 Nov 2025 11:41:09 +0000 Subject: [PATCH 54/56] update docs --- src/bestdose/cost.rs | 9 ++++----- src/bestdose/mod.rs | 18 ++++++++++-------- src/bestdose/optimization.rs | 8 ++++---- src/bestdose/types.rs | 7 ++----- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/src/bestdose/cost.rs b/src/bestdose/cost.rs index 7915a7015..c3ba15aa0 100644 --- a/src/bestdose/cost.rs +++ b/src/bestdose/cost.rs @@ -76,12 +76,11 @@ use pharmsol::Equation; /// /// # Dose Masking /// -/// When `problem.time_offset` is set (past/future separation), only doses where -/// `dose_optimization_mask[i] == true` are updated with values from `candidate_doses`. -/// Past doses (mask == false) remain at their historical values. +/// Only doses with `amount == 0.0` in the target subject are considered optimizable. +/// Doses with non-zero amounts remain fixed at their specified values. /// -/// - **Standard mode**: All doses in `candidate_doses` → all doses updated -/// - **Fortran mode**: Only future doses in `candidate_doses` → only future doses updated +/// The `candidate_doses` parameter contains only the optimizable doses, which are +/// substituted into the target subject before simulation /// /// # Cost Function Details /// diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 016b95e3b..e7c1a9109 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -25,12 +25,12 @@ //! &population_weights, // Population probabilities //! Some(past_data), // Patient history (None = use prior) //! target, // Future template with targets +//! None, // time_offset (None = standard mode) //! eq, // PK/PD model //! error_models, // Error specifications //! DoseRange::new(0.0, 1000.0), // Dose constraints (0-1000 mg) //! 0.5, // bias_weight: 0=personalized, 1=population //! settings, // NPAG settings -//! 500, // NPAGFULL refinement cycles //! Target::Concentration, // Target type //! )?; //! @@ -161,10 +161,11 @@ //! .build(); //! //! let problem = BestDoseProblem::new( -//! &population_theta, &population_weights, Some(past), target, eq, error_models, +//! &population_theta, &population_weights, Some(past), target, None, +//! eq, error_models, //! DoseRange::new(10.0, 500.0), // 10-500 mg allowed //! 0.3, // Slight population emphasis -//! settings, 500, Target::Concentration, +//! settings, Target::Concentration, //! )?; //! //! let result = problem.optimize()?; @@ -194,7 +195,8 @@ //! .build(); //! //! let problem = BestDoseProblem::new( -//! &population_theta, &population_weights, Some(past), target, eq, error_models, +//! &population_theta, &population_weights, Some(past), target, None, +//! eq, error_models, //! DoseRange::new(50.0, 300.0), //! 0.0, // Full personalization //! settings, Target::AUCFromZero, // Cumulative AUC target! @@ -225,11 +227,11 @@ //! let problem = BestDoseProblem::new( //! &population_theta, &population_weights, //! None, // No past data -//! target, eq, error_models, +//! target, None, // time_offset +//! eq, error_models, //! DoseRange::new(0.0, 1000.0), //! 1.0, // Full population weighting //! settings, -//! 0, // Skip refinement //! Target::Concentration, //! )?; //! @@ -273,10 +275,10 @@ //! # -> anyhow::Result<()> { //! // Reduce refinement cycles //! let problem = BestDoseProblem::new( -//! &population_theta, &population_weights, None, target, eq, error_models, +//! &population_theta, &population_weights, None, target, None, +//! eq, error_models, //! DoseRange::new(0.0, 1000.0), 0.5, //! settings.clone(), -//! 100, // Faster: 100 instead of 500 //! Target::Concentration, //! )?; //! diff --git a/src/bestdose/optimization.rs b/src/bestdose/optimization.rs index 1e1e02d1d..4fb6c4cca 100644 --- a/src/bestdose/optimization.rs +++ b/src/bestdose/optimization.rs @@ -92,10 +92,10 @@ impl CostFunction for BestDoseProblem { /// /// This is a helper for the dual optimization approach. /// -/// When `problem.time_offset` is set (past/future separation mode): -/// - Only optimizes doses where `dose_optimization_mask[i] == true` -/// - Creates a reduced-dimension simplex for future doses only -/// - Maps optimized doses back to full vector (past doses unchanged) +/// Only optimizes doses with `amount == 0.0` in the target subject: +/// - Counts optimizable doses (amount == 0) vs fixed doses (amount > 0) +/// - Creates a reduced-dimension simplex for optimizable doses only +/// - Maps optimized doses back to full vector (fixed doses unchanged) /// /// Returns: (optimal_doses, final_cost) fn run_single_optimization( diff --git a/src/bestdose/types.rs b/src/bestdose/types.rs index ca3a6fdc8..61872d3dc 100644 --- a/src/bestdose/types.rs +++ b/src/bestdose/types.rs @@ -203,13 +203,11 @@ impl Default for DoseRange { /// # Fields /// /// ## Input Data -/// - `past_data`: Patient history for posterior calculation /// - `target`: Future dosing template with target observations /// - `target_type`: [`Target::Concentration`] or [`Target::AUC`] /// /// ## Population Prior -/// - `population_theta`: Support points from NPAG population model -/// - `population_weights`: Probability weights for each support point +/// - `population_weights`: Filtered population probability weights (used for bias term) /// /// ## Patient-Specific Posterior /// - `theta`: Refined posterior support points (from NPAGFULL11 + NPAGFULL) @@ -217,8 +215,7 @@ impl Default for DoseRange { /// /// ## Model Components /// - `eq`: Pharmacokinetic/pharmacodynamic ODE model -/// - `error_models`: Error model specifications -/// - `settings`: NPAG configuration settings +/// - `settings`: NPAG configuration settings (used for prediction grid) /// /// ## Optimization Parameters /// - `doserange`: Min/max dose constraints From 78f7dc271a345a08383fd9984ac36da546783b06 Mon Sep 17 00:00:00 2001 From: Markus Hovd Date: Wed, 5 Nov 2025 12:55:40 +0100 Subject: [PATCH 55/56] chore: Suggestions for BestDose (#218) * Clean up result structure * Remove redundant code * Remove clone * Update bestdose.rs * Use enum for optimization method * Enum for the status of BestDose --- examples/bestdose.rs | 23 +--- examples/bestdose_auc.rs | 48 ++----- examples/bestdose_bounds.rs | 7 +- src/bestdose/optimization.rs | 15 +- src/bestdose/types.rs | 108 +++++++++++++-- tests/bestdose_tests.rs | 257 ++++++----------------------------- 6 files changed, 163 insertions(+), 295 deletions(-) diff --git a/examples/bestdose.rs b/examples/bestdose.rs index 35e9deb06..b3be5a096 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose.rs @@ -114,35 +114,22 @@ fn main() -> Result<()> { // Print results for (bias_weight, optimal) in &results { - let opt_doses = optimal - .optimal_subject - .iter() - .flat_map(|occ| { - occ.events() - .iter() - .filter_map(|event| match event { - Event::Bolus(bolus) => Some(bolus.amount()), - Event::Infusion(infusion) => Some(infusion.amount()), - _ => None, - }) - .collect::>() - }) - .collect::>(); + let opt_doses = optimal.doses(); println!( "Bias weight: {:.2}\t\t Optimal dose: {:?}\t\tCost: {:.6}\t\tln Cost: {:.4}\t\tMethod: {}", bias_weight, opt_doses, - optimal.objf, - optimal.objf.ln(), - optimal.optimization_method + optimal.objf(), + optimal.objf().ln(), + optimal.optimization_method() ); } // Print concentration-time predictions for the optimal dose let optimal = &results.last().unwrap().1; println!("\nConcentration-time predictions for optimal dose:"); - for pred in optimal.preds.predictions().into_iter() { + for pred in optimal.predictions().predictions().into_iter() { println!( "Time: {:.2} h, Observed: {:.2}, (Pop Mean: {:.4}, Pop Median: {:.4}, Post Mean: {:.4}, Post Median: {:.4})", pred.time(), pred.obs().unwrap_or(0.0), pred.pop_mean(), pred.pop_median(), pred.post_mean(), pred.post_median() diff --git a/examples/bestdose_auc.rs b/examples/bestdose_auc.rs index 0d9a46cfe..17738da1d 100644 --- a/examples/bestdose_auc.rs +++ b/examples/bestdose_auc.rs @@ -83,33 +83,20 @@ fn main() -> Result<()> { println!("Optimizing dose...\n"); let optimal = problem.optimize()?; - let opt_doses = optimal - .optimal_subject - .iter() - .flat_map(|occ| { - occ.events() - .iter() - .filter_map(|event| match event { - Event::Bolus(bolus) => Some(bolus.amount()), - Event::Infusion(infusion) => Some(infusion.amount()), - _ => None, - }) - .collect::>() - }) - .collect::>(); + let opt_doses = optimal.doses(); println!("=== RESULTS ==="); println!("Optimal dose: {:.1} mg", opt_doses[0]); - println!("Cost function: {:.6}", optimal.objf); + println!("Cost function: {:.6}", optimal.objf()); - if let Some(auc_preds) = &optimal.auc_predictions { + if let Some(auc_preds) = &optimal.auc_predictions() { println!("\nAUC Predictions:"); let mut total_error = 0.0; for (time, auc) in auc_preds { // Find the target AUC for this time - let target = if (*time - 6.0).abs() < 0.1 { + let target = if (time - 6.0).abs() < 0.1 { 50.0 - } else if (*time - 12.0).abs() < 0.1 { + } else if (time - 12.0).abs() < 0.1 { 80.0 } else { 0.0 @@ -127,7 +114,7 @@ fn main() -> Result<()> { ); } else { println!("\nConcentration Predictions:"); - for pred in optimal.preds.predictions() { + for pred in optimal.predictions().predictions() { println!( " Time: {:5.1}h | Target: {:6.1} | Predicted: {:6.2}", pred.time(), @@ -172,30 +159,13 @@ fn main() -> Result<()> { println!("Optimizing maintenance dose...\n"); let optimal_interval = problem_interval.optimize()?; - let doses: Vec = optimal_interval - .optimal_subject - .iter() - .map(|occ| { - occ.iter() - .filter(|event| match event { - Event::Bolus(_) => true, - Event::Infusion(_) => true, - _ => false, - }) - .map(|event| match event { - Event::Bolus(bolus) => bolus.amount(), - Event::Infusion(infusion) => infusion.amount(), - _ => 0.0, - }) - }) - .flatten() - .collect(); + let doses: Vec = optimal_interval.doses(); println!("=== INTERVAL AUC RESULTS ==="); println!("Optimal maintenance dose (at t=12h): {:.1} mg", doses[0]); - println!("Cost function: {:.6}", optimal_interval.objf); + println!("Cost function: {:.6}", optimal_interval.objf()); - if let Some(auc_preds) = &optimal_interval.auc_predictions { + if let Some(auc_preds) = &optimal_interval.auc_predictions() { println!("\nInterval AUC Predictions:"); for (time, auc) in auc_preds { let target = 60.0; diff --git a/examples/bestdose_bounds.rs b/examples/bestdose_bounds.rs index 4fab2862c..2c3bdd6dd 100644 --- a/examples/bestdose_bounds.rs +++ b/examples/bestdose_bounds.rs @@ -89,7 +89,7 @@ fn main() -> Result<()> { let result = problem.optimize()?; let doses: Vec = result - .optimal_subject + .optimal_subject() .iter() .map(|occ| { occ.iter() @@ -118,7 +118,10 @@ fn main() -> Result<()> { println!( "{:<30} | {:>10.1} mg | {:>10.6}{}", - description, doses[0], result.objf, at_bound + description, + doses[0], + result.objf(), + at_bound ); } diff --git a/src/bestdose/optimization.rs b/src/bestdose/optimization.rs index 4fb6c4cca..bd4056ca2 100644 --- a/src/bestdose/optimization.rs +++ b/src/bestdose/optimization.rs @@ -45,7 +45,7 @@ use argmin::solver::neldermead::NelderMead; use crate::bestdose::cost::calculate_cost; use crate::bestdose::predictions::calculate_final_predictions; -use crate::bestdose::types::{BestDoseProblem, BestDoseResult}; +use crate::bestdose::types::{BestDoseProblem, BestDoseResult, BestDoseStatus, OptimalMethod}; use crate::structs::weights::Weights; use pharmsol::prelude::*; @@ -244,10 +244,15 @@ pub fn dual_optimization(problem: &BestDoseProblem) -> Result { let (final_doses, final_cost, method, final_weights) = if cost1 <= cost2 { tracing::info!(" → Winner: Posterior (lower cost) ✓"); - (doses1, cost1, "posterior", problem.posterior.clone()) + ( + doses1, + cost1, + OptimalMethod::Posterior, + problem.posterior.clone(), + ) } else { tracing::info!(" → Winner: Uniform (lower cost) ✓"); - (doses2, cost2, "uniform", uniform_weights) + (doses2, cost2, OptimalMethod::Uniform, uniform_weights) }; // ═════════════════════════════════════════════════════════════ @@ -290,9 +295,9 @@ pub fn dual_optimization(problem: &BestDoseProblem) -> Result { Ok(BestDoseResult { optimal_subject, objf: final_cost, - status: "Converged".to_string(), + status: BestDoseStatus::Converged, preds, auc_predictions, - optimization_method: method.to_string(), + optimization_method: method, }) } diff --git a/src/bestdose/types.rs b/src/bestdose/types.rs index 61872d3dc..e422cd0be 100644 --- a/src/bestdose/types.rs +++ b/src/bestdose/types.rs @@ -6,12 +6,15 @@ //! - [`Target`]: Enum specifying concentration or AUC targets //! - [`DoseRange`]: Dose constraint specification +use std::fmt::Display; + use crate::prelude::*; use crate::routines::output::predictions::NPPredictions; use crate::routines::settings::Settings; use crate::structs::theta::Theta; use crate::structs::weights::Weights; use pharmsol::prelude::*; +use serde::{Deserialize, Serialize}; /// Target type for dose optimization /// @@ -49,7 +52,7 @@ use pharmsol::prelude::*; /// - Automatically finds the most recent bolus/infusion before each observation /// /// Both methods use trapezoidal rule on a dense time grid controlled by `settings.predictions().idelta`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Target { /// Target concentrations at observation times /// @@ -133,10 +136,10 @@ pub enum Target { /// ```rust,ignore /// use pmcore::bestdose::DoseRange; /// -/// // Standard range: 0-1000 mg +/// // Large range: 0-1000 mg /// let range = DoseRange::new(0.0, 1000.0); /// -/// // Narrow therapeutic window +/// // Narrow range: 50-150 mg /// let range = DoseRange::new(50.0, 150.0); /// /// // Access bounds @@ -357,31 +360,31 @@ pub struct BestDoseProblem { /// # Ok(()) /// # } /// ``` -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct BestDoseResult { - /// Optimal dose amount(s) + /// Subject with optimal doses /// - /// Vector contains one element per dose in the target subject. - /// Order matches the dose events in the target subject. - pub optimal_subject: Subject, + /// The [Subject] contains the same events as the target subject, + /// but with the dose amounts updated to the optimal values. + pub(crate) optimal_subject: Subject, /// Final cost function value /// /// Lower is better. Represents the weighted combination of variance /// (patient-specific error) and bias (deviation from population). - pub objf: f64, + pub(crate) objf: f64, /// Optimization status message /// /// Examples: "converged", "maximum iterations reached", etc. - pub status: String, + pub(crate) status: BestDoseStatus, /// Concentration-time predictions for optimal doses /// /// Contains predicted concentrations at observation times using the /// optimal doses. Predictions use the weights from the winning optimization /// method (posterior or uniform). - pub preds: NPPredictions, + pub(crate) preds: NPPredictions, /// AUC values at observation times /// @@ -389,7 +392,7 @@ pub struct BestDoseResult { /// Each tuple contains `(time, cumulative_auc)`. /// /// For [`Target::Concentration`], this field is `None`. - pub auc_predictions: Option>, + pub(crate) auc_predictions: Option>, /// Which optimization method produced the best result /// @@ -397,5 +400,84 @@ pub struct BestDoseResult { /// - `"uniform"`: Population-based optimization (uses uniform weights) /// /// The algorithm runs both optimizations and selects the one with lower cost. - pub optimization_method: String, + pub(crate) optimization_method: OptimalMethod, +} + +impl BestDoseResult { + /// Get the optimized subject + pub fn optimal_subject(&self) -> &Subject { + &self.optimal_subject + } + + /// Get the dose amounts of the optimized subject + /// + /// This includes all doses (bolus and infusion) in the order they appear + /// in the optimal subject, and returns their amounts as a vector of f64. + pub fn doses(&self) -> Vec { + self.optimal_subject() + .iter() + .flat_map(|occ| { + occ.events() + .iter() + .filter_map(|event| match event { + Event::Bolus(bolus) => Some(bolus.amount()), + Event::Infusion(infusion) => Some(infusion.amount()), + _ => None, + }) + .collect::>() + }) + .collect::>() + } + + /// Get the objective cost function value + pub fn objf(&self) -> f64 { + self.objf + } + + /// Get the optimization status + pub fn status(&self) -> &BestDoseStatus { + &self.status + } + + /// Get the concentration-time predictions + pub fn predictions(&self) -> &NPPredictions { + &self.preds + } + + /// Get the AUC predictions, if available + pub fn auc_predictions(&self) -> Option> { + self.auc_predictions.clone() + } + + /// Get the optimization method used + pub fn optimization_method(&self) -> OptimalMethod { + self.optimization_method + } +} + +/// Optimization method used in BestDose +/// +/// This returns the type of optimization method that produced the best result: +/// - `Posterior`: Patient-specific optimization using posterior weights +/// - `Uniform`: Population-based optimization using uniform weights +#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq, Eq)] +pub enum OptimalMethod { + Posterior, + Uniform, +} + +impl Display for OptimalMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + OptimalMethod::Posterior => write!(f, "Posterior"), + OptimalMethod::Uniform => write!(f, "Uniform"), + } + } +} + +/// Status of the BestDose optimization +#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq, Eq)] +pub enum BestDoseStatus { + Converged, + MaxIterations, } diff --git a/tests/bestdose_tests.rs b/tests/bestdose_tests.rs index 220c88991..e99d74e69 100644 --- a/tests/bestdose_tests.rs +++ b/tests/bestdose_tests.rs @@ -105,36 +105,12 @@ fn test_infusion_mask_inclusion() -> Result<()> { // We should get back 1 optimized dose (the infusion placeholder) assert_eq!( - result - .optimal_subject - .iter() - .flat_map(|occ| { - occ.events() - .iter() - .filter_map(|event| match event { - Event::Infusion(inf) => Some(inf.amount()), - _ => None, - }) - .collect::>() - }) - .count(), + result.doses().len(), 1, "Should have 1 optimized dose (the infusion)" ); - let optinf = result - .optimal_subject - .iter() - .flat_map(|occ| { - occ.events() - .iter() - .filter_map(|event| match event { - Event::Infusion(inf) => Some(inf.amount()), - _ => None, - }) - .collect::>() - }) - .collect::>(); + let optinf = result.doses(); // The optimized dose should be reasonable (not NaN, not infinite) assert!( @@ -222,22 +198,15 @@ fn test_fixed_infusion_preservation() -> Result<()> { let result = problem.optimize()?; // Should only optimize the future bolus, not the past infusion - let doses = result - .optimal_subject - .iter() - .flat_map(|occ| { - occ.events() - .iter() - .filter_map(|event| match event { - Event::Infusion(inf) if inf.amount() != 200.0 => Some(inf.amount()), - Event::Bolus(bol) => Some(bol.amount()), - _ => None, - }) - .collect::>() - }) - .collect::>(); + let doses = result.doses(); eprintln!("Optimized doses: {:?}", doses); - assert_eq!(doses.len(), 1, "Should have 1 optimized dose"); + assert_eq!( + doses.len(), + 2, + "Should have 2 doses (past infusion + future bolus)" + ); + assert_eq!(doses[0], 200.0, "Past infusion dose should be preserved"); + assert!(doses[1] > 0.0, "Future bolus dose should be optimized"); Ok(()) } @@ -465,25 +434,12 @@ fn test_basic_auc_mode() -> Result<()> { ); let result = result?; - let doses = result - .optimal_subject - .iter() - .flat_map(|occ| { - occ.events() - .iter() - .filter_map(|event| match event { - Event::Infusion(inf) => Some(inf.amount()), - Event::Bolus(bol) => Some(bol.amount()), - _ => None, - }) - .collect::>() - }) - .collect::>(); + let doses = result.doses(); assert_eq!(doses.len(), 1); - assert!(result.auc_predictions.is_some()); + assert!(result.auc_predictions().is_some()); - let auc_preds = result.auc_predictions.unwrap(); + let auc_preds = result.auc_predictions().unwrap(); eprintln!("Basic AUC test - AUC predictions: {:?}", auc_preds); assert_eq!(auc_preds.len(), 1); @@ -574,20 +530,7 @@ fn test_infusion_auc_mode() -> Result<()> { ); let result = result?; - let doses = result - .optimal_subject - .iter() - .flat_map(|occ| { - occ.events() - .iter() - .filter_map(|event| match event { - Event::Infusion(inf) => Some(inf.amount()), - Event::Bolus(bol) => Some(bol.amount()), - _ => None, - }) - .collect::>() - }) - .collect::>(); + let doses = result.doses(); eprintln!("Optimized dose: {:?}", doses); @@ -596,11 +539,11 @@ fn test_infusion_auc_mode() -> Result<()> { // Should have AUC predictions assert!( - result.auc_predictions.is_some(), + result.auc_predictions().is_some(), "Should have AUC predictions" ); - let auc_preds = result.auc_predictions.unwrap(); + let auc_preds = result.auc_predictions().unwrap(); eprintln!("AUC predictions: {:?}", auc_preds); assert_eq!(auc_preds.len(), 2, "Should have 2 AUC predictions"); @@ -767,27 +710,14 @@ fn test_multi_outeq_auc_optimization() -> Result<()> { let best_dose_result = result?; - let doses = best_dose_result - .optimal_subject - .iter() - .flat_map(|occ| { - occ.events() - .iter() - .filter_map(|event| match event { - Event::Infusion(inf) => Some(inf.amount()), - Event::Bolus(bol) => Some(bol.amount()), - _ => None, - }) - .collect::>() - }) - .collect::>(); + let doses = best_dose_result.doses(); assert_eq!(doses.len(), 1); assert!(doses[0] > 0.0); - assert!(best_dose_result.objf.is_finite()); + assert!(best_dose_result.objf().is_finite()); - assert!(best_dose_result.auc_predictions.is_some()); - let auc_preds = best_dose_result.auc_predictions.unwrap(); + assert!(best_dose_result.auc_predictions().is_some()); + let auc_preds = best_dose_result.auc_predictions().unwrap(); assert_eq!( auc_preds.len(), 2, @@ -868,33 +798,16 @@ fn test_auc_from_zero_single_dose() -> Result<()> { let result = problem.optimize()?; - let doses: Vec = result - .optimal_subject - .iter() - .map(|occ| { - occ.iter() - .filter(|event| match event { - Event::Bolus(_) => true, - Event::Infusion(_) => true, - _ => false, - }) - .map(|event| match event { - Event::Bolus(bolus) => bolus.amount(), - Event::Infusion(infusion) => infusion.amount(), - _ => 0.0, - }) - }) - .flatten() - .collect(); + let doses: Vec = result.doses(); // Verify we got a result assert_eq!(doses.len(), 1); assert!(doses[0] > 0.0); - assert!(result.objf.is_finite()); + assert!(result.objf().is_finite()); // Verify we have AUC predictions - assert!(result.auc_predictions.is_some()); - let auc_preds = result.auc_predictions.unwrap(); + assert!(result.auc_predictions().is_some()); + let auc_preds = result.auc_predictions().unwrap(); assert_eq!(auc_preds.len(), 1); let (time, auc) = auc_preds[0]; @@ -977,31 +890,18 @@ fn test_auc_from_last_dose_maintenance() -> Result<()> { )?; let result = problem.optimize()?; - let doses = result - .optimal_subject - .iter() - .flat_map(|occ| { - occ.events() - .iter() - .filter_map(|event| match event { - Event::Infusion(inf) => Some(inf.amount()), - Event::Bolus(bol) => Some(bol.amount()), - _ => None, - }) - .collect::>() - }) - .collect::>(); + let doses = result.doses(); // Verify we got a result assert_eq!(doses.len(), 2, "Should be 2 doses (loading + maintenance)"); // Very first one is fixed loading dose, second is optimized maintenance dose assert_eq!(doses[0], 300.0); assert!(doses[0] > 0.0); - assert!(result.objf.is_finite()); + assert!(result.objf().is_finite()); // Verify we have AUC predictions - assert!(result.auc_predictions.is_some()); - let auc_preds = result.auc_predictions.unwrap(); + assert!(result.auc_predictions().is_some()); + let auc_preds = result.auc_predictions().unwrap(); assert_eq!(auc_preds.len(), 1); let (time, auc) = auc_preds[0]; @@ -1088,21 +988,7 @@ fn test_auc_modes_comparison() -> Result<()> { let result_zero = problem_zero.optimize()?; // Extract only the second dose (the optimized one at t=12) - let dose_zero = result_zero - .optimal_subject - .iter() - .flat_map(|occ| { - occ.events() - .iter() - .filter_map(|event| match event { - Event::Bolus(bol) if bol.time() == 12.0 => Some(bol.amount()), - Event::Infusion(inf) if inf.time() == 12.0 => Some(inf.amount()), - _ => None, - }) - .collect::>() - }) - .next() - .unwrap(); + let dose_zero = result_zero.doses()[1]; // Mode 2: AUCFromLastDose - target is interval AUC from t=12 to t=24 let target_last = Subject::builder("patient_last") @@ -1127,21 +1013,7 @@ fn test_auc_modes_comparison() -> Result<()> { let result_last = problem_last.optimize()?; // Extract only the second dose (the optimized one at t=12) - let dose_last = result_last - .optimal_subject - .iter() - .flat_map(|occ| { - occ.events() - .iter() - .filter_map(|event| match event { - Event::Bolus(bol) if bol.time() == 12.0 => Some(bol.amount()), - Event::Infusion(inf) if inf.time() == 12.0 => Some(inf.amount()), - _ => None, - }) - .collect::>() - }) - .next() - .unwrap(); + let dose_last = result_last.doses()[1]; // The two modes should recommend DIFFERENT doses for the same target value // because they're measuring different things @@ -1153,14 +1025,14 @@ fn test_auc_modes_comparison() -> Result<()> { eprintln!(" Optimal 2nd dose: {:.1} mg", dose_zero); eprintln!( " AUC prediction: {:.2}", - result_zero.auc_predictions.as_ref().unwrap()[0].1 + result_zero.auc_predictions().as_ref().unwrap()[0].1 ); eprintln!(" "); eprintln!(" AUCFromLastDose (interval 12→24h):"); eprintln!(" Optimal 2nd dose: {:.1} mg", dose_last); eprintln!( " AUC prediction: {:.2}", - result_last.auc_predictions.as_ref().unwrap()[0].1 + result_last.auc_predictions().as_ref().unwrap()[0].1 ); // Verify both modes work @@ -1248,24 +1120,7 @@ fn test_auc_from_last_dose_multiple_observations() -> Result<()> { )?; let result = problem.optimize()?; - let doses: Vec = result - .optimal_subject - .iter() - .map(|occ| { - occ.iter() - .filter(|event| match event { - Event::Bolus(_) => true, - Event::Infusion(_) => true, - _ => false, - }) - .map(|event| match event { - Event::Bolus(bolus) => bolus.amount(), - Event::Infusion(infusion) => infusion.amount(), - _ => 0.0, - }) - }) - .flatten() - .collect(); + let doses: Vec = result.doses(); // Should optimize 2 doses assert_eq!(doses.len(), 2); @@ -1273,8 +1128,8 @@ fn test_auc_from_last_dose_multiple_observations() -> Result<()> { assert!(doses[1] > 0.0); // Should have 2 AUC predictions - assert!(result.auc_predictions.is_some()); - let auc_preds = result.auc_predictions.unwrap(); + assert!(result.auc_predictions().is_some()); + let auc_preds = result.auc_predictions().unwrap(); assert_eq!(auc_preds.len(), 2); // First observation measures AUC from t=0 (first dose) to t=12 @@ -1364,30 +1219,13 @@ fn test_auc_from_last_dose_no_prior_dose() -> Result<()> { )?; let result = problem.optimize()?; - let doses: Vec = result - .optimal_subject - .iter() - .map(|occ| { - occ.iter() - .filter(|event| match event { - Event::Bolus(_) => true, - Event::Infusion(_) => true, - _ => false, - }) - .map(|event| match event { - Event::Bolus(bolus) => bolus.amount(), - Event::Infusion(infusion) => infusion.amount(), - _ => 0.0, - }) - }) - .flatten() - .collect(); + let doses: Vec = result.doses(); assert_eq!(doses.len(), 1); assert!(doses[0] > 0.0); - assert!(result.auc_predictions.is_some()); - let auc_preds = result.auc_predictions.unwrap(); + assert!(result.auc_predictions().is_some()); + let auc_preds = result.auc_predictions().unwrap(); assert_eq!(auc_preds.len(), 1); let (_time, auc) = auc_preds[0]; @@ -1479,24 +1317,7 @@ fn test_dose_range_bounds_respected() -> Result<()> { )?; let result = problem.optimize()?; - let doses: Vec = result - .optimal_subject - .iter() - .map(|occ| { - occ.iter() - .filter(|event| match event { - Event::Bolus(_) => true, - Event::Infusion(_) => true, - _ => false, - }) - .map(|event| match event { - Event::Bolus(bolus) => bolus.amount(), - Event::Infusion(infusion) => infusion.amount(), - _ => 0.0, - }) - }) - .flatten() - .collect(); + let doses: Vec = result.doses(); println!("Optimal dose: {:.1} mg", doses[0]); println!("Dose range: 50-200 mg"); From 533b73b0c6febd12c9b3e8a0f789603779f541b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Wed, 5 Nov 2025 12:08:39 +0000 Subject: [PATCH 56/56] cleanup redundant parameter --- examples/bestdose.rs | 1 - examples/bestdose_auc.rs | 2 -- examples/bestdose_bounds.rs | 1 - src/bestdose/mod.rs | 3 +-- tests/bestdose_tests.rs | 15 --------------- 5 files changed, 1 insertion(+), 21 deletions(-) diff --git a/examples/bestdose.rs b/examples/bestdose.rs index b3be5a096..7761d8433 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose.rs @@ -94,7 +94,6 @@ fn main() -> Result<()> { target_data.clone(), None, eq.clone(), - ems.clone(), bestdose::DoseRange::new(0.0, 300.0), 0.0, settings.clone(), diff --git a/examples/bestdose_auc.rs b/examples/bestdose_auc.rs index 17738da1d..c1f44d2e2 100644 --- a/examples/bestdose_auc.rs +++ b/examples/bestdose_auc.rs @@ -73,7 +73,6 @@ fn main() -> Result<()> { target_data.clone(), None, eq.clone(), - ems.clone(), DoseRange::new(100.0, 2000.0), // Wider range for AUC targets 0.8, // for AUC targets higher bias_weight usually works best settings.clone(), @@ -149,7 +148,6 @@ fn main() -> Result<()> { target_interval.clone(), None, eq.clone(), - ems.clone(), DoseRange::new(50.0, 500.0), 0.8, settings.clone(), diff --git a/examples/bestdose_bounds.rs b/examples/bestdose_bounds.rs index 2c3bdd6dd..f442278db 100644 --- a/examples/bestdose_bounds.rs +++ b/examples/bestdose_bounds.rs @@ -79,7 +79,6 @@ fn main() -> Result<()> { target_data.clone(), None, eq.clone(), - ems.clone(), DoseRange::new(min, max), 0.5, settings.clone(), diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index e7c1a9109..98547c157 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -673,7 +673,6 @@ impl BestDoseProblem { target: Subject, time_offset: Option, eq: ODE, - error_models: ErrorModels, doserange: DoseRange, bias_weight: f64, settings: Settings, @@ -698,7 +697,7 @@ impl BestDoseProblem { population_weights, past_data.as_ref(), &eq, - &error_models, + &settings.errormodels, &settings, )?; diff --git a/tests/bestdose_tests.rs b/tests/bestdose_tests.rs index e99d74e69..53ac2618e 100644 --- a/tests/bestdose_tests.rs +++ b/tests/bestdose_tests.rs @@ -67,7 +67,6 @@ fn test_infusion_mask_inclusion() -> Result<()> { target.clone(), None, eq.clone(), - ems.clone(), DoseRange::new(10.0, 300.0), 0.5, settings.clone(), @@ -188,7 +187,6 @@ fn test_fixed_infusion_preservation() -> Result<()> { target, Some(2.0), // Current time = 2.0 hours eq.clone(), - ems.clone(), DoseRange::new(0.0, 500.0), 0.5, settings.clone(), @@ -270,7 +268,6 @@ fn test_dose_count_validation() -> Result<()> { target, None, eq, - ems, DoseRange::new(10.0, 300.0), 0.5, settings, @@ -346,7 +343,6 @@ fn test_empty_observations_validation() -> Result<()> { target, None, eq, - ems, DoseRange::new(10.0, 300.0), 0.5, settings, @@ -418,7 +414,6 @@ fn test_basic_auc_mode() -> Result<()> { target, None, eq, - ems, DoseRange::new(100.0, 2000.0), 0.8, settings, @@ -513,7 +508,6 @@ fn test_infusion_auc_mode() -> Result<()> { target, None, eq, - ems, DoseRange::new(100.0, 2000.0), 0.8, // Higher bias weight typically works better for AUC targets settings, @@ -623,7 +617,6 @@ fn test_multi_outeq_auc_mode() -> Result<()> { target, None, eq, - ems, DoseRange::new(0.0, 2000.0), 0.5, settings, @@ -694,7 +687,6 @@ fn test_multi_outeq_auc_optimization() -> Result<()> { target, None, eq, - ems, DoseRange::new(0.0, 2000.0), 0.5, settings, @@ -789,7 +781,6 @@ fn test_auc_from_zero_single_dose() -> Result<()> { target, None, eq, - ems, DoseRange::new(100.0, 1000.0), 0.8, settings, @@ -882,7 +873,6 @@ fn test_auc_from_last_dose_maintenance() -> Result<()> { target, None, eq, - ems, DoseRange::new(50.0, 500.0), 0.8, settings, @@ -979,7 +969,6 @@ fn test_auc_modes_comparison() -> Result<()> { target_zero, None, eq.clone(), - ems.clone(), DoseRange::new(10.0, 2000.0), 0.8, settings.clone(), @@ -1004,7 +993,6 @@ fn test_auc_modes_comparison() -> Result<()> { target_last, None, eq, - ems, DoseRange::new(10.0, 2000.0), 0.8, settings, @@ -1112,7 +1100,6 @@ fn test_auc_from_last_dose_multiple_observations() -> Result<()> { target, None, eq, - ems, DoseRange::new(50.0, 500.0), 0.8, settings, @@ -1211,7 +1198,6 @@ fn test_auc_from_last_dose_no_prior_dose() -> Result<()> { target, None, eq, - ems, DoseRange::new(50.0, 500.0), 0.8, settings, @@ -1309,7 +1295,6 @@ fn test_dose_range_bounds_respected() -> Result<()> { target.clone(), None, eq.clone(), - ems.clone(), dose_range, 0.0, settings.clone(),