diff --git a/.gitignore b/.gitignore index 802b1ab3c..5de9feb35 100644 --- a/.gitignore +++ b/.gitignore @@ -2,13 +2,21 @@ /Cargo.lock *.log theta.csv -cycles.csv -pred.csv obs.csv time.csv n_psi.csv psi.csv r.csv +correlation.csv +/docs +diagnostics.json +predictions.csv +summary.csv +summary.json +iterations.csv +population.csv +shrinkage.csv +statistics.csv posterior.csv simulation_output.csv /examples/rosuva/* @@ -17,6 +25,8 @@ simulation_output.csv /examples/data/iohexol* /examples/data/rosuva* /examples/data/vori* +/examples/paper_benchmarks +/examples/*/output /.idea stop .vscode @@ -28,7 +38,13 @@ settings.json log.txt op.csv *results.txt -covs.csv +covariates.csv +individual_effects.csv +individual_parameters.csv +residual_error.csv error_theta.csv lcov.info -Fortran/ \ No newline at end of file +Fortran/ +paper/ +docs/ +examples/**/outputs/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 8ef4dd7ee..7e7d0c55d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,19 +28,31 @@ tracing-subscriber = { version = "0.3.19", features = [ "time", ] } faer = "0.24.0" -pharmsol = "=0.26.1" +pharmsol = { version = "=0.27.1"} anyhow = "1.0.100" rayon = "1.10.0" rand = "0.10.1" +cobyla = "0.8.0" +rand_distr = "0.6.0" +statrs = "0.18" [features] default = [] -exa = ["pharmsol/exa"] +json = [] +exa = [] +dsl-core = ["pharmsol/dsl-core"] +dsl-jit = ["pharmsol/dsl-jit"] +dsl-aot = ["pharmsol/dsl-aot"] +dsl-aot-load = ["pharmsol/dsl-aot-load"] +dsl-wasm = ["pharmsol/dsl-wasm"] [profile.release] codegen-units = 1 opt-level = 3 +[profile.test] +inherits = "release" + [dev-dependencies] approx = "0.5.1" criterion = { version = "0.8" } diff --git a/benches/bimodal_ke.rs b/benches/bimodal_ke.rs index b8ce6c55e..b6f12d869 100644 --- a/benches/bimodal_ke.rs +++ b/benches/bimodal_ke.rs @@ -1,101 +1,108 @@ use anyhow::Result; use criterion::{criterion_group, criterion_main, Criterion}; -use pmcore::prelude::*; +use pmcore::{algorithms::Algorithm, prelude::*}; use std::hint::black_box; fn create_equation() -> equation::ODE { - equation::ODE::new( - |x, p, _t, dx, b, rateiv, _cov| { - fetch_params!(p, ke, _v); - dx[0] = -ke * x[0] + rateiv[1] + b[1]; + ode! { + name: "bimodal_ke", + params: [ke, v], + states: [central], + outputs: [outeq_1], + routes: [ + infusion(input_1) -> central, + ], + diffeq: |x, _t, dx| { + dx[central] = -ke * x[central]; }, - |_p, _t, _cov| lag! {}, - |_p, _t, _cov| fa! {}, - |_p, _t, _cov, _x| {}, - |x, p, _t, _cov, y| { - fetch_params!(p, _ke, v); - y[1] = x[0] / v; + out: |x, _t, y| { + y[outeq_1] = x[central] / v; }, - ) -} - -fn create_parameters() -> Parameters { - Parameters::new() - .add("ke", 0.001, 3.0) - .add("v", 25.0, 250.0) -} - -fn create_error_models() -> Result { - Ok(AssayErrorModels::new().add( - 1, - AssayErrorModel::additive(ErrorPoly::new(0.0, 0.5, 0.0, 0.0), 0.0), - )?) + } } fn load_data() -> Result { Ok(data::read_pmetrics("examples/bimodal_ke/bimodal_ke.csv")?) } -fn setup_with_algorithm(algorithm: Algorithm) -> Result<(Settings, equation::ODE, data::Data)> { - let params = create_parameters(); - let ems = create_error_models()?; - - let mut settings = Settings::builder() - .set_algorithm(algorithm) - .set_parameters(params) - .set_error_models(ems) - .build(); - - settings.set_cycles(1000); - settings.set_prior(Prior::sobol(2048, 22)); - settings.disable_output(); - settings.set_progress(false); - +fn setup_npag() -> Result> { let data = load_data()?; - Ok((settings, create_equation(), data)) -} - -fn setup_npag() -> Result<(Settings, equation::ODE, data::Data)> { - setup_with_algorithm(Algorithm::NPAG) + let parameters = ParameterSpace::bounded() + .add("ke", 0.001, 3.0) + .add("v", 25.0, 250.0); + let prior = Theta::sobol_default(¶meters)?; + let error_models = AssayErrorModels::new().add( + "outeq_1", + AssayErrorModel::additive(ErrorPoly::new(0.0, 0.5, 0.0, 0.0), 0.0), + )?; + EstimationProblem::nonparametric(create_equation(), data, prior, error_models) } -fn setup_npod() -> Result<(Settings, equation::ODE, data::Data)> { - setup_with_algorithm(Algorithm::NPOD) +fn setup_npod() -> Result> { + let data = load_data()?; + let parameters = ParameterSpace::bounded() + .add("ke", 0.001, 3.0) + .add("v", 25.0, 250.0); + let prior = Theta::sobol_default(¶meters)?; + let error_models = AssayErrorModels::new().add( + "outeq_1", + AssayErrorModel::additive(ErrorPoly::new(0.0, 0.5, 0.0, 0.0), 0.0), + )?; + EstimationProblem::nonparametric(create_equation(), data, prior, error_models) } -fn setup_postprob() -> Result<(Settings, equation::ODE, data::Data)> { - setup_with_algorithm(Algorithm::POSTPROB) +fn setup_postprob() -> Result> { + let data = load_data()?; + let parameters = ParameterSpace::bounded() + .add("ke", 0.001, 3.0) + .add("v", 25.0, 250.0); + let prior = Theta::sobol_default(¶meters)?; + let error_models = AssayErrorModels::new().add( + "outeq_1", + AssayErrorModel::additive(ErrorPoly::new(0.0, 0.5, 0.0, 0.0), 0.0), + )?; + EstimationProblem::nonparametric(create_equation(), data, prior, error_models) } -fn benchmark_algorithm(c: &mut Criterion, bench_name: &str, setup_fn: F) +fn benchmark_algorithm(c: &mut Criterion, bench_name: &str, setup_fn: F, config: A) where - F: Fn() -> Result<(Settings, equation::ODE, data::Data)>, + F: Fn() -> Result>, + A: Algorithm + Clone, { - let (settings, eq, data) = setup_fn().unwrap(); - c.bench_function(bench_name, |b| { b.iter_with_setup( - || (settings.clone(), eq.clone(), data.clone()), - |(s, e, d)| { - let mut algorithm = dispatch_algorithm(s, e, d).unwrap(); - let result = algorithm.fit().unwrap(); - black_box(result) - }, + || setup_fn().unwrap(), + |problem| black_box(problem.fit_with(config.clone()).unwrap()), ) }); } fn benchmark_bimodal_ke_npag(c: &mut Criterion) { - benchmark_algorithm(c, "bimodal_ke_npag", setup_npag); + benchmark_algorithm( + c, + "bimodal_ke_npag", + setup_npag, + NonParametricAlgorithm::npag(), + ); } fn benchmark_bimodal_ke_npod(c: &mut Criterion) { - benchmark_algorithm(c, "bimodal_ke_npod", setup_npod); + benchmark_algorithm( + c, + "bimodal_ke_npod", + setup_npod, + NonParametricAlgorithm::npod(), + ); } fn benchmark_bimodal_ke_postprob(c: &mut Criterion) { - benchmark_algorithm(c, "bimodal_ke_postprob", setup_postprob); + benchmark_algorithm( + c, + "bimodal_ke_postprob", + setup_postprob, + NonParametricAlgorithm::npmap(), + ); } criterion_group! { @@ -104,3 +111,4 @@ criterion_group! { targets = benchmark_bimodal_ke_npag, benchmark_bimodal_ke_npod, benchmark_bimodal_ke_postprob } criterion_main!(benches); + diff --git a/examples/bestdose.rs b/examples/bestdose.rs index 6968514a6..9fd9aed2e 100644 --- a/examples/bestdose.rs +++ b/examples/bestdose.rs @@ -1,24 +1,26 @@ use anyhow::Result; -use pmcore::bestdose::{BestDosePosterior, DoseRange, Target}; - +use pmcore::bestdose::{BestDoseConfig, BestDosePosterior, DoseRange, Target}; use pmcore::prelude::*; -use pmcore::routines::initialization::parse_prior; fn main() -> Result<()> { // Example model let eq = ode! { - diffeq: |x, p, _t, dx, b, _rateiv, _cov| { - // fetch_cov!(cov, t, wt); - fetch_params!(p, ke, _v); - dx[0] = -ke * x[0] + b[0]; + name: "bestdose_one_compartment", + params: [ke, v], + states: [central], + outputs: [cp], + routes: [ + bolus(dose) -> central, + ], + diffeq: |x, _t, dx| { + dx[central] = -ke * x[central]; }, - out: |x, p, _t, _cov, y| { - fetch_params!(p, _ke, v); - y[0] = x[0] / v; + out: |x, _t, y| { + y[cp] = x[central] / v; }, }; - let params = Parameters::new() + let parameter_space = ParameterSpace::::new() .add("ke", 0.001, 3.0) .add("v", 25.0, 250.0); @@ -26,15 +28,7 @@ fn main() -> Result<()> { 0, AssayErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), )?; - - // Make settings - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems.clone()) - .build(); - - settings.disable_output(); + let config = BestDoseConfig::new(parameter_space.clone(), ems.clone()).with_progress(false); // Generate a patient with known parameters // Ke = 0.5, V = 50 @@ -71,21 +65,15 @@ fn main() -> Result<()> { .observation(18.0, conc(6.0, 75.0) + conc(18.0, 150.0), 0) .build(); - let (theta, prior) = parse_prior( - &"examples/bimodal_ke/output/theta.csv".to_string(), - &settings, - ) - .unwrap(); + let (theta, prior) = + Theta::from_file("examples/bimodal_ke/output/theta.csv", ¶meter_space)?; - // Example usage - two-stage API: - // Stage 1: Compute posterior (expensive, done once) - // Stage 2: Optimize doses (can be called multiple times with different params) let posterior = BestDosePosterior::compute( &theta, &prior.unwrap(), Some(past_data.clone()), // Optional: past data for Bayesian updating eq.clone(), - settings.clone(), + config.clone(), )?; println!("Optimizing dose..."); diff --git a/examples/bestdose_auc.rs b/examples/bestdose_auc.rs index 70aecb429..0bcacc0e4 100644 --- a/examples/bestdose_auc.rs +++ b/examples/bestdose_auc.rs @@ -1,28 +1,34 @@ use anyhow::Result; -use pmcore::bestdose::{BestDosePosterior, DoseRange, Target}; +use pmcore::bestdose::{BestDoseConfig, BestDosePosterior, DoseRange, Target}; use pmcore::prelude::*; -use pmcore::routines::initialization::parse_prior; fn main() -> Result<()> { - tracing_subscriber::fmt::init(); + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::new("info,diffsol=off")) + .init(); println!("BestDose AUC Target - Minimal Example\n"); println!("======================================\n"); // Simple one-compartment PK model let eq = ode! { - diffeq: |x, p, _t, dx, b, _rateiv, _cov| { - fetch_params!(p, ke, _v); - dx[0] = -ke * x[0] + b[0]; + name: "bestdose_auc_one_compartment", + params: [ke, v], + states: [central], + outputs: [cp], + routes: [ + bolus(dose) -> central, + ], + diffeq: |x, _t, dx| { + dx[central] = -ke * x[central]; }, - out: |x, p, _t, _cov, y| { - fetch_params!(p, _ke, v); - y[0] = x[0] / v; + out: |x, _t, y| { + y[cp] = x[central] / v; }, }; // Minimal parameter ranges - let params = Parameters::new() + let parameter_space = ParameterSpace::::new() .add("ke", 0.001, 3.0) .add("v", 25.0, 250.0); @@ -31,20 +37,15 @@ fn main() -> Result<()> { AssayErrorModel::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(60.0); // 1 hour intervals for AUC calculation + let config = BestDoseConfig::new(parameter_space.clone(), ems.clone()) + .with_progress(false) + .with_prediction_interval(60.0); // 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 (theta, prior) = Theta::from_file( + "examples/bimodal_ke/output/theta.csv", + ¶meter_space, )?; let weights = prior.as_ref().unwrap(); @@ -67,16 +68,16 @@ fn main() -> Result<()> { weights, None, // No past data - use prior directly eq.clone(), - settings.clone(), + config.clone(), )?; println!("Optimizing dose...\n"); let optimal = posterior.optimize( target_data.clone(), None, - DoseRange::new(100.0, 2000.0), // Wider range for AUC targets - 0.8, // for AUC targets higher bias_weight usually works best - Target::AUCFromZero, // Cumulative AUC from time 0 + DoseRange::new(100.0, 2000.0), + 0.8, + Target::AUCFromZero, )?; let opt_doses = optimal.doses(); @@ -120,9 +121,8 @@ fn main() -> Result<()> { } } - // ========================================================================= // EXAMPLE 2: Interval AUC (AUCFromLastDose) - // ========================================================================= + println!("\n\n"); println!("════════════════════════════════════════════════════════"); println!(" EXAMPLE 2: Interval AUC (AUCFromLastDose)"); @@ -143,7 +143,7 @@ fn main() -> Result<()> { None, DoseRange::new(50.0, 500.0), 0.8, - Target::AUCFromLastDose, // Interval AUC from last dose! + Target::AUCFromLastDose, )?; let doses: Vec = optimal_interval.doses(); diff --git a/examples/bestdose_bounds.rs b/examples/bestdose_bounds.rs index 4b2eab3bb..286251618 100644 --- a/examples/bestdose_bounds.rs +++ b/examples/bestdose_bounds.rs @@ -1,27 +1,33 @@ use anyhow::Result; -use pmcore::bestdose::{BestDosePosterior, DoseRange, Target}; +use pmcore::bestdose::{BestDoseConfig, BestDosePosterior, DoseRange, Target}; use pmcore::prelude::*; -use pmcore::routines::initialization::parse_prior; fn main() -> Result<()> { - tracing_subscriber::fmt::init(); + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::new("info,diffsol=off")) + .init(); println!("BestDose with Dose Range Bounds - Example\n"); println!("==========================================\n"); // Simple one-compartment PK model let eq = ode! { - diffeq: |x, p, _t, dx, b, _rateiv, _cov| { - fetch_params!(p, ke, _v); - dx[0] = -ke * x[0] + b[0]; + name: "bestdose_bounds_one_compartment", + params: [ke, v], + states: [central], + outputs: [cp], + routes: [ + bolus(dose) -> central, + ], + diffeq: |x, _t, dx| { + dx[central] = -ke * x[central]; }, - out: |x, p, _t, _cov, y| { - fetch_params!(p, _ke, v); - y[0] = x[0] / v; + out: |x, _t, y| { + y[cp] = x[central] / v; }, }; - let params = Parameters::new() + let parameter_space = ParameterSpace::::new() .add("ke", 0.001, 3.0) .add("v", 25.0, 250.0); @@ -30,19 +36,13 @@ fn main() -> Result<()> { AssayErrorModel::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(); + let config = BestDoseConfig::new(parameter_space.clone(), ems.clone()).with_progress(false); // 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 (theta, prior) = Theta::from_file( + "examples/bimodal_ke/output/theta.csv", + ¶meter_space, )?; let weights = prior.as_ref().unwrap(); @@ -63,14 +63,12 @@ fn main() -> Result<()> { (50.0, 2000.0, "Wide range (50-2000 mg)"), ]; + let posterior = BestDosePosterior::compute(&theta, weights, None, eq.clone(), config.clone())?; + println!("\nTesting optimization with different dose range constraints:\n"); println!("{:<30} | {:>12} | {:>10}", "Range", "Optimal Dose", "Cost"); println!("{}", "-".repeat(60)); - // Compute posterior once, reuse for all dose ranges - let posterior = - BestDosePosterior::compute(&theta, weights, None, eq.clone(), settings.clone())?; - for (min, max, description) in dose_ranges { let result = posterior.optimize( target_data.clone(), @@ -80,7 +78,19 @@ fn main() -> Result<()> { Target::Concentration, )?; - let doses: Vec = result.doses(); + let doses: Vec = result + .optimal_subject() + .iter() + .flat_map(|occ| { + occ.iter() + .filter(|event| matches!(event, Event::Bolus(_) | Event::Infusion(_))) + .map(|event| match event { + Event::Bolus(bolus) => bolus.amount(), + Event::Infusion(infusion) => infusion.amount(), + _ => 0.0, + }) + }) + .collect(); // Check if dose hit the bound let at_bound = if (doses[0] - max).abs() < 1.0 { diff --git a/examples/bestdose_cov.rs b/examples/bestdose_cov.rs new file mode 100644 index 000000000..1cb6ac3e2 --- /dev/null +++ b/examples/bestdose_cov.rs @@ -0,0 +1,153 @@ +use anyhow::Result; +use pmcore::bestdose::{BestDoseConfig, BestDosePosterior, DoseRange, Target}; +use pmcore::prelude::*; + +fn main() -> Result<()> { + // Example model + let eq = ode! { + name: "bestdose_cov_one_compartment", + params: [ke, v], + states: [central], + outputs: [outeq_0], + routes: [ + bolus(input_0) -> central, + ], + diffeq: |x, _t, dx| { + dx[central] = -ke * x[central]; + }, + out: |x, _t, y| { + let scaled_v = v * 70.0; + y[outeq_0] = x[central] / scaled_v; + }, + }; + + let parameter_space = ParameterSpace::::new() + .add("ke", 0.001, 3.0) + .add("v", 25.0 / 70.0, 250.0 / 70.0); + + let ems = AssayErrorModels::new().add( + 0, + AssayErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), + )?; + + let config = BestDoseConfig::new(parameter_space.clone(), ems.clone()).with_progress(false); + + // Generate a patient with known parameters + // Ke = 0.5, V = 50 + // C(t) = Dose * exp(-ke * t) / V + + fn conc(t: f64, dose: f64) -> f64 { + let ke = 0.3406021231412888; // Elimination rate constant + let v = 99.99475717544556; // 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, 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(); + + // simulate subject concentrations + + // for event in subject.occasions().first().unwrap().events().into_iter() { + // // if event is observations + // if let Event::Observation(obs) = event { + // println!("Time: {:.2} h, Observed: {:?}", obs.time(), obs.value()); + // } + // } + + // println!("++++++++++++++++++++++++++++++++++++++++++++++++++"); + + // let sim = eq.simulate_subject(&subject, &vec![0.09, 1.49], None)?; + // // dbg subject concentrations + + // for pred in sim.0.predictions().into_iter() { + // println!( + // "Time: {:.2} h, Observed: {:?}, Predicted: {:.4}", + // pred.time(), + // pred.observation(), + // pred.prediction() + // ); + // } + + let past_data = subject.clone(); + + let target_data = Subject::builder("Thomas Edison") + .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(); + + let (mut theta, prior) = Theta::from_file( + "examples/bimodal_ke/output/theta.csv", + ¶meter_space, + )?; + + let m_t = theta.matrix_mut(); + for i in 0..m_t.nrows() { + m_t[(i, 1)] /= 70.0; + } + + let posterior = BestDosePosterior::compute( + &theta, + &prior.unwrap(), + Some(past_data.clone()), // Optional: past data for Bayesian updating + eq.clone(), + config.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 mut results = Vec::new(); + + for bias_weight in &bias_weights { + println!("Running optimization with bias weight: {}", bias_weight); + let optimal = posterior.optimize( + target_data.clone(), + None, + DoseRange::new(0.0, 300.0), + *bias_weight, + Target::Concentration, + )?; + results.push((bias_weight, optimal)); + } + + // Print results + for (bias_weight, optimal) in &results { + 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() + ); + } + + // 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.predictions().predictions().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/examples/bimodal_ke/config.toml b/examples/bimodal_ke/config.toml deleted file mode 100644 index 498ba70a4..000000000 --- a/examples/bimodal_ke/config.toml +++ /dev/null @@ -1,24 +0,0 @@ -[config] -cycles = 1024 -algorithm = "NPAG" -cache = true - -[random] -Ke = [0.001, 3.0] -V = [25.0, 250.0] - -[error] -value = 0.0 -class = "additive" -poly = [0.0, 0.05, 0.0, 0.0] - -[log] -level = "info" - -[prior] -sampler = "sobol" -# file = "examples/bimodal_ke/prior.csv" - -[output] -write = true -path = "examples/bimodal_ke/output" diff --git a/examples/bimodal_ke/main.rs b/examples/bimodal_ke/main.rs index b4796a710..bc6fe7604 100644 --- a/examples/bimodal_ke/main.rs +++ b/examples/bimodal_ke/main.rs @@ -2,47 +2,41 @@ use anyhow::Result; use pmcore::prelude::*; fn main() -> Result<()> { + Logger::new().stdout(true).init()?; + let eq = ode! { - diffeq: |x, p, _t, dx, b, rateiv, _cov| { - // fetch_cov!(cov, t, wt); - fetch_params!(p, ke, _v); - dx[0] = -ke * x[0] + rateiv[1] + b[1]; + name: "bimodal_ke", + params: [ke, v], + states: [central], + outputs: [outeq_1], + routes: [ + infusion(input_1) -> central, + ], + diffeq: |x, _t, dx| { + dx[central] = -ke * x[central]; }, - out: |x, p, _t, _cov, y| { - fetch_params!(p, _ke, v); - y[1] = x[0] / v; + out: |x, _t, y| { + y[outeq_1] = x[central] / v; }, } .with_solver(OdeSolver::ExplicitRk(ExplicitRkTableau::Tsit45)); - let params = Parameters::new() + let data = data::read_pmetrics("examples/bimodal_ke/bimodal_ke.csv")?; + + let parameters = ParameterSpace::bounded() .add("ke", 0.001, 3.0) .add("v", 25.0, 250.0); - let ems = AssayErrorModels::new().add( - 1, + let prior = Theta::sobol_default(¶meters)?; + + let error_models = AssayErrorModels::new().add( + "outeq_1", AssayErrorModel::additive(ErrorPoly::new(0.0, 0.5, 0.0, 0.0), 0.0), )?; - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems) - .build(); + let problem = EstimationProblem::nonparametric(eq, data, prior, error_models)?; - settings.set_cycles(1000); - settings.set_prior(Prior::sobol(2028, 22)); - settings.set_output_path("examples/bimodal_ke/output/"); - settings.set_write_logs(true); - - settings.write()?; - - // settings.enable_logs(stdout: bool, ) - settings.initialize_logs()?; - let data = data::read_pmetrics("examples/bimodal_ke/bimodal_ke.csv")?; - let mut algorithm = dispatch_algorithm(settings, eq, data)?; - let mut result = algorithm.fit()?; - result.write_outputs()?; + let _result = problem.fit_with(NonParametricAlgorithm::npag())?; Ok(()) } diff --git a/examples/bimodal_ke_backend_compare.rs b/examples/bimodal_ke_backend_compare.rs new file mode 100644 index 000000000..5aa08b8bc --- /dev/null +++ b/examples/bimodal_ke_backend_compare.rs @@ -0,0 +1,316 @@ +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Result}; +use pharmsol::Cache; +use pmcore::prelude::*; + +#[cfg(any( + feature = "dsl-jit", + all(feature = "dsl-aot", feature = "dsl-aot-load"), + feature = "dsl-wasm" +))] +use anyhow::{bail, Context}; +#[cfg(any( + feature = "dsl-jit", + all(feature = "dsl-aot", feature = "dsl-aot-load"), + feature = "dsl-wasm" +))] +use pharmsol::dsl::{self, CompiledRuntimeModel, RuntimeBackend, RuntimeCompilationTarget}; +#[cfg(any( + feature = "dsl-jit", + all(feature = "dsl-aot", feature = "dsl-aot-load"), + feature = "dsl-wasm" +))] +use std::fs; +#[cfg(any( + feature = "dsl-jit", + all(feature = "dsl-aot", feature = "dsl-aot-load"), + feature = "dsl-wasm" +))] +use std::path::PathBuf; +#[cfg(any( + feature = "dsl-jit", + all(feature = "dsl-aot", feature = "dsl-aot-load"), + feature = "dsl-wasm" +))] +use std::time::{SystemTime, UNIX_EPOCH}; + +const DATA_PATH: &str = "examples/bimodal_ke/bimodal_ke.csv"; +#[cfg(any( + feature = "dsl-jit", + all(feature = "dsl-aot", feature = "dsl-aot-load"), + feature = "dsl-wasm" +))] +const BIMODAL_KE_DSL: &str = r#" +name = bimodal_ke +kind = ode + +params = ke, v +states = central +outputs = outeq_1 + +infusion(input_1) -> central + +dx(central) = -ke * central + +out(outeq_1) = central / v +"#; + +#[derive(Debug, Clone)] +struct ComparisonResult { + label: &'static str, + compile_time: Duration, + fit_time: Duration, + total_time: Duration, + objf: f64, +} + +fn main() -> Result<()> { + let mut results = Vec::new(); + results.push(run_legacy()?); + results.push(run_macro()?); + + #[cfg(feature = "dsl-jit")] + results.push(run_runtime_jit()?); + + #[cfg(all(feature = "dsl-aot", feature = "dsl-aot-load"))] + results.push(run_runtime_native_aot()?); + + #[cfg(feature = "dsl-wasm")] + results.push(run_runtime_wasm()?); + + print_summary(&results)?; + + Ok(()) +} + +fn legacy_equation() -> equation::ODE { + equation::ODE::new( + |x, p, _t, dx, _bolus, rateiv, _cov| { + fetch_params!(p, ke, _v); + dx[0] = -ke * x[0] + rateiv[0]; + }, + |_p, _t, _cov| lag! {}, + |_p, _t, _cov| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, _ke, v); + y[0] = x[0] / v; + }, + ) + .with_nstates(1) + .with_ndrugs(1) + .with_nout(1) + .with_metadata( + equation::metadata::new("bimodal_ke_legacy") + .parameters(["ke", "v"]) + .states(["central"]) + .outputs(["outeq_1"]) + .route( + equation::Route::infusion("input_1") + .to_state("central") + .inject_input_to_destination(), + ), + ) + .expect("legacy bimodal_ke metadata should validate") + .enable_cache() + .with_solver(OdeSolver::ExplicitRk(ExplicitRkTableau::Tsit45)) +} + +fn macro_equation() -> equation::ODE { + ode! { + name: "bimodal_ke", + params: [ke, v], + states: [central], + outputs: [outeq_1], + routes: [ + infusion(input_1) -> central, + ], + diffeq: |x, _t, dx| { + dx[central] = -ke * x[central]; + }, + out: |x, _t, y| { + y[outeq_1] = x[central] / v; + }, + } + .enable_cache() + .with_solver(OdeSolver::ExplicitRk(ExplicitRkTableau::Tsit45)) +} + +#[cfg(any( + feature = "dsl-jit", + all(feature = "dsl-aot", feature = "dsl-aot-load"), + feature = "dsl-wasm" +))] +fn artifact_root(name: &str) -> Result { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before unix epoch")? + .as_millis(); + let path = std::env::current_dir()? + .join("target") + .join("bimodal_ke_backend_compare") + .join(format!("{name}-{stamp}-{}", std::process::id())); + fs::create_dir_all(&path)?; + Ok(path) +} + +#[cfg(any( + feature = "dsl-jit", + all(feature = "dsl-aot", feature = "dsl-aot-load"), + feature = "dsl-wasm" +))] +fn compile_runtime_ode( + label: &'static str, + expected_backend: RuntimeBackend, + target: RuntimeCompilationTarget, +) -> Result<(pharmsol::dsl::RuntimeOdeModel, Duration)> { + let started = Instant::now(); + let compiled = dsl::compile_module_source_to_runtime( + BIMODAL_KE_DSL, + Some("bimodal_ke"), + target, + |_, _| {}, + )?; + let compile_time = started.elapsed(); + + let model = match compiled { + CompiledRuntimeModel::Ode(model) => model + .enable_cache() + .with_solver(OdeSolver::ExplicitRk(ExplicitRkTableau::Tsit45)), + CompiledRuntimeModel::Analytical(_) => { + bail!("{label} compiled bimodal_ke as analytical instead of ode") + } + CompiledRuntimeModel::Sde(_) => bail!("{label} compiled bimodal_ke as sde instead of ode"), + }; + + if model.backend() != expected_backend { + bail!( + "{label} compiled to {:?}, expected {:?}", + model.backend(), + expected_backend + ); + } + + Ok((model, compile_time)) +} + +fn run_case( + label: &'static str, + compile_time: Duration, + equation: E, +) -> Result { + let data = data::read_pmetrics(DATA_PATH)?; + let fit_started = Instant::now(); + let parameters = ParameterSpace::bounded() + .add("ke", 0.001, 3.0) + .add("v", 25.0, 250.0); + let prior = Theta::sobol_default(¶meters)?; + let error_models = AssayErrorModels::new().add( + "outeq_1", + AssayErrorModel::additive(ErrorPoly::new(0.0, 0.5, 0.0, 0.0), 0.0), + )?; + let result = EstimationProblem::nonparametric(equation, data, prior, error_models)? + .fit_with(NonParametricAlgorithm::npag())?; + let fit_time = fit_started.elapsed(); + + summarize_result(label, compile_time, fit_time, &result) +} + +fn summarize_result( + label: &'static str, + compile_time: Duration, + fit_time: Duration, + result: &NonParametricResult, +) -> Result { + Ok(ComparisonResult { + label, + compile_time, + fit_time, + total_time: compile_time + fit_time, + objf: result.objf(), + }) +} + +fn run_legacy() -> Result { + let started = Instant::now(); + let equation = legacy_equation(); + run_case("legacy-handwritten", started.elapsed(), equation) +} + +fn run_macro() -> Result { + let started = Instant::now(); + let equation = macro_equation(); + run_case("macro-ode", started.elapsed(), equation) +} + +#[cfg(feature = "dsl-jit")] +fn run_runtime_jit() -> Result { + let (equation, compile_time) = compile_runtime_ode( + "dsl-jit", + RuntimeBackend::Jit, + RuntimeCompilationTarget::Jit, + )?; + run_case("dsl-jit", compile_time, equation) +} + +#[cfg(all(feature = "dsl-aot", feature = "dsl-aot-load"))] +fn run_runtime_native_aot() -> Result { + let root = artifact_root("native-aot")?; + let (equation, compile_time) = compile_runtime_ode( + "dsl-native-aot", + RuntimeBackend::NativeAot, + RuntimeCompilationTarget::NativeAot( + dsl::NativeAotCompileOptions::new(root.join("build")) + .with_output(root.join("bimodal_ke_runtime_aot.pkm")), + ), + )?; + run_case("dsl-native-aot", compile_time, equation) +} + +#[cfg(feature = "dsl-wasm")] +fn run_runtime_wasm() -> Result { + let (equation, compile_time) = compile_runtime_ode( + "dsl-wasm", + RuntimeBackend::Wasm, + RuntimeCompilationTarget::Wasm, + )?; + run_case("dsl-wasm", compile_time, equation) +} + +fn print_summary(results: &[ComparisonResult]) -> Result<()> { + let baseline = results + .iter() + .find(|result| result.label == "legacy-handwritten") + .ok_or_else(|| anyhow!("missing legacy-handwritten baseline"))?; + + println!("bimodal_ke NPAG backend comparison"); + println!("dataset: {DATA_PATH}"); + println!("cache: on"); + #[cfg(not(any( + feature = "dsl-jit", + all(feature = "dsl-aot", feature = "dsl-aot-load"), + feature = "dsl-wasm" + )))] + println!("runtime DSL backends skipped; enable dsl-jit, dsl-aot+dsl-aot-load, or dsl-wasm to include them"); + println!(); + println!( + "{:<18} {:>12} {:>12} {:>12} {:>14} {:>14}", + "representation", "compile s", "fit s", "total s", "objf diff", "objf" + ); + + for result in results { + println!( + "{:<18} {:>12.3} {:>12.3} {:>12.3} {:>14.6} {:>14.6}", + result.label, + result.compile_time.as_secs_f64(), + result.fit_time.as_secs_f64(), + result.total_time.as_secs_f64(), + (result.objf - baseline.objf).abs(), + result.objf, + ); + } + + Ok(()) +} + diff --git a/examples/drusano/config.toml b/examples/drusano/config.toml deleted file mode 100644 index ee5f133f0..000000000 --- a/examples/drusano/config.toml +++ /dev/null @@ -1,50 +0,0 @@ -[config] -cycles = 1024 -algorithm = "NPAG" -cache = true - -[random] -v1 = [5.0, 160.0] -cl1 = [4.0, 9.0] -v2 = [100.0, 200.0] -cl2 = [25.0, 35.0] -popmax = [100000000.0, 100000000000.0] -kgs = [0.01, 0.25] -kks = [0.01, 0.5] -e50_1s = [0.1, 2.5] -e50_2s = [0.1, 10.0] -alpha_s = [-8.0, 5.0] -kgr1 = [0.004, 0.1] -kkr1 = [0.08, 0.4] -e50_1r1 = [8.0, 17.0] -alpha_r1 = [-8.0, 5.0] -kgr2 = [0.004, 0.3] -kkr2 = [0.1, 0.5] -e50_2r2 = [5.0, 8.0] -alpha_r2 = [-5.0, 5.0] -init_4 = [-1.0, 4.0] -init_5 = [-1.0, 3.0] -h1s = [0.5, 8.0] -h2s = [0.1, 4.0] -h1r1 = [5.0, 25.0] -h2r2 = [10.0, 22.0] - -[error] -value = 1.0 -class = "proportional" -poly = [0.1, 0.1, 0.0, 0.0] - -[log] -level = "info" -write = true - -[prior] -sampler = "sobol" -points = 212900 -seed = 347 - -[output] -write = true -path = "examples/drusano/output" - - diff --git a/examples/drusano/main.rs b/examples/drusano/main.rs index 640fb20eb..240b69656 100644 --- a/examples/drusano/main.rs +++ b/examples/drusano/main.rs @@ -3,75 +3,68 @@ use pmcore::prelude::*; #[allow(unused_variables)] fn main() -> Result<()> { let eq = ode! { - diffeq:|x, p, t, dx, b, rateiv, _cov| { - fetch_params!( - p, v1, cl1, v2, cl2, popmax, kgs, kks, e50_1s, e50_2s, alpha_s, kgr1, kkr1, - e50_1r1, alpha_r1, kgr2, kkr2, e50_2r2, alpha_r2, init_4, init_5, h1s, h2s, h1r1, - h2r2 - ); + name: "drusano", + params: [ + v1, cl1, v2, cl2, popmax, kgs, kks, e50_1s, e50_2s, alpha_s, kgr1, kkr1, e50_1r1, + alpha_r1, kgr2, kkr2, e50_2r2, alpha_r2, init_4, init_5, h1s, h2s, h1r1, h2r2 + ], + covariates: [ic_t], + states: [drug_1_amount, drug_2_amount, total_bacteria, resistant_1, resistant_2], + outputs: [outeq_1, outeq_2, outeq_3, outeq_4], + routes: [ + bolus(input_1) -> drug_1_amount, + bolus(input_2) -> drug_2_amount, + ], + diffeq: |x, _t, dx| { let e50_2r1 = e50_2s; let e50_1r2 = e50_1s; let h2r1 = h2s; let h1r2 = h1s; - dx[0] = rateiv[0] - cl1 * x[0] / v1; - dx[1] = rateiv[1] - cl2 * x[1] / v2; + dx[drug_1_amount] = -cl1 * x[drug_1_amount] / v1; + dx[drug_2_amount] = -cl2 * x[drug_2_amount] / v2; - let xns = x[2]; - let xnr1 = x[3]; - let xnr2 = x[4]; + let xns = x[total_bacteria]; + let xnr1 = x[resistant_1]; + let xnr2 = x[resistant_2]; let e = 1.0 - (xns + xnr1 + xnr2) / popmax; - // Case s - let u_s = x[0] / (v1 * e50_1s); - let v_s = x[1] / (v2 * e50_2s); + let u_s = x[drug_1_amount] / (v1 * e50_1s); + let v_s = x[drug_2_amount] / (v2 * e50_2s); let w_s = alpha_s * u_s * v_s / (e50_1s * e50_2s); let xm0best = get_e2(u_s, v_s, w_s, 1.0 / h1s, 1.0 / h2s, alpha_s); - dx[2] = xns * (kgs * e - kks * xm0best); + dx[total_bacteria] = xns * (kgs * e - kks * xm0best); - // Case r1 - let u_r1 = x[0] / (v1 * e50_1r1); - let v_r1 = x[1] / (v2 * e50_2r1); + let u_r1 = x[drug_1_amount] / (v1 * e50_1r1); + let v_r1 = x[drug_2_amount] / (v2 * e50_2r1); let w_r1 = alpha_r1 * u_r1 * v_r1 / (e50_1r1 * e50_2r1); let xm0best = get_e2(u_r1, v_r1, w_r1, 1.0 / h1r1, 1.0 / h2r1, alpha_s); - dx[3] = xnr1 * (kgr1 * e - kkr1 * xm0best); + dx[resistant_1] = xnr1 * (kgr1 * e - kkr1 * xm0best); - // Case r2 - let u_r2 = x[0] / (v1 * e50_1r2); - let v_r2 = x[1] / (v2 * e50_2r2); + let u_r2 = x[drug_1_amount] / (v1 * e50_1r2); + let v_r2 = x[drug_2_amount] / (v2 * e50_2r2); let w_r2 = alpha_r2 * u_r2 * v_r2 / (e50_1r2 * e50_2r2); let xm0best = get_e2(u_r2, v_r2, w_r2, 1.0 / h1r2, 1.0 / h2r2, alpha_s); - dx[4] = xnr2 * (kgr2 * e - kkr2 * xm0best); + dx[resistant_2] = xnr2 * (kgr2 * e - kkr2 * xm0best); }, - init: |p, t, cov, x| { - fetch_params!( - p, v1, cl1, v2, cl2, popmax, kgs, kks, e50_1s, e50_2s, alpha_s, kgr1, kkr1, - e50_1r1, alpha_r1, kgr2, kkr2, e50_2r2, alpha_r2, init_4, init_5, h1s, h2s, h1r1, - h2r2 - ); - fetch_cov!(cov, t, ic_t); - x[0] = 0.0; - x[1] = 0.0; - x[2] = 10.0_f64.powf(ic_t); - x[3] = 10.0_f64.powf(init_4); - x[4] = 10.0_f64.powf(init_5); + init: |_t, x| { + x[drug_1_amount] = 0.0; + x[drug_2_amount] = 0.0; + x[total_bacteria] = 10.0_f64.powf(ic_t); + x[resistant_1] = 10.0_f64.powf(init_4); + x[resistant_2] = 10.0_f64.powf(init_5); }, - out: |x, p, _t, _cov, y| { - fetch_params!( - p, v1, cl1, v2, cl2, popmax, kgs, kks, e50_1s, e50_2s, alpha_s, kgr1, kkr1, - e50_1r1, alpha_r1, kgr2, kkr2, e50_2r2, alpha_r2, init_4, init_5, h1s, h2s, h1r1, - h2r2 - ); - y[0] = x[0] / v1; - y[1] = x[1] / v2; - y[2] = (x[2] + x[3] + x[4]).log10(); - y[3] = x[3].log10(); - y[4] = x[4].log10(); + out: |x, _t, y| { + y[outeq_1] = x[drug_1_amount] / v1; + y[outeq_2] = x[drug_2_amount] / v2; + y[outeq_3] = (x[total_bacteria] + x[resistant_1] + x[resistant_2]).log10(); + y[outeq_4] = x[resistant_1].log10(); }, }; - let params = Parameters::new() + let data = data::read_pmetrics("examples/drusano/data.csv")?; + let parameters = ParameterSpace::bounded() .add("v1", 5.0, 160.0) .add("cl1", 4.0, 9.0) .add("v2", 100.0, 200.0) @@ -96,45 +89,26 @@ fn main() -> Result<()> { .add("h2s", 0.1, 4.0) .add("h1r1", 5.0, 25.0) .add("h2r2", 10.0, 22.0); - - let ems = AssayErrorModels::new() - .add( - 0, - AssayErrorModel::proportional(ErrorPoly::new(0.1, 0.1, 0.0, 0.0), 1.0), - )? + let prior = Theta::sobol_default(¶meters)?; + let error_models = AssayErrorModels::new() .add( - 1, + "outeq_1", AssayErrorModel::proportional(ErrorPoly::new(0.1, 0.1, 0.0, 0.0), 1.0), )? .add( - 2, + "outeq_2", AssayErrorModel::proportional(ErrorPoly::new(0.1, 0.1, 0.0, 0.0), 1.0), )? .add( - 3, + "outeq_3", AssayErrorModel::proportional(ErrorPoly::new(0.1, 0.1, 0.0, 0.0), 1.0), )? .add( - 4, + "outeq_4", AssayErrorModel::proportional(ErrorPoly::new(0.1, 0.1, 0.0, 0.0), 1.0), )?; - - let mut settings = SettingsBuilder::new() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems) - .build(); - - settings.set_prior(Prior::sobol(212900, 347)); - settings.set_output_path("examples/drusano/output"); - - settings.initialize_logs()?; - let data = data::read_pmetrics("examples/drusano/data.csv").unwrap(); - let mut algorithm = dispatch_algorithm(settings, eq, data).unwrap(); - algorithm.initialize().unwrap(); - algorithm.fit().unwrap(); - // while !algorithm.next_cycle().unwrap() {} - let mut result = algorithm.into_npresult()?; - result.write_outputs().unwrap(); + EstimationProblem::nonparametric(eq, data, prior, error_models)? + .fit_with(NonParametricAlgorithm::npag())?; Ok(()) } + diff --git a/examples/drusano/main_n.rs b/examples/drusano/main_n.rs deleted file mode 100644 index cba01d469..000000000 --- a/examples/drusano/main_n.rs +++ /dev/null @@ -1,351 +0,0 @@ -use std::process::exit; - -use anyhow::Error; -use argmin::{ - core::{CostFunction, Executor, TerminationReason, TerminationStatus}, - solver::neldermead::NelderMead, -}; -use logger::setup_log; -use pmcore::prelude::*; - -fn main() { - let eq = equation::ODE::new( - |x, p, _t, dx, rateiv, _cov| { - // fetch_cov!(cov, t, wt); - fetch_params!( - p, v1, cl1, v2, cl2, popmax, kgs, kks, e50_1s, e50_2s, alpha_s, kgr1, kkr1, - e50_1r1, alpha_r1, kgr2, kkr2, e50_2r2, alpha_r2, init_3, init_4, init_5, h1s, h2s, - h1r1, h2r2 - ); - // Sec - let e50_2r1 = e50_2s; - let e50_1r2 = e50_1s; - let h2r1 = h2s; - let h1r2 = h1s; - let mut xm0best = 0.0; - - dx[0] = rateiv[0] - cl1 * x[0] / v1; - dx[1] = rateiv[1] - cl2 * x[1] / v2; - - let xns = x[2]; - let xnr1 = x[3]; - let xnr2 = x[4]; - let e = 1.0 - (xns + xnr1 + xnr2) / popmax; - let mut d1 = x[0] / v1; - let mut d2 = x[1] / v2; - let mut u = d1 / e50_1s; - let mut v = d2 / e50_2s; - let mut w = alpha_s * d1 * d2 / (e50_1s * e50_2s); - let mut h1 = 1.0_f64 / h1s; - let mut h2 = 1.0_f64 / h2s; - let mut xx = (h1 + h2) / 2.0; - if u < 1.0E-5 && v < 1.0E-5 { - xm0best = 0.0; - } else { - if v < 0.0 { - xm0best = u.powf(1.0 / h1); - } - if u < 0.0 { - xm0best = v.powf(1.0 / h2); - } - - if v > 0.0 && u > 0.0 { - let start = 0.00001; - let tol = 1.0e-10; - let step = -2.0 * start; - // CALL ELDERY(1,START,XM0BEST1,VALMIN1,TOL,STEP,1000,BESTM0,0,ICONV,NITER,ICNT) - let bm0 = BESTM0 { - u, - v, - w, - h1, - h2, - xx, - }; - let (xm0best1, valmin1, iconv) = bm0.get_best(start, step); - if iconv == false { - // Output a message indicating no convergence on the selection of best M0 for s - println!(" NO CONVERGENCE ON SELECTION OF BEST M0 FOR s."); - - // Output a message indicating the XP(3) EQ... - println!(" FOR THE XP(3) EQ.... "); - - // Output the values of XM0BEST1 and VALMIN1 with formatting - println!(" THE EST. FOR M0 FROM ELDERY WAS {:>20.12}", xm0best1); - println!(" AND THIS GAVE A VALMIN OF {:>20.12}", valmin1); - - // Output the values of D1, D2, U, V, W, ALPHA_S, H1, and H2 with formatting - println!(" NOTE THAT D1,D2 = {:>20.12} {:>20.12}", d1, d2); - println!(" U,V = {:>20.12} {:>20.12}", u, v); - println!(" W,ALPHA_S = {:>20.12} {:>20.12}", w, alpha_s); - println!(" H1,H2 = {:>20.12} {:>20.12}", h1, h2); - - exit(-1); - } - if valmin1 < 1.0e-10 { - xm0best = xm0best1; - } else { - // CALL FINDM0(U,V,alpha_s,H1,H2,XM0EST) - let xm0est = find_m0(u, v, alpha_s, h1, h2); - if xm0est < 0.0 { - xm0best = xm0best1; - } else { - // START(1) = XM0EST - // STEP(1)= -.2D0*START(1) - // CALL ELDERY(1,START,XM0BEST2,VALMIN2,TOL,STEP,1000,BESTM0,0,ICONV,NITER,ICNT) - let bm0 = BESTM0 { - u, - v, - w, - h1, - h2, - xx, - }; - let (xm0best2, valmin2, iconv) = bm0.get_best(xm0est, -2.0 * xm0est); - xm0best = xm0best1; - if valmin2 < valmin1 { - xm0best = xm0best2; - } - if iconv == false { - panic!("NO CONVERGENCE ON SELECTION OF BEST M0 FOR s."); - } //235 - } //237 - } //240 - } //243 - } - let xms = xm0best / (xm0best + 1.0); - dx[2] = xns * (kgs * e - kks * xms); - - d1 = x[0] / v1; - d2 = x[1] / v2; - u = d1 / e50_1r1; - v = d2 / e50_2r1; - w = alpha_r1 * d1 * d2 / (e50_1r1 * e50_2r1); - h1 = 1.0_f64 / h1r1; - h2 = 1.0_f64 / h2r1; - xx = (h1 + h2) / 2.0; - if u < 1.0e-5 && v < 1.0e-5 { - xm0best = 0.0; - } else { - if v < 0.0 { - xm0best = u.powf(1.0 / h1); - } - if u < 0.0 { - xm0best = v.powf(1.0 / h2); - } - if v > 0.0 && u > 0.0 { - //START(1) = .00001 - let tol = 1.0e-10; - // STEP(1)= -.2D0*START(1) - // CALL ELDERY(1,START,XM0BEST1,VALMIN1,TOL,STEP,1000,BESTM0,0,ICONV,NITER,ICNT) - let bm0 = BESTM0 { - u, - v, - w, - h1, - h2, - xx, - }; - let (xm0best1, valmin1, iconv) = bm0.get_best(0.00001, -2.0 * 0.00001); - if iconv == false { - panic!("NO CONVERGENCE ON SELECTION OF BEST M0 FOR r1."); - } - if valmin1 < 1.0e-10 { - xm0best = xm0best1; - } else { - // CALL FINDM0(U,V,alpha_r1,H1,H2,XM0EST) - let xm0est = find_m0(u, v, alpha_s, h1, h2); - if xm0est < 0.0 { - xm0best = xm0best1; - } else { - // START(1) = XM0EST - // STEP(1)= -.2D0*START(1) - // CALL ELDERY(1,START,XM0BEST2,VALMIN2,TOL,STEP,1000,BESTM0,0,ICONV,NITER,ICNT) - let bm0 = BESTM0 { - u, - v, - w, - h1, - h2, - xx, - }; - let (xm0best2, valmin2, iconv) = bm0.get_best(xm0est, -2.0 * xm0est); - xm0best = xm0best1; - if valmin2 < valmin1 { - xm0best = xm0best2; - } - if iconv == false { - panic!("NO CONVERGENCE ON SELECTION OF BEST M0 FOR r1."); - } //235 - } //237 - } //240 - } - } - let xmr1 = xm0best / (xm0best + 1.0); - dx[3] = xnr1 * (kgr1 * e - kkr1 * xmr1); - - d1 = x[0] / v1; - d2 = x[1] / v2; - u = d1 / e50_1r2; - v = d2 / e50_2r2; - w = alpha_r2 * d1 * d2 / (e50_1r2 * e50_2r2); - h1 = 1.0_f64 / h1r2; - h2 = 1.0_f64 / h2r2; - xx = (h1 + h2) / 2.0; - if u < 1.0e-5 && v < 1.0e-5 { - xm0best = 0.0; - } else { - if v < 0.0 { - xm0best = u.powf(1.0 / h1); - } - if u < 0.0 { - xm0best = v.powf(1.0 / h2); - } - - if v > 0.0 && u > 0.0 { - //START(1) = .00001 - let tol = 1.0e-10; - // STEP(1)= -.2D0*START(1) - // CALL ELDERY(1,START,XM0BEST1,VALMIN1,TOL,STEP,1000,BESTM0,0,ICONV,NITER,ICNT) - let xm0best1 = 0.0; - let valmin1 = 0.0; - let iconv = 0.0; - if iconv == 0.0 { - panic!("NO CONVERGENCE ON SELECTION OF BEST M0 FOR r1."); - } - if valmin1 < 1.0e-10 { - xm0best = xm0best1; - } else { - // CALL FINDM0(U,V,alpha_s,H1,H2,XM0EST) - let xm0est = find_m0(u, v, alpha_s, h1, h2); - if xm0est < 0.0 { - xm0best = xm0best1; - } else { - // START(1) = XM0EST - // STEP(1)= -.2D0*START(1) - // CALL ELDERY(1,START,XM0BEST2,VALMIN2,TOL,STEP,1000,BESTM0,0,ICONV,NITER,ICNT) - let xm0best2 = 0.0; - let valmin2 = 0.0; - let iconv = 0.0; - xm0best = xm0best1; - if valmin2 < valmin1 { - xm0best = xm0best2; - } - if iconv == 0.0 { - panic!("NO CONVERGENCE ON SELECTION OF BEST M0 FOR s."); - } //235 - } //237 - } //240 - } //243 - } - let xmr2 = xm0best / (xm0best + 1.0); - dx[4] = xnr2 * (kgr2 * e - kkr2 * xmr2); - }, - |_p, _t, _cov| lag! {}, - |_p, _t, _cov| fa! {}, - |p, t, cov, x| { - fetch_params!( - p, v1, cl1, v2, cl2, popmax, kgs, kks, e50_1s, e50_2s, alpha_s, kgr1, kkr1, - e50_1r1, alpha_r1, kgr2, kkr2, e50_2r2, alpha_r2, init_3, init_4, init_5, h1s, h2s, - h1r1, h2r2 - ); - fetch_cov!(cov, t, ic_t); - x[0] = 0.0; - x[1] = 0.0; - x[2] = 10.0_f64.powf(ic_t); - x[3] = 10.0_f64.powf(init_4); - x[4] = 10.0_f64.powf(init_5); - }, - |x, p, _t, _cov, y| { - fetch_params!( - p, v1, cl1, v2, cl2, popmax, kgs, kks, e50_1s, e50_2s, alpha_s, kgr1, kkr1, - e50_1r1, alpha_r1, kgr2, kkr2, e50_2r2, alpha_r2, init_3, init_4, init_5, h1s, h2s, - h1r1, h2r2 - ); - y[0] = x[0] / v1; - y[1] = x[1] / v2; - y[2] = (x[2] + x[3] + x[4]).log10(); - y[3] = x[3].log10(); - y[4] = x[4].log10(); - }, - (1, 1), - ); - let settings = settings::read("examples/drusano/config.toml").unwrap(); - setup_log(&settings); - let data = data::read_pmetrics("examples/drusano/drusano.csv").unwrap(); - let mut algorithm = dispatch_algorithm(settings, eq, data).unwrap(); - let result = algorithm.fit().unwrap(); - result.write_outputs().unwrap(); -} - -struct BESTM0 { - u: f64, - v: f64, - w: f64, - h1: f64, - h2: f64, - xx: f64, -} -impl CostFunction for BESTM0 { - type Param = f64; - type Output = f64; - fn cost(&self, xm0: &Self::Param) -> Result { - let t1 = self.u / xm0.powf(self.h1); - let t2 = self.v / xm0.powf(self.h2); - let t3 = self.w / xm0.powf(self.xx); - - Ok((1.0 - t1 - t2 - t3).powi(2)) - } -} - -impl BESTM0 { - fn get_best(self, start: f64, step: f64) -> (f64, f64, bool) { - let other_point = start + step; - let solver = NelderMead::new(vec![start, other_point]) - .with_sd_tolerance(0.0001) - .unwrap(); - let res = Executor::new(self, solver) - .configure(|state| state.max_iters(1000)) - // .add_observer(SlogLogger::term(), ObserverMode::Always) - .run() - .unwrap(); - let converged = match res.state.termination_status { - TerminationStatus::Terminated(reason) => match reason { - TerminationReason::SolverConverged => true, - _ => false, - }, - _ => false, - }; - - ( - res.state.best_param.unwrap(), - res.state.best_cost, - converged, - ) - } -} -fn find_m0(ufinal: f64, v: f64, alpha: f64, h1: f64, h2: f64) -> f64 { - let noint = 1000; - let delu = ufinal / (noint as f64); - let mut xm = v.powf(1.0 / h2); - let mut u = 0.0; - let hh = (h1 + h2) / 2.0; - - for int in 1..=noint { - let top = 1.0 / xm.powf(h1) + alpha * v / xm.powf(hh); - let b1 = u * h1 / xm.powf(h1 + 1.0); - let b2 = v * h2 / xm.powf(h2 + 1.0); - let b3 = alpha * v * u * hh / xm.powf(hh + 1.0); - let xmp = top / (b1 + b2 + b3); - - xm = xm + xmp * delu; - - if xm <= 0.0 { - return -1.0; // Greco equation is not solvable - } - - u = delu * (int as f64); - } - - xm // Return the calculated xm0est -} diff --git a/examples/iov/main.rs b/examples/iov/main.rs index 222015dfc..7e0f5ba4c 100644 --- a/examples/iov/main.rs +++ b/examples/iov/main.rs @@ -1,61 +1,42 @@ -use anyhow::Ok; +use anyhow::Result; use pmcore::prelude::*; + fn main() -> Result<()> { - let sde = equation::SDE::new( - |x, p, _t, dx, _rateiv, _cov| { - // automatically defined - fetch_params!(p, ke0); - // let ke0 = 1.2; - dx[1] = -x[1] + ke0; - let ke = x[1]; - // user defined - dx[0] = -ke * x[0]; + let sde = sde! { + name: "iov", + params: [ke0], + states: [central, ke_latent], + outputs: [outeq_1], + particles: 10000, + routes: [ + bolus(input_1) -> central, + ], + drift: |x, _t, dx| { + dx[ke_latent] = -x[ke_latent] + ke0; + dx[central] = -x[ke_latent] * x[central]; }, - |p, d| { - fetch_params!(p, _ke0); - d[1] = 0.1; + diffusion: |sigma| { + sigma[ke_latent] = 0.1; }, - |_p, _t, _cov| lag! {}, - |_p, _t, _cov| fa! {}, - |p, _t, _cov, x| { - fetch_params!(p, ke0); - x[1] = ke0; + init: |_t, x| { + x[ke_latent] = ke0; }, - |x, p, _t, _cov, y| { - fetch_params!(p, _ke0); - y[0] = x[0] / 50.0; + out: |x, _t, y| { + y[outeq_1] = x[central] / 50.0; }, - 10000, - ) - .with_nstates(2) - .with_ndrugs(1) - .with_nout(1); - - let params = Parameters::new().add("ke0", 0.001, 2.0); + }; - let ems = AssayErrorModels::new().add( - 0, + let data = data::read_pmetrics("examples/iov/test.csv").unwrap(); + let parameters = ParameterSpace::bounded().add("ke0", 0.001, 2.0); + let prior = Theta::sobol_default(¶meters)?; + let error_models = AssayErrorModels::new().add( + "outeq_1", AssayErrorModel::additive(ErrorPoly::new(0.0, 0.0, 0.0, 0.0), 0.0000757575757576), )?; - - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems) - .build(); - - settings.set_cycles(100000); - - settings.set_output_path("examples/iov/output"); - settings.set_prior(Prior::sobol(100, 347)); - - settings.initialize_logs()?; - - let data = data::read_pmetrics("examples/iov/test.csv").unwrap(); - let mut algorithm = dispatch_algorithm(settings, sde, data).unwrap(); - algorithm.initialize().unwrap(); - let mut result = algorithm.fit().unwrap(); - result.write_outputs().unwrap(); + EstimationProblem::nonparametric(sde, data, prior, error_models)? + .fit_with(NonParametricAlgorithm::npag()) + .unwrap(); Ok(()) } + diff --git a/examples/meta/main.rs b/examples/meta/main.rs index 61dc77ebf..ea89bdfd4 100644 --- a/examples/meta/main.rs +++ b/examples/meta/main.rs @@ -2,33 +2,37 @@ #![allow(unused_variables)] #![allow(unused_imports)] -use pmcore::{prelude::*, routines::settings}; +use pmcore::prelude::*; -fn main() { +fn main() -> Result<()> { let eq = ode! { - diffeq: |x, p, t, dx, b, rateiv, cov| { - fetch_cov!(cov, t, wt, pkvisit); - fetch_params!(p, cls, fm, k20, relv, theta1, theta2, vs); + name: "meta", + params: [cls, fm, k20, relv, theta1, theta2, vs], + covariates: [wt, pkvisit], + states: [central, metabolite], + outputs: [outeq_1, outeq_2], + routes: [ + infusion(input_1) -> central, + ], + diffeq: |x, _t, dx| { let cl = cls * ((pkvisit - 1.0) * theta1).exp() * (wt / 70.0).powf(0.75); let v = vs * ((pkvisit - 1.0) * theta2).exp() * (wt / 70.0); let ke = cl / v; - let v2 = relv * v; - dx[0] = rateiv[1] - ke * x[0] * (1.0 - fm) - fm * x[0] + b[1]; - dx[1] = fm * x[0] - k20 * x[1]; + dx[central] = -ke * x[central] * (1.0 - fm) - fm * x[central]; + dx[metabolite] = fm * x[central] - k20 * x[metabolite]; }, - out: |x, p, t, cov, y| { - fetch_cov!(cov, t, wt, pkvisit); - fetch_params!(p, cls, fm, k20, relv, theta1, theta2, vs); + out: |x, _t, y| { let cl = cls * ((pkvisit - 1.0) * theta1).exp() * (wt / 70.0).powf(0.75); let v = vs * ((pkvisit - 1.0) * theta2).exp() * (wt / 70.0); - let ke = cl / v; let v2 = relv * v; - y[1] = x[0] / v; - y[2] = x[1] / v2; + let _ke = cl / v; + y[outeq_1] = x[central] / v; + y[outeq_2] = x[metabolite] / v2; }, }; - let params = Parameters::new() + let data = data::read_pmetrics("examples/meta/meta.csv")?; + let parameters = ParameterSpace::bounded() .add("cls", 0.1, 10.0) .add("fm", 0.0, 1.0) .add("k20", 0.01, 1.0) @@ -36,30 +40,19 @@ fn main() { .add("theta1", 0.1, 10.0) .add("theta2", 0.1, 10.0) .add("vs", 1.0, 10.0); - - let ems = AssayErrorModels::new() + let prior = Theta::sobol_default(¶meters)?; + let error_models = AssayErrorModels::new() .add( - 1, + "outeq_1", AssayErrorModel::proportional(ErrorPoly::new(1.0, 0.1, 0.0, 0.0), 5.0), - ) - .unwrap() + )? .add( - 2, + "outeq_2", AssayErrorModel::proportional(ErrorPoly::new(1.0, 0.1, 0.0, 0.0), 5.0), - ) - .unwrap(); + )?; + EstimationProblem::nonparametric(eq, data, prior, error_models)? + .fit_with(NonParametricAlgorithm::npod())?; - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems) - .build(); - - settings.initialize_logs().unwrap(); - let data = data::read_pmetrics("examples/meta/meta.csv").unwrap(); - let mut algorithm = dispatch_algorithm(settings, eq, data).unwrap(); - // let result = algorithm.fit().unwrap(); - algorithm.initialize().unwrap(); - let mut result = algorithm.fit().unwrap(); - result.write_outputs().unwrap(); + Ok(()) } + diff --git a/examples/neely/main.rs b/examples/neely/main.rs index 51883c1ae..922547721 100644 --- a/examples/neely/main.rs +++ b/examples/neely/main.rs @@ -1,34 +1,33 @@ use pmcore::prelude::*; -fn main() { - let ode = ode! { - diffeq: |x, p, t, dx, b, rateiv, cov| { - fetch_params!(p, cls, k30, k40, qs, vps, vs, fm1, fm2, theta1, theta2); - fetch_cov!(cov, t, wt, pkvisit); - let vfrac1 = 0.068202; - let vfrac2 = 0.022569; +fn main() -> Result<()> { + let eq = ode! { + name: "neely", + params: [cls, k30, k40, qs, vps, vs, fm1, fm2, theta1, theta2], + covariates: [wt, pkvisit], + states: [central, peripheral, metabolite_1, metabolite_2], + outputs: [outeq_1, outeq_2, outeq_3], + routes: [ + infusion(input_1) -> central, + ], + diffeq: |x, _t, dx| { let cl = cls * ((pkvisit - 1.0) * theta1).exp() * (wt / 70.0).powf(0.75); let q = qs * (wt / 70.0).powf(0.75); let v = vs * ((pkvisit - 1.0) * theta2).exp() * (wt / 70.0); let vp = vps * (wt / 70.0); let ke = cl / v; - let _vm1 = vfrac1 * v; - let _vm2 = vfrac2 * v; let k12 = q / v; let k21 = q / vp; - // - dx[0] = rateiv[1] - ke * x[0] * (1.0 - fm1 - fm2) - (fm1 + fm2) * x[0] - k12 * x[0] - + k21 * x[1] - + b[1]; - dx[1] = k12 * x[0] - k21 * x[1]; - dx[2] = fm1 * x[0] - k30 * x[2]; - dx[3] = fm2 * x[0] - k40 * x[3]; + dx[central] = -ke * x[central] * (1.0 - fm1 - fm2) + - (fm1 + fm2) * x[central] + - k12 * x[central] + + k21 * x[peripheral]; + dx[peripheral] = k12 * x[central] - k21 * x[peripheral]; + dx[metabolite_1] = fm1 * x[central] - k30 * x[metabolite_1]; + dx[metabolite_2] = fm2 * x[central] - k40 * x[metabolite_2]; }, - out: |x, p, t, cov, y| { - fetch_params!(p, cls, _k30, _k40, qs, vps, vs, _fm1, _fm2, theta1, theta2); - fetch_cov!(cov, t, wt, pkvisit); - + out: |x, _t, y| { let vfrac1 = 0.068202; let vfrac2 = 0.022569; let cl = cls * ((pkvisit - 1.0) * theta1).exp() * (wt / 70.0).powf(0.75); @@ -41,13 +40,14 @@ fn main() { let _k12 = q / v; let _k21 = q / vp; - y[1] = x[0] / v; - y[2] = x[2] / vm1; - y[3] = x[3] / vm2; + y[outeq_1] = x[central] / v; + y[outeq_2] = x[metabolite_1] / vm1; + y[outeq_3] = x[metabolite_2] / vm2; }, }; - let params = Parameters::new() + let data = data::read_pmetrics("examples/neely/data.csv")?; + let parameters = ParameterSpace::bounded() .add("cls", 0.0, 0.4) .add("k30", 0.0, 0.5) .add("k40", 0.3, 1.5) @@ -58,37 +58,23 @@ fn main() { .add("fm2", 0.0, 0.1) .add("theta1", -4.0, 2.0) .add("theta2", -2.0, 0.5); - - let ems = AssayErrorModels::new() + let prior = Theta::sobol_default(¶meters)?; + let error_models = AssayErrorModels::new() .add( - 1, + "outeq_1", AssayErrorModel::proportional(ErrorPoly::new(1.0, 0.1, 0.0, 0.0), 5.0), - ) - .unwrap() + )? .add( - 2, + "outeq_2", AssayErrorModel::proportional(ErrorPoly::new(1.0, 0.1, 0.0, 0.0), 5.0), - ) - .unwrap() + )? .add( - 3, + "outeq_3", AssayErrorModel::proportional(ErrorPoly::new(1.0, 0.1, 0.0, 0.0), 5.0), - ) - .unwrap(); - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems) - .build(); + )?; + EstimationProblem::nonparametric(eq, data, prior, error_models)? + .fit_with(NonParametricAlgorithm::npag())?; - settings.set_cycles(1000); - settings.set_prior(Prior::sobol(2028, 22)); - settings.set_output_path("examples/neely/output/"); - settings.set_write_logs(true); - settings.write().unwrap(); - settings.initialize_logs().unwrap(); - let data = data::read_pmetrics("examples/neely/data.csv").unwrap(); - let mut algorithm = dispatch_algorithm(settings, ode, data).unwrap(); - let mut result = algorithm.fit().unwrap(); - result.write_outputs().unwrap(); + Ok(()) } + diff --git a/examples/new_iov/main.rs b/examples/new_iov/main.rs index 6cf4e2094..9d1ea6eb6 100644 --- a/examples/new_iov/main.rs +++ b/examples/new_iov/main.rs @@ -1,61 +1,42 @@ use pmcore::prelude::*; -fn main() { - let sde = equation::SDE::new( - |x, p, _t, dx, _rateiv, _cov| { - // automatically defined - fetch_params!(p, ke0, _ske); - // let ke0 = 1.2; - dx[1] = -x[1] + ke0; - let ke = x[1]; - // user defined - dx[0] = -ke * x[0]; +fn main() -> Result<()> { + let sde = sde! { + name: "new_iov", + params: [ke0, ske], + states: [central, ke_latent], + outputs: [outeq_1], + particles: 11, + routes: [ + bolus(input_1) -> central, + ], + drift: |x, _t, dx| { + dx[ke_latent] = -x[ke_latent] + ke0; + dx[central] = -x[ke_latent] * x[central]; }, - |p, d| { - fetch_params!(p, _ke0, ske); - d[1] = ske; + diffusion: |sigma| { + sigma[ke_latent] = ske; }, - |_p, _t, _cov| lag! {}, - |_p, _t, _cov| fa! {}, - |p, _t, _cov, x| { - fetch_params!(p, ke0, _ske); - x[1] = ke0; + init: |_t, x| { + x[ke_latent] = ke0; }, - |x, p, _t, _cov, y| { - fetch_params!(p, _ke0, _ske); - y[0] = x[0] / 50.0; + out: |x, _t, y| { + y[outeq_1] = x[central] / 50.0; }, - 11, - ) - .with_nstates(2) - .with_ndrugs(1) - .with_nout(1); + }; - let params = Parameters::new() + let data = data::read_pmetrics("examples/new_iov/data.csv")?; + let parameters = ParameterSpace::bounded() .add("ke0", 0.0001, 2.4) .add("ske", 0.0001, 0.2); + let prior = Theta::sobol_default(¶meters)?; + let error_models = AssayErrorModels::new().add( + "outeq_1", + AssayErrorModel::additive(ErrorPoly::new(-0.00119, 0.44379, -0.45864, 0.16537), 0.0), + )?; + EstimationProblem::nonparametric(sde, data, prior, error_models)? + .fit_with(NonParametricAlgorithm::npag())?; - let ems = AssayErrorModels::new() - .add( - 0, - AssayErrorModel::additive(ErrorPoly::new(-0.00119, 0.44379, -0.45864, 0.16537), 0.0), - ) - .unwrap(); - - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems) - .build(); - - settings.set_cycles(1000); - settings.set_output_path("examples/new_iov/output"); - settings.set_prior(Prior::sobol(100, 347)); - - settings.initialize_logs().unwrap(); - let data = data::read_pmetrics("examples/new_iov/data.csv").unwrap(); - let mut algorithm = dispatch_algorithm(settings, sde, data).unwrap(); - algorithm.initialize().unwrap(); - let mut result = algorithm.fit().unwrap(); - result.write_outputs().unwrap(); + Ok(()) } + diff --git a/examples/test_cobyla.rs b/examples/test_cobyla.rs new file mode 100644 index 000000000..b8e2769d1 --- /dev/null +++ b/examples/test_cobyla.rs @@ -0,0 +1,173 @@ +//! Test COBYLA optimizer to verify it works before integration +//! +//! Run with: cargo run --example test_cobyla + +use cobyla::{minimize, RhoBeg}; + +fn main() { + println!("Testing COBYLA optimizer...\n"); + + // Test 1: Simple quadratic minimization (Rosenbrock-like) + // f(x,y) = (1-x)² + 100(y-x²)² + // Minimum at (1, 1) + test_rosenbrock(); + + // Test 2: Bounded optimization + // Minimize x² + y² subject to 0.5 <= x <= 2, 0.5 <= y <= 2 + // Constrained minimum at (0.5, 0.5) + test_bounded(); + + // Test 3: Higher dimensional (4D like our PK problems) + test_4d_bounded(); + + println!("\n✅ All COBYLA tests passed!"); +} + +fn test_rosenbrock() { + println!("Test 1: Rosenbrock function (unbounded)"); + + fn rosenbrock(x: &[f64], _data: &mut ()) -> f64 { + let a = 1.0 - x[0]; + let b = x[1] - x[0] * x[0]; + a * a + 100.0 * b * b + } + + let x0 = vec![-1.0, -1.0]; + let bounds: Vec<(f64, f64)> = vec![(-10.0, 10.0), (-10.0, 10.0)]; + let cons: Vec f64> = vec![]; // No constraints + + let result = minimize( + rosenbrock, + &x0, + &bounds, + &cons, + (), // User data + 5000, // Max evaluations (Rosenbrock needs more) + RhoBeg::All(0.5), + None, // Default stopping tolerances + ); + + match result { + Ok((status, x, fval)) => { + println!(" Status: {:?}", status); + println!(" Solution: x = [{:.6}, {:.6}]", x[0], x[1]); + println!(" Expected: x = [1.0, 1.0]"); + println!(" f(x) = {:.6}", fval); + + assert!( + (x[0] - 1.0).abs() < 0.02, + "x[0] should be ~1.0, got {}", + x[0] + ); + assert!( + (x[1] - 1.0).abs() < 0.05, + "x[1] should be ~1.0, got {}", + x[1] + ); + println!(" ✓ PASSED\n"); + } + Err((status, x, fval)) => { + println!(" Failed status: {:?}", status); + println!(" Last x: [{:.6}, {:.6}], f(x) = {:.6}", x[0], x[1], fval); + panic!("COBYLA failed"); + } + } +} + +fn test_bounded() { + println!("Test 2: Bounded quadratic"); + + fn quadratic(x: &[f64], _data: &mut ()) -> f64 { + x[0] * x[0] + x[1] * x[1] + } + + let x0 = vec![1.5, 1.5]; + let bounds: Vec<(f64, f64)> = vec![(0.5, 2.0), (0.5, 2.0)]; + let cons: Vec f64> = vec![]; + + let result = minimize( + quadratic, + &x0, + &bounds, + &cons, + (), + 500, + RhoBeg::All(0.3), + None, + ); + + match result { + Ok((status, x, fval)) => { + println!(" Status: {:?}", status); + println!(" Solution: x = [{:.6}, {:.6}]", x[0], x[1]); + println!(" Expected: x = [0.5, 0.5] (at lower bounds)"); + println!(" f(x) = {:.6}", fval); + + assert!((x[0] - 0.5).abs() < 0.01, "x[0] should be ~0.5"); + assert!((x[1] - 0.5).abs() < 0.01, "x[1] should be ~0.5"); + println!(" ✓ PASSED\n"); + } + Err((status, x, fval)) => { + println!(" Failed status: {:?}", status); + println!(" Last x: [{:.6}, {:.6}], f(x) = {:.6}", x[0], x[1], fval); + panic!("COBYLA failed"); + } + } +} + +fn test_4d_bounded() { + println!("Test 3: 4D bounded optimization (like PK parameters)"); + + // Simulate a multi-modal function with one clear minimum + // f(x) = sum((x_i - target_i)²) where targets are inside bounds + fn multivariate(x: &[f64], _data: &mut ()) -> f64 { + let targets = [0.3, 1.5, 0.8, 2.0]; + x.iter() + .zip(targets.iter()) + .map(|(xi, ti)| (xi - ti).powi(2)) + .sum() + } + + let x0 = vec![1.0, 1.0, 1.0, 1.0]; // Start away from optimum + let bounds: Vec<(f64, f64)> = vec![ + (0.1, 2.0), // Like Ke + (0.5, 5.0), // Like V + (0.1, 3.0), // Like Ka + (0.5, 10.0), // Like another param + ]; + let cons: Vec f64> = vec![]; + + let result = minimize( + multivariate, + &x0, + &bounds, + &cons, + (), + 1000, + RhoBeg::All(0.2), + None, + ); + + match result { + Ok((status, x, fval)) => { + println!(" Status: {:?}", status); + println!( + " Solution: x = [{:.4}, {:.4}, {:.4}, {:.4}]", + x[0], x[1], x[2], x[3] + ); + println!(" Expected: x = [0.3, 1.5, 0.8, 2.0]"); + println!(" f(x) = {:.6}", fval); + + let targets = [0.3, 1.5, 0.8, 2.0]; + for (i, (&xi, &ti)) in x.iter().zip(targets.iter()).enumerate() { + assert!((xi - ti).abs() < 0.05, "x[{}] should be ~{}", i, ti); + } + println!(" ✓ PASSED\n"); + } + Err((status, x, fval)) => { + println!(" Failed status: {:?}", status); + println!(" Last x: {:?}, f(x) = {:.6}", x, fval); + panic!("COBYLA failed"); + } + } +} diff --git a/examples/theophylline/main.rs b/examples/theophylline/main.rs index 9addb5e2f..1d48aaa97 100644 --- a/examples/theophylline/main.rs +++ b/examples/theophylline/main.rs @@ -1,41 +1,33 @@ use pmcore::prelude::*; -fn main() { - let analytical = equation::Analytical::new( - one_compartment_with_absorption, - |_p, _t, _cov| {}, - |_p, _t, _cov| lag! {}, - |_p, _t, _cov| fa! {}, - |_p, _t, _cov, _x| {}, - |x, p, _t, _cov, y| { - fetch_params!(p, _ka, _ke, v); - y[0] = x[1] * 1000.0 / v; +fn main() -> Result<()> { + let analytical = analytical! { + name: "theophylline", + params: [ka, ke, v], + states: [depot, central], + outputs: [outeq_0], + routes: [ + bolus(input_0) -> depot, + ], + structure: one_compartment_with_absorption, + out: |x, _t, y| { + y[outeq_0] = x[central] * 1000.0 / v; }, - ); + }; - let params = Parameters::new() + let data = data::read_pmetrics("examples/theophylline/theophylline.csv")?; + let parameters = ParameterSpace::bounded() .add("ka", 0.001, 3.0) .add("ke", 0.001, 3.0) .add("v", 0.001, 50.0); + let prior = Theta::sobol_default(¶meters)?; + let error_models = AssayErrorModels::new().add( + "outeq_0", + AssayErrorModel::proportional(ErrorPoly::new(0.1, 0.1, 0.0, 0.0), 2.0), + )?; + EstimationProblem::nonparametric(analytical, data, prior, error_models)? + .fit_with(NonParametricAlgorithm::npag())?; - let ems = AssayErrorModels::new() - .add( - 0, - AssayErrorModel::proportional(ErrorPoly::new(0.1, 0.1, 0.0, 0.0), 2.0), - ) - .unwrap(); - - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems) - .build(); - - settings.initialize_logs().unwrap(); - let data = data::read_pmetrics("examples/theophylline/theophylline.csv").unwrap(); - let mut algorithm = dispatch_algorithm(settings, analytical, data).unwrap(); - // let result = algorithm.fit().unwrap(); - algorithm.initialize().unwrap(); - let mut result = algorithm.fit().unwrap(); - result.write_outputs().unwrap(); + Ok(()) } + diff --git a/examples/theophylline/theophylline.csv b/examples/theophylline/theophylline.csv index 6fd956a07..eca08e459 100644 --- a/examples/theophylline/theophylline.csv +++ b/examples/theophylline/theophylline.csv @@ -1,145 +1,145 @@ ID,EVID,TIME,DUR,DOSE,ADDL,II,INPUT,OUT,OUTEQ,C0,C1,C2,C3,WEIGHT -1,1,0,0,4.02,.,.,1,.,.,.,.,.,.,79.6 -1,0,0.1,.,.,.,.,.,0.74,1,.,.,.,.,79.6 -1,0,0.25,.,.,.,.,.,2.84,1,.,.,.,.,79.6 -1,0,0.57,.,.,.,.,.,6.57,1,.,.,.,.,79.6 -1,0,1.12,.,.,.,.,.,10.5,1,.,.,.,.,79.6 -1,0,2.02,.,.,.,.,.,9.66,1,.,.,.,.,79.6 -1,0,3.82,.,.,.,.,.,8.58,1,.,.,.,.,79.6 -1,0,5.1,.,.,.,.,.,8.36,1,.,.,.,.,79.6 -1,0,7.03,.,.,.,.,.,7.47,1,.,.,.,.,79.6 -1,0,9.05,.,.,.,.,.,6.89,1,.,.,.,.,79.6 -1,0,12.12,.,.,.,.,.,5.94,1,.,.,.,.,79.6 -1,0,24.37,.,.,.,.,.,3.28,1,.,.,.,.,79.6 -2,1,0,0,4.4,.,.,1,.,.,.,.,.,.,72.4 -2,0,0.1,.,.,.,.,.,0,1,.,.,.,.,72.4 -2,0,0.27,.,.,.,.,.,1.72,1,.,.,.,.,72.4 -2,0,0.52,.,.,.,.,.,7.91,1,.,.,.,.,72.4 -2,0,1,.,.,.,.,.,8.31,1,.,.,.,.,72.4 -2,0,1.92,.,.,.,.,.,8.33,1,.,.,.,.,72.4 -2,0,3.5,.,.,.,.,.,6.85,1,.,.,.,.,72.4 -2,0,5.02,.,.,.,.,.,6.08,1,.,.,.,.,72.4 -2,0,7.03,.,.,.,.,.,5.4,1,.,.,.,.,72.4 -2,0,9,.,.,.,.,.,4.55,1,.,.,.,.,72.4 -2,0,12,.,.,.,.,.,3.01,1,.,.,.,.,72.4 -2,0,24.3,.,.,.,.,.,0.9,1,.,.,.,.,72.4 -3,1,0,0,4.53,.,.,1,.,.,.,.,.,.,70.5 -3,0,0.1,.,.,.,.,.,0,1,.,.,.,.,70.5 -3,0,0.27,.,.,.,.,.,4.4,1,.,.,.,.,70.5 -3,0,0.58,.,.,.,.,.,6.9,1,.,.,.,.,70.5 -3,0,1.02,.,.,.,.,.,8.2,1,.,.,.,.,70.5 -3,0,2.02,.,.,.,.,.,7.8,1,.,.,.,.,70.5 -3,0,3.62,.,.,.,.,.,7.5,1,.,.,.,.,70.5 -3,0,5.08,.,.,.,.,.,6.2,1,.,.,.,.,70.5 -3,0,7.07,.,.,.,.,.,5.3,1,.,.,.,.,70.5 -3,0,9,.,.,.,.,.,4.9,1,.,.,.,.,70.5 -3,0,12.15,.,.,.,.,.,3.7,1,.,.,.,.,70.5 -3,0,24.17,.,.,.,.,.,1.05,1,.,.,.,.,70.5 -4,1,0,0,4.4,.,.,1,.,.,.,.,.,.,72.7 -4,0,0.1,.,.,.,.,.,0,1,.,.,.,.,72.7 -4,0,0.35,.,.,.,.,.,1.89,1,.,.,.,.,72.7 -4,0,0.6,.,.,.,.,.,4.6,1,.,.,.,.,72.7 -4,0,1.07,.,.,.,.,.,8.6,1,.,.,.,.,72.7 -4,0,2.13,.,.,.,.,.,8.38,1,.,.,.,.,72.7 -4,0,3.5,.,.,.,.,.,7.54,1,.,.,.,.,72.7 -4,0,5.02,.,.,.,.,.,6.88,1,.,.,.,.,72.7 -4,0,7.02,.,.,.,.,.,5.78,1,.,.,.,.,72.7 -4,0,9.02,.,.,.,.,.,5.33,1,.,.,.,.,72.7 -4,0,11.98,.,.,.,.,.,4.19,1,.,.,.,.,72.7 -4,0,24.65,.,.,.,.,.,1.15,1,.,.,.,.,72.7 -5,1,0,0,5.86,.,.,1,.,.,.,.,.,.,54.6 -5,0,0.1,.,.,.,.,.,0,1,.,.,.,.,54.6 -5,0,0.3,.,.,.,.,.,2.02,1,.,.,.,.,54.6 -5,0,0.52,.,.,.,.,.,5.63,1,.,.,.,.,54.6 -5,0,1,.,.,.,.,.,11.4,1,.,.,.,.,54.6 -5,0,2.02,.,.,.,.,.,9.33,1,.,.,.,.,54.6 -5,0,3.5,.,.,.,.,.,8.74,1,.,.,.,.,54.6 -5,0,5.02,.,.,.,.,.,7.56,1,.,.,.,.,54.6 -5,0,7.02,.,.,.,.,.,7.09,1,.,.,.,.,54.6 -5,0,9.1,.,.,.,.,.,5.9,1,.,.,.,.,54.6 -5,0,12,.,.,.,.,.,4.37,1,.,.,.,.,54.6 -5,0,24.35,.,.,.,.,.,1.57,1,.,.,.,.,54.6 -6,1,0,0,4,.,.,1,.,.,.,.,.,.,80 -6,0,0.1,.,.,.,.,.,0,1,.,.,.,.,80 -6,0,0.27,.,.,.,.,.,1.29,1,.,.,.,.,80 -6,0,0.58,.,.,.,.,.,3.08,1,.,.,.,.,80 -6,0,1.15,.,.,.,.,.,6.44,1,.,.,.,.,80 -6,0,2.03,.,.,.,.,.,6.32,1,.,.,.,.,80 -6,0,3.57,.,.,.,.,.,5.53,1,.,.,.,.,80 -6,0,5,.,.,.,.,.,4.94,1,.,.,.,.,80 -6,0,7,.,.,.,.,.,4.02,1,.,.,.,.,80 -6,0,9.22,.,.,.,.,.,3.46,1,.,.,.,.,80 -6,0,12.1,.,.,.,.,.,2.78,1,.,.,.,.,80 -6,0,23.85,.,.,.,.,.,0.92,1,.,.,.,.,80 -7,1,0,0,4.95,.,.,1,.,.,.,.,.,.,64.6 -7,0,0.1,.,.,.,.,.,0.15,1,.,.,.,.,64.6 -7,0,0.25,.,.,.,.,.,0.85,1,.,.,.,.,64.6 -7,0,0.5,.,.,.,.,.,2.35,1,.,.,.,.,64.6 -7,0,1.02,.,.,.,.,.,5.02,1,.,.,.,.,64.6 -7,0,2.02,.,.,.,.,.,6.58,1,.,.,.,.,64.6 -7,0,3.48,.,.,.,.,.,7.09,1,.,.,.,.,64.6 -7,0,5,.,.,.,.,.,6.66,1,.,.,.,.,64.6 -7,0,6.98,.,.,.,.,.,5.25,1,.,.,.,.,64.6 -7,0,9,.,.,.,.,.,4.39,1,.,.,.,.,64.6 -7,0,12.05,.,.,.,.,.,3.53,1,.,.,.,.,64.6 -7,0,24.22,.,.,.,.,.,1.15,1,.,.,.,.,64.6 -8,1,0,0,4.53,.,.,1,.,.,.,.,.,.,70.5 -8,0,0.1,.,.,.,.,.,0,1,.,.,.,.,70.5 -8,0,0.25,.,.,.,.,.,3.05,1,.,.,.,.,70.5 -8,0,0.52,.,.,.,.,.,3.05,1,.,.,.,.,70.5 -8,0,0.98,.,.,.,.,.,7.31,1,.,.,.,.,70.5 -8,0,2.02,.,.,.,.,.,7.56,1,.,.,.,.,70.5 -8,0,3.53,.,.,.,.,.,6.59,1,.,.,.,.,70.5 -8,0,5.05,.,.,.,.,.,5.88,1,.,.,.,.,70.5 -8,0,7.15,.,.,.,.,.,4.73,1,.,.,.,.,70.5 -8,0,9.07,.,.,.,.,.,4.57,1,.,.,.,.,70.5 -8,0,12.1,.,.,.,.,.,3,1,.,.,.,.,70.5 -8,0,24.12,.,.,.,.,.,1.25,1,.,.,.,.,70.5 -9,1,0,0,3.1,.,.,1,.,.,.,.,.,.,86.4 -9,0,0.1,.,.,.,.,.,0,1,.,.,.,.,86.4 -9,0,0.3,.,.,.,.,.,7.37,1,.,.,.,.,86.4 -9,0,0.63,.,.,.,.,.,9.03,1,.,.,.,.,86.4 -9,0,1.05,.,.,.,.,.,7.14,1,.,.,.,.,86.4 -9,0,2.02,.,.,.,.,.,6.33,1,.,.,.,.,86.4 -9,0,3.53,.,.,.,.,.,5.66,1,.,.,.,.,86.4 -9,0,5.02,.,.,.,.,.,5.67,1,.,.,.,.,86.4 -9,0,7.17,.,.,.,.,.,4.24,1,.,.,.,.,86.4 -9,0,8.8,.,.,.,.,.,4.11,1,.,.,.,.,86.4 -9,0,11.6,.,.,.,.,.,3.16,1,.,.,.,.,86.4 -9,0,24.43,.,.,.,.,.,1.12,1,.,.,.,.,86.4 -10,1,0,0,5.5,.,.,1,.,.,.,.,.,.,58.2 -10,0,0.1,.,.,.,.,.,0.24,1,.,.,.,.,58.2 -10,0,0.37,.,.,.,.,.,2.89,1,.,.,.,.,58.2 -10,0,0.77,.,.,.,.,.,5.22,1,.,.,.,.,58.2 -10,0,1.02,.,.,.,.,.,6.41,1,.,.,.,.,58.2 -10,0,2.05,.,.,.,.,.,7.83,1,.,.,.,.,58.2 -10,0,3.55,.,.,.,.,.,10.21,1,.,.,.,.,58.2 -10,0,5.05,.,.,.,.,.,9.18,1,.,.,.,.,58.2 -10,0,7.08,.,.,.,.,.,8.02,1,.,.,.,.,58.2 -10,0,9.38,.,.,.,.,.,7.14,1,.,.,.,.,58.2 -10,0,12.1,.,.,.,.,.,5.68,1,.,.,.,.,58.2 -10,0,23.7,.,.,.,.,.,2.42,1,.,.,.,.,58.2 -11,1,0,0,4.92,.,.,1,.,.,.,.,.,.,65 -11,0,0.1,.,.,.,.,.,0,1,.,.,.,.,65 -11,0,0.25,.,.,.,.,.,4.86,1,.,.,.,.,65 -11,0,0.5,.,.,.,.,.,7.24,1,.,.,.,.,65 -11,0,0.98,.,.,.,.,.,8,1,.,.,.,.,65 -11,0,1.98,.,.,.,.,.,6.81,1,.,.,.,.,65 -11,0,3.6,.,.,.,.,.,5.87,1,.,.,.,.,65 -11,0,5.02,.,.,.,.,.,5.22,1,.,.,.,.,65 -11,0,7.03,.,.,.,.,.,4.45,1,.,.,.,.,65 -11,0,9.03,.,.,.,.,.,3.62,1,.,.,.,.,65 -11,0,12.12,.,.,.,.,.,2.69,1,.,.,.,.,65 -11,0,24.08,.,.,.,.,.,0.86,1,.,.,.,.,65 -12,1,0,0,5.3,.,.,1,.,.,.,.,.,.,60.5 -12,0,0.1,.,.,.,.,.,0,1,.,.,.,.,60.5 -12,0,0.25,.,.,.,.,.,1.25,1,.,.,.,.,60.5 -12,0,0.5,.,.,.,.,.,3.96,1,.,.,.,.,60.5 -12,0,1,.,.,.,.,.,7.82,1,.,.,.,.,60.5 -12,0,2,.,.,.,.,.,9.72,1,.,.,.,.,60.5 -12,0,3.52,.,.,.,.,.,9.75,1,.,.,.,.,60.5 -12,0,5.07,.,.,.,.,.,8.57,1,.,.,.,.,60.5 -12,0,7.07,.,.,.,.,.,6.59,1,.,.,.,.,60.5 -12,0,9.03,.,.,.,.,.,6.11,1,.,.,.,.,60.5 -12,0,12.05,.,.,.,.,.,4.57,1,.,.,.,.,60.5 -12,0,24.15,.,.,.,.,.,1.17,1,.,.,.,.,60.5 +1,1,0,0,4.02,.,.,0,.,.,.,.,.,.,79.6 +1,0,0.1,.,.,.,.,.,0.74,0,.,.,.,.,79.6 +1,0,0.25,.,.,.,.,.,2.84,0,.,.,.,.,79.6 +1,0,0.57,.,.,.,.,.,6.57,0,.,.,.,.,79.6 +1,0,1.12,.,.,.,.,.,10.5,0,.,.,.,.,79.6 +1,0,2.02,.,.,.,.,.,9.66,0,.,.,.,.,79.6 +1,0,3.82,.,.,.,.,.,8.58,0,.,.,.,.,79.6 +1,0,5.1,.,.,.,.,.,8.36,0,.,.,.,.,79.6 +1,0,7.03,.,.,.,.,.,7.47,0,.,.,.,.,79.6 +1,0,9.05,.,.,.,.,.,6.89,0,.,.,.,.,79.6 +1,0,12.12,.,.,.,.,.,5.94,0,.,.,.,.,79.6 +1,0,24.37,.,.,.,.,.,3.28,0,.,.,.,.,79.6 +2,1,0,0,4.4,.,.,0,.,.,.,.,.,.,72.4 +2,0,0.1,.,.,.,.,.,0,0,.,.,.,.,72.4 +2,0,0.27,.,.,.,.,.,1.72,0,.,.,.,.,72.4 +2,0,0.52,.,.,.,.,.,7.91,0,.,.,.,.,72.4 +2,0,1,.,.,.,.,.,8.31,0,.,.,.,.,72.4 +2,0,1.92,.,.,.,.,.,8.33,0,.,.,.,.,72.4 +2,0,3.5,.,.,.,.,.,6.85,0,.,.,.,.,72.4 +2,0,5.02,.,.,.,.,.,6.08,0,.,.,.,.,72.4 +2,0,7.03,.,.,.,.,.,5.4,0,.,.,.,.,72.4 +2,0,9,.,.,.,.,.,4.55,0,.,.,.,.,72.4 +2,0,12,.,.,.,.,.,3.01,0,.,.,.,.,72.4 +2,0,24.3,.,.,.,.,.,0.9,0,.,.,.,.,72.4 +3,1,0,0,4.53,.,.,0,.,.,.,.,.,.,70.5 +3,0,0.1,.,.,.,.,.,0,0,.,.,.,.,70.5 +3,0,0.27,.,.,.,.,.,4.4,0,.,.,.,.,70.5 +3,0,0.58,.,.,.,.,.,6.9,0,.,.,.,.,70.5 +3,0,1.02,.,.,.,.,.,8.2,0,.,.,.,.,70.5 +3,0,2.02,.,.,.,.,.,7.8,0,.,.,.,.,70.5 +3,0,3.62,.,.,.,.,.,7.5,0,.,.,.,.,70.5 +3,0,5.08,.,.,.,.,.,6.2,0,.,.,.,.,70.5 +3,0,7.07,.,.,.,.,.,5.3,0,.,.,.,.,70.5 +3,0,9,.,.,.,.,.,4.9,0,.,.,.,.,70.5 +3,0,12.15,.,.,.,.,.,3.7,0,.,.,.,.,70.5 +3,0,24.17,.,.,.,.,.,1.05,0,.,.,.,.,70.5 +4,1,0,0,4.4,.,.,0,.,.,.,.,.,.,72.7 +4,0,0.1,.,.,.,.,.,0,0,.,.,.,.,72.7 +4,0,0.35,.,.,.,.,.,1.89,0,.,.,.,.,72.7 +4,0,0.6,.,.,.,.,.,4.6,0,.,.,.,.,72.7 +4,0,1.07,.,.,.,.,.,8.6,0,.,.,.,.,72.7 +4,0,2.13,.,.,.,.,.,8.38,0,.,.,.,.,72.7 +4,0,3.5,.,.,.,.,.,7.54,0,.,.,.,.,72.7 +4,0,5.02,.,.,.,.,.,6.88,0,.,.,.,.,72.7 +4,0,7.02,.,.,.,.,.,5.78,0,.,.,.,.,72.7 +4,0,9.02,.,.,.,.,.,5.33,0,.,.,.,.,72.7 +4,0,11.98,.,.,.,.,.,4.19,0,.,.,.,.,72.7 +4,0,24.65,.,.,.,.,.,1.15,0,.,.,.,.,72.7 +5,1,0,0,5.86,.,.,0,.,.,.,.,.,.,54.6 +5,0,0.1,.,.,.,.,.,0,0,.,.,.,.,54.6 +5,0,0.3,.,.,.,.,.,2.02,0,.,.,.,.,54.6 +5,0,0.52,.,.,.,.,.,5.63,0,.,.,.,.,54.6 +5,0,1,.,.,.,.,.,11.4,0,.,.,.,.,54.6 +5,0,2.02,.,.,.,.,.,9.33,0,.,.,.,.,54.6 +5,0,3.5,.,.,.,.,.,8.74,0,.,.,.,.,54.6 +5,0,5.02,.,.,.,.,.,7.56,0,.,.,.,.,54.6 +5,0,7.02,.,.,.,.,.,7.09,0,.,.,.,.,54.6 +5,0,9.1,.,.,.,.,.,5.9,0,.,.,.,.,54.6 +5,0,12,.,.,.,.,.,4.37,0,.,.,.,.,54.6 +5,0,24.35,.,.,.,.,.,1.57,0,.,.,.,.,54.6 +6,1,0,0,4,.,.,0,.,.,.,.,.,.,80 +6,0,0.1,.,.,.,.,.,0,0,.,.,.,.,80 +6,0,0.27,.,.,.,.,.,1.29,0,.,.,.,.,80 +6,0,0.58,.,.,.,.,.,3.08,0,.,.,.,.,80 +6,0,1.15,.,.,.,.,.,6.44,0,.,.,.,.,80 +6,0,2.03,.,.,.,.,.,6.32,0,.,.,.,.,80 +6,0,3.57,.,.,.,.,.,5.53,0,.,.,.,.,80 +6,0,5,.,.,.,.,.,4.94,0,.,.,.,.,80 +6,0,7,.,.,.,.,.,4.02,0,.,.,.,.,80 +6,0,9.22,.,.,.,.,.,3.46,0,.,.,.,.,80 +6,0,12.1,.,.,.,.,.,2.78,0,.,.,.,.,80 +6,0,23.85,.,.,.,.,.,0.92,0,.,.,.,.,80 +7,1,0,0,4.95,.,.,0,.,.,.,.,.,.,64.6 +7,0,0.1,.,.,.,.,.,0.15,0,.,.,.,.,64.6 +7,0,0.25,.,.,.,.,.,0.85,0,.,.,.,.,64.6 +7,0,0.5,.,.,.,.,.,2.35,0,.,.,.,.,64.6 +7,0,1.02,.,.,.,.,.,5.02,0,.,.,.,.,64.6 +7,0,2.02,.,.,.,.,.,6.58,0,.,.,.,.,64.6 +7,0,3.48,.,.,.,.,.,7.09,0,.,.,.,.,64.6 +7,0,5,.,.,.,.,.,6.66,0,.,.,.,.,64.6 +7,0,6.98,.,.,.,.,.,5.25,0,.,.,.,.,64.6 +7,0,9,.,.,.,.,.,4.39,0,.,.,.,.,64.6 +7,0,12.05,.,.,.,.,.,3.53,0,.,.,.,.,64.6 +7,0,24.22,.,.,.,.,.,1.15,0,.,.,.,.,64.6 +8,1,0,0,4.53,.,.,0,.,.,.,.,.,.,70.5 +8,0,0.1,.,.,.,.,.,0,0,.,.,.,.,70.5 +8,0,0.25,.,.,.,.,.,3.05,0,.,.,.,.,70.5 +8,0,0.52,.,.,.,.,.,3.05,0,.,.,.,.,70.5 +8,0,0.98,.,.,.,.,.,7.31,0,.,.,.,.,70.5 +8,0,2.02,.,.,.,.,.,7.56,0,.,.,.,.,70.5 +8,0,3.53,.,.,.,.,.,6.59,0,.,.,.,.,70.5 +8,0,5.05,.,.,.,.,.,5.88,0,.,.,.,.,70.5 +8,0,7.15,.,.,.,.,.,4.73,0,.,.,.,.,70.5 +8,0,9.07,.,.,.,.,.,4.57,0,.,.,.,.,70.5 +8,0,12.1,.,.,.,.,.,3,0,.,.,.,.,70.5 +8,0,24.12,.,.,.,.,.,1.25,0,.,.,.,.,70.5 +9,1,0,0,3.1,.,.,0,.,.,.,.,.,.,86.4 +9,0,0.1,.,.,.,.,.,0,0,.,.,.,.,86.4 +9,0,0.3,.,.,.,.,.,7.37,0,.,.,.,.,86.4 +9,0,0.63,.,.,.,.,.,9.03,0,.,.,.,.,86.4 +9,0,1.05,.,.,.,.,.,7.14,0,.,.,.,.,86.4 +9,0,2.02,.,.,.,.,.,6.33,0,.,.,.,.,86.4 +9,0,3.53,.,.,.,.,.,5.66,0,.,.,.,.,86.4 +9,0,5.02,.,.,.,.,.,5.67,0,.,.,.,.,86.4 +9,0,7.17,.,.,.,.,.,4.24,0,.,.,.,.,86.4 +9,0,8.8,.,.,.,.,.,4.11,0,.,.,.,.,86.4 +9,0,11.6,.,.,.,.,.,3.16,0,.,.,.,.,86.4 +9,0,24.43,.,.,.,.,.,1.12,0,.,.,.,.,86.4 +10,1,0,0,5.5,.,.,0,.,.,.,.,.,.,58.2 +10,0,0.1,.,.,.,.,.,0.24,0,.,.,.,.,58.2 +10,0,0.37,.,.,.,.,.,2.89,0,.,.,.,.,58.2 +10,0,0.77,.,.,.,.,.,5.22,0,.,.,.,.,58.2 +10,0,1.02,.,.,.,.,.,6.41,0,.,.,.,.,58.2 +10,0,2.05,.,.,.,.,.,7.83,0,.,.,.,.,58.2 +10,0,3.55,.,.,.,.,.,10.21,0,.,.,.,.,58.2 +10,0,5.05,.,.,.,.,.,9.18,0,.,.,.,.,58.2 +10,0,7.08,.,.,.,.,.,8.02,0,.,.,.,.,58.2 +10,0,9.38,.,.,.,.,.,7.14,0,.,.,.,.,58.2 +10,0,12.1,.,.,.,.,.,5.68,0,.,.,.,.,58.2 +10,0,23.7,.,.,.,.,.,2.42,0,.,.,.,.,58.2 +11,1,0,0,4.92,.,.,0,.,.,.,.,.,.,65 +11,0,0.1,.,.,.,.,.,0,0,.,.,.,.,65 +11,0,0.25,.,.,.,.,.,4.86,0,.,.,.,.,65 +11,0,0.5,.,.,.,.,.,7.24,0,.,.,.,.,65 +11,0,0.98,.,.,.,.,.,8,0,.,.,.,.,65 +11,0,1.98,.,.,.,.,.,6.81,0,.,.,.,.,65 +11,0,3.6,.,.,.,.,.,5.87,0,.,.,.,.,65 +11,0,5.02,.,.,.,.,.,5.22,0,.,.,.,.,65 +11,0,7.03,.,.,.,.,.,4.45,0,.,.,.,.,65 +11,0,9.03,.,.,.,.,.,3.62,0,.,.,.,.,65 +11,0,12.12,.,.,.,.,.,2.69,0,.,.,.,.,65 +11,0,24.08,.,.,.,.,.,0.86,0,.,.,.,.,65 +12,1,0,0,5.3,.,.,0,.,.,.,.,.,.,60.5 +12,0,0.1,.,.,.,.,.,0,0,.,.,.,.,60.5 +12,0,0.25,.,.,.,.,.,1.25,0,.,.,.,.,60.5 +12,0,0.5,.,.,.,.,.,3.96,0,.,.,.,.,60.5 +12,0,1,.,.,.,.,.,7.82,0,.,.,.,.,60.5 +12,0,2,.,.,.,.,.,9.72,0,.,.,.,.,60.5 +12,0,3.52,.,.,.,.,.,9.75,0,.,.,.,.,60.5 +12,0,5.07,.,.,.,.,.,8.57,0,.,.,.,.,60.5 +12,0,7.07,.,.,.,.,.,6.59,0,.,.,.,.,60.5 +12,0,9.03,.,.,.,.,.,6.11,0,.,.,.,.,60.5 +12,0,12.05,.,.,.,.,.,4.57,0,.,.,.,.,60.5 +12,0,24.15,.,.,.,.,.,1.17,0,.,.,.,.,60.5 diff --git a/examples/two_eq_lag/main.rs b/examples/two_eq_lag/main.rs index 92454a6b6..c6185517d 100644 --- a/examples/two_eq_lag/main.rs +++ b/examples/two_eq_lag/main.rs @@ -1,94 +1,40 @@ -#![allow(dead_code)] -#![allow(unused_variables)] -#![allow(unused_imports)] - use pmcore::prelude::*; -fn main() { +fn main() -> Result<()> { let eq = ode! { - diffeq: |x, p, _t, dx, b, rateiv, _cov| { - fetch_cov!(cov, t,); - fetch_params!(p, ka, ke); - dx[0] = -ka * x[0] + b[1]; - dx[1] = ka * x[0] - ke * x[1]; + name: "two_eq_lag", + params: [ka, ke, tlag, v], + states: [gut, central], + outputs: [outeq_0], + routes: [ + bolus(input_0) -> gut, + ], + diffeq: |x, _t, dx| { + dx[gut] = -ka * x[gut]; + dx[central] = ka * x[gut] - ke * x[central]; }, - lag: |p, _t, _cov| { - fetch_params!(p, _ka, _ke, tlag, _v); - lag! {1=>tlag} + lag: |_t| { + lag! { input_0 => tlag } }, - out: |x, p, _t, _cov, y| { - fetch_params!(p, _ka, _ke, _tlag, v); - y[1] = x[1] / v; + out: |x, _t, y| { + y[outeq_0] = x[central] / v; }, }; - // let eq = Equation::new_analytical( - // one_compartment_with_absorption, - // |_p, _cov| {}, - // |p| { - // fetch_params!(p, _ka, _ke, tlag, _v); - // lag! {0=>tlag} - // }, - // |_p, _t, _cov| fa! {}, - // |_p, _t, _cov, _x| {}, - // |x, p, _t, _cov, y| { - // fetch_params!(p, _ka, _ke, _tlag, v); - // y[0] = x[1] / v; - // }, - // (2, 1), - // ); - // let eq = equation::ODENet::new( - // vec![ - // dmatrix![ - // -1.0,0.; - // 1.,0. - // ], - // dmatrix![ - // 0.,0.; - // 0.,-1. - // ], - // dmatrix![ - // 0.0,0.0; - // 0.0,0.0 - // ], - // dmatrix![ - // 0.0,0.0; - // 0.0,0.0 - // ], - // ], - // vec![], - // vec![], - // vec![Lag::new(0, Op::Equal(P(2)))], - // vec![], - // vec![], - // vec![OutEq::new(0, Op::Div(X(1), P(3)))], - // (2, 1), - // ); - let params = Parameters::new() + let data = data::read_pmetrics("examples/two_eq_lag/two_eq_lag.csv")?; + let parameters = ParameterSpace::bounded() .add("ka", 0.1, 0.9) .add("ke", 0.001, 0.1) .add("tlag", 0.0, 4.0) .add("v", 30.0, 120.0); + let prior = Theta::sobol_default(¶meters)?; + let error_models = AssayErrorModels::new().add( + "outeq_0", + AssayErrorModel::additive(ErrorPoly::new(-0.00119, 0.44379, -0.45864, 0.16537), 0.0), + )?; + EstimationProblem::nonparametric(eq, data, prior, error_models)? + .fit_with(NonParametricAlgorithm::npag())?; - let ems = AssayErrorModels::new() - .add( - 1, - AssayErrorModel::additive(ErrorPoly::new(-0.00119, 0.44379, -0.45864, 0.16537), 0.0), - ) - .unwrap(); - - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems) - .build(); - - settings.initialize_logs().unwrap(); - let data = data::read_pmetrics("examples/two_eq_lag/two_eq_lag.csv").unwrap(); - let mut algorithm = dispatch_algorithm(settings, eq, data).unwrap(); - let mut result = algorithm.fit().unwrap(); - // algorithm.initialize().unwrap(); - // while !algorithm.next_cycle().unwrap() {} - // let result = algorithm.into_npresult(); - result.write_outputs().unwrap(); + Ok(()) } + diff --git a/examples/two_eq_lag/two_eq_lag.csv b/examples/two_eq_lag/two_eq_lag.csv index 5de0fb11b..a62f21508 100644 --- a/examples/two_eq_lag/two_eq_lag.csv +++ b/examples/two_eq_lag/two_eq_lag.csv @@ -1,260 +1,260 @@ ID,EVID,TIME,DUR,DOSE,ADDL,II,INPUT,OUT,OUTEQ,C0,C1,C2,C3,WT,AFRICA,AGE,GENDER,HEIGHT -1,1,0,0,600,.,.,1,.,.,.,.,.,.,46.7,1,21,1,160 -1,1,24,0,600,.,.,1,.,.,.,.,.,.,.,.,.,.,. -1,1,48,0,600,.,.,1,.,.,.,.,.,.,.,.,.,.,. -1,1,72,0,600,.,.,1,.,.,.,.,.,.,.,.,.,.,. -1,1,96,0,600,.,.,1,.,.,.,.,.,.,.,.,.,.,. -1,0,120,.,.,.,.,.,10.44,1,.,.,.,.,.,.,.,.,. -1,1,120,0,600,.,.,1,.,.,.,.,.,.,.,.,.,.,. -1,0,121,.,.,.,.,.,12.89,1,.,.,.,.,.,.,.,.,. -1,0,122,.,.,.,.,.,14.98,1,.,.,.,.,.,.,.,.,. -1,0,125.99,.,.,.,.,.,16.69,1,.,.,.,.,.,.,.,.,. -1,0,129,.,.,.,.,.,20.15,1,.,.,.,.,.,.,.,.,. -1,0,132,.,.,.,.,.,14.97,1,.,.,.,.,.,.,.,.,. -1,0,143.98,.,.,.,.,.,12.57,1,.,.,.,.,.,.,.,.,. -2,1,0,0,600,.,.,1,.,.,.,.,.,.,66.5,1,30,1,174 -2,1,24,0,600,.,.,1,.,.,.,.,.,.,66.5,1,30,1,174 -2,1,48,0,600,.,.,1,.,.,.,.,.,.,66.5,1,30,1,174 -2,1,72,0,600,.,.,1,.,.,.,.,.,.,66.5,1,30,1,174 -2,1,96,0,600,.,.,1,.,.,.,.,.,.,66.5,1,30,1,174 -2,0,120,.,.,.,.,.,3.56,1,.,.,.,.,66.5,1,30,1,174 -2,1,120,0,600,.,.,1,.,.,.,.,.,.,66.5,1,30,1,174 -2,0,120.98,.,.,.,.,.,5.84,1,.,.,.,.,66.5,1,30,1,174 -2,0,121.98,.,.,.,.,.,6.54,1,.,.,.,.,66.5,1,30,1,174 -2,0,126,.,.,.,.,.,6.14,1,.,.,.,.,66.5,1,30,1,174 -2,0,129.02,.,.,.,.,.,6.56,1,.,.,.,.,66.5,1,30,1,174 -2,0,132.02,.,.,.,.,.,4.44,1,.,.,.,.,66.5,1,30,1,174 -2,0,144,.,.,.,.,.,3.76,1,.,.,.,.,66.5,1,30,1,174 -3,1,0,0,600,.,.,1,.,.,.,.,.,.,46.7,1,24,0,164 -3,1,24,0,600,.,.,1,.,.,.,.,.,.,46.7,1,24,0,164 -3,1,48,0,600,.,.,1,.,.,.,.,.,.,46.7,1,24,0,164 -3,1,72,0,600,.,.,1,.,.,.,.,.,.,46.7,1,24,0,164 -3,1,96,0,600,.,.,1,.,.,.,.,.,.,46.7,1,24,0,164 -3,1,120,0,600,.,.,1,.,.,.,.,.,.,46.7,1,24,0,164 -3,0,120.08,.,.,.,.,.,4.06,1,.,.,.,.,46.7,1,24,0,164 -3,0,121.07,.,.,.,.,.,3.24,1,.,.,.,.,46.7,1,24,0,164 -3,0,122.08,.,.,.,.,.,3.09,1,.,.,.,.,46.7,1,24,0,164 -3,0,126.08,.,.,.,.,.,7.98,1,.,.,.,.,46.7,1,24,0,164 -3,0,129.05,.,.,.,.,.,7.23,1,.,.,.,.,46.7,1,24,0,164 -3,0,132.1,.,.,.,.,.,4.71,1,.,.,.,.,46.7,1,24,0,164 -3,0,144.08,.,.,.,.,.,3.82,1,.,.,.,.,46.7,1,24,0,164 -4,1,0,0,600,.,.,1,.,.,.,.,.,.,50.8,1,25,1,165 -4,1,24,0,600,.,.,1,.,.,.,.,.,.,50.8,1,25,1,165 -4,1,48,0,600,.,.,1,.,.,.,.,.,.,50.8,1,25,1,165 -4,1,72,0,600,.,.,1,.,.,.,.,.,.,50.8,1,25,1,165 -4,1,96,0,600,.,.,1,.,.,.,.,.,.,50.8,1,25,1,165 -4,0,120,.,.,.,.,.,2.1,1,.,.,.,.,50.8,1,25,1,165 -4,1,120,0,600,.,.,1,.,.,.,.,.,.,50.8,1,25,1,165 -4,0,121,.,.,.,.,.,3.05,1,.,.,.,.,50.8,1,25,1,165 -4,0,122.02,.,.,.,.,.,5.21,1,.,.,.,.,50.8,1,25,1,165 -4,0,126,.,.,.,.,.,5.09,1,.,.,.,.,50.8,1,25,1,165 -4,0,129.03,.,.,.,.,.,4.24,1,.,.,.,.,50.8,1,25,1,165 -4,0,132,.,.,.,.,.,3.69,1,.,.,.,.,50.8,1,25,1,165 -4,0,144.02,.,.,.,.,.,1.96,1,.,.,.,.,50.8,1,25,1,165 -5,1,0,0,600,.,.,1,.,.,.,.,.,.,65.8,1,22,1,181 -5,1,24,0,600,.,.,1,.,.,.,.,.,.,65.8,1,22,1,181 -5,1,48,0,600,.,.,1,.,.,.,.,.,.,65.8,1,22,1,181 -5,1,72,0,600,.,.,1,.,.,.,.,.,.,65.8,1,22,1,181 -5,1,96,0,600,.,.,1,.,.,.,.,.,.,65.8,1,22,1,181 -5,0,120,.,.,.,.,.,2.93,1,.,.,.,.,65.8,1,22,1,181 -5,1,120,0,600,.,.,1,.,.,.,.,.,.,65.8,1,22,1,181 -5,0,121,.,.,.,.,.,2.64,1,.,.,.,.,65.8,1,22,1,181 -5,0,122,.,.,.,.,.,4.8,1,.,.,.,.,65.8,1,22,1,181 -5,0,126,.,.,.,.,.,3.7,1,.,.,.,.,65.8,1,22,1,181 -5,0,129.02,.,.,.,.,.,4.13,1,.,.,.,.,65.8,1,22,1,181 -5,0,132,.,.,.,.,.,2.81,1,.,.,.,.,65.8,1,22,1,181 -5,0,144,.,.,.,.,.,2.21,1,.,.,.,.,65.8,1,22,1,181 -6,1,0,0,600,.,.,1,.,.,.,.,.,.,65,1,23,1,177 -6,1,24,0,600,.,.,1,.,.,.,.,.,.,65,1,23,1,177 -6,1,48,0,600,.,.,1,.,.,.,.,.,.,65,1,23,1,177 -6,1,72,0,600,.,.,1,.,.,.,.,.,.,65,1,23,1,177 -6,1,96,0,600,.,.,1,.,.,.,.,.,.,65,1,23,1,177 -6,0,120,.,.,.,.,.,6.92,1,.,.,.,.,65,1,23,1,177 -6,1,120,0,600,.,.,1,.,.,.,.,.,.,65,1,23,1,177 -6,0,121,.,.,.,.,.,6.89,1,.,.,.,.,65,1,23,1,177 -6,0,121.98,.,.,.,.,.,6.64,1,.,.,.,.,65,1,23,1,177 -6,0,126,.,.,.,.,.,13.72,1,.,.,.,.,65,1,23,1,177 -6,0,129,.,.,.,.,.,12.69,1,.,.,.,.,65,1,23,1,177 -6,0,131.98,.,.,.,.,.,10.58,1,.,.,.,.,65,1,23,1,177 -6,0,144.98,.,.,.,.,.,6.62,1,.,.,.,.,65,1,23,1,177 -7,1,0,0,600,.,.,1,.,.,.,.,.,.,51.7,1,27,0,161 -7,1,24,0,600,.,.,1,.,.,.,.,.,.,51.7,1,27,0,161 -7,1,48,0,600,.,.,1,.,.,.,.,.,.,51.7,1,27,0,161 -7,1,72,0,600,.,.,1,.,.,.,.,.,.,51.7,1,27,0,161 -7,1,96,0,600,.,.,1,.,.,.,.,.,.,51.7,1,27,0,161 -7,0,120,.,.,.,.,.,5.41,1,.,.,.,.,51.7,1,27,0,161 -7,1,120,0,600,.,.,1,.,.,.,.,.,.,51.7,1,27,0,161 -7,0,121.03,.,.,.,.,.,4.46,1,.,.,.,.,51.7,1,27,0,161 -7,0,122.03,.,.,.,.,.,4.54,1,.,.,.,.,51.7,1,27,0,161 -7,0,126.02,.,.,.,.,.,12.19,1,.,.,.,.,51.7,1,27,0,161 -7,0,129.08,.,.,.,.,.,12.1,1,.,.,.,.,51.7,1,27,0,161 -7,0,132.03,.,.,.,.,.,8.61,1,.,.,.,.,51.7,1,27,0,161 -7,0,144.03,.,.,.,.,.,6.37,1,.,.,.,.,51.7,1,27,0,161 -8,1,0,0,600,.,.,1,.,.,.,.,.,.,51.2,1,22,1,163 -8,1,24,0,600,.,.,1,.,.,.,.,.,.,51.2,1,22,1,163 -8,1,48,0,600,.,.,1,.,.,.,.,.,.,51.2,1,22,1,163 -8,1,72,0,600,.,.,1,.,.,.,.,.,.,51.2,1,22,1,163 -8,1,96,0,600,.,.,1,.,.,.,.,.,.,51.2,1,22,1,163 -8,0,120,.,.,.,.,.,6.19,1,.,.,.,.,51.2,1,22,1,163 -8,1,120,0,600,.,.,1,.,.,.,.,.,.,51.2,1,22,1,163 -8,0,121.03,.,.,.,.,.,6.33,1,.,.,.,.,51.2,1,22,1,163 -8,0,122,.,.,.,.,.,6.24,1,.,.,.,.,51.2,1,22,1,163 -8,0,125.98,.,.,.,.,.,13.03,1,.,.,.,.,51.2,1,22,1,163 -8,0,128.98,.,.,.,.,.,11.86,1,.,.,.,.,51.2,1,22,1,163 -8,0,132,.,.,.,.,.,11.45,1,.,.,.,.,51.2,1,22,1,163 -8,0,143.98,.,.,.,.,.,7.83,1,.,.,.,.,51.2,1,22,1,163 -9,1,0,0,600,.,.,1,.,.,.,.,.,.,55,1,23,1,174 -9,1,24,0,600,.,.,1,.,.,.,.,.,.,55,1,23,1,174 -9,1,48,0,600,.,.,1,.,.,.,.,.,.,55,1,23,1,174 -9,1,72,0,600,.,.,1,.,.,.,.,.,.,55,1,23,1,174 -9,1,96,0,600,.,.,1,.,.,.,.,.,.,55,1,23,1,174 -9,0,120,.,.,.,.,.,2.85,1,.,.,.,.,55,1,23,1,174 -9,1,120,0,600,.,.,1,.,.,.,.,.,.,55,1,23,1,174 -9,0,120.97,.,.,.,.,.,3.7,1,.,.,.,.,55,1,23,1,174 -9,0,122,.,.,.,.,.,6.65,1,.,.,.,.,55,1,23,1,174 -9,0,125.98,.,.,.,.,.,6.81,1,.,.,.,.,55,1,23,1,174 -9,0,128.98,.,.,.,.,.,6.51,1,.,.,.,.,55,1,23,1,174 -9,0,132,.,.,.,.,.,7.48,1,.,.,.,.,55,1,23,1,174 -9,0,143.98,.,.,.,.,.,4.51,1,.,.,.,.,55,1,23,1,174 -10,1,0,0,600,.,.,1,.,.,.,.,.,.,52.1,1,32,1,163 -10,1,24,0,600,.,.,1,.,.,.,.,.,.,52.1,1,32,1,163 -10,1,48,0,600,.,.,1,.,.,.,.,.,.,52.1,1,32,1,163 -10,1,72,0,600,.,.,1,.,.,.,.,.,.,52.1,1,32,1,163 -10,1,96,0,600,.,.,1,.,.,.,.,.,.,52.1,1,32,1,163 -10,0,120,.,.,.,.,.,2.93,1,.,.,.,.,52.1,1,32,1,163 -10,1,120,0,600,.,.,1,.,.,.,.,.,.,52.1,1,32,1,163 -10,0,121,.,.,.,.,.,4.36,1,.,.,.,.,52.1,1,32,1,163 -10,0,122.02,.,.,.,.,.,7.79,1,.,.,.,.,52.1,1,32,1,163 -10,0,126,.,.,.,.,.,11.02,1,.,.,.,.,52.1,1,32,1,163 -10,0,129,.,.,.,.,.,8.86,1,.,.,.,.,52.1,1,32,1,163 -10,0,131.97,.,.,.,.,.,6.09,1,.,.,.,.,52.1,1,32,1,163 -10,0,144,.,.,.,.,.,4.15,1,.,.,.,.,52.1,1,32,1,163 -11,1,0,0,600,.,.,1,.,.,.,.,.,.,56.5,1,34,1,165 -11,1,24,0,600,.,.,1,.,.,.,.,.,.,56.5,1,34,1,165 -11,1,48,0,600,.,.,1,.,.,.,.,.,.,56.5,1,34,1,165 -11,1,72,0,600,.,.,1,.,.,.,.,.,.,56.5,1,34,1,165 -11,1,96,0,600,.,.,1,.,.,.,.,.,.,56.5,1,34,1,165 -11,0,120,.,.,.,.,.,2.09,1,.,.,.,.,56.5,1,34,1,165 -11,1,120,0,600,.,.,1,.,.,.,.,.,.,56.5,1,34,1,165 -11,0,121.03,.,.,.,.,.,2.68,1,.,.,.,.,56.5,1,34,1,165 -11,0,122,.,.,.,.,.,4.71,1,.,.,.,.,56.5,1,34,1,165 -11,0,125.98,.,.,.,.,.,7.71,1,.,.,.,.,56.5,1,34,1,165 -11,0,129,.,.,.,.,.,6.31,1,.,.,.,.,56.5,1,34,1,165 -11,0,132,.,.,.,.,.,5.82,1,.,.,.,.,56.5,1,34,1,165 -11,0,144.13,.,.,.,.,.,2.63,1,.,.,.,.,56.5,1,34,1,165 -12,1,0,0,600,.,.,1,.,.,.,.,.,.,47.9,1,54,0,160 -12,1,24,0,600,.,.,1,.,.,.,.,.,.,47.9,1,54,0,160 -12,1,48,0,600,.,.,1,.,.,.,.,.,.,47.9,1,54,0,160 -12,1,72,0,600,.,.,1,.,.,.,.,.,.,47.9,1,54,0,160 -12,1,96,0,600,.,.,1,.,.,.,.,.,.,47.9,1,54,0,160 -12,0,120,.,.,.,.,.,7.09,1,.,.,.,.,47.9,1,54,0,160 -12,1,120,0,600,.,.,1,.,.,.,.,.,.,47.9,1,54,0,160 -12,0,121.03,.,.,.,.,.,6.18,1,.,.,.,.,47.9,1,54,0,160 -12,0,122.13,.,.,.,.,.,8.66,1,.,.,.,.,47.9,1,54,0,160 -12,0,126,.,.,.,.,.,11.16,1,.,.,.,.,47.9,1,54,0,160 -12,0,129,.,.,.,.,.,9.51,1,.,.,.,.,47.9,1,54,0,160 -12,0,132,.,.,.,.,.,8.14,1,.,.,.,.,47.9,1,54,0,160 -12,0,144,.,.,.,.,.,7.89,1,.,.,.,.,47.9,1,54,0,160 -13,1,0,0,600,.,.,1,.,.,.,.,.,.,60.5,1,24,1,180 -13,1,24,0,600,.,.,1,.,.,.,.,.,.,60.5,1,24,1,180 -13,1,48,0,600,.,.,1,.,.,.,.,.,.,60.5,1,24,1,180 -13,1,72,0,600,.,.,1,.,.,.,.,.,.,60.5,1,24,1,180 -13,1,96,0,600,.,.,1,.,.,.,.,.,.,60.5,1,24,1,180 -13,0,120,.,.,.,.,.,6.62,1,.,.,.,.,60.5,1,24,1,180 -13,1,120,0,600,.,.,1,.,.,.,.,.,.,60.5,1,24,1,180 -13,0,121,.,.,.,.,.,3.18,1,.,.,.,.,60.5,1,24,1,180 -13,0,122,.,.,.,.,.,5.41,1,.,.,.,.,60.5,1,24,1,180 -13,0,126,.,.,.,.,.,10.18,1,.,.,.,.,60.5,1,24,1,180 -13,0,129.02,.,.,.,.,.,12.84,1,.,.,.,.,60.5,1,24,1,180 -13,0,132,.,.,.,.,.,12.35,1,.,.,.,.,60.5,1,24,1,180 -13,0,144,.,.,.,.,.,8.06,1,.,.,.,.,60.5,1,24,1,180 -14,1,0,0,600,.,.,1,.,.,.,.,.,.,59.2,1,26,1,174 -14,1,24,0,600,.,.,1,.,.,.,.,.,.,59.2,1,26,1,174 -14,1,48,0,600,.,.,1,.,.,.,.,.,.,59.2,1,26,1,174 -14,1,72,0,600,.,.,1,.,.,.,.,.,.,59.2,1,26,1,174 -14,1,96,0,600,.,.,1,.,.,.,.,.,.,59.2,1,26,1,174 -14,0,120,.,.,.,.,.,3.63,1,.,.,.,.,59.2,1,26,1,174 -14,1,120,0,600,.,.,1,.,.,.,.,.,.,59.2,1,26,1,174 -14,0,121,.,.,.,.,.,4.49,1,.,.,.,.,59.2,1,26,1,174 -14,0,122,.,.,.,.,.,5.5,1,.,.,.,.,59.2,1,26,1,174 -14,0,126,.,.,.,.,.,7.28,1,.,.,.,.,59.2,1,26,1,174 -14,0,129,.,.,.,.,.,5.27,1,.,.,.,.,59.2,1,26,1,174 -14,0,132,.,.,.,.,.,4.89,1,.,.,.,.,59.2,1,26,1,174 -14,0,144,.,.,.,.,.,2.68,1,.,.,.,.,59.2,1,26,1,174 -15,1,0,0,450,.,.,1,.,.,.,.,.,.,43,1,19,0,150 -15,1,24,0,450,.,.,1,.,.,.,.,.,.,43,1,19,0,150 -15,1,48,0,450,.,.,1,.,.,.,.,.,.,43,1,19,0,150 -15,1,72,0,450,.,.,1,.,.,.,.,.,.,43,1,19,0,150 -15,1,96,0,450,.,.,1,.,.,.,.,.,.,43,1,19,0,150 -15,0,120,.,.,.,.,.,5.53,1,.,.,.,.,43,1,19,0,150 -15,1,120,0,450,.,.,1,.,.,.,.,.,.,43,1,19,0,150 -15,0,121,.,.,.,.,.,4.81,1,.,.,.,.,43,1,19,0,150 -15,0,122,.,.,.,.,.,8.14,1,.,.,.,.,43,1,19,0,150 -15,0,126,.,.,.,.,.,9.96,1,.,.,.,.,43,1,19,0,150 -15,0,129,.,.,.,.,.,8.55,1,.,.,.,.,43,1,19,0,150 -15,0,132.05,.,.,.,.,.,7.54,1,.,.,.,.,43,1,19,0,150 -15,0,144.05,.,.,.,.,.,5.74,1,.,.,.,.,43,1,19,0,150 -16,1,0,0,600,.,.,1,.,.,.,.,.,.,64.4,1,25,1,173 -16,1,24,0,600,.,.,1,.,.,.,.,.,.,64.4,1,25,1,173 -16,1,48,0,600,.,.,1,.,.,.,.,.,.,64.4,1,25,1,173 -16,1,72,0,600,.,.,1,.,.,.,.,.,.,64.4,1,25,1,173 -16,1,96,0,600,.,.,1,.,.,.,.,.,.,64.4,1,25,1,173 -16,0,120,.,.,.,.,.,5.48,1,.,.,.,.,64.4,1,25,1,173 -16,1,120,0,600,.,.,1,.,.,.,.,.,.,64.4,1,25,1,173 -16,0,121,.,.,.,.,.,6.59,1,.,.,.,.,64.4,1,25,1,173 -16,0,122,.,.,.,.,.,8.91,1,.,.,.,.,64.4,1,25,1,173 -16,0,126,.,.,.,.,.,10.57,1,.,.,.,.,64.4,1,25,1,173 -16,0,129,.,.,.,.,.,9.52,1,.,.,.,.,64.4,1,25,1,173 -16,0,132,.,.,.,.,.,7.83,1,.,.,.,.,64.4,1,25,1,173 -16,0,143.97,.,.,.,.,.,4.96,1,.,.,.,.,64.4,1,25,1,173 -17,1,0,0,600,.,.,1,.,.,.,.,.,.,54.8,1,23,1,170 -17,1,24,0,600,.,.,1,.,.,.,.,.,.,54.8,1,23,1,170 -17,1,48,0,600,.,.,1,.,.,.,.,.,.,54.8,1,23,1,170 -17,1,72,0,600,.,.,1,.,.,.,.,.,.,54.8,1,23,1,170 -17,1,96,0,600,.,.,1,.,.,.,.,.,.,54.8,1,23,1,170 -17,0,120,.,.,.,.,.,2.11,1,.,.,.,.,54.8,1,23,1,170 -17,1,120,0,600,.,.,1,.,.,.,.,.,.,54.8,1,23,1,170 -17,0,121.02,.,.,.,.,.,1.86,1,.,.,.,.,54.8,1,23,1,170 -17,0,122.02,.,.,.,.,.,6.92,1,.,.,.,.,54.8,1,23,1,170 -17,0,126,.,.,.,.,.,9.11,1,.,.,.,.,54.8,1,23,1,170 -17,0,129,.,.,.,.,.,6.96,1,.,.,.,.,54.8,1,23,1,170 -17,0,132,.,.,.,.,.,5.64,1,.,.,.,.,54.8,1,23,1,170 -17,0,144.08,.,.,.,.,.,3.59,1,.,.,.,.,54.8,1,23,1,170 -18,1,0,0,450,.,.,1,.,.,.,.,.,.,44.3,1,20,0,164 -18,1,24,0,450,.,.,1,.,.,.,.,.,.,44.3,1,20,0,164 -18,1,48,0,450,.,.,1,.,.,.,.,.,.,44.3,1,20,0,164 -18,1,72,0,450,.,.,1,.,.,.,.,.,.,44.3,1,20,0,164 -18,1,96,0,450,.,.,1,.,.,.,.,.,.,44.3,1,20,0,164 -18,0,120,.,.,.,.,.,7.95,1,.,.,.,.,44.3,1,20,0,164 -18,1,120,0,450,.,.,1,.,.,.,.,.,.,44.3,1,20,0,164 -18,0,120.98,.,.,.,.,.,7.47,1,.,.,.,.,44.3,1,20,0,164 -18,0,121.98,.,.,.,.,.,8.67,1,.,.,.,.,44.3,1,20,0,164 -18,0,126,.,.,.,.,.,13.83,1,.,.,.,.,44.3,1,20,0,164 -18,0,129.17,.,.,.,.,.,14.01,1,.,.,.,.,44.3,1,20,0,164 -18,0,132.17,.,.,.,.,.,8.97,1,.,.,.,.,44.3,1,20,0,164 -18,0,143.97,.,.,.,.,.,8.4,1,.,.,.,.,44.3,1,20,0,164 -19,1,0,0,600,.,.,1,.,.,.,.,.,.,50,1,36,1,168 -19,1,24,0,600,.,.,1,.,.,.,.,.,.,50,1,36,1,168 -19,1,48,0,600,.,.,1,.,.,.,.,.,.,50,1,36,1,168 -19,1,72,0,600,.,.,1,.,.,.,.,.,.,50,1,36,1,168 -19,1,96,0,600,.,.,1,.,.,.,.,.,.,50,1,36,1,168 -19,0,120,.,.,.,.,.,5.42,1,.,.,.,.,50,1,36,1,168 -19,1,120,0,600,.,.,1,.,.,.,.,.,.,50,1,36,1,168 -19,0,121,.,.,.,.,.,7.08,1,.,.,.,.,50,1,36,1,168 -19,0,122,.,.,.,.,.,7.27,1,.,.,.,.,50,1,36,1,168 -19,0,125.98,.,.,.,.,.,20.07,1,.,.,.,.,50,1,36,1,168 -19,0,128.98,.,.,.,.,.,18.24,1,.,.,.,.,50,1,36,1,168 -19,0,132,.,.,.,.,.,15.36,1,.,.,.,.,50,1,36,1,168 -19,0,144,.,.,.,.,.,10.92,1,.,.,.,.,50,1,36,1,168 -20,1,0,0,600,.,.,1,.,.,.,.,.,.,59,1,31,1,170 -20,1,24,0,600,.,.,1,.,.,.,.,.,.,59,1,31,1,170 -20,1,48,0,600,.,.,1,.,.,.,.,.,.,59,1,31,1,170 -20,1,72,0,600,.,.,1,.,.,.,.,.,.,59,1,31,1,170 -20,1,96,0,600,.,.,1,.,.,.,.,.,.,59,1,31,1,170 -20,0,120,.,.,.,.,.,4.71,1,.,.,.,.,59,1,31,1,170 -20,1,120,0,600,.,.,1,.,.,.,.,.,.,59,1,31,1,170 -20,0,120.77,.,.,.,.,.,4.5,1,.,.,.,.,59,1,31,1,170 -20,0,121.75,.,.,.,.,.,3.35,1,.,.,.,.,59,1,31,1,170 -20,0,125.67,.,.,.,.,.,12.35,1,.,.,.,.,59,1,31,1,170 -20,0,128.67,.,.,.,.,.,11.56,1,.,.,.,.,59,1,31,1,170 -20,0,143.67,.,.,.,.,.,6.45,1,.,.,.,.,59,1,31,1,170 +1,1,0,0,600,.,.,0,.,.,.,.,.,.,46.7,1,21,1,160 +1,1,24,0,600,.,.,0,.,.,.,.,.,.,.,.,.,.,. +1,1,48,0,600,.,.,0,.,.,.,.,.,.,.,.,.,.,. +1,1,72,0,600,.,.,0,.,.,.,.,.,.,.,.,.,.,. +1,1,96,0,600,.,.,0,.,.,.,.,.,.,.,.,.,.,. +1,0,120,.,.,.,.,.,10.44,0,.,.,.,.,.,.,.,.,. +1,1,120,0,600,.,.,0,.,.,.,.,.,.,.,.,.,.,. +1,0,121,.,.,.,.,.,12.89,0,.,.,.,.,.,.,.,.,. +1,0,122,.,.,.,.,.,14.98,0,.,.,.,.,.,.,.,.,. +1,0,125.99,.,.,.,.,.,16.69,0,.,.,.,.,.,.,.,.,. +1,0,129,.,.,.,.,.,20.15,0,.,.,.,.,.,.,.,.,. +1,0,132,.,.,.,.,.,14.97,0,.,.,.,.,.,.,.,.,. +1,0,143.98,.,.,.,.,.,12.57,0,.,.,.,.,.,.,.,.,. +2,1,0,0,600,.,.,0,.,.,.,.,.,.,66.5,1,30,1,174 +2,1,24,0,600,.,.,0,.,.,.,.,.,.,66.5,1,30,1,174 +2,1,48,0,600,.,.,0,.,.,.,.,.,.,66.5,1,30,1,174 +2,1,72,0,600,.,.,0,.,.,.,.,.,.,66.5,1,30,1,174 +2,1,96,0,600,.,.,0,.,.,.,.,.,.,66.5,1,30,1,174 +2,0,120,.,.,.,.,.,3.56,0,.,.,.,.,66.5,1,30,1,174 +2,1,120,0,600,.,.,0,.,.,.,.,.,.,66.5,1,30,1,174 +2,0,120.98,.,.,.,.,.,5.84,0,.,.,.,.,66.5,1,30,1,174 +2,0,121.98,.,.,.,.,.,6.54,0,.,.,.,.,66.5,1,30,1,174 +2,0,126,.,.,.,.,.,6.14,0,.,.,.,.,66.5,1,30,1,174 +2,0,129.02,.,.,.,.,.,6.56,0,.,.,.,.,66.5,1,30,1,174 +2,0,132.02,.,.,.,.,.,4.44,0,.,.,.,.,66.5,1,30,1,174 +2,0,144,.,.,.,.,.,3.76,0,.,.,.,.,66.5,1,30,1,174 +3,1,0,0,600,.,.,0,.,.,.,.,.,.,46.7,1,24,0,164 +3,1,24,0,600,.,.,0,.,.,.,.,.,.,46.7,1,24,0,164 +3,1,48,0,600,.,.,0,.,.,.,.,.,.,46.7,1,24,0,164 +3,1,72,0,600,.,.,0,.,.,.,.,.,.,46.7,1,24,0,164 +3,1,96,0,600,.,.,0,.,.,.,.,.,.,46.7,1,24,0,164 +3,1,120,0,600,.,.,0,.,.,.,.,.,.,46.7,1,24,0,164 +3,0,120.08,.,.,.,.,.,4.06,0,.,.,.,.,46.7,1,24,0,164 +3,0,121.07,.,.,.,.,.,3.24,0,.,.,.,.,46.7,1,24,0,164 +3,0,122.08,.,.,.,.,.,3.09,0,.,.,.,.,46.7,1,24,0,164 +3,0,126.08,.,.,.,.,.,7.98,0,.,.,.,.,46.7,1,24,0,164 +3,0,129.05,.,.,.,.,.,7.23,0,.,.,.,.,46.7,1,24,0,164 +3,0,132.1,.,.,.,.,.,4.71,0,.,.,.,.,46.7,1,24,0,164 +3,0,144.08,.,.,.,.,.,3.82,0,.,.,.,.,46.7,1,24,0,164 +4,1,0,0,600,.,.,0,.,.,.,.,.,.,50.8,1,25,1,165 +4,1,24,0,600,.,.,0,.,.,.,.,.,.,50.8,1,25,1,165 +4,1,48,0,600,.,.,0,.,.,.,.,.,.,50.8,1,25,1,165 +4,1,72,0,600,.,.,0,.,.,.,.,.,.,50.8,1,25,1,165 +4,1,96,0,600,.,.,0,.,.,.,.,.,.,50.8,1,25,1,165 +4,0,120,.,.,.,.,.,2.1,0,.,.,.,.,50.8,1,25,1,165 +4,1,120,0,600,.,.,0,.,.,.,.,.,.,50.8,1,25,1,165 +4,0,121,.,.,.,.,.,3.05,0,.,.,.,.,50.8,1,25,1,165 +4,0,122.02,.,.,.,.,.,5.21,0,.,.,.,.,50.8,1,25,1,165 +4,0,126,.,.,.,.,.,5.09,0,.,.,.,.,50.8,1,25,1,165 +4,0,129.03,.,.,.,.,.,4.24,0,.,.,.,.,50.8,1,25,1,165 +4,0,132,.,.,.,.,.,3.69,0,.,.,.,.,50.8,1,25,1,165 +4,0,144.02,.,.,.,.,.,1.96,0,.,.,.,.,50.8,1,25,1,165 +5,1,0,0,600,.,.,0,.,.,.,.,.,.,65.8,1,22,1,181 +5,1,24,0,600,.,.,0,.,.,.,.,.,.,65.8,1,22,1,181 +5,1,48,0,600,.,.,0,.,.,.,.,.,.,65.8,1,22,1,181 +5,1,72,0,600,.,.,0,.,.,.,.,.,.,65.8,1,22,1,181 +5,1,96,0,600,.,.,0,.,.,.,.,.,.,65.8,1,22,1,181 +5,0,120,.,.,.,.,.,2.93,0,.,.,.,.,65.8,1,22,1,181 +5,1,120,0,600,.,.,0,.,.,.,.,.,.,65.8,1,22,1,181 +5,0,121,.,.,.,.,.,2.64,0,.,.,.,.,65.8,1,22,1,181 +5,0,122,.,.,.,.,.,4.8,0,.,.,.,.,65.8,1,22,1,181 +5,0,126,.,.,.,.,.,3.7,0,.,.,.,.,65.8,1,22,1,181 +5,0,129.02,.,.,.,.,.,4.13,0,.,.,.,.,65.8,1,22,1,181 +5,0,132,.,.,.,.,.,2.81,0,.,.,.,.,65.8,1,22,1,181 +5,0,144,.,.,.,.,.,2.21,0,.,.,.,.,65.8,1,22,1,181 +6,1,0,0,600,.,.,0,.,.,.,.,.,.,65,1,23,1,177 +6,1,24,0,600,.,.,0,.,.,.,.,.,.,65,1,23,1,177 +6,1,48,0,600,.,.,0,.,.,.,.,.,.,65,1,23,1,177 +6,1,72,0,600,.,.,0,.,.,.,.,.,.,65,1,23,1,177 +6,1,96,0,600,.,.,0,.,.,.,.,.,.,65,1,23,1,177 +6,0,120,.,.,.,.,.,6.92,0,.,.,.,.,65,1,23,1,177 +6,1,120,0,600,.,.,0,.,.,.,.,.,.,65,1,23,1,177 +6,0,121,.,.,.,.,.,6.89,0,.,.,.,.,65,1,23,1,177 +6,0,121.98,.,.,.,.,.,6.64,0,.,.,.,.,65,1,23,1,177 +6,0,126,.,.,.,.,.,13.72,0,.,.,.,.,65,1,23,1,177 +6,0,129,.,.,.,.,.,12.69,0,.,.,.,.,65,1,23,1,177 +6,0,131.98,.,.,.,.,.,10.58,0,.,.,.,.,65,1,23,1,177 +6,0,144.98,.,.,.,.,.,6.62,0,.,.,.,.,65,1,23,1,177 +7,1,0,0,600,.,.,0,.,.,.,.,.,.,51.7,1,27,0,161 +7,1,24,0,600,.,.,0,.,.,.,.,.,.,51.7,1,27,0,161 +7,1,48,0,600,.,.,0,.,.,.,.,.,.,51.7,1,27,0,161 +7,1,72,0,600,.,.,0,.,.,.,.,.,.,51.7,1,27,0,161 +7,1,96,0,600,.,.,0,.,.,.,.,.,.,51.7,1,27,0,161 +7,0,120,.,.,.,.,.,5.41,0,.,.,.,.,51.7,1,27,0,161 +7,1,120,0,600,.,.,0,.,.,.,.,.,.,51.7,1,27,0,161 +7,0,121.03,.,.,.,.,.,4.46,0,.,.,.,.,51.7,1,27,0,161 +7,0,122.03,.,.,.,.,.,4.54,0,.,.,.,.,51.7,1,27,0,161 +7,0,126.02,.,.,.,.,.,12.19,0,.,.,.,.,51.7,1,27,0,161 +7,0,129.08,.,.,.,.,.,12.1,0,.,.,.,.,51.7,1,27,0,161 +7,0,132.03,.,.,.,.,.,8.61,0,.,.,.,.,51.7,1,27,0,161 +7,0,144.03,.,.,.,.,.,6.37,0,.,.,.,.,51.7,1,27,0,161 +8,1,0,0,600,.,.,0,.,.,.,.,.,.,51.2,1,22,1,163 +8,1,24,0,600,.,.,0,.,.,.,.,.,.,51.2,1,22,1,163 +8,1,48,0,600,.,.,0,.,.,.,.,.,.,51.2,1,22,1,163 +8,1,72,0,600,.,.,0,.,.,.,.,.,.,51.2,1,22,1,163 +8,1,96,0,600,.,.,0,.,.,.,.,.,.,51.2,1,22,1,163 +8,0,120,.,.,.,.,.,6.19,0,.,.,.,.,51.2,1,22,1,163 +8,1,120,0,600,.,.,0,.,.,.,.,.,.,51.2,1,22,1,163 +8,0,121.03,.,.,.,.,.,6.33,0,.,.,.,.,51.2,1,22,1,163 +8,0,122,.,.,.,.,.,6.24,0,.,.,.,.,51.2,1,22,1,163 +8,0,125.98,.,.,.,.,.,13.03,0,.,.,.,.,51.2,1,22,1,163 +8,0,128.98,.,.,.,.,.,11.86,0,.,.,.,.,51.2,1,22,1,163 +8,0,132,.,.,.,.,.,11.45,0,.,.,.,.,51.2,1,22,1,163 +8,0,143.98,.,.,.,.,.,7.83,0,.,.,.,.,51.2,1,22,1,163 +9,1,0,0,600,.,.,0,.,.,.,.,.,.,55,1,23,1,174 +9,1,24,0,600,.,.,0,.,.,.,.,.,.,55,1,23,1,174 +9,1,48,0,600,.,.,0,.,.,.,.,.,.,55,1,23,1,174 +9,1,72,0,600,.,.,0,.,.,.,.,.,.,55,1,23,1,174 +9,1,96,0,600,.,.,0,.,.,.,.,.,.,55,1,23,1,174 +9,0,120,.,.,.,.,.,2.85,0,.,.,.,.,55,1,23,1,174 +9,1,120,0,600,.,.,0,.,.,.,.,.,.,55,1,23,1,174 +9,0,120.97,.,.,.,.,.,3.7,0,.,.,.,.,55,1,23,1,174 +9,0,122,.,.,.,.,.,6.65,0,.,.,.,.,55,1,23,1,174 +9,0,125.98,.,.,.,.,.,6.81,0,.,.,.,.,55,1,23,1,174 +9,0,128.98,.,.,.,.,.,6.51,0,.,.,.,.,55,1,23,1,174 +9,0,132,.,.,.,.,.,7.48,0,.,.,.,.,55,1,23,1,174 +9,0,143.98,.,.,.,.,.,4.51,0,.,.,.,.,55,1,23,1,174 +10,1,0,0,600,.,.,0,.,.,.,.,.,.,52.1,1,32,1,163 +10,1,24,0,600,.,.,0,.,.,.,.,.,.,52.1,1,32,1,163 +10,1,48,0,600,.,.,0,.,.,.,.,.,.,52.1,1,32,1,163 +10,1,72,0,600,.,.,0,.,.,.,.,.,.,52.1,1,32,1,163 +10,1,96,0,600,.,.,0,.,.,.,.,.,.,52.1,1,32,1,163 +10,0,120,.,.,.,.,.,2.93,0,.,.,.,.,52.1,1,32,1,163 +10,1,120,0,600,.,.,0,.,.,.,.,.,.,52.1,1,32,1,163 +10,0,121,.,.,.,.,.,4.36,0,.,.,.,.,52.1,1,32,1,163 +10,0,122.02,.,.,.,.,.,7.79,0,.,.,.,.,52.1,1,32,1,163 +10,0,126,.,.,.,.,.,11.02,0,.,.,.,.,52.1,1,32,1,163 +10,0,129,.,.,.,.,.,8.86,0,.,.,.,.,52.1,1,32,1,163 +10,0,131.97,.,.,.,.,.,6.09,0,.,.,.,.,52.1,1,32,1,163 +10,0,144,.,.,.,.,.,4.15,0,.,.,.,.,52.1,1,32,1,163 +11,1,0,0,600,.,.,0,.,.,.,.,.,.,56.5,1,34,1,165 +11,1,24,0,600,.,.,0,.,.,.,.,.,.,56.5,1,34,1,165 +11,1,48,0,600,.,.,0,.,.,.,.,.,.,56.5,1,34,1,165 +11,1,72,0,600,.,.,0,.,.,.,.,.,.,56.5,1,34,1,165 +11,1,96,0,600,.,.,0,.,.,.,.,.,.,56.5,1,34,1,165 +11,0,120,.,.,.,.,.,2.09,0,.,.,.,.,56.5,1,34,1,165 +11,1,120,0,600,.,.,0,.,.,.,.,.,.,56.5,1,34,1,165 +11,0,121.03,.,.,.,.,.,2.68,0,.,.,.,.,56.5,1,34,1,165 +11,0,122,.,.,.,.,.,4.71,0,.,.,.,.,56.5,1,34,1,165 +11,0,125.98,.,.,.,.,.,7.71,0,.,.,.,.,56.5,1,34,1,165 +11,0,129,.,.,.,.,.,6.31,0,.,.,.,.,56.5,1,34,1,165 +11,0,132,.,.,.,.,.,5.82,0,.,.,.,.,56.5,1,34,1,165 +11,0,144.13,.,.,.,.,.,2.63,0,.,.,.,.,56.5,1,34,1,165 +12,1,0,0,600,.,.,0,.,.,.,.,.,.,47.9,1,54,0,160 +12,1,24,0,600,.,.,0,.,.,.,.,.,.,47.9,1,54,0,160 +12,1,48,0,600,.,.,0,.,.,.,.,.,.,47.9,1,54,0,160 +12,1,72,0,600,.,.,0,.,.,.,.,.,.,47.9,1,54,0,160 +12,1,96,0,600,.,.,0,.,.,.,.,.,.,47.9,1,54,0,160 +12,0,120,.,.,.,.,.,7.09,0,.,.,.,.,47.9,1,54,0,160 +12,1,120,0,600,.,.,0,.,.,.,.,.,.,47.9,1,54,0,160 +12,0,121.03,.,.,.,.,.,6.18,0,.,.,.,.,47.9,1,54,0,160 +12,0,122.13,.,.,.,.,.,8.66,0,.,.,.,.,47.9,1,54,0,160 +12,0,126,.,.,.,.,.,11.16,0,.,.,.,.,47.9,1,54,0,160 +12,0,129,.,.,.,.,.,9.51,0,.,.,.,.,47.9,1,54,0,160 +12,0,132,.,.,.,.,.,8.14,0,.,.,.,.,47.9,1,54,0,160 +12,0,144,.,.,.,.,.,7.89,0,.,.,.,.,47.9,1,54,0,160 +13,1,0,0,600,.,.,0,.,.,.,.,.,.,60.5,1,24,1,180 +13,1,24,0,600,.,.,0,.,.,.,.,.,.,60.5,1,24,1,180 +13,1,48,0,600,.,.,0,.,.,.,.,.,.,60.5,1,24,1,180 +13,1,72,0,600,.,.,0,.,.,.,.,.,.,60.5,1,24,1,180 +13,1,96,0,600,.,.,0,.,.,.,.,.,.,60.5,1,24,1,180 +13,0,120,.,.,.,.,.,6.62,0,.,.,.,.,60.5,1,24,1,180 +13,1,120,0,600,.,.,0,.,.,.,.,.,.,60.5,1,24,1,180 +13,0,121,.,.,.,.,.,3.18,0,.,.,.,.,60.5,1,24,1,180 +13,0,122,.,.,.,.,.,5.41,0,.,.,.,.,60.5,1,24,1,180 +13,0,126,.,.,.,.,.,10.18,0,.,.,.,.,60.5,1,24,1,180 +13,0,129.02,.,.,.,.,.,12.84,0,.,.,.,.,60.5,1,24,1,180 +13,0,132,.,.,.,.,.,12.35,0,.,.,.,.,60.5,1,24,1,180 +13,0,144,.,.,.,.,.,8.06,0,.,.,.,.,60.5,1,24,1,180 +14,1,0,0,600,.,.,0,.,.,.,.,.,.,59.2,1,26,1,174 +14,1,24,0,600,.,.,0,.,.,.,.,.,.,59.2,1,26,1,174 +14,1,48,0,600,.,.,0,.,.,.,.,.,.,59.2,1,26,1,174 +14,1,72,0,600,.,.,0,.,.,.,.,.,.,59.2,1,26,1,174 +14,1,96,0,600,.,.,0,.,.,.,.,.,.,59.2,1,26,1,174 +14,0,120,.,.,.,.,.,3.63,0,.,.,.,.,59.2,1,26,1,174 +14,1,120,0,600,.,.,0,.,.,.,.,.,.,59.2,1,26,1,174 +14,0,121,.,.,.,.,.,4.49,0,.,.,.,.,59.2,1,26,1,174 +14,0,122,.,.,.,.,.,5.5,0,.,.,.,.,59.2,1,26,1,174 +14,0,126,.,.,.,.,.,7.28,0,.,.,.,.,59.2,1,26,1,174 +14,0,129,.,.,.,.,.,5.27,0,.,.,.,.,59.2,1,26,1,174 +14,0,132,.,.,.,.,.,4.89,0,.,.,.,.,59.2,1,26,1,174 +14,0,144,.,.,.,.,.,2.68,0,.,.,.,.,59.2,1,26,1,174 +15,1,0,0,450,.,.,0,.,.,.,.,.,.,43,1,19,0,150 +15,1,24,0,450,.,.,0,.,.,.,.,.,.,43,1,19,0,150 +15,1,48,0,450,.,.,0,.,.,.,.,.,.,43,1,19,0,150 +15,1,72,0,450,.,.,0,.,.,.,.,.,.,43,1,19,0,150 +15,1,96,0,450,.,.,0,.,.,.,.,.,.,43,1,19,0,150 +15,0,120,.,.,.,.,.,5.53,0,.,.,.,.,43,1,19,0,150 +15,1,120,0,450,.,.,0,.,.,.,.,.,.,43,1,19,0,150 +15,0,121,.,.,.,.,.,4.81,0,.,.,.,.,43,1,19,0,150 +15,0,122,.,.,.,.,.,8.14,0,.,.,.,.,43,1,19,0,150 +15,0,126,.,.,.,.,.,9.96,0,.,.,.,.,43,1,19,0,150 +15,0,129,.,.,.,.,.,8.55,0,.,.,.,.,43,1,19,0,150 +15,0,132.05,.,.,.,.,.,7.54,0,.,.,.,.,43,1,19,0,150 +15,0,144.05,.,.,.,.,.,5.74,0,.,.,.,.,43,1,19,0,150 +16,1,0,0,600,.,.,0,.,.,.,.,.,.,64.4,1,25,1,173 +16,1,24,0,600,.,.,0,.,.,.,.,.,.,64.4,1,25,1,173 +16,1,48,0,600,.,.,0,.,.,.,.,.,.,64.4,1,25,1,173 +16,1,72,0,600,.,.,0,.,.,.,.,.,.,64.4,1,25,1,173 +16,1,96,0,600,.,.,0,.,.,.,.,.,.,64.4,1,25,1,173 +16,0,120,.,.,.,.,.,5.48,0,.,.,.,.,64.4,1,25,1,173 +16,1,120,0,600,.,.,0,.,.,.,.,.,.,64.4,1,25,1,173 +16,0,121,.,.,.,.,.,6.59,0,.,.,.,.,64.4,1,25,1,173 +16,0,122,.,.,.,.,.,8.91,0,.,.,.,.,64.4,1,25,1,173 +16,0,126,.,.,.,.,.,10.57,0,.,.,.,.,64.4,1,25,1,173 +16,0,129,.,.,.,.,.,9.52,0,.,.,.,.,64.4,1,25,1,173 +16,0,132,.,.,.,.,.,7.83,0,.,.,.,.,64.4,1,25,1,173 +16,0,143.97,.,.,.,.,.,4.96,0,.,.,.,.,64.4,1,25,1,173 +17,1,0,0,600,.,.,0,.,.,.,.,.,.,54.8,1,23,1,170 +17,1,24,0,600,.,.,0,.,.,.,.,.,.,54.8,1,23,1,170 +17,1,48,0,600,.,.,0,.,.,.,.,.,.,54.8,1,23,1,170 +17,1,72,0,600,.,.,0,.,.,.,.,.,.,54.8,1,23,1,170 +17,1,96,0,600,.,.,0,.,.,.,.,.,.,54.8,1,23,1,170 +17,0,120,.,.,.,.,.,2.11,0,.,.,.,.,54.8,1,23,1,170 +17,1,120,0,600,.,.,0,.,.,.,.,.,.,54.8,1,23,1,170 +17,0,121.02,.,.,.,.,.,1.86,0,.,.,.,.,54.8,1,23,1,170 +17,0,122.02,.,.,.,.,.,6.92,0,.,.,.,.,54.8,1,23,1,170 +17,0,126,.,.,.,.,.,9.11,0,.,.,.,.,54.8,1,23,1,170 +17,0,129,.,.,.,.,.,6.96,0,.,.,.,.,54.8,1,23,1,170 +17,0,132,.,.,.,.,.,5.64,0,.,.,.,.,54.8,1,23,1,170 +17,0,144.08,.,.,.,.,.,3.59,0,.,.,.,.,54.8,1,23,1,170 +18,1,0,0,450,.,.,0,.,.,.,.,.,.,44.3,1,20,0,164 +18,1,24,0,450,.,.,0,.,.,.,.,.,.,44.3,1,20,0,164 +18,1,48,0,450,.,.,0,.,.,.,.,.,.,44.3,1,20,0,164 +18,1,72,0,450,.,.,0,.,.,.,.,.,.,44.3,1,20,0,164 +18,1,96,0,450,.,.,0,.,.,.,.,.,.,44.3,1,20,0,164 +18,0,120,.,.,.,.,.,7.95,0,.,.,.,.,44.3,1,20,0,164 +18,1,120,0,450,.,.,0,.,.,.,.,.,.,44.3,1,20,0,164 +18,0,120.98,.,.,.,.,.,7.47,0,.,.,.,.,44.3,1,20,0,164 +18,0,121.98,.,.,.,.,.,8.67,0,.,.,.,.,44.3,1,20,0,164 +18,0,126,.,.,.,.,.,13.83,0,.,.,.,.,44.3,1,20,0,164 +18,0,129.17,.,.,.,.,.,14.01,0,.,.,.,.,44.3,1,20,0,164 +18,0,132.17,.,.,.,.,.,8.97,0,.,.,.,.,44.3,1,20,0,164 +18,0,143.97,.,.,.,.,.,8.4,0,.,.,.,.,44.3,1,20,0,164 +19,1,0,0,600,.,.,0,.,.,.,.,.,.,50,1,36,1,168 +19,1,24,0,600,.,.,0,.,.,.,.,.,.,50,1,36,1,168 +19,1,48,0,600,.,.,0,.,.,.,.,.,.,50,1,36,1,168 +19,1,72,0,600,.,.,0,.,.,.,.,.,.,50,1,36,1,168 +19,1,96,0,600,.,.,0,.,.,.,.,.,.,50,1,36,1,168 +19,0,120,.,.,.,.,.,5.42,0,.,.,.,.,50,1,36,1,168 +19,1,120,0,600,.,.,0,.,.,.,.,.,.,50,1,36,1,168 +19,0,121,.,.,.,.,.,7.08,0,.,.,.,.,50,1,36,1,168 +19,0,122,.,.,.,.,.,7.27,0,.,.,.,.,50,1,36,1,168 +19,0,125.98,.,.,.,.,.,20.07,0,.,.,.,.,50,1,36,1,168 +19,0,128.98,.,.,.,.,.,18.24,0,.,.,.,.,50,1,36,1,168 +19,0,132,.,.,.,.,.,15.36,0,.,.,.,.,50,1,36,1,168 +19,0,144,.,.,.,.,.,10.92,0,.,.,.,.,50,1,36,1,168 +20,1,0,0,600,.,.,0,.,.,.,.,.,.,59,1,31,1,170 +20,1,24,0,600,.,.,0,.,.,.,.,.,.,59,1,31,1,170 +20,1,48,0,600,.,.,0,.,.,.,.,.,.,59,1,31,1,170 +20,1,72,0,600,.,.,0,.,.,.,.,.,.,59,1,31,1,170 +20,1,96,0,600,.,.,0,.,.,.,.,.,.,59,1,31,1,170 +20,0,120,.,.,.,.,.,4.71,0,.,.,.,.,59,1,31,1,170 +20,1,120,0,600,.,.,0,.,.,.,.,.,.,59,1,31,1,170 +20,0,120.77,.,.,.,.,.,4.5,0,.,.,.,.,59,1,31,1,170 +20,0,121.75,.,.,.,.,.,3.35,0,.,.,.,.,59,1,31,1,170 +20,0,125.67,.,.,.,.,.,12.35,0,.,.,.,.,59,1,31,1,170 +20,0,128.67,.,.,.,.,.,11.56,0,.,.,.,.,59,1,31,1,170 +20,0,143.67,.,.,.,.,.,6.45,0,.,.,.,.,59,1,31,1,170 diff --git a/examples/vanco.rs b/examples/vanco.rs index f76b60c67..469d20bca 100644 --- a/examples/vanco.rs +++ b/examples/vanco.rs @@ -1,16 +1,22 @@ use pmcore::prelude::*; -fn main() { +fn main() -> Result<()> { let eq = ode! { - diffeq: |x, p, _t, dx, b, _rateiv, _cov| { - fetch_params!(p, ke, kcp, kpc); - dx[0] = -ke * x[0] - kcp * x[0] + kpc * x[1] + b[0]; - dx[1] = -kpc * x[1] + kcp * x[0]; + name: "vanco_two_compartment", + params: [ke, kcp, kpc], + states: [central, peripheral], + outputs: [peripheral_amount], + routes: [ + bolus(dose) -> central, + ], + diffeq: |x, _t, dx| { + dx[central] = -ke * x[central] - kcp * x[central] + kpc * x[peripheral]; + dx[peripheral] = -kpc * x[peripheral] + kcp * x[central]; }, - init: |_p, _t, _cov, x| { - x[0] = 500.0; + init: |_t, x| { + x[central] = 500.0; }, - out: |x, _p, _t, _cov, y| { - y[0] = x[1]; + out: |x, _t, y| { + y[peripheral_amount] = x[peripheral]; }, }; // same eq but analytical @@ -35,8 +41,7 @@ fn main() { .build(); let op = eq - .simulate_subject(&subject, &vec![0.3, 0.2, 0.5], None) - .unwrap() + .simulate_subject_dense(&subject, &[0.3, 0.2, 0.5], None)? .0; let times = op.flat_times(); @@ -45,4 +50,6 @@ fn main() { for (t, p) in times.iter().zip(pred.iter()) { println!("{}, {}", t, p); } + + Ok(()) } diff --git a/examples/vanco_sde/main.rs b/examples/vanco_sde/main.rs index a0837f97f..36e77449a 100644 --- a/examples/vanco_sde/main.rs +++ b/examples/vanco_sde/main.rs @@ -1,33 +1,33 @@ use pmcore::prelude::*; -fn main() { - let sde = equation::SDE::new( - |x, p, _t, dx, _rateiv, _cov| { - fetch_params!(p, ka, ke0, kcp, kpc, _vol); - dx[3] = -x[3] + ke0; - let ke = x[3]; - // dbg!(x[3], ke0, dx[3]); - dx[0] = -ka * x[0]; - dx[1] = ka * x[0] - (ke + kcp) * x[1] + kpc * x[2]; - dx[2] = kcp * x[1] - kpc * x[2]; +fn main() -> Result<()> { + let sde = sde! { + name: "vanco_sde", + params: [ka, ke0, kcp, kpc, vol, ske], + covariates: [wt], + states: [depot, central, peripheral, ke_latent], + outputs: [outeq_1], + particles: 100, + routes: [ + bolus(input_1) -> depot, + ], + drift: |x, _t, dx| { + dx[ke_latent] = -x[ke_latent] + ke0; + let ke = x[ke_latent]; + dx[depot] = -ka * x[depot]; + dx[central] = ka * x[depot] - (ke + kcp) * x[central] + kpc * x[peripheral]; + dx[peripheral] = kcp * x[central] - kpc * x[peripheral]; }, - |p, d| { - fetch_params!(p, _ka, _ke0, _kcp, _kpc, _vol, ske); - d[3] = ske; + diffusion: |sigma| { + sigma[ke_latent] = ske; }, - |_p, _t, _cov| lag! {}, - |_p, _t, _cov| fa! {}, - |p, _t, _cov, x| { - fetch_params!(p, _ka, ke0, _kcp, _kpc, _vol); - x[3] = ke0; + init: |_t, x| { + x[ke_latent] = ke0; }, - |x, p, t, cov, y| { - fetch_params!(p, _ka, _ke0, _kcp, _kpc, vol); - fetch_cov!(cov, t, wt); - y[0] = x[1] / (vol * wt); + out: |x, _t, y| { + y[outeq_1] = x[central] / (vol * wt); }, - 100, - ); + }; // let ode = equation::ODE::new( // |x, p, _t, dx, _rateiv, _cov| { @@ -47,35 +47,22 @@ fn main() { // (3, 1), // ); - let params = Parameters::new() + let data = data::read_pmetrics("examples/vanco_sde/vanco_clean.csv")?; + let parameters = ParameterSpace::bounded() .add("ka", 0.0001, 2.4) .add("ke0", 0.0001, 2.7) .add("kcp", 0.0001, 2.4) .add("kpc", 0.0001, 2.4) .add("vol", 0.2, 12.0) .add("ske", 0.0001, 0.2); + let prior = Theta::sobol_default(¶meters)?; + let error_models = AssayErrorModels::new().add( + "outeq_1", + AssayErrorModel::additive(ErrorPoly::new(0.00119, 0.20, 0.0, 0.0), 0.0), + )?; + EstimationProblem::nonparametric(sde, data, prior, error_models)? + .fit_with(NonParametricAlgorithm::npag())?; - let ems = AssayErrorModels::new() - .add( - 0, - AssayErrorModel::additive(ErrorPoly::new(0.00119, 0.20, 0.0, 0.0), 0.0), - ) - .unwrap(); - - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems) - .build(); - - settings.set_cycles(usize::MAX); - settings.set_output_path("examples/vanco_sde/output"); - settings.set_prior(Prior::sobol(100, 347)); - settings.initialize_logs().unwrap(); - let data = data::read_pmetrics("examples/vanco_sde/vanco_clean.csv").unwrap(); - - let mut algorithm = dispatch_algorithm(settings, sde, data).unwrap(); - algorithm.initialize().unwrap(); - let mut result = algorithm.fit().unwrap(); - result.write_outputs().unwrap(); + Ok(()) } + diff --git a/examples/vanco_sde/vanco_sde.zip b/examples/vanco_sde/vanco_sde.zip deleted file mode 100644 index f8a7bdde4..000000000 Binary files a/examples/vanco_sde/vanco_sde.zip and /dev/null differ diff --git a/src/algorithms/mod.rs b/src/algorithms/mod.rs index 0ce1dea8a..2c7c6c9e0 100644 --- a/src/algorithms/mod.rs +++ b/src/algorithms/mod.rs @@ -1,219 +1,209 @@ use std::fs; use std::path::Path; -use crate::routines::output::NPResult; -use crate::routines::settings::Settings; -use crate::structs::psi::Psi; -use crate::structs::theta::Theta; +use crate::estimation::nonparametric::{NonParametricResult, Psi, Theta}; +use crate::estimation::{EstimationProblem, Framework}; +use crate::results::FitResult; + use anyhow::Context; use anyhow::Result; use ndarray::parallel::prelude::{IntoParallelIterator, ParallelIterator}; -use npag::*; -use npod::NPOD; use pharmsol::prelude::{data::Data, simulator::Equation}; + use pharmsol::{Predictions, Subject}; -use postprob::POSTPROB; use serde::{Deserialize, Serialize}; -pub mod npag; -pub mod npod; -pub mod postprob; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] -pub enum Algorithm { - NPAG, - NPOD, - POSTPROB, +/// Defines an algorithm that can fit an [`EstimationProblem`] to produce a result. +/// +/// Implementors are the lightweight, user-facing configuration structs (e.g. +/// `NpagConfig`). The heavy, mutable execution state used while fitting is an +/// internal implementation detail. +pub trait Algorithm { + /// The specific result struct (e.g. `NonParametricResult`). + type Output: FitResult; + + /// Consumes the configuration and the problem, runs the optimization to + /// completion, and returns the strictly-typed result. + fn fit(self, problem: EstimationProblem) -> Result; } -pub trait Algorithms: Sync + Send + 'static { - fn new(config: Settings, equation: E, data: Data) -> Result> +// Module organization for algorithm types +pub mod nonparametric; +pub mod parametric; + +impl EstimationProblem { + /// Consumes the problem and an algorithm configuration, runs the fit to + /// completion, and returns the result. + pub fn fit_with(self, algorithm: A) -> Result where - Self: Sized; - fn validate_psi(&mut self) -> Result<()> { - // Count problematic values in psi - let mut nan_count = 0; - let mut inf_count = 0; + A: Algorithm, + { + algorithm.fit(self) + } +} +pub trait NonParametricRunner: Sync + Send + 'static { + /// Identify subjects whose total probability given the model is zero or + /// non-finite. + /// + /// Each row of [`Psi`] holds the likelihood of a subject across every + /// support point, so a subject's probability is the sum across its row. A + /// subject is flagged when that sum is zero or not finite, meaning the model + /// cannot explain the subject's data. When any subject is flagged, detailed + /// per-subject diagnostics are logged and an error is returned. + fn check_zero_probability_subjects(&self) -> Result<()> { let psi = self.psi().matrix(); - // First coerce all NaN and infinite in psi to 0.0 - for i in 0..psi.nrows() { - for j in 0..psi.ncols() { - let val = psi[(i, j)]; - if val.is_nan() { - nan_count += 1; - // *val = 0.0; - } else if val.is_infinite() { - inf_count += 1; - // *val = 0.0; - } - } - } - if nan_count + inf_count > 0 { + // Report non-finite entries; these propagate into the row sums below. + let nonfinite = psi + .row_iter() + .flat_map(|row| row.iter().copied()) + .filter(|v| !v.is_finite()) + .count(); + if nonfinite > 0 { tracing::warn!( - "Psi matrix contains {} NaN, {} Infinite values of {} total values", - nan_count, - inf_count, - psi.ncols() * psi.nrows() + "Psi matrix contains {} non-finite value(s) of {} total", + nonfinite, + psi.nrows() * psi.ncols() ); } - let (row, col) = (psi.nrows(), psi.ncols()); - let plam: Vec = (0..row) - .map(|i| (0..col).map(|j| psi[(i, j)]).sum::()) + // A subject's probability is the sum across its row. + let subjects = self.data().subjects(); + let flagged: Vec = (0..psi.nrows()) + .filter(|&i| { + let probability: f64 = (0..psi.ncols()).map(|j| psi[(i, j)]).sum(); + !probability.is_finite() || probability == 0.0 + }) .collect(); - let w: Vec = plam.iter().map(|&x| 1.0 / x).collect(); - // Get the index of each element in `w` that is NaN or infinite - let indices: Vec = w - .iter() + if flagged.is_empty() { + return Ok(()); + } + + tracing::error!( + "{}/{} subjects have zero probability given the model", + flagged.len(), + psi.nrows() + ); + + for &i in &flagged { + self.log_zero_probability_subject(subjects[i]); + } + + let ids: Vec<&String> = flagged.iter().map(|&i| subjects[i].id()).collect(); + Err(anyhow::anyhow!( + "The probability of {}/{} subjects is zero given the model. Affected subjects: {:?}", + flagged.len(), + psi.nrows(), + ids + )) + } + + /// Log detailed likelihood diagnostics for a single subject whose + /// probability given the model is zero or non-finite. + fn log_zero_probability_subject(&self, subject: &Subject) { + tracing::debug!("Subject with zero probability: {}", subject.id()); + + let error_model = self.error_models().clone(); + + // Simulate every support point for this subject in parallel. + let mut results: Vec<_> = self + .theta() + .matrix() + .row_iter() .enumerate() - .filter(|(_, x)| x.is_nan() || x.is_infinite()) - .map(|(i, _)| i) - .collect::>(); - - if !indices.is_empty() { - let subject: Vec<&Subject> = self.data().subjects(); - let zero_probability_subjects: Vec<&String> = - indices.iter().map(|&i| subject[i].id()).collect(); - - tracing::error!( - "{}/{} subjects have zero probability given the model", - indices.len(), - psi.nrows() - ); + .collect::>() + .into_par_iter() + .map(|(i, spp)| { + let support_point: Vec = spp.iter().copied().collect(); + let (pred, ll) = self + .equation() + .simulate_subject_dense(subject, &support_point, Some(&error_model)) + .unwrap(); //TODO: Handle error + (i, support_point, pred.get_predictions(), ll) + }) + .collect(); - // For each problematic subject - for index in &indices { - tracing::debug!("Subject with zero probability: {}", subject[*index].id()); - - let error_model = self.settings().errormodels().clone(); - - // Simulate all support points in parallel - let spp_results: Vec<_> = self - .theta() - .matrix() - .row_iter() - .enumerate() - .collect::>() - .into_par_iter() - .map(|(i, spp)| { - let support_point: Vec = spp.iter().copied().collect(); - let (pred, ll) = self - .equation() - .simulate_subject(subject[*index], &support_point, Some(&error_model)) - .unwrap(); //TODO: Handle error - (i, support_point, pred.get_predictions(), ll) - }) - .collect(); - - // Count problematic likelihoods for this subject - let mut nan_ll = 0; - let mut inf_pos_ll = 0; - let mut inf_neg_ll = 0; - let mut zero_ll = 0; - let mut valid_ll = 0; - - for (_, _, _, ll) in &spp_results { - match ll { - Some(ll_val) if ll_val.is_nan() => nan_ll += 1, - Some(ll_val) if ll_val.is_infinite() && ll_val.is_sign_positive() => { - inf_pos_ll += 1 - } - Some(ll_val) if ll_val.is_infinite() && ll_val.is_sign_negative() => { - inf_neg_ll += 1 - } - Some(ll_val) if *ll_val == 0.0 => zero_ll += 1, - Some(_) => valid_ll += 1, - None => nan_ll += 1, - } - } - - tracing::debug!( - "\tLikelihood analysis for subject {} ({} support points):", - subject[*index].id(), - spp_results.len() - ); - tracing::debug!( - "\tNaN likelihoods: {} ({:.1}%)", - nan_ll, - 100.0 * nan_ll as f64 / spp_results.len() as f64 - ); - tracing::debug!( - "\t+Inf likelihoods: {} ({:.1}%)", - inf_pos_ll, - 100.0 * inf_pos_ll as f64 / spp_results.len() as f64 - ); - tracing::debug!( - "\t-Inf likelihoods: {} ({:.1}%)", - inf_neg_ll, - 100.0 * inf_neg_ll as f64 / spp_results.len() as f64 - ); - tracing::debug!( - "\tZero likelihoods: {} ({:.1}%)", - zero_ll, - 100.0 * zero_ll as f64 / spp_results.len() as f64 - ); - tracing::debug!( - "\tValid likelihoods: {} ({:.1}%)", - valid_ll, - 100.0 * valid_ll as f64 / spp_results.len() as f64 - ); - - // Sort and show top 10 most likely support points - let mut sorted_results = spp_results; - sorted_results.sort_by(|a, b| { - b.3.unwrap_or(f64::NEG_INFINITY) - .partial_cmp(&a.3.unwrap_or(f64::NEG_INFINITY)) - .unwrap_or(std::cmp::Ordering::Equal) - }); - let take = 3; - - tracing::debug!("Top {} most likely support points:", take); - for (i, support_point, preds, ll) in sorted_results.iter().take(take) { - tracing::debug!("\tSupport point #{}: {:?}", i, support_point); - tracing::debug!("\t\tLog-likelihood: {:?}", ll); - - let times = preds.iter().map(|x| x.time()).collect::>(); - let observations = preds - .iter() - .map(|x| x.observation()) - .collect::>>(); - let predictions = preds.iter().map(|x| x.prediction()).collect::>(); - let outeqs = preds.iter().map(|x| x.outeq()).collect::>(); - let states = preds - .iter() - .map(|x| x.state().to_vec()) - .collect::>>(); - - tracing::debug!("\t\tTimes: {:?}", times); - tracing::debug!("\t\tObservations: {:?}", observations); - tracing::debug!("\t\tPredictions: {:?}", predictions); - tracing::debug!("\t\tOuteqs: {:?}", outeqs); - tracing::debug!("\t\tStates: {:?}", states); - } - tracing::debug!("====================="); + // Summarise the distribution of likelihood values. + let mut nan = 0; + let mut pos_inf = 0; + let mut neg_inf = 0; + let mut zero = 0; + let mut valid = 0; + for (_, _, _, ll) in &results { + match ll { + Some(v) if v.is_nan() => nan += 1, + Some(v) if v.is_infinite() && v.is_sign_positive() => pos_inf += 1, + Some(v) if v.is_infinite() => neg_inf += 1, + Some(v) if *v == 0.0 => zero += 1, + Some(_) => valid += 1, + None => nan += 1, } - - return Err(anyhow::anyhow!( - "The probability of {}/{} subjects is zero given the model. Affected subjects: {:?}", - indices.len(), - self.psi().matrix().nrows(), - zero_probability_subjects - )); } - Ok(()) + let total = results.len(); + let pct = |n: usize| 100.0 * n as f64 / total as f64; + tracing::debug!( + "\tLikelihood analysis for subject {} ({} support points):", + subject.id(), + total + ); + tracing::debug!("\tNaN likelihoods: {} ({:.1}%)", nan, pct(nan)); + tracing::debug!("\t+Inf likelihoods: {} ({:.1}%)", pos_inf, pct(pos_inf)); + tracing::debug!("\t-Inf likelihoods: {} ({:.1}%)", neg_inf, pct(neg_inf)); + tracing::debug!("\tZero likelihoods: {} ({:.1}%)", zero, pct(zero)); + tracing::debug!("\tValid likelihoods: {} ({:.1}%)", valid, pct(valid)); + + // Show the most likely support points to aid debugging. + results.sort_by(|a, b| { + b.3.unwrap_or(f64::NEG_INFINITY) + .partial_cmp(&a.3.unwrap_or(f64::NEG_INFINITY)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + const TAKE: usize = 3; + tracing::debug!("Top {} most likely support points:", TAKE); + for (i, support_point, preds, ll) in results.iter().take(TAKE) { + tracing::debug!("\tSupport point #{}: {:?}", i, support_point); + tracing::debug!("\t\tLog-likelihood: {:?}", ll); + tracing::debug!( + "\t\tTimes: {:?}", + preds.iter().map(|x| x.time()).collect::>() + ); + tracing::debug!( + "\t\tObservations: {:?}", + preds + .iter() + .map(|x| x.observation()) + .collect::>>() + ); + tracing::debug!( + "\t\tPredictions: {:?}", + preds.iter().map(|x| x.prediction()).collect::>() + ); + tracing::debug!( + "\t\tOuteqs: {:?}", + preds.iter().map(|x| x.outeq()).collect::>() + ); + tracing::debug!( + "\t\tStates: {:?}", + preds + .iter() + .map(|x| x.state().to_vec()) + .collect::>>() + ); + } + tracing::debug!("====================="); } - fn settings(&self) -> &Settings; + fn error_models(&self) -> &pharmsol::prelude::data::AssayErrorModels; /// Get the equation used in the algorithm fn equation(&self) -> &E; /// Get the data used in the algorithm fn data(&self) -> &Data; - fn get_prior(&self) -> Theta; + /// Increment the cycle counter and return the new value fn increment_cycle(&mut self) -> usize; /// Get the current cycle number @@ -248,7 +238,7 @@ pub trait Algorithms: Sync + Send + 'static { fs::remove_file("stop").context("Unable to remove previous stop file")?; } self.set_status(Status::Continue); - self.set_theta(self.get_prior()); + Ok(()) } fn estimation(&mut self) -> Result<()>; @@ -259,7 +249,7 @@ pub trait Algorithms: Sync + Send + 'static { /// It is typically performed after the estimation step in each cycle of the algorithm. fn condensation(&mut self) -> Result<()>; - /// Performs optimizations on the current [ErrorModels] and updates [Psi] accordingly + /// Performs optimizations on the current [AssayErrorModels] and updates [Psi] accordingly /// /// This step refines the error model parameters to better fit the data, /// and subsequently updates the [Psi] matrix to reflect these changes. @@ -296,32 +286,14 @@ pub trait Algorithms: Sync + Send + 'static { /// This method runs the full fitting process, starting with initialization, /// followed by iterative cycles of estimation, condensation, optimization, and evaluation /// until the algorithm converges or meets a stopping criteria. - fn fit(&mut self) -> Result> { - self.initialize().unwrap(); - #[allow(clippy::while_let_loop)] - loop { - match self.next_cycle()? { - Status::Continue => continue, - Status::Stop(_) => break, - } - } - self.into_npresult() + fn fit(&mut self) -> Result> { + self.initialize()?; + while let Status::Continue = self.next_cycle()? {} + self.into_result() } #[allow(clippy::wrong_self_convention)] - fn into_npresult(&self) -> Result>; -} - -pub fn dispatch_algorithm( - settings: Settings, - equation: E, - data: Data, -) -> Result>> { - match settings.config().algorithm { - Algorithm::NPAG => Ok(NPAG::new(settings, equation, data)?), - Algorithm::NPOD => Ok(NPOD::new(settings, equation, data)?), - Algorithm::POSTPROB => Ok(POSTPROB::new(settings, equation, data)?), - } + fn into_result(&self) -> Result>; } /// Represents the status/result of the algorithm diff --git a/src/algorithms/nonparametric/mod.rs b/src/algorithms/nonparametric/mod.rs new file mode 100644 index 000000000..8e8eb809c --- /dev/null +++ b/src/algorithms/nonparametric/mod.rs @@ -0,0 +1,186 @@ +//! Non-parametric algorithm implementations +//! +//! This module contains the trait definition and implementations for non-parametric +//! population pharmacokinetic algorithms. These algorithms estimate the population +//! distribution as a discrete set of support points with associated probability weights. +//! +//! # Available Algorithms +//! +//! - [`NPAG`](npag): Non-Parametric Adaptive Grid +//! - [`NPOD`](npod): Non-Parametric Optimal Design +//! - [`POSTPROB`](postprob): Posterior probability reweighting +//! +//! # Algorithm Selection +//! +//! Use the [`NonParametricAlgorithm`] enum to select and configure an algorithm. Each +//! variant wraps its algorithm-specific configuration struct (e.g. [`NpagConfig`]). The +//! internal execution state used while fitting implements the [`NonParametricRunner`] +//! trait, which defines the common interface for initialization, estimation, condensation, +//! expansion, and convergence evaluation. + +// Algorithm implementations +pub mod npag; +pub mod npmap; +pub mod npod; + +// Re-export algorithm structs +pub use npag::NPAG; +pub use npmap::NPMAP; +pub use npod::NPOD; + +// Re-export per-algorithm configuration structs +pub use npag::NpagConfig; +pub use npmap::NpmapConfig; +pub use npod::NpodConfig; + +use crate::algorithms::{Algorithm, NonParametricRunner}; +use crate::estimation::nonparametric::NonParametricResult; +use crate::estimation::{EstimationProblem, NonParametric}; +use anyhow::Result; +use pharmsol::prelude::simulator::Equation; + +/// The non-parametric algorithms supported by PMcore. +/// +/// Use the constructors to select an algorithm with its default configuration: +/// +/// ```no_run +/// use pmcore::prelude::*; +/// +/// // Default NPAG configuration. +/// let algorithm = NonParametricAlgorithm::npag(); +/// ``` +/// +/// To customize an algorithm, build its configuration struct (which exposes only the +/// setters valid for that algorithm) and pass it directly to +/// [`fit_with`](crate::estimation::EstimationProblem::fit_with): +/// +/// ```no_run +/// use pmcore::prelude::*; +/// +/// // NPAG with a tighter convergence criterion and a cycle cap. +/// let config = NpagConfig::new().eps(0.1).max_cycles(500); +/// // `problem.fit_with(config)` accepts the config directly. +/// ``` +/// +/// Each configuration type ([`NpagConfig`], [`NpodConfig`], [`NpmapConfig`]) implements +/// [`Algorithm`] by delegating to the matching enum variant, so configs can be passed to +/// `fit_with` without converting them first. +#[derive(Debug, Clone)] +pub enum NonParametricAlgorithm { + /// Non-Parametric Adaptive Grid. + Npag(NpagConfig), + /// Non-Parametric Optimal Design. + Npod(NpodConfig), + /// Non-parametric maximum a posteriori (posterior probability reweighting). + Npmap(NpmapConfig), +} + +impl Default for NonParametricAlgorithm { + fn default() -> Self { + Self::npag() + } +} + +impl From for NonParametricAlgorithm { + fn from(config: NpagConfig) -> Self { + Self::Npag(config) + } +} + +impl From for NonParametricAlgorithm { + fn from(config: NpodConfig) -> Self { + Self::Npod(config) + } +} + +impl From for NonParametricAlgorithm { + fn from(config: NpmapConfig) -> Self { + Self::Npmap(config) + } +} + +impl NonParametricAlgorithm { + /// The Non-Parametric Adaptive Grid (NPAG) algorithm with its default configuration. + pub fn npag() -> Self { + Self::Npag(NpagConfig::default()) + } + + /// The Non-Parametric Optimal Design (NPOD) algorithm with its default configuration. + pub fn npod() -> Self { + Self::Npod(NpodConfig::default()) + } + + /// The non-parametric maximum a posteriori (NPMAP) algorithm with its default + /// configuration. + pub fn npmap() -> Self { + Self::Npmap(NpmapConfig::default()) + } +} + +impl Algorithm for NonParametricAlgorithm { + type Output = NonParametricResult; + + fn fit(self, problem: EstimationProblem) -> Result { + match self { + Self::Npag(config) => { + // `problem.prior` is the prior `Theta` (which also carries the parameter + // space) and `problem.error_models` is strictly `AssayErrorModels`. + let mut runner = NPAG::from_parts( + problem.model.equation, + problem.data, + problem.error_models, + problem.prior, + config, + )?; + NonParametricRunner::fit(&mut runner) + } + Self::Npod(config) => { + let mut runner = NPOD::from_parts( + problem.model.equation, + problem.data, + problem.error_models, + problem.prior, + config, + )?; + NonParametricRunner::fit(&mut runner) + } + Self::Npmap(config) => { + let mut runner = NPMAP::from_parts( + problem.model.equation, + problem.data, + problem.error_models, + problem.prior, + config, + )?; + NonParametricRunner::fit(&mut runner) + } + } + } +} + +// Each configuration struct delegates to its matching `NonParametricAlgorithm` variant so it +// can be passed directly to `fit_with`. This keeps the variant-specific setters on the config +// types (compile-time checked) while the enum remains the single source of fitting logic. +impl Algorithm for NpagConfig { + type Output = NonParametricResult; + + fn fit(self, problem: EstimationProblem) -> Result { + NonParametricAlgorithm::from(self).fit(problem) + } +} + +impl Algorithm for NpodConfig { + type Output = NonParametricResult; + + fn fit(self, problem: EstimationProblem) -> Result { + NonParametricAlgorithm::from(self).fit(problem) + } +} + +impl Algorithm for NpmapConfig { + type Output = NonParametricResult; + + fn fit(self, problem: EstimationProblem) -> Result { + NonParametricAlgorithm::from(self).fit(problem) + } +} diff --git a/src/algorithms/npag.rs b/src/algorithms/nonparametric/npag.rs similarity index 56% rename from src/algorithms/npag.rs rename to src/algorithms/nonparametric/npag.rs index 3f17989e8..efe0caf0e 100644 --- a/src/algorithms/npag.rs +++ b/src/algorithms/nonparametric/npag.rs @@ -1,14 +1,10 @@ -use crate::algorithms::{Status, StopReason}; -use crate::prelude::algorithms::Algorithms; - -pub use crate::routines::estimation::ipm::burke; -pub use crate::routines::estimation::qr; -use crate::routines::settings::Settings; +use crate::algorithms::{NonParametricRunner, Status, StopReason}; +use crate::estimation::nonparametric::{ + calculate_psi, CycleLog, NPCycle, NonParametricResult, Psi, Theta, Weights, +}; -use crate::routines::output::{cycles::CycleLog, cycles::NPCycle, NPResult}; -use crate::structs::psi::{calculate_psi, Psi}; -use crate::structs::theta::Theta; -use crate::structs::weights::Weights; +pub(crate) use crate::estimation::nonparametric::ipm::burke; +pub(crate) use crate::estimation::nonparametric::qr; use anyhow::bail; use anyhow::Result; @@ -19,20 +15,125 @@ use pharmsol::prelude::{ use pharmsol::prelude::AssayErrorModel; -use crate::routines::initialization; +use crate::estimation::nonparametric::adaptative_grid; + +use serde::{Deserialize, Serialize}; + +/// Configuration options for the Non-Parametric Adaptive Grid (NPAG) algorithm. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NpagConfig { + pub eps: f64, + pub min_eps: f64, + pub objective_tolerance: f64, + pub pyl_tolerance: f64, + pub prune_threshold: f64, + pub qr_tolerance: f64, + pub grid_tolerance: f64, + pub error_step: f64, + pub min_error_step: f64, + pub error_step_growth: f64, + pub error_step_shrink: f64, + pub max_cycles: usize, + pub progress: bool, +} + +impl Default for NpagConfig { + fn default() -> Self { + Self { + eps: 0.2, + min_eps: 1e-4, + objective_tolerance: 1e-4, + pyl_tolerance: 1e-2, + prune_threshold: 1e-3, + qr_tolerance: 1e-8, + grid_tolerance: 1e-4, + error_step: 0.1, + min_error_step: 0.01, + error_step_growth: 4.0, + error_step_shrink: 0.5, + max_cycles: 1000, + progress: true, + } + } +} + +impl NpagConfig { + pub fn new() -> Self { + Self::default() + } -use crate::routines::expansion::adaptative_grid::adaptative_grid; + pub fn eps(mut self, eps: f64) -> Self { + self.eps = eps; + self + } -const THETA_E: f64 = 1e-4; // Convergence criteria -const THETA_G: f64 = 1e-4; // Objective function convergence criteria -const THETA_F: f64 = 1e-2; -const THETA_D: f64 = 1e-4; + pub fn min_eps(mut self, min_eps: f64) -> Self { + self.min_eps = min_eps; + self + } + + pub fn objective_tolerance(mut self, tolerance: f64) -> Self { + self.objective_tolerance = tolerance; + self + } + + pub fn pyl_tolerance(mut self, tolerance: f64) -> Self { + self.pyl_tolerance = tolerance; + self + } + + pub fn prune_threshold(mut self, threshold: f64) -> Self { + self.prune_threshold = threshold; + self + } + + pub fn qr_tolerance(mut self, tolerance: f64) -> Self { + self.qr_tolerance = tolerance; + self + } + + pub fn grid_tolerance(mut self, tolerance: f64) -> Self { + self.grid_tolerance = tolerance; + self + } + + pub fn error_step(mut self, step: f64) -> Self { + self.error_step = step; + self + } + + pub fn min_error_step(mut self, step: f64) -> Self { + self.min_error_step = step; + self + } + + pub fn error_step_growth(mut self, factor: f64) -> Self { + self.error_step_growth = factor; + self + } + + pub fn error_step_shrink(mut self, factor: f64) -> Self { + self.error_step_shrink = factor; + self + } + + pub fn max_cycles(mut self, cycles: usize) -> Self { + self.max_cycles = cycles; + self + } + + pub fn progress(mut self, progress: bool) -> Self { + self.progress = progress; + self + } +} #[derive(Debug)] pub struct NPAG { equation: E, ranges: Vec<(f64, f64)>, psi: Psi, + prior: Theta, theta: Theta, lambda: Weights, w: Weights, @@ -47,63 +148,79 @@ pub struct NPAG { status: Status, cycle_log: CycleLog, data: Data, - settings: Settings, + config: NpagConfig, } -impl Algorithms for NPAG { - fn new(settings: Settings, equation: E, data: Data) -> Result, anyhow::Error> { - Ok(Box::new(Self { +impl NPAG { + /// Construct an `NPAG` instance from explicit parts. + /// + /// The `parameter_space` is used solely to derive the finite bounds for the + /// adaptive grid. Initial support points can be supplied separately via + /// [`NonParametricRunner::set_theta`]. + pub(crate) fn from_parts( + equation: E, + data: Data, + error_models: AssayErrorModels, + theta: Theta, + config: NpagConfig, + ) -> Result { + let ranges = theta.parameters().finite_ranges(); + let gamma_delta = vec![config.error_step; error_models.len()]; + let eps = config.eps; + + Ok(Self { equation, - ranges: settings.parameters().ranges(), + ranges, psi: Psi::new(), - theta: Theta::new(), + prior: theta.clone(), + theta, lambda: Weights::default(), w: Weights::default(), - eps: 0.2, + eps, last_objf: -1e30, objf: f64::NEG_INFINITY, f0: -1e30, f1: f64::default(), cycle: 0, - gamma_delta: vec![0.1; settings.errormodels().len()], - error_models: settings.errormodels().clone(), + gamma_delta, + error_models, status: Status::Continue, cycle_log: CycleLog::new(), - settings, data, - })) + config, + }) } +} +impl NonParametricRunner for NPAG { fn equation(&self) -> &E { &self.equation } - fn into_npresult(&self) -> Result> { - NPResult::new( + + fn into_result(&self) -> Result> { + NonParametricResult::new( self.equation.clone(), self.data.clone(), + self.error_models.clone(), + self.prior.clone(), self.theta.clone(), self.psi.clone(), self.w.clone(), -2. * self.objf, self.cycle, self.status.clone(), - self.settings.clone(), self.cycle_log.clone(), ) } - fn settings(&self) -> &Settings { - &self.settings + fn error_models(&self) -> &AssayErrorModels { + &self.error_models } fn data(&self) -> &Data { &self.data } - fn get_prior(&self) -> Theta { - initialization::sample_space(&self.settings).unwrap() - } - fn likelihood(&self) -> f64 { self.objf } @@ -157,25 +274,27 @@ impl Algorithms for NPAG { let psi = self.psi.matrix(); let w = &self.w; - if (self.last_objf - self.objf).abs() <= THETA_G && self.eps > THETA_E { + if (self.last_objf - self.objf).abs() <= self.config.objective_tolerance + && self.eps > self.config.min_eps + { self.eps /= 2.; - if self.eps <= THETA_E { + if self.eps <= self.config.min_eps { let pyl = psi * w.weights(); self.f1 = pyl.iter().map(|x| x.ln()).sum(); - if (self.f1 - self.f0).abs() <= THETA_F { + if (self.f1 - self.f0).abs() <= self.config.pyl_tolerance { tracing::info!("The model converged after {} cycles", self.cycle,); self.set_status(Status::Stop(StopReason::Converged)); self.log_cycle_state(); return Ok(self.status().clone()); } else { self.f0 = self.f1; - self.eps = 0.2; + self.eps = self.config.eps; } } } // Stop if we have reached maximum number of cycles - if self.cycle >= self.settings.config().cycles { + if self.cycle >= self.config.max_cycles { tracing::warn!("Maximum number of cycles reached"); self.set_status(Status::Stop(StopReason::MaxCycles)); self.log_cycle_state(); @@ -202,10 +321,10 @@ impl Algorithms for NPAG { &self.data, &self.theta, &self.error_models, - self.cycle == 1 && self.settings.config().progress, + self.cycle == 1 && self.config.progress, )?; - if let Err(err) = self.validate_psi() { + if let Err(err) = self.check_zero_probability_subjects() { bail!(err); } @@ -228,7 +347,7 @@ impl Algorithms for NPAG { let mut keep = Vec::::new(); for (index, lam) in self.lambda.iter().enumerate() { - if lam > max_lambda / 1000_f64 { + if lam > max_lambda * self.config.prune_threshold { keep.push(index); } } @@ -253,7 +372,7 @@ impl Algorithms for NPAG { let test = r.col(i).norm_l2(); let r_diag_val = r.get(i, i); let ratio = r_diag_val / test; - if ratio.abs() >= 1e-8 { + if ratio.abs() >= self.config.qr_tolerance { keep.push(*perm.get(i).unwrap()); } } @@ -271,7 +390,7 @@ impl Algorithms for NPAG { // Filter to keep only the support points (columns) that are in the `keep` vector self.psi.filter_column_indices(keep.as_slice()); - self.validate_psi()?; + self.check_zero_probability_subjects()?; (self.lambda, self.objf) = match burke(&self.psi) { Ok((lambda, objf)) => (lambda, objf), Err(err) => { @@ -338,20 +457,20 @@ impl Algorithms for NPAG { if objf_up > self.objf { self.error_models.set_factor(outeq, gamma_up)?; self.objf = objf_up; - self.gamma_delta[outeq] *= 4.; + self.gamma_delta[outeq] *= self.config.error_step_growth; self.lambda = lambda_up; self.psi = psi_up; } if objf_down > self.objf { self.error_models.set_factor(outeq, gamma_down)?; self.objf = objf_down; - self.gamma_delta[outeq] *= 4.; + self.gamma_delta[outeq] *= self.config.error_step_growth; self.lambda = lambda_down; self.psi = psi_down; } - self.gamma_delta[outeq] *= 0.5; - if self.gamma_delta[outeq] <= 0.01 { - self.gamma_delta[outeq] = 0.1; + self.gamma_delta[outeq] *= self.config.error_step_shrink; + if self.gamma_delta[outeq] <= self.config.min_error_step { + self.gamma_delta[outeq] = self.config.error_step; } Ok(()) })?; @@ -360,7 +479,12 @@ impl Algorithms for NPAG { } fn expansion(&mut self) -> Result<()> { - adaptative_grid(&mut self.theta, self.eps, &self.ranges, THETA_D)?; + adaptative_grid( + &mut self.theta, + self.eps, + &self.ranges, + self.config.grid_tolerance, + )?; Ok(()) } @@ -386,3 +510,72 @@ impl Algorithms for NPAG { self.last_objf = self.objf; } } + +#[cfg(test)] +mod tests { + use crate::prelude::*; + + use pharmsol::{fa, fetch_params, lag, Subject, SubjectBuilderExt}; + + fn simple_equation() -> pharmsol::equation::ODE { + pharmsol::equation::ODE::new( + |x, p, _t, dx, b, _rateiv, _cov| { + fetch_params!(p, ke); + dx[0] = -ke * x[0] + b[0]; + }, + |_p, _t, _cov| lag! {}, + |_p, _t, _cov| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, v); + y[0] = x[0] / v; + }, + ) + .with_nstates(1) + .with_ndrugs(1) + .with_nout(1) + .with_metadata( + pharmsol::equation::metadata::new("npag_settings_test") + .parameters(["ke", "v"]) + .states(["central"]) + .outputs(["0"]) + .route(pharmsol::equation::Route::bolus("0").to_state("central")), + ) + .expect("metadata attachment should validate") + } + + fn simple_data() -> Data { + let subject = Subject::builder("1") + .bolus(0.0, 100.0, 0) + .observation(1.0, 10.0, 0) + .observation(2.0, 8.0, 0) + .build(); + + Data::new(vec![subject]) + } + + #[test] + fn npag_runs_without_error() { + let parameters = ParameterSpace::bounded() + .add("ke", 0.001, 3.0) + .add("v", 25.0, 250.0); + let prior = Theta::sobol_default(¶meters).expect("Failed to build prior"); + let error_models = AssayErrorModels::new() + .add( + "0", + AssayErrorModel::additive(ErrorPoly::new(0.0, 0.5, 0.0, 0.0), 0.0), + ) + .expect("Failed to build error models"); + let problem = + EstimationProblem::nonparametric(simple_equation(), simple_data(), prior, error_models) + .expect("Failed to build problem"); + + let result = problem.fit_with(NonParametricAlgorithm::npag()); + + assert!( + result.is_ok(), + "NPAG algorithm should run without error, but got: {:?}", + result.err() + ); + } +} diff --git a/src/algorithms/postprob.rs b/src/algorithms/nonparametric/npmap.rs similarity index 57% rename from src/algorithms/postprob.rs rename to src/algorithms/nonparametric/npmap.rs index 19d030f66..92b122f64 100644 --- a/src/algorithms/postprob.rs +++ b/src/algorithms/nonparametric/npmap.rs @@ -1,27 +1,34 @@ use crate::{ - algorithms::{Status, StopReason}, - prelude::algorithms::Algorithms, - structs::{ - psi::{calculate_psi, Psi}, - theta::Theta, - weights::Weights, + algorithms::{NonParametricRunner, Status, StopReason}, + estimation::nonparametric::{ + calculate_psi, CycleLog, NPCycle, NonParametricResult, Psi, Theta, Weights, }, }; -use anyhow::{Context, Result}; +use anyhow::{Context, Result}; use pharmsol::prelude::{ data::{AssayErrorModels, Data}, simulator::Equation, }; -use crate::routines::estimation::ipm::burke; -use crate::routines::initialization; -use crate::routines::output::{cycles::CycleLog, NPResult}; -use crate::routines::settings::Settings; +use crate::estimation::nonparametric::ipm::burke; +use serde::{Deserialize, Serialize}; + +/// Configuration options for the non-parametric maximum a posteriori (NPMAP) algorithm +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct NpmapConfig {} + +impl NpmapConfig { + pub fn new() -> Self { + Self::default() + } +} -/// Posterior probability algorithm -/// Reweights the prior probabilities to the observed data and error model -pub struct POSTPROB { +/// Non-parametric maximum a posteriori (NPMAP) algorithm +/// +/// This algorithm is a wrapper around the IPM algorithm that calculates the posterior probabilities of the support points +/// given a prior distribution and the likelihood of the data. +pub struct NPMAP { equation: E, psi: Psi, theta: Theta, @@ -30,43 +37,54 @@ pub struct POSTPROB { cycle: usize, status: Status, data: Data, - settings: Settings, cyclelog: CycleLog, error_models: AssayErrorModels, + prior: Theta, } -impl Algorithms for POSTPROB { - fn new(settings: Settings, equation: E, data: Data) -> Result, anyhow::Error> { - Ok(Box::new(Self { +impl NPMAP { + pub(crate) fn from_parts( + equation: E, + data: Data, + error_models: AssayErrorModels, + theta: Theta, + _config: NpmapConfig, + ) -> Result { + Ok(Self { equation, psi: Psi::new(), - theta: Theta::new(), + theta: theta.clone(), w: Weights::default(), objf: f64::INFINITY, cycle: 0, status: Status::Continue, - error_models: settings.errormodels().clone(), - settings, data, cyclelog: CycleLog::new(), - })) + error_models, + prior: theta, + }) } - fn into_npresult(&self) -> Result> { - NPResult::new( +} + +impl NonParametricRunner for NPMAP { + fn into_result(&self) -> Result> { + NonParametricResult::new( self.equation.clone(), self.data.clone(), + self.error_models.clone(), + self.prior.clone(), self.theta.clone(), self.psi.clone(), self.w.clone(), self.objf, self.cycle, self.status.clone(), - self.settings.clone(), self.cyclelog.clone(), ) } - fn settings(&self) -> &Settings { - &self.settings + + fn error_models(&self) -> &AssayErrorModels { + &self.error_models } fn equation(&self) -> &E { @@ -77,10 +95,6 @@ impl Algorithms for POSTPROB { &self.data } - fn get_prior(&self) -> Theta { - initialization::sample_space(&self.settings).unwrap() - } - fn likelihood(&self) -> f64 { self.objf } @@ -133,6 +147,7 @@ impl Algorithms for POSTPROB { fn condensation(&mut self) -> Result<()> { Ok(()) } + fn optimizations(&mut self) -> Result<()> { Ok(()) } @@ -143,7 +158,7 @@ impl Algorithms for POSTPROB { fn log_cycle_state(&mut self) { // Postprob doesn't track last_objf, so we use 0.0 as the delta - let state = crate::routines::output::cycles::NPCycle::new( + let state = NPCycle::new( self.cycle, self.objf, self.error_models.clone(), @@ -154,4 +169,14 @@ impl Algorithms for POSTPROB { ); self.cyclelog.push(state); } + + /// POSTPROB is a single-pass reweighting: it evaluates the likelihood of the + /// fixed prior support points once, rather than iterating cycles. + fn fit(&mut self) -> Result> { + self.estimation()?; + self.evaluation()?; + self.log_cycle_state(); + + self.into_result() + } } diff --git a/src/algorithms/npod.rs b/src/algorithms/nonparametric/npod.rs similarity index 72% rename from src/algorithms/npod.rs rename to src/algorithms/nonparametric/npod.rs index a5756ffe8..634c673f8 100644 --- a/src/algorithms/npod.rs +++ b/src/algorithms/nonparametric/npod.rs @@ -1,40 +1,61 @@ -use crate::algorithms::StopReason; -use crate::routines::initialization::sample_space; -use crate::routines::output::{cycles::CycleLog, cycles::NPCycle, NPResult}; -use crate::structs::weights::Weights; use crate::{ - algorithms::Status, - prelude::{ - algorithms::Algorithms, - routines::{ - estimation::{ipm::burke, qr}, - settings::Settings, - }, - }, - structs::{ - psi::{calculate_psi, Psi}, - theta::Theta, + algorithms::{NonParametricRunner, Status, StopReason}, + estimation::nonparametric::{ + calculate_psi, ipm::burke, qr, CycleLog, NPCycle, NonParametricResult, Psi, Theta, Weights, }, }; -use pharmsol::SppOptimizer; +use pharmsol::ParameterOptimizer; use anyhow::bail; use anyhow::Result; +use pharmsol::prelude::{data::Data, simulator::Equation}; use pharmsol::{prelude::AssayErrorModel, AssayErrorModels}; -use pharmsol::{ - prelude::{data::Data, simulator::Equation}, - Subject, -}; use ndarray::Array1; use rayon::prelude::{IntoParallelRefMutIterator, ParallelIterator}; +use serde::{Deserialize, Serialize}; const THETA_F: f64 = 1e-2; const THETA_D: f64 = 1e-4; +/// Configuration options for the Non-Parametric Optimal Design (NPOD) algorithm. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NpodConfig { + /// Maximum number of cycles to run the algorithm for. + pub max_cycles: usize, + /// Whether to print progress information during the first cycle. + pub progress: bool, +} + +impl NpodConfig { + pub fn new() -> Self { + Self::default() + } + + pub fn max_cycles(mut self, cycles: usize) -> Self { + self.max_cycles = cycles; + self + } + + pub fn progress(mut self, progress: bool) -> Self { + self.progress = progress; + self + } +} + +impl Default for NpodConfig { + fn default() -> Self { + Self { + max_cycles: 100, + progress: true, + } + } +} + pub struct NPOD { equation: E, psi: Psi, + prior: Theta, theta: Theta, lambda: Weights, w: Weights, @@ -47,40 +68,53 @@ pub struct NPOD { status: Status, cycle_log: CycleLog, data: Data, - settings: Settings, + config: NpodConfig, } -impl Algorithms for NPOD { - fn new(settings: Settings, equation: E, data: Data) -> Result, anyhow::Error> { - Ok(Box::new(Self { +impl NPOD { + pub(crate) fn from_parts( + equation: E, + data: Data, + error_models: AssayErrorModels, + theta: Theta, + config: NpodConfig, + ) -> Result { + let gamma_delta = vec![0.1; error_models.len()]; + + Ok(Self { equation, psi: Psi::new(), - theta: Theta::new(), + prior: theta.clone(), + theta: theta, lambda: Weights::default(), w: Weights::default(), last_objf: -1e30, objf: f64::NEG_INFINITY, cycle: 0, - gamma_delta: vec![0.1; settings.errormodels().len()], - error_models: settings.errormodels().clone(), + gamma_delta, + error_models, converged: false, status: Status::Continue, cycle_log: CycleLog::new(), - settings, data, - })) + config, + }) } - fn into_npresult(&self) -> Result> { - NPResult::new( +} + +impl NonParametricRunner for NPOD { + fn into_result(&self) -> Result> { + NonParametricResult::new( self.equation.clone(), self.data.clone(), + self.error_models.clone(), + self.prior.clone(), self.theta.clone(), self.psi.clone(), self.w.clone(), -2. * self.objf, self.cycle, self.status.clone(), - self.settings.clone(), self.cycle_log.clone(), ) } @@ -89,18 +123,14 @@ impl Algorithms for NPOD { &self.equation } - fn settings(&self) -> &Settings { - &self.settings + fn error_models(&self) -> &AssayErrorModels { + &self.error_models } fn data(&self) -> &Data { &self.data } - fn get_prior(&self) -> Theta { - sample_space(&self.settings).unwrap() - } - fn increment_cycle(&mut self) -> usize { self.cycle += 1; self.cycle @@ -161,7 +191,6 @@ impl Algorithms for NPOD { em.factor().unwrap_or_default() ); }); - // Increasing objf signals instability or model misspecification. if self.last_objf > self.objf + 1e-4 { tracing::warn!( "Objective function decreased from {:.4} to {:.4} (delta = {})", @@ -179,8 +208,7 @@ impl Algorithms for NPOD { return Ok(self.status.clone()); } - // Stop if we have reached maximum number of cycles - if self.cycle >= self.settings.config().cycles { + if self.cycle >= self.config.max_cycles { tracing::warn!("Maximum number of cycles reached"); self.converged = true; self.set_status(Status::Stop(StopReason::MaxCycles)); @@ -188,7 +216,6 @@ impl Algorithms for NPOD { return Ok(self.status.clone()); } - // Stop if stopfile exists if std::path::Path::new("stop").exists() { tracing::warn!("Stopfile detected - breaking"); self.converged = true; @@ -197,7 +224,6 @@ impl Algorithms for NPOD { return Ok(self.status.clone()); } - // Continue with normal operation self.status = Status::Continue; self.log_cycle_state(); Ok(self.status.clone()) @@ -211,10 +237,10 @@ impl Algorithms for NPOD { &self.data, &self.theta, &error_model, - self.cycle == 1 && self.settings.config().progress, + self.cycle == 1 && self.config.progress, )?; - if let Err(err) = self.validate_psi() { + if let Err(err) = self.check_zero_probability_subjects() { bail!(err); } @@ -249,12 +275,9 @@ impl Algorithms for NPOD { self.theta.filter_indices(keep.as_slice()); self.psi.filter_column_indices(keep.as_slice()); - //Rank-Revealing Factorization let (r, perm) = qr::qrd(&self.psi)?; let mut keep = Vec::::new(); - - // The minimum between the number of subjects and the actual number of support points let keep_n = self.psi.matrix().ncols().min(self.psi.matrix().nrows()); for i in 0..keep_n { let test = r.col(i).norm_l2(); @@ -265,7 +288,6 @@ impl Algorithms for NPOD { } } - // If a support point is dropped, log it as a debug message if self.psi.matrix().ncols() != keep.len() { tracing::debug!( "QR decomposition dropped {} support point(s)", @@ -298,8 +320,6 @@ impl Algorithms for NPOD { } }) .try_for_each(|(outeq, em)| -> Result<()> { - // OPTIMIZATION - let gamma_up = em.factor()? * (1.0 + self.gamma_delta[outeq]); let gamma_down = em.factor()? / (1.0 + self.gamma_delta[outeq]); @@ -361,11 +381,9 @@ impl Algorithms for NPOD { } fn expansion(&mut self) -> Result<()> { - // Compute pyl = psi * w using faer native operations let pyl_col = self.psi().matrix().as_ref() * self.w.weights().as_ref(); let pyl: Array1 = pyl_col.iter().copied().collect(); - // Add new point to theta based on the optimization of the D function let error_model: AssayErrorModels = self.error_models.clone(); let mut candididate_points: Vec> = Vec::default(); @@ -375,14 +393,9 @@ impl Algorithms for NPOD { candididate_points.push(spp.to_owned()); } candididate_points.par_iter_mut().for_each(|spp| { - let optimizer = SppOptimizer::new(&self.equation, &self.data, &error_model, &pyl); + let optimizer = ParameterOptimizer::new(&self.equation, &self.data, &error_model, &pyl); let candidate_point = optimizer.optimize_point(spp.to_owned()).unwrap(); *spp = candidate_point; - // add spp to theta - // recalculate psi - // re-run ipm to re-calculate w - // re-calculate pyl - // re-define a new optimization }); for cp in candididate_points { self.theta.suggest_point(cp.to_vec().as_slice(), THETA_D)?; @@ -390,47 +403,3 @@ impl Algorithms for NPOD { Ok(()) } } - -impl NPOD { - fn validate_psi(&mut self) -> Result<()> { - let mut psi = self.psi().matrix().to_owned(); - // First coerce all NaN and infinite in psi to 0.0 - let mut has_bad_values = false; - for i in 0..psi.nrows() { - for j in 0..psi.ncols() { - let val = psi[(i, j)]; - if val.is_nan() || val.is_infinite() { - has_bad_values = true; - psi[(i, j)] = 0.0; - } - } - } - if has_bad_values { - tracing::warn!("Psi contains NaN or Inf values, coercing to 0.0"); - } - - // Calculate row sums and check for zero-probability subjects - let nrows = psi.nrows(); - let ncols = psi.ncols(); - let indices: Vec = (0..nrows) - .filter(|&i| { - let row_sum: f64 = (0..ncols).map(|j| psi[(i, j)]).sum(); - let w: f64 = 1.0 / row_sum; - w.is_nan() || w.is_infinite() - }) - .collect(); - - // If any elements in `w` are NaN or infinite, return the subject IDs for each index - if !indices.is_empty() { - let subject: Vec<&Subject> = self.data.subjects(); - let zero_probability_subjects: Vec<&String> = - indices.iter().map(|&i| subject[i].id()).collect(); - - return Err(anyhow::anyhow!( - "The probability of one or more subjects, given the model, is zero. The following subjects have zero probability: {:?}", zero_probability_subjects - )); - } - - Ok(()) - } -} diff --git a/src/algorithms/parametric/mod.rs b/src/algorithms/parametric/mod.rs new file mode 100644 index 000000000..9951345f1 --- /dev/null +++ b/src/algorithms/parametric/mod.rs @@ -0,0 +1,100 @@ +//! Parametric algorithm implementations. +//! +//! Parametric algorithms estimate the population distribution as a parametric model (for +//! example a multivariate normal) whose parameters are fitted to the data. +//! +//! # Algorithm Selection +//! +//! Use the [`ParametricAlgorithm`] enum to select and configure an algorithm. Each variant +//! wraps its algorithm-specific configuration struct (e.g. [`SaemConfig`]). +//! +//! Note: the parametric fitting machinery is not yet implemented. Constructing a problem and +//! calling [`fit_with`](crate::estimation::EstimationProblem::fit_with) with a +//! [`ParametricAlgorithm`] type-checks today, but running it will panic until the SAEM solver +//! is implemented. + +pub mod saem_config; + +pub use saem_config::SaemConfig; + +use crate::algorithms::Algorithm; +use crate::estimation::{EstimationProblem, Parametric}; +use crate::results::ParametricResult; +use anyhow::Result; +use pharmsol::prelude::simulator::Equation; + +/// The parametric algorithms supported by PMcore. +/// +/// Use the constructors to select an algorithm with its default configuration: +/// +/// ```no_run +/// use pmcore::prelude::*; +/// +/// // Default SAEM configuration. +/// let algorithm = ParametricAlgorithm::saem(); +/// ``` +/// +/// To customize an algorithm, build its configuration struct (which exposes only the +/// setters valid for that algorithm) and pass it directly to +/// [`fit_with`](crate::estimation::EstimationProblem::fit_with): +/// +/// ```no_run +/// use pmcore::prelude::*; +/// +/// // SAEM with a custom iteration schedule and seed. +/// let config = SaemConfig::new() +/// .k1_iterations(500) +/// .k2_iterations(200) +/// .seed(42); +/// // `problem.fit_with(config)` accepts the config directly. +/// ``` +/// +/// [`SaemConfig`] implements [`Algorithm`] by delegating to the matching enum variant, so +/// configs can be passed to `fit_with` without converting them first. +#[derive(Debug, Clone)] +pub enum ParametricAlgorithm { + /// Stochastic Approximation Expectation-Maximization. + Saem(SaemConfig), +} + +impl Default for ParametricAlgorithm { + fn default() -> Self { + Self::saem() + } +} + +impl From for ParametricAlgorithm { + fn from(config: SaemConfig) -> Self { + Self::Saem(config) + } +} + +impl ParametricAlgorithm { + /// The Stochastic Approximation Expectation-Maximization (SAEM) algorithm with its + /// default configuration. + pub fn saem() -> Self { + Self::Saem(SaemConfig::default()) + } +} + +impl Algorithm for ParametricAlgorithm { + type Output = ParametricResult; + + fn fit(self, _problem: EstimationProblem) -> Result { + match self { + Self::Saem(_config) => { + unimplemented!("SAEM fitting is not yet implemented") + } + } + } +} + +// `SaemConfig` delegates to the matching `ParametricAlgorithm` variant so it can be passed +// directly to `fit_with`, keeping its setters compile-time checked. +impl Algorithm for SaemConfig { + type Output = ParametricResult; + + fn fit(self, problem: EstimationProblem) -> Result { + ParametricAlgorithm::from(self).fit(problem) + } +} diff --git a/src/algorithms/parametric/saem_config.rs b/src/algorithms/parametric/saem_config.rs new file mode 100644 index 000000000..289c41750 --- /dev/null +++ b/src/algorithms/parametric/saem_config.rs @@ -0,0 +1,165 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Clone, Serialize)] +#[serde(deny_unknown_fields, default)] +pub struct SaemConfig { + pub k1_iterations: usize, + pub k2_iterations: usize, + pub burn_in: usize, + pub sa_iterations: usize, + pub sa_cooling_factor: f64, + pub mcmc_step_size: f64, + pub rw_init: f64, + pub n_chains: usize, + pub mcmc_iterations: usize, + pub omega_min_variance: f64, + pub use_gibbs: bool, + pub n_kernels: usize, + pub transform_par: Vec, + pub compute_map: bool, + pub compute_fim: bool, + pub compute_ll_is: bool, + pub compute_ll_gq: bool, + pub n_mc_is: usize, + pub nu_is: usize, + pub n_nodes_gq: usize, + pub n_sd_gq: f64, + pub display_progress: usize, + pub seed: u64, + pub fix_seed: bool, +} + +impl Default for SaemConfig { + fn default() -> Self { + Self { + k1_iterations: 300, + k2_iterations: 100, + burn_in: 5, + sa_iterations: 0, + sa_cooling_factor: 0.97, + mcmc_step_size: 0.4, + rw_init: 0.5, + n_chains: 1, + mcmc_iterations: 1, + omega_min_variance: 1e-6, + use_gibbs: false, + n_kernels: 4, + transform_par: vec![], + compute_map: true, + compute_fim: true, + compute_ll_is: true, + compute_ll_gq: false, + n_mc_is: 5000, + nu_is: 4, + n_nodes_gq: 12, + n_sd_gq: 4.0, + display_progress: 10, + seed: 123456, + fix_seed: true, + } + } +} + +impl SaemConfig { + /// Creates a new `SaemConfig` with default values. + pub fn new() -> Self { + Self::default() + } + + /// Number of exploration-phase (K1) iterations. + pub fn k1_iterations(mut self, iterations: usize) -> Self { + self.k1_iterations = iterations; + self + } + + /// Number of smoothing-phase (K2) iterations. + pub fn k2_iterations(mut self, iterations: usize) -> Self { + self.k2_iterations = iterations; + self + } + + /// Number of burn-in iterations. + pub fn burn_in(mut self, burn_in: usize) -> Self { + self.burn_in = burn_in; + self + } + + /// Number of MCMC chains. + pub fn n_chains(mut self, n_chains: usize) -> Self { + self.n_chains = n_chains; + self + } + + /// MCMC step size. + pub fn mcmc_step_size(mut self, step_size: f64) -> Self { + self.mcmc_step_size = step_size; + self + } + + /// Random-number-generator seed. + pub fn seed(mut self, seed: u64) -> Self { + self.seed = seed; + self + } + + pub fn total_iterations(&self) -> usize { + self.k1_iterations + self.k2_iterations + } + + pub fn is_exploration_phase(&self, iteration: usize) -> bool { + iteration <= self.k1_iterations + } + + pub fn is_smoothing_phase(&self, iteration: usize) -> bool { + iteration > self.k1_iterations + } + + pub fn is_sa_active(&self, iteration: usize) -> bool { + self.sa_iterations > 0 && iteration <= self.sa_iterations + } + + pub fn sa_temperature(&self, iteration: usize) -> f64 { + if self.is_sa_active(iteration) { + self.sa_cooling_factor.powi(iteration as i32) + } else { + 1.0 + } + } + + pub fn step_size(&self, iteration: usize) -> f64 { + if iteration <= self.k1_iterations { + 1.0 + } else { + let k_smooth = iteration - self.k1_iterations; + 1.0 / (k_smooth as f64 + 1.0) + } + } + + pub fn get_transform(&self, param_idx: usize) -> u8 { + self.transform_par.get(param_idx).copied().unwrap_or(1) + } + + pub fn get_transforms(&self, n_params: usize) -> Vec { + let mut transforms = self.transform_par.clone(); + while transforms.len() < n_params { + transforms.push(1); + } + transforms.truncate(n_params); + transforms + } + + pub fn infer_transforms_from_ranges(&mut self, ranges: &[(f64, f64)]) { + self.transform_par = ranges + .iter() + .map(|(lower, upper)| { + if *lower >= 0.0 && *upper > 0.0 && lower.is_finite() && upper.is_finite() { + 1 + } else if (*lower - 0.0).abs() < 1e-10 && (*upper - 1.0).abs() < 1e-10 { + 3 + } else { + 0 + } + }) + .collect(); + } +} diff --git a/src/bestdose/cost.rs b/src/bestdose/cost.rs index b9dede94c..bd06cc44e 100644 --- a/src/bestdose/cost.rs +++ b/src/bestdose/cost.rs @@ -114,7 +114,7 @@ use pharmsol::Equation; /// /// - **AUC** ([`Target::AUC`]): /// Predictions are cumulative AUC values calculated via trapezoidal rule -/// on a dense time grid (controlled by `settings.predictions().idelta`) +/// on a dense time grid (controlled by `config.prediction_interval()`) /// /// # Example /// @@ -129,7 +129,7 @@ use pharmsol::Equation; /// - Model simulation fails /// - Prediction length doesn't match observation count /// - AUC calculation fails (for AUC targets) -pub(crate) fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) -> Result { +pub fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) -> Result { // Validate candidate_doses length matches expected optimizable dose count let expected_optimizable = problem .target @@ -233,7 +233,7 @@ pub(crate) fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) // Calculate variance (using posterior weights) and population mean (using prior weights) - for ((row, post_prob), _prior_prob) in problem + for ((row, post_prob), prior_prob) in problem .theta .matrix() .row_iter() @@ -247,18 +247,19 @@ pub(crate) fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) 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)?; + let pred = problem + .eq + .simulate_subject_dense(&target_subject, &spp, None)?; pred.0.flat_predictions() } Target::AUCFromZero => { // For AUC: simulate at dense time grid and calculate cumulative AUC - let idelta = problem.settings.predictions().idelta; + let idelta = problem.config.prediction_interval(); 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); + let dense_times = calculate_dense_times(start_time, end_time, &obs_times, idelta); // Create temporary subject with dense time points for simulation let subject_id = target_subject.id().to_string(); @@ -291,10 +292,19 @@ pub(crate) fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) .iter() .flat_map(|occ| occ.events()) .filter_map(|event| match event { - Event::Observation(obs) => Some((obs.time(), obs.outeq())), + Event::Observation(obs) => Some( + obs.outeq_index() + .map(|outeq| (obs.time(), outeq)) + .ok_or_else(|| { + anyhow::anyhow!( + "BestDose AUC calculations require numeric observation output labels; got `{}`", + obs.outeq() + ) + }), + ), _ => None, }) - .collect(); + .collect::>>()?; let mut unique_outeqs: Vec = obs_time_outeq.iter().map(|(_, outeq)| *outeq).collect(); @@ -311,7 +321,9 @@ pub(crate) fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) let dense_subject = builder.build(); // Simulate at dense times - let pred = problem.eq.simulate_subject(&dense_subject, &spp, None)?; + let pred = problem + .eq + .simulate_subject_dense(&dense_subject, &spp, None)?; let dense_predictions_with_outeq = pred.0.predictions(); // Group predictions by outeq using the Prediction struct @@ -372,11 +384,11 @@ pub(crate) fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) } Target::AUCFromLastDose => { // For interval AUC: simulate at dense time grid and calculate AUC from last dose - let idelta = problem.settings.predictions().idelta; + let idelta = problem.config.prediction_interval(); 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); + let dense_times = calculate_dense_times(0.0, end_time, &obs_times, idelta); // Create temporary subject with dense time points for simulation let subject_id = target_subject.id().to_string(); @@ -409,10 +421,19 @@ pub(crate) fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) .iter() .flat_map(|occ| occ.events()) .filter_map(|event| match event { - Event::Observation(obs) => Some((obs.time(), obs.outeq())), + Event::Observation(obs) => Some( + obs.outeq_index() + .map(|outeq| (obs.time(), outeq)) + .ok_or_else(|| { + anyhow::anyhow!( + "BestDose AUC calculations require numeric observation output labels; got `{}`", + obs.outeq() + ) + }), + ), _ => None, }) - .collect(); + .collect::>>()?; let mut unique_outeqs: Vec = obs_time_outeq.iter().map(|(_, outeq)| *outeq).collect(); @@ -429,7 +450,9 @@ pub(crate) fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) let dense_subject = builder.build(); // Simulate at dense times - let pred = problem.eq.simulate_subject(&dense_subject, &spp, None)?; + let pred = problem + .eq + .simulate_subject_dense(&dense_subject, &spp, None)?; let dense_predictions_with_outeq = pred.0.predictions(); // Group predictions by outeq @@ -509,8 +532,8 @@ pub(crate) fn calculate_cost(problem: &BestDoseProblem, candidate_doses: &[f64]) let pj = preds_i[j]; let se = (obs_val - pj).powi(2); sumsq_i += se; - // Calculate population mean using POSTERIOR probabilities - y_bar[j] += post_prob * pj; + // Calculate population mean using population probabilities + y_bar[j] += prior_prob * pj; } variance += post_prob * sumsq_i; // Weighted by posterior diff --git a/src/bestdose/mod.rs b/src/bestdose/mod.rs index 4502b817e..6ffdedd4b 100644 --- a/src/bestdose/mod.rs +++ b/src/bestdose/mod.rs @@ -9,37 +9,36 @@ //! # Quick Start //! //! ```rust,no_run,ignore -//! use pmcore::bestdose::{BestDosePosterior, Target, DoseRange}; +//! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; //! -//! # fn example(population_theta: pmcore::structs::theta::Theta, -//! # population_weights: pmcore::structs::weights::Weights, +//! # fn example(population_theta: pmcore::estimation::nonparametric::Theta, +//! # population_weights: pmcore::estimation::nonparametric::Weights, //! # past_data: pharmsol::prelude::Subject, //! # target: pharmsol::prelude::Subject, //! # eq: pharmsol::prelude::ODE, -//! # settings: pmcore::routines::settings::Settings) +//! # config: pmcore::bestdose::BestDoseConfig) //! # -> anyhow::Result<()> { -//! // Stage 1: Compute posterior from patient history -//! let posterior = BestDosePosterior::compute( -//! &population_theta, // Population support points from NPAG -//! &population_weights, // Population probabilities +//! // Create optimization problem +//! let problem = BestDoseProblem::new( +//! &population_theta, // Population support points from NPAG +//! &population_weights, // Population probabilities //! Some(past_data), // Patient history (None = use prior) -//! eq, // PK/PD model -//! settings, // NPAG settings -//! )?; -//! -//! // Stage 2 & 3: Optimize doses and get predictions -//! let result = posterior.optimize( //! target, // Future template with targets //! None, // time_offset (None = standard mode) +//! eq, // PK/PD model //! DoseRange::new(0.0, 1000.0), // Dose constraints (0-1000 mg) //! 0.5, // bias_weight: 0=personalized, 1=population +//! config, // BestDose refinement and prediction settings //! Target::Concentration, // Target type //! )?; //! +//! // Run optimization +//! let result = problem.optimize()?; +//! //! // Extract results -//! println!("Optimal dose: {:?} mg", result.doses()); -//! println!("Final cost: {}", result.objf()); -//! println!("Method: {}", result.optimization_method()); +//! println!("Optimal dose: {:?} mg", result.dose); +//! println!("Final cost: {}", result.objf); +//! println!("Method: {}", result.optimization_method); // "posterior" or "uniform" //! # Ok(()) //! # } //! ``` @@ -143,33 +142,31 @@ //! ## Single Dose Optimization //! //! ```rust,no_run,ignore -//! use pmcore::bestdose::{BestDosePosterior, Target, DoseRange}; +//! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; //! use pharmsol::prelude::Subject; //! -//! # fn example(population_theta: pmcore::structs::theta::Theta, -//! # population_weights: pmcore::structs::weights::Weights, +//! # fn example(population_theta: pmcore::estimation::nonparametric::Theta, +//! # population_weights: pmcore::estimation::nonparametric::Weights, //! # past: pharmsol::prelude::Subject, //! # eq: pharmsol::prelude::ODE, -//! # settings: pmcore::routines::settings::Settings) +//! # config: pmcore::bestdose::BestDoseConfig) //! # -> anyhow::Result<()> { //! // Define target: 5 mg/L at 24 hours //! let target = Subject::builder("patient_001") -//! .bolus(0.0, 0.0, 0) // Dose placeholder (will be optimized) +//! .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 posterior = BestDosePosterior::compute( -//! &population_theta, &population_weights, Some(past), eq, settings, -//! )?; -//! -//! let result = posterior.optimize( -//! target, None, +//! let problem = BestDoseProblem::new( +//! &population_theta, &population_weights, Some(past), target, None, +//! eq, //! DoseRange::new(10.0, 500.0), // 10-500 mg allowed //! 0.3, // Slight population emphasis -//! Target::Concentration, +//! config, Target::Concentration, //! )?; //! -//! println!("Optimal dose: {} mg", result.doses()[0]); +//! let result = problem.optimize()?; +//! println!("Optimal dose: {} mg", result.dose[0]); //! # Ok(()) //! # } //! ``` @@ -177,36 +174,34 @@ //! ## Multiple Doses with AUC Target //! //! ```rust,no_run,ignore -//! use pmcore::bestdose::{BestDosePosterior, Target, DoseRange}; +//! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; //! use pharmsol::prelude::Subject; //! -//! # fn example(population_theta: pmcore::structs::theta::Theta, -//! # population_weights: pmcore::structs::weights::Weights, +//! # fn example(population_theta: pmcore::estimation::nonparametric::Theta, +//! # population_weights: pmcore::estimation::nonparametric::Weights, //! # past: pharmsol::prelude::Subject, //! # eq: pharmsol::prelude::ODE, -//! # settings: pmcore::routines::settings::Settings) +//! # config: pmcore::bestdose::BestDoseConfig) //! # -> anyhow::Result<()> { //! // Target: Achieve AUC₂₄ = 400 mg·h/L //! let target = Subject::builder("patient_002") -//! .bolus(0.0, 0.0, 0) // Dose 1 placeholder (optimized) -//! .bolus(12.0, 0.0, 0) // Dose 2 placeholder (optimized) +//! .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 posterior = BestDosePosterior::compute( -//! &population_theta, &population_weights, Some(past), eq, settings, -//! )?; -//! -//! let result = posterior.optimize( -//! target, None, +//! let problem = BestDoseProblem::new( +//! &population_theta, &population_weights, Some(past), target, None, +//! eq, //! DoseRange::new(50.0, 300.0), //! 0.0, // Full personalization -//! Target::AUCFromZero, // Cumulative AUC target! +//! config, Target::AUCFromZero, // Cumulative AUC target! //! )?; //! -//! println!("Dose 1: {} mg at t=0", result.doses()[0]); -//! println!("Dose 2: {} mg at t=12", result.doses()[1]); -//! if let Some(auc) = result.auc_predictions() { +//! 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(()) @@ -216,26 +211,26 @@ //! ## Population-Only Optimization //! //! ```rust,no_run,ignore -//! # use pmcore::bestdose::{BestDosePosterior, Target, DoseRange}; -//! # fn example(population_theta: pmcore::structs::theta::Theta, -//! # population_weights: pmcore::structs::weights::Weights, +//! # use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; +//! # fn example(population_theta: pmcore::estimation::nonparametric::Theta, +//! # population_weights: pmcore::estimation::nonparametric::Weights, //! # target: pharmsol::prelude::Subject, //! # eq: pharmsol::prelude::ODE, -//! # settings: pmcore::routines::settings::Settings) +//! # config: pmcore::bestdose::BestDoseConfig) //! # -> anyhow::Result<()> { //! // No patient history - use population prior directly -//! let posterior = BestDosePosterior::compute( +//! let problem = BestDoseProblem::new( //! &population_theta, &population_weights, -//! None, // No past data → use prior -//! eq, settings, -//! )?; -//! -//! let result = posterior.optimize( -//! target, None, +//! None, // No past data +//! target, None, // time_offset +//! eq, //! DoseRange::new(0.0, 1000.0), //! 1.0, // Full population weighting +//! config, //! Target::Concentration, //! )?; +//! +//! let result = problem.optimize()?; //! // Returns population-typical dose //! # Ok(()) //! # } @@ -249,7 +244,7 @@ //! - `0.0`: Minimize patient-specific variance (full personalization) //! - `1.0`: Minimize deviation from population (robustness) //! -//! - **`max_cycles`**: NPAGFULL refinement iterations +//! - **`refinement_cycles`**: NPAGFULL refinement iterations //! - `0`: Skip refinement (use filtered points directly) //! - `100-500`: Typical range for refinement //! @@ -261,46 +256,70 @@ //! - `Target::AUCFromZero`: Cumulative AUC from time 0 //! - `Target::AUCFromLastDose`: Interval AUC from last dose //! +//! ## Performance Tuning +//! +//! For faster optimization: +//! ```rust,no_run,ignore +//! # use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; +//! # fn example(population_theta: pmcore::estimation::nonparametric::Theta, +//! # population_weights: pmcore::estimation::nonparametric::Weights, +//! # target: pharmsol::prelude::Subject, +//! # eq: pharmsol::ODE, +//! # error_models: pharmsol::prelude::AssayErrorModels, +//! # parameter_space: pmcore::prelude::ParameterSpace) +//! # -> anyhow::Result<()> { +//! let config = pmcore::bestdose::BestDoseConfig::new(parameter_space, error_models) +//! .with_refinement_cycles(100) +//! .with_prediction_interval(30.0); +//! +//! let problem = BestDoseProblem::new( +//! &population_theta, &population_weights, None, target, None, +//! eq, +//! DoseRange::new(0.0, 1000.0), 0.5, +//! config, +//! Target::Concentration, +//! )?; +//! # Ok(()) +//! # } +//! ``` +//! //! # See Also //! -//! - [`BestDosePosterior`]: Two-stage API entry point (compute posterior, then optimize) +//! - [`BestDoseProblem`]: Main entry point for optimization //! - [`BestDoseResult`]: Output structure with optimal doses //! - [`Target`]: Enum for concentration vs AUC targets //! - [`DoseRange`]: Dose constraint specification -pub(crate) mod cost; +pub mod cost; mod optimization; mod posterior; -pub(crate) mod predictions; +pub mod predictions; mod types; // Re-export public API pub use types::{ - BestDosePosterior, BestDoseResult, BestDoseStatus, DoseRange, OptimalMethod, Target, + BestDoseConfig, BestDosePosterior, BestDoseProblem, BestDoseResult, BestDoseStatus, DoseRange, + OptimalMethod, 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 `effective_offset` (absolute) +/// 2. Offsets all future subject event times by `time_offset` /// 3. Combines into single continuous subject /// -/// Note: This function receives the **effective** (absolute) offset, computed -/// by `optimize()` as `max_past_time + time_offset` where `time_offset` is the -/// user-facing gap parameter. -/// /// # Arguments /// /// * `past` - Subject with past history (only doses will be used) /// * `future` - Subject template for future (all events: doses + observations) -/// * `effective_offset` - Absolute 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, effective_offset) -/// - Future doses + observations at offset times [effective_offset, ∞) +/// - Past doses at original times [0, time_offset) +/// - Future doses + observations at offset times [time_offset, ∞) /// /// # Example /// @@ -308,24 +327,24 @@ pub use types::{ /// // 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 (max_past_time = 6) +/// .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) // At absolute t=6 (with gap=0) -/// .observation(24.0, 10.0, 0) // At absolute t=30 (with gap=0) +/// .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(); /// -/// // effective_offset = max_past_time(6) + gap(0) = 6 +/// // 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), +/// // 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, - effective_offset: f64, + time_offset: f64, ) -> pharmsol::prelude::Subject { use pharmsol::prelude::*; @@ -349,20 +368,17 @@ fn concatenate_past_and_future( } } - // Add future events with effective offset + // 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() + effective_offset, - bolus.amount(), - bolus.input(), - ); + builder = + builder.bolus(bolus.time() + time_offset, bolus.amount(), bolus.input()); } Event::Infusion(inf) => { builder = builder.infusion( - inf.time() + effective_offset, + inf.time() + time_offset, inf.amount(), inf.input(), inf.duration(), @@ -371,7 +387,7 @@ fn concatenate_past_and_future( Event::Observation(obs) => { builder = match obs.value() { Some(val) => { - builder.observation(obs.time() + effective_offset, val, obs.outeq()) + builder.observation(obs.time() + time_offset, val, obs.outeq()) } None => builder, }; @@ -383,65 +399,79 @@ fn concatenate_past_and_future( builder.build() } +/// 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(infusion) => { + // Infusion is optimizable if amount is 0 (placeholder) + mask.push(infusion.amount() == 0.0); + } + Event::Observation(_) => { + // Observations don't go in the mask + } + } + } + } + + mask +} + use anyhow::Result; use pharmsol::prelude::*; use pharmsol::ODE; -use crate::routines::settings::Settings; -use crate::structs::theta::Theta; -use crate::structs::weights::Weights; - -use types::BestDoseProblem; +use crate::estimation::nonparametric::{Theta, Weights}; // ═════════════════════════════════════════════════════════════════════════════ // BestDosePosterior: Public two-stage API // ═════════════════════════════════════════════════════════════════════════════ impl BestDosePosterior { - /// **Stage 1**: Compute the Bayesian posterior density from population prior and patient data - /// - /// This performs the expensive posterior calculation (NPAGFULL11 filtering + NPAGFULL refinement) - /// and returns a reusable `BestDosePosterior` that can be optimized multiple times. - /// - /// # Algorithm - /// - /// ```text - /// Prior (N support points) - /// ↓ - /// NPAGFULL11: Bayesian filtering - /// P(θᵢ|data) ∝ P(data|θᵢ) × P(θᵢ) - /// ↓ - /// Filtered posterior (M points) - /// ↓ - /// NPAGFULL: Local refinement (max_cycles iterations) - /// ↓ - /// Refined posterior (M points with updated weights) - /// ``` - /// - /// # Arguments - /// - /// * `population_theta` - Population support points from NPAG - /// * `population_weights` - Population probabilities - /// * `past_data` - Patient history (`None` = use prior directly) - /// * `eq` - Pharmacokinetic/pharmacodynamic model - /// * `settings` - NPAG settings (includes error models and posterior refinement config) - /// - /// # Example - /// - /// ```rust,no_run,ignore - /// let posterior = BestDosePosterior::compute( - /// &theta, &weights, - /// Some(past_subject), - /// eq, settings, - /// )?; - /// println!("Posterior has {} support points", posterior.n_support_points()); - /// ``` + /// Stage 1: compute the reusable posterior density from the population prior and patient data. pub fn compute( population_theta: &Theta, population_weights: &Weights, past_data: Option, eq: ODE, - settings: Settings, + config: BestDoseConfig, ) -> Result { tracing::info!("╔══════════════════════════════════════════════════════════╗"); tracing::info!("║ BestDose Algorithm: STAGE 1 ║"); @@ -454,8 +484,8 @@ impl BestDosePosterior { population_weights, past_data.as_ref(), &eq, - &settings.errormodels, - &settings, + config.error_models(), + &config, )?; tracing::info!("╔══════════════════════════════════════════════════════════╗"); @@ -469,41 +499,11 @@ impl BestDosePosterior { population_weights: filtered_population_weights, past_data, eq, - settings, + config, }) } - /// **Stage 2**: Optimize doses for target outcomes using the computed posterior - /// - /// This runs the dual optimization (posterior weights vs uniform weights) and - /// returns the best dosing regimen. Can be called multiple times on the same - /// posterior with different parameters. - /// - /// # Arguments - /// - /// * `target` - Future dosing template with target observations - /// * `time_offset` - Optional gap (in hours) between the last past event and the start of - /// the future target. 0 means the future starts immediately after the last past event. - /// The effective absolute offset is `max_past_time + time_offset`. - /// * `dose_range` - Allowable dose constraints - /// * `bias_weight` - λ in \[0,1\]: 0=personalized, 1=population - /// * `target_type` - Concentration or AUC targets - /// - /// # Example - /// - /// ```rust,no_run,ignore - /// // Try different bias weights - /// for &bw in &[0.0, 0.25, 0.5, 0.75, 1.0] { - /// let result = posterior.optimize( - /// target.clone(), - /// None, - /// DoseRange::new(0.0, 300.0), - /// bw, - /// Target::Concentration, - /// )?; - /// println!("λ={}: dose={:.1}", bw, result.doses()[0]); - /// } - /// ``` + /// Stage 2: optimize future doses against the computed posterior. pub fn optimize( &self, target: Subject, @@ -519,10 +519,6 @@ impl BestDosePosterior { tracing::info!(" Target type: {:?}", target_type); tracing::info!(" Bias weight (λ): {}", bias_weight); - // Validate and compute effective time_offset - // time_offset is a gap relative to the last past event: - // effective_offset = max_past_time + time_offset - // So time_offset=0 means "future starts right after last past event" if let Some(t) = time_offset { if t < 0.0 { return Err(anyhow::anyhow!( @@ -533,7 +529,6 @@ impl BestDosePosterior { } } - // Compute the absolute offset for concatenation let effective_offset = time_offset.map(|t| { let max_past_time = self .past_data @@ -553,9 +548,6 @@ impl BestDosePosterior { max_past_time + t }); - // Handle past/future concatenation if needed - // When time_offset is provided, offset all target event times by the - // effective offset (max_past_time + gap) and prepend past doses. let final_target = match effective_offset { None => target, Some(eff) => { @@ -581,7 +573,6 @@ impl BestDosePosterior { } }; - // Validate that the target has observations let has_observations = final_target .occasions() .iter() @@ -593,7 +584,6 @@ impl BestDosePosterior { )); } - // Build the internal optimization problem let problem = BestDoseProblem { target: final_target, target_type, @@ -601,12 +591,11 @@ impl BestDosePosterior { theta: self.theta.clone(), posterior: self.posterior.clone(), eq: self.eq.clone(), - settings: self.settings.clone(), + config: self.config.clone(), doserange: dose_range, bias_weight, }; - // Run dual optimization + final predictions optimization::dual_optimization(&problem) } } @@ -615,6 +604,32 @@ impl BestDosePosterior { // Helper Functions for STAGE 1: Posterior Density Calculation // ═════════════════════════════════════════════════════════════════════════════ +/// 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() + .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 time_offset < max_past_time { + return Err(anyhow::anyhow!( + "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 + )); + } + } + Ok(()) +} + /// Calculate posterior density (STAGE 1: Two-step process) /// /// # Algorithm Flow (Matches Diagram) @@ -647,7 +662,7 @@ fn calculate_posterior_density( past_data: Option<&Subject>, eq: &ODE, error_models: &AssayErrorModels, - settings: &Settings, + config: &BestDoseConfig, ) -> Result<(Theta, Weights, Weights, Subject)> { match past_data { None => { @@ -691,7 +706,7 @@ fn calculate_posterior_density( &past_data_obj, eq, error_models, - settings, + config, )?; Ok(( @@ -704,3 +719,235 @@ fn calculate_posterior_density( } } } + +/// 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, + time_offset: Option, +) -> Result<(Subject, Subject)> { + match time_offset { + 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..."); + + let combined = concatenate_past_and_future(&past_subject, &target, t); + + // 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); + + Ok((combined, past_subject)) + } + } +} + +// ═════════════════════════════════════════════════════════════════════════════ + +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 + /// + /// * `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 + /// * `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 + /// * `bias_weight` - λ ∈ [0,1]: 0=personalized, 1=population + /// * `config` - BestDose nonparametric configuration + /// * `target_type` - Concentration or AUC targets + /// + /// # Returns + /// + /// BestDoseProblem ready for `optimize()` + #[allow(clippy::too_many_arguments)] + pub fn new( + population_theta: &Theta, + population_weights: &Weights, + past_data: Option, + target: Subject, + time_offset: Option, + eq: ODE, + doserange: DoseRange, + bias_weight: f64, + config: BestDoseConfig, + 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) = time_offset { + validate_time_offset(t, &past_data)?; + } + + // ═════════════════════════════════════════════════════════════ + // STAGE 1: Calculate Posterior Density + // ═════════════════════════════════════════════════════════════ + let (posterior_theta, posterior_weights, filtered_population_weights, past_subject) = + calculate_posterior_density( + population_theta, + population_weights, + past_data.as_ref(), + &eq, + config.error_models(), + &config, + )?; + + // Handle past/future concatenation if needed + let (final_target, _) = prepare_target_subject(past_subject, target, time_offset)?; + + 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 { + target: final_target, + target_type, + population_weights: filtered_population_weights, + theta: posterior_theta, + posterior: posterior_weights, + eq, + config, + doserange, + bias_weight, + }) + } + + /// 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!("╚══════════════════════════════════════════════════════════╝"); + + // STAGE 2 & 3: Dual optimization + predictions + optimization::dual_optimization(&self) + } + + /// 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 + } + + /// Get a reference to the refined posterior support points (Θ) + pub fn posterior_theta(&self) -> &Theta { + &self.theta + } + + /// Get the posterior probability weights + pub fn posterior_weights(&self) -> &Weights { + &self.posterior + } + + /// Get the filtered population weights used for the bias term + pub fn population_weights(&self) -> &Weights { + &self.population_weights + } + + /// Get the prepared target subject + pub fn target_subject(&self) -> &Subject { + &self.target + } + + /// Get the currently configured bias weight (λ) + pub fn bias_weight(&self) -> f64 { + self.bias_weight + } + + /// Get the selected optimization target type + pub fn target_type(&self) -> Target { + self.target_type + } +} diff --git a/src/bestdose/optimization.rs b/src/bestdose/optimization.rs index bd4056ca2..f06b0382c 100644 --- a/src/bestdose/optimization.rs +++ b/src/bestdose/optimization.rs @@ -46,7 +46,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, BestDoseStatus, OptimalMethod}; -use crate::structs::weights::Weights; +use crate::estimation::nonparametric::Weights; use pharmsol::prelude::*; /// Create initial simplex for Nelder-Mead optimization diff --git a/src/bestdose/posterior.rs b/src/bestdose/posterior.rs index 9506d390e..3c7d30a7f 100644 --- a/src/bestdose/posterior.rs +++ b/src/bestdose/posterior.rs @@ -53,14 +53,14 @@ use anyhow::Result; use faer::Mat; -use crate::algorithms::npag::burke; -use crate::algorithms::npag::NPAG; -use crate::algorithms::Algorithms; +use crate::algorithms::nonparametric::npag::burke; +use crate::algorithms::nonparametric::npag::NPAG; +use crate::algorithms::NonParametricRunner; + use crate::algorithms::Status; +use crate::bestdose::types::BestDoseConfig; +use crate::estimation::nonparametric::{calculate_psi, Theta, Weights}; use crate::prelude::*; -use crate::structs::psi::calculate_psi; -use crate::structs::theta::Theta; -use crate::structs::weights::Weights; use pharmsol::prelude::*; // ============================================================================= @@ -172,21 +172,22 @@ pub fn npagfull_refinement( filtered_weights: &Weights, past_data: &Data, eq: &ODE, - settings: &Settings, + config: &BestDoseConfig, ) -> Result<(Theta, Weights)> { - if settings.config.cycles == 0 { + if config.refinement_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={})", - settings.config.cycles + config.refinement_cycles() ); let mut refined_points = Vec::new(); let mut kept_weights: Vec = Vec::new(); let num_points = filtered_theta.matrix().nrows(); + let parameter_space = config.parameter_space().clone(); for i in 0..num_points { tracing::debug!(" Refining point {}/{}", i + 1, num_points); @@ -197,19 +198,22 @@ pub fn npagfull_refinement( // 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()).unwrap(); - - // 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(), - )); + let single_point_theta = Theta::from_parts(single_point_matrix, parameter_space.clone())?; // Create and run NPAG - let mut npag = NPAG::new(npag_settings, eq.clone(), past_data.clone())?; - npag.set_theta(single_point_theta); + let npag_config = NpagConfig { + max_cycles: config.refinement_cycles(), + progress: config.progress(), + ..Default::default() + }; + + let mut npag = NPAG::from_parts( + eq.clone(), + past_data.clone(), + config.error_models().clone(), + single_point_theta, + npag_config, + )?; // Run NPAG optimization let refinement_result = npag.initialize().and_then(|_| { @@ -280,10 +284,10 @@ pub fn npagfull_refinement( } // Build refined theta matrix - let n_params = settings.parameters().len(); + let n_params = parameter_space.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()).unwrap(); + let refined_theta = Theta::from_parts(refined_matrix, parameter_space).unwrap(); // Renormalize weights let weight_sum: f64 = kept_weights.iter().sum(); @@ -314,7 +318,7 @@ pub fn calculate_two_step_posterior( past_data: &Data, eq: &ODE, error_models: &AssayErrorModels, - settings: &Settings, + config: &BestDoseConfig, ) -> Result<(Theta, Weights, Weights)> { tracing::info!("=== STAGE 1: Posterior Density Calculation ==="); @@ -334,7 +338,7 @@ pub fn calculate_two_step_posterior( &filtered_posterior_weights, past_data, eq, - settings, + config, )?; tracing::info!( diff --git a/src/bestdose/predictions.rs b/src/bestdose/predictions.rs index 369c1065a..f7cf4946c 100644 --- a/src/bestdose/predictions.rs +++ b/src/bestdose/predictions.rs @@ -25,15 +25,13 @@ //! //! # See Also //! -//! - Configuration: `settings.predictions().idelta` controls time grid resolution +//! - Configuration: `BestDoseConfig::prediction_interval()` 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 crate::estimation::nonparametric::{NPPredictions, Posterior, Weights}; use pharmsol::prelude::*; use pharmsol::Equation; @@ -97,13 +95,27 @@ pub fn find_last_dose_time_before(subject: &Subject, obs_time: f64) -> f64 { /// /// # Returns /// Sorted, unique time vector suitable for AUC calculation +fn prediction_interval_hours(interval: f64) -> f64 { + if interval <= 0.0 { + return 1.0 / 60.0; + } + + if interval < 1.0 { + interval + } else { + interval / 60.0 + } +} + pub fn calculate_dense_times( start_time: f64, end_time: f64, obs_times: &[f64], - idelta: usize, + idelta: f64, ) -> Vec { - let idelta_hours = (idelta as f64) / 60.0; + // BestDose historically used both sub-hour values like 0.12 and minute-style + // values like 60.0. Treat values below 1.0 as hours and larger values as minutes. + let idelta_hours = prediction_interval_hours(idelta); let mut times = Vec::new(); // Add observation times @@ -341,11 +353,10 @@ pub(crate) fn calculate_final_predictions( }) .collect(); - let idelta = problem.settings.predictions().idelta; + let idelta = problem.config.prediction_interval(); 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 dense_times = calculate_dense_times(start_time, end_time, &obs_times, idelta); let subject_id = target_with_optimal.id().to_string(); let mut builder = Subject::builder(&subject_id); @@ -376,10 +387,19 @@ pub(crate) fn calculate_final_predictions( .iter() .flat_map(|occ| occ.events()) .filter_map(|event| match event { - Event::Observation(obs) => Some((obs.time(), obs.outeq())), + Event::Observation(obs) => Some( + obs.outeq_index() + .map(|outeq| (obs.time(), outeq)) + .ok_or_else(|| { + anyhow::anyhow!( + "BestDose predictions require numeric observation output labels; got `{}`", + obs.outeq() + ) + }), + ), _ => None, }) - .collect(); + .collect::>>()?; let mut unique_outeqs: Vec = obs_time_outeq.iter().map(|(_, outeq)| *outeq).collect(); @@ -410,7 +430,9 @@ pub(crate) fn calculate_final_predictions( // 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 pred = problem + .eq + .simulate_subject_dense(&dense_subject, &spp, None)?; let dense_predictions_with_outeq = pred.0.predictions(); // Group predictions by outeq diff --git a/src/bestdose/types.rs b/src/bestdose/types.rs index 1e2a943c5..5c08de9c5 100644 --- a/src/bestdose/types.rs +++ b/src/bestdose/types.rs @@ -1,18 +1,17 @@ //! Core data types for the BestDose algorithm //! //! This module defines the main structures used throughout the BestDose optimization: -//! - [`BestDosePosterior`]: Two-stage API entry point — compute posterior, then optimize +//! - [`BestDosePosterior`]: Reusable posterior from stage 1 +//! - [`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 std::fmt::Display; +use crate::estimation::nonparametric::{NPPredictions, Theta, Weights}; +use crate::model::{BoundedParameter, ParameterSpace}; 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}; @@ -51,7 +50,8 @@ use serde::{Deserialize, Serialize}; /// - 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`. +/// Both methods use trapezoidal rule on a dense time grid controlled by +/// `BestDoseConfig::prediction_interval()`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Target { /// Target concentrations at observation times @@ -85,9 +85,10 @@ pub enum Target { /// /// # Time Grid Resolution /// - /// Control the time grid density via settings: + /// Control the time grid density via BestDoseConfig: /// ```rust,ignore - /// settings.predictions().idelta = 15; // 15-minute intervals + /// let config = BestDoseConfig::new(parameter_space, error_models) + /// .with_prediction_interval(15.0); /// ``` AUCFromZero, @@ -183,131 +184,228 @@ impl Default for DoseRange { } } -/// The computed Bayesian posterior for a patient -/// -/// This is the main public entry point for the two-stage BestDose API: -/// -/// 1. **Stage 1: Posterior computation** ([`BestDosePosterior::compute()`]) -/// - NPAGFULL11: Bayesian filtering of prior support points -/// - NPAGFULL: Local refinement of each filtered point -/// -/// 2. **Stage 2: Dose optimization** ([`BestDosePosterior::optimize()`]) -/// - Dual optimization (posterior vs uniform weights) -/// - Final predictions with optimal doses -/// -/// The posterior can be reused across multiple `optimize()` calls with -/// different targets, dose ranges, or bias weights. -/// -/// # Example -/// -/// ```rust,no_run,ignore -/// use pmcore::bestdose::{BestDosePosterior, Target, DoseRange}; -/// -/// # 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, -/// # settings: pmcore::routines::settings::Settings) -/// # -> anyhow::Result<()> { -/// // Stage 1: Compute posterior (expensive, done once) -/// let posterior = BestDosePosterior::compute( -/// &population_theta, -/// &population_weights, -/// Some(past), -/// eq, -/// settings, -/// )?; +#[derive(Debug, Clone)] +pub struct BestDoseConfig { + pub(crate) parameter_space: ParameterSpace, + pub(crate) error_models: AssayErrorModels, + pub(crate) refinement_cycles: usize, + pub(crate) progress: bool, + pub(crate) prediction_interval: f64, +} + +impl BestDoseConfig { + pub fn new(parameter_space: ParameterSpace, error_models: AssayErrorModels) -> Self { + Self { + parameter_space, + error_models, + refinement_cycles: 500, + progress: true, + prediction_interval: 0.12, + } + } + + pub fn with_refinement_cycles(mut self, refinement_cycles: usize) -> Self { + self.refinement_cycles = refinement_cycles; + self + } + + pub fn with_progress(mut self, progress: bool) -> Self { + self.progress = progress; + self + } + + pub fn with_prediction_interval(mut self, prediction_interval: f64) -> Self { + self.prediction_interval = prediction_interval; + self + } + + pub fn parameter_space(&self) -> &ParameterSpace { + &self.parameter_space + } + + pub fn error_models(&self) -> &AssayErrorModels { + &self.error_models + } + + pub fn refinement_cycles(&self) -> usize { + self.refinement_cycles + } + + pub fn progress(&self) -> bool { + self.progress + } + + pub fn prediction_interval(&self) -> f64 { + self.prediction_interval + } + + pub fn parameter_names(&self) -> Vec { + self.parameter_space + .iter() + .map(|parameter| parameter.name.clone()) + .collect() + } +} + +/// The computed Bayesian posterior for a patient. /// -/// // Stage 2: Optimize doses (can be called multiple times) -/// let result = posterior.optimize( -/// target, -/// None, // No time offset -/// DoseRange::new(0.0, 1000.0), -/// 0.5, // bias_weight -/// Target::Concentration, -/// )?; -/// # Ok(()) -/// # } -/// ``` +/// This reusable object is the public two-stage BestDose entry point: +/// first compute the posterior once, then optimize multiple future targets. #[derive(Debug, Clone)] pub struct BestDosePosterior { - /// Refined posterior support points (from NPAGFULL11 + NPAGFULL) pub(crate) theta: Theta, - /// Posterior probability weights pub(crate) posterior: Weights, - /// Filtered population weights (used for bias term in cost function) pub(crate) population_weights: Weights, - /// Past patient data (stored for use in optimize() with time_offset) pub(crate) past_data: Option, - /// PK/PD model pub(crate) eq: ODE, - /// Settings (used for prediction grid, error models, etc.) - pub(crate) settings: Settings, + pub(crate) config: BestDoseConfig, } impl BestDosePosterior { - /// Get the refined posterior support points (Θ) pub fn theta(&self) -> &Theta { &self.theta } - /// Get the posterior probability weights pub fn posterior_weights(&self) -> &Weights { &self.posterior } - /// Get the filtered population weights used for the bias term pub fn population_weights(&self) -> &Weights { &self.population_weights } - /// Get the number of support points in the posterior pub fn n_support_points(&self) -> usize { self.theta.matrix().nrows() } } -/// Internal optimization problem (not exposed in public API) +/// 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 /// -/// Contains all data needed for dose optimization. -/// Created internally by [`BestDosePosterior::optimize()`]. +/// ## Input Data +/// - `target`: Future dosing template with target observations +/// - `target_type`: [`Target::Concentration`] or [`Target::AUC`] +/// +/// ## Population Prior +/// - `population_weights`: Filtered population probability weights (used for bias term) +/// +/// ## Patient-Specific Posterior +/// - `theta`: Refined posterior support points (from NPAGFULL11 + NPAGFULL) +/// - `posterior`: Posterior probability weights +/// +/// ## Model Components +/// - `eq`: Pharmacokinetic/pharmacodynamic ODE model +/// - `config`: BestDose nonparametric configuration (used for prediction grid) +/// +/// ## Optimization Parameters +/// - `doserange`: Min/max dose constraints +/// - `bias_weight` (λ): Personalization parameter (0=personalized, 1=population) +/// +/// # Example +/// +/// ```rust,no_run,ignore +/// use pmcore::bestdose::{BestDoseProblem, Target, DoseRange}; +/// +/// # fn example(population_theta: pmcore::estimation::nonparametric::Theta, +/// # population_weights: pmcore::estimation::nonparametric::Weights, +/// # past: pharmsol::prelude::Subject, +/// # target: pharmsol::prelude::Subject, +/// # eq: pharmsol::prelude::ODE, +/// # config: pmcore::bestdose::BestDoseConfig) +/// # -> anyhow::Result<()> { +/// let problem = BestDoseProblem::new( +/// &population_theta, +/// &population_weights, +/// Some(past), // Patient history +/// target, // Dosing template with targets +/// None, // time offset +/// eq, +/// DoseRange::new(0.0, 1000.0), +/// 0.5, // Balanced personalization +/// config, +/// Target::Concentration, +/// )?; +/// +/// let result = problem.optimize()?; +/// # Ok(()) +/// # } +/// ``` #[derive(Debug, Clone)] -pub(crate) struct BestDoseProblem { +pub struct BestDoseProblem { + /// 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, + + /// 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(crate) theta: Theta, pub(crate) posterior: Weights, + + // Model and configuration pub(crate) eq: ODE, - pub(crate) settings: Settings, + pub(crate) config: BestDoseConfig, + + // Optimization parameters pub(crate) doserange: DoseRange, - pub(crate) bias_weight: f64, + pub(crate) bias_weight: f64, // λ: 0=personalized, 1=population } /// Result from BestDose optimization /// /// Contains the optimal doses and associated predictions from running -/// [`BestDosePosterior::optimize()`]. +/// [`BestDoseProblem::optimize()`]. /// /// # Fields /// -/// - `doses`: Optimal dose amount(s) in the same order as doses in target subject +/// - `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 (converged or max iterations) -/// - `predictions`: Concentration-time predictions using optimal doses -/// - `auc_predictions`: AUC values at observation times (only for AUC targets) -/// - `optimization_method`: Which method won: `Posterior` or `Uniform` +/// - `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) +/// - **"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) +/// - **"uniform"**: Population-based optimization won (uses uniform weights) /// - Indicates patient is population-typical or has limited history /// - Doses are more conservative/robust /// @@ -323,26 +421,32 @@ pub(crate) struct BestDoseProblem { /// ## Extracting Results /// /// ```rust,no_run,ignore -/// # use pmcore::bestdose::{BestDosePosterior, Target, DoseRange, BestDoseResult}; -/// # fn example(posterior: BestDosePosterior, -/// # target: pharmsol::prelude::Subject) -> anyhow::Result<()> { -/// let result = posterior.optimize( -/// target, None, DoseRange::new(0.0, 1000.0), 0.5, Target::Concentration, -/// )?; +/// # use pmcore::bestdose::BestDoseProblem; +/// # fn example(problem: BestDoseProblem) -> anyhow::Result<()> { +/// let result = problem.optimize()?; /// /// // Single dose -/// println!("Optimal dose: {} mg", result.doses()[0]); +/// println!("Optimal dose: {} mg", result.dose[0]); /// /// // Multiple doses -/// for (i, dose) in result.doses().iter().enumerate() { +/// for (i, &dose) in result.dose.iter().enumerate() { /// println!("Dose {}: {} mg", i + 1, dose); /// } /// /// // Check which method was used -/// println!("Method: {}", result.optimization_method()); +/// 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() { +/// 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); /// } diff --git a/src/estimation/error_models.rs b/src/estimation/error_models.rs new file mode 100644 index 000000000..4dcc27b5b --- /dev/null +++ b/src/estimation/error_models.rs @@ -0,0 +1,26 @@ +use pharmsol::AssayErrorModels; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "family", content = "models", rename_all = "snake_case")] +pub enum ErrorModels { + Nonparametric(AssayErrorModels), +} + +impl ErrorModels { + pub fn models(&self) -> &AssayErrorModels { + match self { + Self::Nonparametric(models) => models, + } + } + + pub fn is_empty(&self) -> bool { + self.models().iter().next().is_none() + } +} + +impl Into for ErrorModels { + fn into(self) -> AssayErrorModels { + self.models().clone() + } +} diff --git a/src/estimation/mod.rs b/src/estimation/mod.rs new file mode 100644 index 000000000..2bf9f0104 --- /dev/null +++ b/src/estimation/mod.rs @@ -0,0 +1,14 @@ +pub mod error_models; +pub mod nonparametric; +pub mod problem; +pub mod progress; + +pub use crate::algorithms::nonparametric::{ + NonParametricAlgorithm, NpagConfig, NpmapConfig, NpodConfig, +}; +pub use crate::algorithms::parametric::{ParametricAlgorithm, SaemConfig}; +pub use error_models::ErrorModels; +pub use problem::{ + EstimationProblem, Framework, NonParametric, Parametric, ParametricBuilder, +}; +pub use progress::{FitProgress, NonparametricCycleProgress}; diff --git a/src/routines/output/cycles.rs b/src/estimation/nonparametric/cycles.rs similarity index 81% rename from src/routines/output/cycles.rs rename to src/estimation/nonparametric/cycles.rs index 24af587e0..8115a9bff 100644 --- a/src/routines/output/cycles.rs +++ b/src/estimation/nonparametric/cycles.rs @@ -1,3 +1,5 @@ +use std::{fs::File, path::Path}; + use anyhow::Result; use csv::WriterBuilder; use pharmsol::{AssayErrorModel, AssayErrorModels}; @@ -5,20 +7,10 @@ use serde::Serialize; use crate::{ algorithms::{Status, StopReason}, - prelude::Settings, - routines::output::{median, OutputFile}, - structs::theta::Theta, + estimation::nonparametric::median, + estimation::nonparametric::theta::Theta, }; -/// An [NPCycle] object contains the summary of a cycle -/// It holds the following information: -/// - `cycle`: The cycle number -/// - `objf`: The objective function value -/// - `gamlam`: The assay noise parameter, either gamma or lambda -/// - `theta`: The support points and their associated probabilities -/// - `nspp`: The number of support points -/// - `delta_objf`: The change in objective function value from last cycle -/// - `converged`: Whether the algorithm has reached convergence #[derive(Debug, Clone, Serialize)] pub struct NPCycle { cycle: usize, @@ -86,7 +78,6 @@ impl NPCycle { } } -/// This holdes a vector of [NPCycle] objects to provide a more detailed log #[derive(Debug, Clone, Serialize)] pub struct CycleLog { cycles: Vec, @@ -105,14 +96,13 @@ impl CycleLog { self.cycles.push(cycle); } - pub fn write(&self, settings: &Settings) -> Result<()> { + pub fn write(&self, path: &Path) -> Result<()> { tracing::debug!("Writing cycles..."); - let outputfile = OutputFile::new(&settings.output().path, "cycles.csv")?; - let mut writer = WriterBuilder::new() - .has_headers(false) - .from_writer(&outputfile.file); - // Write headers + std::fs::create_dir_all(path)?; + let file = File::create(path)?; + let mut writer = WriterBuilder::new().has_headers(true).from_writer(file); + writer.write_field("cycle")?; writer.write_field("converged")?; writer.write_field("status")?; @@ -135,8 +125,13 @@ impl CycleLog { )?; } - let parameter_names = settings.parameters().names(); - for param_name in ¶meter_names { + let names = self + .cycles + .first() + .map(|cycle| cycle.theta.param_names()) + .expect("No cycles"); + + for param_name in names { writer.write_field(format!("{}.mean", param_name))?; writer.write_field(format!("{}.median", param_name))?; writer.write_field(format!("{}.sd", param_name))?; @@ -156,7 +151,6 @@ impl CycleLog { .write_field(format!("{}", cycle.theta.nspp())) .unwrap(); - // Write the error models cycle.error_models.iter().try_for_each( |(_, errmod): (usize, &AssayErrorModel)| -> Result<()> { match errmod { @@ -187,7 +181,8 @@ impl CycleLog { writer.write_record(None::<&[u8]>)?; } writer.flush()?; - tracing::debug!("Cycles written to {:?}", &outputfile.relative_path()); + + tracing::debug!("Cycles written to {:?}", path); Ok(()) } } diff --git a/src/estimation/nonparametric/expansion.rs b/src/estimation/nonparametric/expansion.rs new file mode 100644 index 000000000..7ca892ae0 --- /dev/null +++ b/src/estimation/nonparametric/expansion.rs @@ -0,0 +1,63 @@ +use crate::estimation::nonparametric::Theta; +use anyhow::Result; +use faer::Row; + +/// Implements the adaptive grid algorithm for support point expansion. +pub fn adaptative_grid( + theta: &mut Theta, + eps: f64, + ranges: &[(f64, f64)], + min_dist: f64, +) -> Result<()> { + let mut candidates = Vec::new(); + + for spp in theta.matrix().row_iter() { + for (j, val) in spp.iter().enumerate() { + let l = eps * (ranges[j].1 - ranges[j].0); + if val + l < ranges[j].1 { + let mut plus = Row::zeros(spp.ncols()); + plus[j] = l; + plus += spp; + candidates.push(plus.iter().copied().collect::>()); + } + if val - l > ranges[j].0 { + let mut minus = Row::zeros(spp.ncols()); + minus[j] = -l; + minus += spp; + candidates.push(minus.iter().copied().collect::>()); + } + } + } + + let keep = candidates + .iter() + .filter(|point| theta.check_point(point, min_dist)) + .cloned() + .collect::>(); + + for point in keep { + theta.add_point(point.as_slice())?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{BoundedParameter, ParameterSpace}; + use faer::mat; + + #[test] + fn adaptive_grid_expands_points_within_bounds() { + let parameters = ParameterSpace::::new() + .add("x", 0.0, 1.0) + .add("y", 0.0, 1.0); + let mut theta = Theta::from_parts(mat![[0.5, 0.5]], parameters).unwrap(); + let ranges = [(0.0, 1.0), (0.0, 1.0)]; + + adaptative_grid(&mut theta, 0.1, &ranges, 0.05).unwrap(); + + assert_eq!(theta.matrix().nrows(), 5); + } +} diff --git a/src/estimation/nonparametric/ipm.rs b/src/estimation/nonparametric/ipm.rs new file mode 100644 index 000000000..719fe1b55 --- /dev/null +++ b/src/estimation/nonparametric/ipm.rs @@ -0,0 +1,242 @@ +use crate::estimation::nonparametric::{Psi, Weights}; +use anyhow::bail; +use faer::linalg::triangular_solve::solve_lower_triangular_in_place; +use faer::linalg::triangular_solve::solve_upper_triangular_in_place; +use faer::{Col, Mat, Row}; +use rayon::prelude::*; + +/// Applies Burke's Interior Point Method (IPM) to solve a convex optimization problem. +pub fn burke(psi: &Psi) -> anyhow::Result<(Weights, f64)> { + let mut psi = psi.matrix().to_owned(); + + psi.row_iter_mut().try_for_each(|row| { + row.iter_mut().try_for_each(|x| { + if !x.is_finite() { + bail!("Input matrix must have finite entries") + } else { + *x = x.abs(); + Ok(()) + } + }) + })?; + + let (n_sub, n_point) = psi.shape(); + let ecol: Col = Col::from_fn(n_point, |_| 1.0); + let erow: Row = Row::from_fn(n_sub, |_| 1.0); + let mut plam: Col = &psi * &ecol; + let eps: f64 = 1e-8; + let mut sig: f64 = 0.0; + let mut lam = ecol.clone(); + let mut w: Col = Col::from_fn(plam.nrows(), |i| 1.0 / plam.get(i)); + let mut ptw: Col = psi.transpose() * &w; + + let ptw_max = ptw.iter().fold(f64::NEG_INFINITY, |acc, &x| x.max(acc)); + let shrink = 2.0 * ptw_max; + lam *= shrink; + plam *= shrink; + w /= shrink; + ptw /= shrink; + + let mut y: Col = &ecol - &ptw; + let mut r: Col = Col::from_fn(n_sub, |i| erow.get(i) - w.get(i) * plam.get(i)); + let mut norm_r: f64 = r.iter().fold(0.0, |max, &val| max.max(val.abs())); + let sum_log_plam: f64 = plam.iter().map(|x| x.ln()).sum(); + let sum_log_w: f64 = w.iter().map(|x| x.ln()).sum(); + let mut gap: f64 = (sum_log_w + sum_log_plam).abs() / (1.0 + sum_log_plam); + let mut mu = lam.transpose() * &y / n_point as f64; + + let mut psi_inner: Mat = Mat::zeros(psi.nrows(), psi.ncols()); + let n_threads = faer::get_global_parallelism().degree(); + let rows = psi.nrows(); + let mut output: Vec> = (0..n_threads).map(|_| Mat::zeros(rows, rows)).collect(); + let mut h: Mat = Mat::zeros(rows, rows); + + while mu > eps || norm_r > eps || gap > eps { + let smu = sig * mu; + let inner = Col::from_fn(lam.nrows(), |i| lam.get(i) / y.get(i)); + let w_plam = Col::from_fn(plam.nrows(), |i| plam.get(i) / w.get(i)); + + if psi.ncols() > n_threads * 128 { + psi_inner + .par_col_partition_mut(n_threads) + .zip(psi.par_col_partition(n_threads)) + .zip(inner.par_partition(n_threads)) + .zip(output.par_iter_mut()) + .for_each(|(((mut psi_inner, psi), inner), output)| { + psi_inner + .as_mut() + .col_iter_mut() + .zip(psi.col_iter()) + .zip(inner.iter()) + .for_each(|((col, psi_col), inner_val)| { + col.iter_mut().zip(psi_col.iter()).for_each(|(x, psi_val)| { + *x = psi_val * inner_val; + }); + }); + faer::linalg::matmul::triangular::matmul( + output.as_mut(), + faer::linalg::matmul::triangular::BlockStructure::TriangularLower, + faer::Accum::Replace, + &psi_inner, + faer::linalg::matmul::triangular::BlockStructure::Rectangular, + psi.transpose(), + faer::linalg::matmul::triangular::BlockStructure::Rectangular, + 1.0, + faer::Par::Seq, + ); + }); + + let mut first_iter = true; + for output in &output { + if first_iter { + h.copy_from(output); + first_iter = false; + } else { + h += output; + } + } + } else { + psi_inner + .as_mut() + .col_iter_mut() + .zip(psi.col_iter()) + .zip(inner.iter()) + .for_each(|((col, psi_col), inner_val)| { + col.iter_mut().zip(psi_col.iter()).for_each(|(x, psi_val)| { + *x = psi_val * inner_val; + }); + }); + faer::linalg::matmul::triangular::matmul( + h.as_mut(), + faer::linalg::matmul::triangular::BlockStructure::TriangularLower, + faer::Accum::Replace, + &psi_inner, + faer::linalg::matmul::triangular::BlockStructure::Rectangular, + psi.transpose(), + faer::linalg::matmul::triangular::BlockStructure::Rectangular, + 1.0, + faer::Par::Seq, + ); + } + + for i in 0..h.nrows() { + h[(i, i)] += w_plam[i]; + } + + let uph = match h.llt(faer::Side::Lower) { + Ok(llt) => llt, + Err(_) => { + bail!("Error during Cholesky decomposition. The matrix might not be positive definite. This is usually due to model misspecification or numerical issues.") + } + }; + let uph = uph.L().transpose().to_owned(); + + let smuyinv: Col = Col::from_fn(ecol.nrows(), |i| smu * (ecol[i] / y[i])); + let psi_dot_muyinv: Col = &psi * &smuyinv; + let rhsdw: Row = Row::from_fn(erow.ncols(), |i| erow[i] / w[i] - psi_dot_muyinv[i]); + let mut dw = Mat::from_fn(rhsdw.ncols(), 1, |i, _j| *rhsdw.get(i)); + + solve_lower_triangular_in_place(uph.transpose().as_ref(), dw.as_mut(), faer::Par::rayon(0)); + solve_upper_triangular_in_place(uph.as_ref(), dw.as_mut(), faer::Par::rayon(0)); + + let dw = dw.col(0); + let dy = -(psi.transpose() * dw); + let inner_times_dy = Col::from_fn(ecol.nrows(), |i| inner[i] * dy[i]); + let dlam: Row = + Row::from_fn(ecol.nrows(), |i| smuyinv[i] - lam[i] - inner_times_dy[i]); + + let ratio_dlam_lam = Row::from_fn(lam.nrows(), |i| dlam[i] / lam[i]); + let min_ratio_dlam = ratio_dlam_lam.iter().cloned().fold(f64::INFINITY, f64::min); + let mut alfpri: f64 = -1.0 / min_ratio_dlam.min(-0.5); + alfpri = (0.99995 * alfpri).min(1.0); + + let ratio_dy_y = Row::from_fn(y.nrows(), |i| dy[i] / y[i]); + let min_ratio_dy = ratio_dy_y.iter().cloned().fold(f64::INFINITY, f64::min); + let ratio_dw_w = Row::from_fn(dw.nrows(), |i| dw[i] / w[i]); + let min_ratio_dw = ratio_dw_w.iter().cloned().fold(f64::INFINITY, f64::min); + let mut alfdual = -1.0 / min_ratio_dy.min(-0.5); + alfdual = alfdual.min(-1.0 / min_ratio_dw.min(-0.5)); + alfdual = (0.99995 * alfdual).min(1.0); + + lam += alfpri * dlam.transpose(); + w += alfdual * dw; + y += alfdual * &dy; + + mu = lam.transpose() * &y / n_point as f64; + plam = &psi * &lam; + r = Col::from_fn(n_sub, |i| erow.get(i) - w.get(i) * plam.get(i)); + ptw -= alfdual * dy; + + norm_r = r.norm_max(); + let sum_log_plam: f64 = plam.iter().map(|x| x.ln()).sum(); + let sum_log_w: f64 = w.iter().map(|x| x.ln()).sum(); + gap = (sum_log_w + sum_log_plam).abs() / (1.0 + sum_log_plam); + + if mu < eps && norm_r > eps { + sig = 1.0; + } else { + let candidate1 = (1.0 - alfpri).powi(2); + let candidate2 = (1.0 - alfdual).powi(2); + let candidate3 = (norm_r - mu) / (norm_r + 100.0 * mu); + sig = candidate1.max(candidate2).max(candidate3).min(0.3); + } + } + + lam /= n_sub as f64; + let obj = (psi * &lam).iter().map(|x| x.ln()).sum(); + let lam_sum: f64 = lam.iter().sum(); + lam = &lam / lam_sum; + + Ok((lam.into(), obj)) +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + use faer::Mat; + + #[test] + fn test_burke_identity() { + let n = 100; + let mat = Mat::identity(n, n); + let psi = Psi::from(mat); + let (lam, _) = burke(&psi).unwrap(); + + let expected = 1.0 / n as f64; + for i in 0..n { + assert_relative_eq!(lam[i], expected, epsilon = 1e-10); + } + assert_relative_eq!(lam.iter().sum::(), 1.0, epsilon = 1e-10); + } + + #[test] + fn test_burke_uniform_square() { + let n_sub = 10; + let n_point = 10; + let mat = Mat::from_fn(n_sub, n_point, |_, _| 1.0); + let psi = Psi::from(mat); + let (lam, _) = burke(&psi).unwrap(); + + assert_relative_eq!(lam.iter().sum::(), 1.0, epsilon = 1e-10); + let expected = 1.0 / n_point as f64; + for i in 0..n_point { + assert_relative_eq!(lam[i], expected, epsilon = 1e-10); + } + } + + #[test] + fn test_burke_with_non_finite_values() { + let n_sub = 10; + let n_point = 10; + let mat = Mat::from_fn(n_sub, n_point, |i, j| { + if i == 0 && j == 0 { + f64::NAN + } else { + 1.0 + } + }); + let psi = Psi::from(mat); + assert!(burke(&psi).is_err()); + } +} diff --git a/src/estimation/nonparametric/mod.rs b/src/estimation/nonparametric/mod.rs new file mode 100644 index 000000000..c3cdb91cb --- /dev/null +++ b/src/estimation/nonparametric/mod.rs @@ -0,0 +1,28 @@ +mod cycles; + +mod expansion; +pub(crate) mod ipm; +mod posterior; +mod predictions; + +pub mod sampling; +mod psi; +pub(crate) mod qr; +mod result; +mod statistics; +mod summaries; +mod theta; +mod weights; + +pub use cycles::{CycleLog, NPCycle}; +pub(crate) use expansion::adaptative_grid; +pub use ipm::burke; +pub use posterior::{posterior, Posterior}; +pub use predictions::{NPPredictionRow, NPPredictions}; +pub(crate) use psi::calculate_psi; +pub use psi::Psi; +pub use result::NonParametricResult; +pub use statistics::{median, population_mean_median, posterior_mean_median, weighted_median}; +pub use summaries::{fit_summary, individual_summaries, population_summary}; +pub use theta::Theta; +pub use weights::Weights; diff --git a/src/routines/output/posterior.rs b/src/estimation/nonparametric/posterior.rs similarity index 68% rename from src/routines/output/posterior.rs rename to src/estimation/nonparametric/posterior.rs index 008ce16c1..d9ff19a18 100644 --- a/src/routines/output/posterior.rs +++ b/src/estimation/nonparametric/posterior.rs @@ -2,31 +2,18 @@ pub use anyhow::{bail, Result}; use faer::Mat; use serde::{Deserialize, Serialize}; -use crate::structs::{psi::Psi, weights::Weights}; +use crate::estimation::nonparametric::{psi::Psi, weights::Weights}; -/// Posterior probabilities for each support points #[derive(Debug, Clone)] pub struct Posterior { mat: Mat, } impl Posterior { - /// Create a new Posterior from a matrix fn new(mat: Mat) -> Self { Posterior { mat } } - /// Calculate the posterior probabilities for each support point given the weights - /// - /// The shape is the same as [Psi], and thus subjects are the rows and support points are the columns. - /// /// # Errors - /// Returns an error if the number of rows in `psi` does not match the number of weights in `w`. - /// # Arguments - /// * `psi` - The Psi object containing the matrix of support points. - /// * `w` - The weights for each support point. - /// # Returns - /// A Result containing the Posterior probabilities if successful, or an error if the - /// dimensions do not match. pub fn calculate(psi: &Psi, w: &Weights) -> Result { if psi.matrix().ncols() != w.weights().nrows() { bail!( @@ -46,17 +33,13 @@ impl Posterior { Ok(posterior.into()) } - /// Get a reference to the underlying matrix pub fn matrix(&self) -> &Mat { &self.mat } - /// Write the posterior probabilities to a CSV file - /// Each row represents a subject, each column represents a support point pub fn to_csv(&self, writer: W) -> Result<()> { let mut csv_writer = csv::Writer::from_writer(writer); - // Write each row for i in 0..self.mat.nrows() { let row: Vec = (0..self.mat.ncols()).map(|j| *self.mat.get(i, j)).collect(); csv_writer.serialize(row)?; @@ -66,8 +49,6 @@ impl Posterior { Ok(()) } - /// Read posterior probabilities from a CSV file - /// Each row represents a subject, each column represents a support point pub fn from_csv(reader: R) -> Result { let mut csv_reader = csv::Reader::from_reader(reader); let mut rows: Vec> = Vec::new(); @@ -84,21 +65,18 @@ impl Posterior { let nrows = rows.len(); let ncols = rows[0].len(); - // Verify all rows have the same length for (i, row) in rows.iter().enumerate() { if row.len() != ncols { bail!("Row {} has {} columns, expected {}", i, row.len(), ncols); } } - // Create matrix from rows let mat = Mat::from_fn(nrows, ncols, |i, j| rows[i][j]); Ok(Posterior::new(mat)) } } -/// Convert a matrix to a [Posterior] impl From> for Posterior { fn from(mat: Mat) -> Self { Posterior::new(mat) @@ -114,7 +92,6 @@ impl Serialize for Posterior { let mut seq = serializer.serialize_seq(Some(self.mat.nrows()))?; - // Serialize each row as a vector for i in 0..self.mat.nrows() { let row: Vec = (0..self.mat.ncols()).map(|j| *self.mat.get(i, j)).collect(); seq.serialize_element(&row)?; @@ -158,7 +135,6 @@ impl<'de> Deserialize<'de> for Posterior { let nrows = rows.len(); let ncols = rows[0].len(); - // Verify all rows have the same length for (i, row) in rows.iter().enumerate() { if row.len() != ncols { return Err(serde::de::Error::custom(format!( @@ -170,7 +146,6 @@ impl<'de> Deserialize<'de> for Posterior { } } - // Create matrix from rows let mat = Mat::from_fn(nrows, ncols, |i, j| rows[i][j]); Ok(Posterior::new(mat)) @@ -181,24 +156,6 @@ impl<'de> Deserialize<'de> for Posterior { } } -/// Calculates the posterior probabilities for each support point given the weights -/// -/// The shape is the same as [Psi], and thus subjects are the rows and support points are the columns. pub fn posterior(psi: &Psi, w: &Weights) -> Result { - if psi.matrix().ncols() != w.len() { - bail!( - "Number of rows in psi ({}) and number of weights ({}) do not match.", - psi.matrix().nrows(), - w.len() - ); - } - - let psi_matrix = psi.matrix(); - 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.weights().get(j) / py.get(i) - }); - - Ok(posterior.into()) + Posterior::calculate(psi, w) } diff --git a/src/routines/output/predictions.rs b/src/estimation/nonparametric/predictions.rs similarity index 62% rename from src/routines/output/predictions.rs rename to src/estimation/nonparametric/predictions.rs index 64f78e41b..0e34b20bc 100644 --- a/src/routines/output/predictions.rs +++ b/src/estimation/nonparametric/predictions.rs @@ -3,36 +3,21 @@ use pharmsol::{prelude::simulator::Prediction, Censor, Data, Predictions as Pred use serde::{Deserialize, Serialize}; use crate::{ - routines::output::{posterior::Posterior, weighted_median}, - structs::{theta::Theta, weights::Weights}, + estimation::nonparametric::{theta::Theta, weights::Weights}, + estimation::nonparametric::{weighted_median, Posterior}, }; -/// Container for the multiple model estimated predictions -/// -/// Each row contains the predictions for a single time point for a single subject -/// It includes the population and posterior mean and median predictions -/// These are defined by the mean and median of the prediction for each model, weighted by the population or posterior weights #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NPPredictionRow { - /// The subject ID id: String, - /// The time of the prediction time: f64, - /// The output equation number outeq: usize, - /// The occasion of the prediction block: usize, - /// The observed value, if any obs: Option, - /// Censored observation flag cens: Censor, - /// The population mean prediction pop_mean: f64, - /// The population median prediction pop_median: f64, - /// The posterior mean prediction post_mean: f64, - /// The posterior median prediction post_median: f64, } @@ -97,28 +82,14 @@ impl NPPredictions { } } - /// Add a [NPPredictionRow] to the predictions pub fn add(&mut self, row: NPPredictionRow) { self.predictions.push(row); } - /// Get a reference to the predictions pub fn predictions(&self) -> &[NPPredictionRow] { &self.predictions } - /// Calculate the population and posterior predictions - /// - /// # Arguments - /// * `equation` - The equation to use for simulation - /// * `data` - The data to use for simulation - /// * `theta` - The theta values for the simulation - /// * `w` - The weights for the simulation - /// * `posterior` - The posterior probabilities for the simulation - /// * `idelta` - The delta for the simulation - /// * `tad` - The time after dose for the simulation - /// # Returns - /// A Result containing the NPPredictions or an error pub fn calculate( equation: &impl pharmsol::prelude::simulator::Equation, data: &Data, @@ -128,10 +99,8 @@ impl NPPredictions { idelta: f64, tad: f64, ) -> Result { - // Create a new NPPredictions instance let mut container = NPPredictions::new(); - // Expand data let data = data.clone().expand(idelta, tad); let subjects = data.subjects(); @@ -139,42 +108,29 @@ impl NPPredictions { bail!("Number of subjects and number of posterior means do not match"); }; - // Iterate over each subject and then each support point - for subject in subjects.iter().enumerate() { - let (subject_index, subject) = subject; - - // Container for predictions for this subject - // This will hold predictions for each support point - // The outer vector is for each support point - // The inner vector is for the vector of predictions for that support point + for (subject_index, subject) in subjects.iter().enumerate() { let mut predictions: Vec> = Vec::new(); - // And each support points for spp in theta.matrix().row_iter() { - // Simulate the subject with the current support point let spp_values = spp.iter().cloned().collect::>(); let pred = equation - .simulate_subject(subject, &spp_values, None)? + .simulate_subject_dense(subject, &spp_values, None)? .0 .get_predictions(); predictions.push(pred); } if predictions.is_empty() { - continue; // Skip this subject if no predictions are available + continue; } - // Calculate population mean using let mut pop_mean: Vec = vec![0.0; predictions.first().unwrap().len()]; - for outer_pred in predictions.iter().enumerate() { - let (i, outer_pred) = outer_pred; - for inner_pred in outer_pred.iter().enumerate() { - let (j, pred) = inner_pred; + for (i, outer_pred) in predictions.iter().enumerate() { + for (j, pred) in outer_pred.iter().enumerate() { pop_mean[j] += pred.prediction() * w[i]; } } - // Calculate population median let mut pop_median: Vec = Vec::new(); for j in 0..predictions.first().unwrap().len() { let mut values: Vec = Vec::new(); @@ -189,17 +145,13 @@ impl NPPredictions { pop_median.push(median_val); } - // Calculate posterior mean let mut posterior_mean: Vec = vec![0.0; predictions.first().unwrap().len()]; - for outer_pred in predictions.iter().enumerate() { - let (i, outer_pred) = outer_pred; - for inner_pred in outer_pred.iter().enumerate() { - let (j, pred) = inner_pred; + for (i, outer_pred) in predictions.iter().enumerate() { + for (j, pred) in outer_pred.iter().enumerate() { posterior_mean[j] += pred.prediction() * posterior.matrix()[(subject_index, i)]; } } - // Calculate posterior median let mut posterior_median: Vec = Vec::new(); for j in 0..predictions.first().unwrap().len() { let mut values: Vec = Vec::new(); @@ -214,8 +166,6 @@ impl NPPredictions { posterior_median.push(median_val); } - // Iterate over the aggregated predictions (one row per timepoint per subject) - // Use the first support point predictions to get time, outeq, block, and obs info if let Some(first_spp_preds) = predictions.first() { for (j, p) in first_spp_preds.iter().enumerate() { let row = NPPredictionRow { diff --git a/src/structs/psi.rs b/src/estimation/nonparametric/psi.rs similarity index 79% rename from src/structs/psi.rs rename to src/estimation/nonparametric/psi.rs index e50dfd0c7..82b8a7d37 100644 --- a/src/structs/psi.rs +++ b/src/estimation/nonparametric/psi.rs @@ -33,7 +33,11 @@ impl Psi { self.matrix.ncols() } - /// Modify the [Psi::matrix] to only include the columns specified by `indices` + pub fn to_ndarray(&self) -> Array2 { + let m = &self.matrix; + Array2::from_shape_fn((m.nrows(), m.ncols()), |(i, j)| m[(i, j)]) + } + pub(crate) fn filter_column_indices(&mut self, indices: &[usize]) { let matrix = self.matrix.to_owned(); @@ -44,7 +48,6 @@ impl Psi { self.matrix = new; } - /// Write the matrix to a CSV file pub fn write(&self, path: &str) { let mut writer = csv::Writer::from_path(path).unwrap(); for row in self.matrix.row_iter() { @@ -54,12 +57,9 @@ impl Psi { } } - /// Write the psi matrix to a CSV writer - /// Each row represents a subject, each column represents a support point pub fn to_csv(&self, writer: W) -> Result<()> { let mut csv_writer = csv::Writer::from_writer(writer); - // Write each row for i in 0..self.matrix.nrows() { let row: Vec = (0..self.matrix.ncols()) .map(|j| *self.matrix.get(i, j)) @@ -71,8 +71,6 @@ impl Psi { Ok(()) } - /// Read psi matrix from a CSV reader - /// Each row represents a subject, each column represents a support point pub fn from_csv(reader: R) -> Result { let mut csv_reader = csv::Reader::from_reader(reader); let mut rows: Vec> = Vec::new(); @@ -89,14 +87,12 @@ impl Psi { let nrows = rows.len(); let ncols = rows[0].len(); - // Verify all rows have the same length for (i, row) in rows.iter().enumerate() { if row.len() != ncols { bail!("Row {} has {} columns, expected {}", i, row.len(), ncols); } } - // Create matrix from rows let mat = Mat::from_fn(nrows, ncols, |i, j| rows[i][j]); Ok(Psi { matrix: mat }) @@ -138,7 +134,6 @@ impl Serialize for Psi { let mut seq = serializer.serialize_seq(Some(self.matrix.nrows()))?; - // Serialize each row as a vector for i in 0..self.matrix.nrows() { let row: Vec = (0..self.matrix.ncols()) .map(|j| *self.matrix.get(i, j)) @@ -184,7 +179,6 @@ impl<'de> Deserialize<'de> for Psi { let nrows = rows.len(); let ncols = rows[0].len(); - // Verify all rows have the same length for (i, row) in rows.iter().enumerate() { if row.len() != ncols { return Err(serde::de::Error::custom(format!( @@ -196,7 +190,6 @@ impl<'de> Deserialize<'de> for Psi { } } - // Create matrix from rows let mat = Mat::from_fn(nrows, ncols, |i, j| rows[i][j]); Ok(Psi { matrix: mat }) @@ -230,16 +223,13 @@ mod tests { #[test] fn test_from_array2() { - // Create a test 2x3 array let array = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap(); let psi = Psi::from(array.clone()); - // Check dimensions assert_eq!(psi.nspp(), 2); assert_eq!(psi.nsub(), 3); - // Check values using faer matrix directly let m = psi.matrix(); for i in 0..2 { for j in 0..3 { @@ -250,17 +240,14 @@ mod tests { #[test] fn test_from_array2_ref() { - // Create a test 3x2 array let array = Array2::from_shape_vec((3, 2), vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0]).unwrap(); let psi = Psi::from(&array); - // Check dimensions assert_eq!(psi.nspp(), 3); assert_eq!(psi.nsub(), 2); - // Check values using faer matrix directly let m = psi.matrix(); for i in 0..3 { for j in 0..2 { @@ -271,7 +258,6 @@ mod tests { #[test] fn test_nspp() { - // Test with a 4x2 matrix let array = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap(); let psi = Psi::from(array); @@ -281,14 +267,12 @@ mod tests { #[test] fn test_nspp_empty() { - // Test with empty matrix let psi = Psi::new(); assert_eq!(psi.nspp(), 0); } #[test] fn test_nspp_single_row() { - // Test with 1x3 matrix let array = Array2::from_shape_vec((1, 3), vec![1.0, 2.0, 3.0]).unwrap(); let psi = Psi::from(array); @@ -297,7 +281,6 @@ mod tests { #[test] fn test_nsub() { - // Test with a 2x5 matrix let array = Array2::from_shape_vec( (2, 5), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0], @@ -310,40 +293,7 @@ mod tests { #[test] fn test_nsub_empty() { - // Test with empty matrix let psi = Psi::new(); assert_eq!(psi.nsub(), 0); } - - #[test] - fn test_nsub_single_column() { - // Test with 3x1 matrix - let array = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap(); - let psi = Psi::from(array); - - assert_eq!(psi.nsub(), 1); - } - - #[test] - fn test_from_implementations_consistency() { - // Test that both From implementations produce the same result - let array = Array2::from_shape_vec((2, 3), vec![1.5, 2.5, 3.5, 4.5, 5.5, 6.5]).unwrap(); - - let psi_from_owned = Psi::from(array.clone()); - let psi_from_ref = Psi::from(&array); - - // Both should have the same dimensions - assert_eq!(psi_from_owned.nspp(), psi_from_ref.nspp()); - assert_eq!(psi_from_owned.nsub(), psi_from_ref.nsub()); - - // And the same values - let owned_m = psi_from_owned.matrix(); - let ref_m = psi_from_ref.matrix(); - - for i in 0..2 { - for j in 0..3 { - assert_eq!(owned_m[(i, j)], ref_m[(i, j)]); - } - } - } } diff --git a/src/estimation/nonparametric/qr.rs b/src/estimation/nonparametric/qr.rs new file mode 100644 index 000000000..6338d80d1 --- /dev/null +++ b/src/estimation/nonparametric/qr.rs @@ -0,0 +1,38 @@ +use crate::estimation::nonparametric::Psi; +use anyhow::{bail, Result}; +use faer::linalg::solvers::ColPivQr; +use faer::Mat; + +/// Perform a QR decomposition on the Psi matrix. +pub fn qrd(psi: &Psi) -> Result<(Mat, Vec)> { + let mut mat = psi.matrix().to_owned(); + + for (index, row) in mat.row_iter_mut().enumerate() { + let row_sum: f64 = row.as_ref().iter().sum(); + if row_sum.abs() == 0.0 { + bail!("In psi, the row with index {} sums to zero", index); + } + row.iter_mut().for_each(|x| *x /= row_sum); + } + + let qr: ColPivQr = mat.col_piv_qr(); + let r_mat: faer::Mat = qr.R().to_owned(); + let perm = qr.P().arrays().0.to_vec(); + Ok((r_mat, perm)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_identity() { + let mat: Mat = Mat::identity(10, 10); + let psi = Psi::from(mat); + let (r_mat, perm) = qrd(&psi).unwrap(); + + let expected_r_mat: Mat = Mat::identity(10, 10); + assert_eq!(r_mat, expected_r_mat); + assert_eq!(perm, vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + } +} diff --git a/src/estimation/nonparametric/result.rs b/src/estimation/nonparametric/result.rs new file mode 100644 index 000000000..96cc75bd3 --- /dev/null +++ b/src/estimation/nonparametric/result.rs @@ -0,0 +1,264 @@ +use std::path::Path; + +use pharmsol::Equation; + +use crate::algorithms::{Status, StopReason}; +use crate::estimation::nonparametric::{CycleLog, NPPredictions, Posterior, Psi, Theta, Weights}; + +use pharmsol::{AssayErrorModels, Data}; + +/// Contains the results of a nonparametric estimation, including the final parameter +#[derive(Debug)] +pub struct NonParametricResult { + equation: E, + data: Data, + error_models: AssayErrorModels, + prior: Theta, + theta: Theta, + psi: Psi, + weights: Weights, + objf: f64, + cycles: usize, + status: Status, + cyclelog: CycleLog, +} + +impl NonParametricResult { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + equation: E, + data: Data, + error_models: AssayErrorModels, + prior: Theta, + theta: Theta, + psi: Psi, + weights: Weights, + objf: f64, + cycles: usize, + status: Status, + cyclelog: CycleLog, + ) -> anyhow::Result { + Ok(Self { + equation, + data, + error_models, + prior, + theta, + psi, + weights, + objf, + cycles, + status, + cyclelog, + }) + } + + pub fn cycles(&self) -> usize { + self.cycles + } + + pub fn objf(&self) -> f64 { + self.objf + } + + pub fn converged(&self) -> bool { + self.status == Status::Stop(StopReason::Converged) + } + + pub fn get_theta(&self) -> &Theta { + &self.theta + } + + /// The prior distribution ([`Theta`]) that seeded the algorithm. + /// + /// This is the initial set of support points, as opposed to the optimized + /// solution returned by [`get_theta`](Self::get_theta). + pub fn prior(&self) -> &Theta { + &self.prior + } + + pub fn data(&self) -> &Data { + &self.data + } + + pub fn equation(&self) -> &E { + &self.equation + } + + pub fn cycle_log(&self) -> &CycleLog { + &self.cyclelog + } + + pub fn psi(&self) -> &Psi { + &self.psi + } + + pub fn weights(&self) -> &Weights { + &self.weights + } + + pub fn error_models(&self) -> &AssayErrorModels { + &self.error_models + } + + /// Compute the posterior probabilities on demand from [`Psi`] and the + /// [`Weights`]. This is a cheap matrix operation and is intentionally not + /// cached on the result. + pub fn posterior(&self) -> anyhow::Result { + Posterior::calculate(&self.psi, &self.weights) + } + + /// Compute predictions on demand. Nothing is cached on the result; callers + /// that need the predictions repeatedly should hold on to the returned + /// value themselves. + pub fn predictions(&self, idelta: f64, tad: f64) -> anyhow::Result { + let posterior = self.posterior()?; + NPPredictions::calculate( + &self.equation, + &self.data, + &self.theta, + &self.weights, + &posterior, + idelta, + tad, + ) + } + + pub fn write_theta(&self, path: &Path) -> anyhow::Result<()> { + use anyhow::bail; + use csv::WriterBuilder; + + tracing::debug!("Writing population parameter distribution..."); + + let w: Vec = self.weights.to_vec(); + if w.len() != self.theta.matrix().nrows() { + bail!( + "Number of weights ({}) and number of support points ({}) do not match.", + w.len(), + self.theta.matrix().nrows() + ); + } + + std::fs::create_dir_all(path)?; + let file = std::fs::File::create(path)?; + let mut writer = WriterBuilder::new().has_headers(true).from_writer(file); + + let mut theta_header = self.theta.param_names().clone(); + theta_header.push("prob".to_string()); + writer.write_record(&theta_header)?; + + for (theta_row, &w_val) in self.theta.matrix().row_iter().zip(w.iter()) { + let mut row: Vec = theta_row.iter().map(|&val| val.to_string()).collect(); + row.push(w_val.to_string()); + writer.write_record(&row)?; + } + writer.flush()?; + Ok(()) + } + + pub fn write_posterior(&self, path: &str) -> anyhow::Result<()> { + use csv::WriterBuilder; + + tracing::debug!("Writing posterior parameter probabilities..."); + + std::fs::create_dir_all(path)?; + let file = std::fs::File::create(path)?; + let mut writer = WriterBuilder::new().has_headers(true).from_writer(file); + + writer.write_field("id")?; + writer.write_field("point")?; + self.theta.param_names().iter().for_each(|name| { + writer.write_field(name).unwrap(); + }); + writer.write_field("prob")?; + writer.write_record(None::<&[u8]>)?; + + let posterior = self.posterior()?; + let subjects = self.data.subjects(); + posterior + .matrix() + .row_iter() + .enumerate() + .for_each(|(i, row)| { + let subject = subjects.get(i).unwrap(); + let id = subject.id(); + + row.iter().enumerate().for_each(|(spp, prob)| { + writer.write_field(id.clone()).unwrap(); + writer.write_field(spp.to_string()).unwrap(); + + self.theta.matrix().row(spp).iter().for_each(|val| { + writer.write_field(val.to_string()).unwrap(); + }); + + writer.write_field(prob.to_string()).unwrap(); + writer.write_record(None::<&[u8]>).unwrap(); + }); + }); + + writer.flush()?; + Ok(()) + } + + pub fn write_covariates(&self, path: &Path) -> anyhow::Result<()> { + use csv::WriterBuilder; + use pharmsol::Event; + + tracing::debug!("Writing covariates..."); + std::fs::create_dir_all(path)?; + let file = std::fs::File::create(path)?; + let mut writer = WriterBuilder::new().has_headers(true).from_writer(file); + + let mut covariate_names = std::collections::HashSet::new(); + for subject in self.data.subjects() { + for occasion in subject.occasions() { + let covmap = occasion.covariates().covariates(); + for cov_name in covmap.keys() { + covariate_names.insert(cov_name.clone()); + } + } + } + let mut covariate_names: Vec = covariate_names.into_iter().collect(); + covariate_names.sort(); + + let mut headers = vec!["id", "time", "block"]; + headers.extend(covariate_names.iter().map(|s| s.as_str())); + writer.write_record(&headers)?; + + for subject in self.data.subjects() { + for occasion in subject.occasions() { + let covmap = occasion.covariates().covariates(); + + for event in occasion.iter() { + let time = match event { + Event::Bolus(bolus) => bolus.time(), + Event::Infusion(infusion) => infusion.time(), + Event::Observation(observation) => observation.time(), + }; + + let mut row: Vec = Vec::new(); + row.push(subject.id().clone()); + row.push(time.to_string()); + row.push(occasion.index().to_string()); + + for cov_name in &covariate_names { + if let Some(cov) = covmap.get(cov_name) { + if let Ok(value) = cov.interpolate(time) { + row.push(value.to_string()); + } else { + row.push(String::new()); + } + } else { + row.push(String::new()); + } + } + + writer.write_record(&row)?; + } + } + } + + writer.flush()?; + Ok(()) + } +} diff --git a/src/estimation/nonparametric/sampling/latin.rs b/src/estimation/nonparametric/sampling/latin.rs new file mode 100644 index 000000000..b6f7f4f32 --- /dev/null +++ b/src/estimation/nonparametric/sampling/latin.rs @@ -0,0 +1,47 @@ +use anyhow::Result; +use faer::Mat; +use rand::prelude::*; +use rand::rngs::StdRng; + +use crate::estimation::nonparametric::Theta; +use crate::model::{BoundedParameter, ParameterSpace}; + +pub fn generate(parameters: &ParameterSpace, points: usize, seed: usize) -> Result { + let ranges = parameters.finite_ranges(); + let mut rng = StdRng::seed_from_u64(seed as u64); + + let mut intervals = Vec::new(); + for _ in 0..ranges.len() { + let mut param_intervals: Vec = (0..points).map(|i| i as f64).collect(); + param_intervals.shuffle(&mut rng); + intervals.push(param_intervals); + } + + let rand_matrix = Mat::from_fn(points, ranges.len(), |i, j| { + let interval = intervals[j][i]; + let random_offset = rng.random::(); + let unscaled = (interval + random_offset) / points as f64; + let (lower, upper) = ranges[j]; + lower + unscaled * (upper - lower) + }); + + Theta::from_parts(rand_matrix, parameters.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{BoundedParameter, ParameterSpace}; + + #[test] + fn latin_generate_produces_requested_shape() { + let params = ParameterSpace::::new() + .add("a", 0.0, 1.0) + .add("b", 0.0, 1.0) + .add("c", 0.0, 1.0); + + let theta = generate(¶ms, 10, 22).unwrap(); + assert_eq!(theta.nspp(), 10); + assert_eq!(theta.matrix().ncols(), 3); + } +} diff --git a/src/estimation/nonparametric/sampling/mod.rs b/src/estimation/nonparametric/sampling/mod.rs new file mode 100644 index 000000000..899edc1cf --- /dev/null +++ b/src/estimation/nonparametric/sampling/mod.rs @@ -0,0 +1,10 @@ +//! Methods for generating the initial set of support points ([`Theta`]). + +pub mod latin; +pub mod sobol; + +/// Default seed used for quasi-random sampling when none is given. +pub const DEFAULT_SEED: usize = 22; + +/// Default number of support points sampled for a quasi-random starting grid. +pub const DEFAULT_POINTS: usize = 2028; diff --git a/src/estimation/nonparametric/sampling/sobol.rs b/src/estimation/nonparametric/sampling/sobol.rs new file mode 100644 index 000000000..1dc8f2af0 --- /dev/null +++ b/src/estimation/nonparametric/sampling/sobol.rs @@ -0,0 +1,40 @@ +use anyhow::Result; +use faer::Mat; +use sobol_burley::sample; + +use crate::estimation::nonparametric::Theta; +use crate::model::{BoundedParameter, ParameterSpace}; + +pub fn generate( + parameters: &ParameterSpace, + points: usize, + seed: usize, +) -> Result { + let seed = seed as u32; + let ranges = parameters.finite_ranges(); + + let rand_matrix = Mat::from_fn(points, ranges.len(), |i, j| { + let unscaled = sample((i).try_into().unwrap(), j.try_into().unwrap(), seed) as f64; + let (lower, upper) = ranges[j]; + lower + unscaled * (upper - lower) + }); + + Theta::from_parts(rand_matrix, parameters.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sobol_generate_produces_requested_shape() { + let params = ParameterSpace::::new() + .add("a", 0.0, 1.0) + .add("b", 0.0, 1.0) + .add("c", 0.0, 1.0); + + let theta = generate(¶ms, 10, 22).unwrap(); + assert_eq!(theta.nspp(), 10); + assert_eq!(theta.matrix().ncols(), 3); + } +} diff --git a/src/estimation/nonparametric/statistics.rs b/src/estimation/nonparametric/statistics.rs new file mode 100644 index 000000000..8c85e801d --- /dev/null +++ b/src/estimation/nonparametric/statistics.rs @@ -0,0 +1,238 @@ +use anyhow::{bail, Result}; +use ndarray::{Array, Array1, Array2, Axis}; + +pub fn median(data: &[f64]) -> f64 { + let mut data: Vec = data.to_vec(); + data.sort_by(|a, b| a.partial_cmp(b).unwrap()); + + let size = data.len(); + match size { + even if even % 2 == 0 => { + let fst = data.get(even / 2 - 1).unwrap(); + let snd = data.get(even / 2).unwrap(); + (fst + snd) / 2.0 + } + odd => *data.get(odd / 2_usize).unwrap(), + } +} + +pub fn weighted_median(data: &[f64], weights: &[f64]) -> f64 { + assert_eq!( + data.len(), + weights.len(), + "The length of data and weights must be the same" + ); + assert!( + weights.iter().all(|&x| x >= 0.0), + "Weights must be non-negative, weights: {:?}", + weights + ); + + let mut weighted_data: Vec<(f64, f64)> = data + .iter() + .zip(weights.iter()) + .map(|(&d, &w)| (d, w)) + .collect(); + + weighted_data.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + + let total_weight: f64 = weights.iter().sum(); + let mut cumulative_sum = 0.0; + + for (i, &(_, weight)) in weighted_data.iter().enumerate() { + cumulative_sum += weight; + + if cumulative_sum == total_weight / 2.0 { + if i + 1 < weighted_data.len() { + return (weighted_data[i].0 + weighted_data[i + 1].0) / 2.0; + } else { + return weighted_data[i].0; + } + } else if cumulative_sum > total_weight / 2.0 { + return weighted_data[i].0; + } + } + + unreachable!("The function should have returned a value before reaching this point."); +} + +pub fn population_mean_median( + theta: &Array2, + w: &Array1, +) -> Result<(Array1, Array1)> { + let w = if w.is_empty() { + tracing::warn!("w.len() == 0, setting all weights to 1/n"); + Array1::from_elem(theta.nrows(), 1.0 / theta.nrows() as f64) + } else { + w.clone() + }; + + if theta.nrows() != w.len() { + bail!( + "Number of parameters and number of weights do not match. Theta: {}, w: {}", + theta.nrows(), + w.len() + ); + } + + let mut mean = Array1::zeros(theta.ncols()); + let mut median = Array1::zeros(theta.ncols()); + + for (i, (mn, mdn)) in mean.iter_mut().zip(&mut median).enumerate() { + let col = theta.column(i).to_owned() * w.to_owned(); + *mn = col.sum(); + + let ct = theta.column(i); + let mut params = vec![]; + let mut weights = vec![]; + for (ti, wi) in ct.iter().zip(w.clone()) { + params.push(*ti); + weights.push(wi); + } + + *mdn = weighted_median(¶ms, &weights); + } + + Ok((mean, median)) +} + +pub fn posterior_mean_median( + theta: &Array2, + psi: &Array2, + w: &Array1, +) -> Result<(Array2, Array2)> { + let mut mean = Array2::zeros((0, theta.ncols())); + let mut median = Array2::zeros((0, theta.ncols())); + + let w = if w.is_empty() { + tracing::warn!("w is empty, setting all weights to 1/n"); + Array1::from_elem(theta.nrows(), 1.0 / theta.nrows() as f64) + } else { + w.clone() + }; + + if theta.nrows() != w.len() || theta.nrows() != psi.ncols() || psi.ncols() != w.len() { + bail!("Number of parameters and number of weights do not match, theta.nrows(): {}, w.len(): {}, psi.ncols(): {}", theta.nrows(), w.len(), psi.ncols()); + } + + let mut psi_norm: Array2 = Array2::zeros((0, psi.ncols())); + for (i, row) in psi.axis_iter(Axis(0)).enumerate() { + let row_w = row.to_owned() * w.to_owned(); + let row_sum = row_w.sum(); + let row_norm = if row_sum == 0.0 { + tracing::warn!("Sum of row {} of psi is 0.0, setting that row to 1/n", i); + Array1::from_elem(psi.ncols(), 1.0 / psi.ncols() as f64) + } else { + &row_w / row_sum + }; + psi_norm.push_row(row_norm.view())?; + } + if psi_norm.iter().any(|&x| x.is_nan()) { + dbg!(&psi); + bail!("NaN values found in psi_norm"); + }; + + for probs in psi_norm.axis_iter(Axis(0)) { + let mut post_mean: Vec = Vec::new(); + let mut post_median: Vec = Vec::new(); + + for pars in theta.axis_iter(Axis(1)) { + let weighted_par = &probs * &pars; + let the_mean = weighted_par.sum(); + post_mean.push(the_mean); + + let median = weighted_median(&pars.to_vec(), &probs.to_vec()); + post_median.push(median); + } + + mean.push_row(Array::from(post_mean.clone()).view())?; + median.push_row(Array::from(post_median.clone()).view())?; + } + + Ok((mean, median)) +} + +#[cfg(test)] +mod tests { + use super::{median, weighted_median}; + + #[test] + fn test_median_odd() { + let data = vec![1.0, 3.0, 2.0]; + assert_eq!(median(&data), 2.0); + } + + #[test] + fn test_median_even() { + let data = vec![1.0, 2.0, 3.0, 4.0]; + assert_eq!(median(&data), 2.5); + } + + #[test] + fn test_median_single() { + let data = vec![42.0]; + assert_eq!(median(&data), 42.0); + } + + #[test] + fn test_median_sorted() { + let data = vec![5.0, 10.0, 15.0, 20.0, 25.0]; + assert_eq!(median(&data), 15.0); + } + + #[test] + fn test_median_unsorted() { + let data = vec![10.0, 30.0, 20.0, 50.0, 40.0]; + assert_eq!(median(&data), 30.0); + } + + #[test] + fn test_median_with_duplicates() { + let data = vec![1.0, 2.0, 2.0, 3.0, 4.0]; + assert_eq!(median(&data), 2.0); + } + + #[test] + fn test_weighted_median_simple() { + let data = vec![1.0, 2.0, 3.0]; + let weights = vec![0.2, 0.5, 0.3]; + assert_eq!(weighted_median(&data, &weights), 2.0); + } + + #[test] + fn test_weighted_median_even_weights() { + let data = vec![1.0, 2.0, 3.0, 4.0]; + let weights = vec![0.25, 0.25, 0.25, 0.25]; + assert_eq!(weighted_median(&data, &weights), 2.5); + } + + #[test] + fn test_weighted_median_single_element() { + let data = vec![42.0]; + let weights = vec![1.0]; + assert_eq!(weighted_median(&data, &weights), 42.0); + } + + #[test] + #[should_panic(expected = "The length of data and weights must be the same")] + fn test_weighted_median_mismatched_lengths() { + let data = vec![1.0, 2.0, 3.0]; + let weights = vec![0.1, 0.2]; + weighted_median(&data, &weights); + } + + #[test] + fn test_weighted_median_all_same_elements() { + let data = vec![5.0, 5.0, 5.0, 5.0]; + let weights = vec![0.1, 0.2, 0.3, 0.4]; + assert_eq!(weighted_median(&data, &weights), 5.0); + } + + #[test] + #[should_panic(expected = "Weights must be non-negative")] + fn test_weighted_median_negative_weights() { + let data = vec![1.0, 2.0, 3.0, 4.0]; + let weights = vec![0.2, -0.5, 0.5, 0.8]; + assert_eq!(weighted_median(&data, &weights), 4.0); + } +} diff --git a/src/estimation/nonparametric/summaries.rs b/src/estimation/nonparametric/summaries.rs new file mode 100644 index 000000000..6d979aaef --- /dev/null +++ b/src/estimation/nonparametric/summaries.rs @@ -0,0 +1,100 @@ +use ndarray::{Array1, Array2}; +use pharmsol::{Data, Equation, Event}; + +use crate::estimation::nonparametric::NonParametricResult; +use crate::estimation::nonparametric::{population_mean_median, posterior_mean_median}; +use crate::results::{FitSummary, IndividualSummary, ParameterSummary, PopulationSummary}; + +pub fn fit_summary(result: &NonParametricResult) -> FitSummary { + FitSummary { + objective_function: result.objf(), + converged: result.converged(), + iterations: result.cycles(), + subject_count: result.data().subjects().len(), + observation_count: count_observations(result.data()), + parameter_count: result.get_theta().parameters().len(), + } +} + +pub fn population_summary(result: &NonParametricResult) -> PopulationSummary { + let theta_matrix = to_ndarray_matrix(result.get_theta().matrix()); + let weights = Array1::from_iter(result.weights().iter()); + let (mean, median) = population_mean_median(&theta_matrix, &weights) + .expect("population summary should be derivable from theta and weights"); + + let parameters = result + .get_theta() + .parameters() + .names() + .into_iter() + .enumerate() + .map(|(index, name)| { + let column = theta_matrix.column(index).to_vec(); + let mean_value = mean[index]; + let sd = weighted_sd(&column, &weights, mean_value); + let cv_percent = if mean_value.abs() > f64::EPSILON { + (sd / mean_value.abs()) * 100.0 + } else { + 0.0 + }; + + ParameterSummary { + name, + mean: mean_value, + median: median[index], + sd, + cv_percent, + } + }) + .collect(); + + PopulationSummary { parameters } +} + +pub fn individual_summaries( + result: &NonParametricResult, +) -> Vec { + let theta_matrix = to_ndarray_matrix(result.get_theta().matrix()); + let psi_matrix = to_ndarray_matrix(result.psi().matrix()); + let weights = Array1::from_iter(result.weights().iter()); + let (means, _) = posterior_mean_median(&theta_matrix, &psi_matrix, &weights) + .expect("individual summaries should be derivable from theta, psi, and weights"); + let parameter_names = result.get_theta().parameters().names(); + + result + .data() + .subjects() + .iter() + .enumerate() + .map(|(subject_index, subject)| IndividualSummary { + id: subject.id().clone(), + parameter_names: parameter_names.clone(), + estimates: means.row(subject_index).to_vec(), + standard_errors: None, + }) + .collect() +} + +fn count_observations(data: &Data) -> usize { + data.subjects() + .iter() + .flat_map(|subject| subject.occasions()) + .flat_map(|occasion| occasion.events()) + .filter(|event| matches!(event, Event::Observation(_))) + .count() +} + +fn to_ndarray_matrix(matrix: &faer::Mat) -> Array2 { + Array2::from_shape_fn((matrix.nrows(), matrix.ncols()), |(row, col)| { + matrix[(row, col)] + }) +} + +fn weighted_sd(values: &[f64], weights: &Array1, mean: f64) -> f64 { + let variance = values + .iter() + .zip(weights.iter()) + .map(|(value, weight)| weight * (value - mean).powi(2)) + .sum::(); + variance.sqrt() +} diff --git a/src/estimation/nonparametric/theta.rs b/src/estimation/nonparametric/theta.rs new file mode 100644 index 000000000..abe0c0a70 --- /dev/null +++ b/src/estimation/nonparametric/theta.rs @@ -0,0 +1,528 @@ +use std::{fmt::Debug, fs::File, path::Path}; + +use anyhow::{bail, Context, Result}; +use faer::Mat; +use serde::{Deserialize, Serialize}; + +use super::sampling::{self, latin, sobol}; +use super::weights::Weights; +use crate::model::{BoundedParameter, ParameterSpace}; + +/// [Theta] is a structure that holds the support points +/// These represent the joint population parameter distribution +/// +/// Each row represents a support points, and each column a parameter +#[derive(Clone, PartialEq)] +pub struct Theta { + matrix: Mat, + parameters: ParameterSpace, +} + +impl Default for Theta { + fn default() -> Self { + Theta { + matrix: Mat::new(), + parameters: ParameterSpace::::new(), + } + } +} + +impl Theta { + pub fn new() -> Self { + Theta::default() + } + + /// Create a new [Theta] from a matrix and [ParameterSpace] + /// + /// It is important that the number of columns in the matrix matches the number of parameters + /// in the [ParameterSpace] + /// + /// The order of parameters in the [ParameterSpace] should match the order of columns in the matrix + pub fn from_parts( + matrix: Mat, + parameters: ParameterSpace, + ) -> Result { + if matrix.ncols() != parameters.len() { + bail!( + "Number of columns in matrix ({}) does not match number of parameters ({})", + matrix.ncols(), + parameters.len() + ); + } + + Ok(Theta { matrix, parameters }) + } + + /// Get the matrix containing parameter values + /// + /// The matrix is a 2D array where each row represents a support point, and each column a parameter + pub fn matrix(&self) -> &Mat { + &self.matrix + } + + /// Get a mutable reference to the matrix + pub fn matrix_mut(&mut self) -> &mut Mat { + &mut self.matrix + } + + /// Get the [ParameterSpace] associated with this [Theta] + pub fn parameters(&self) -> &ParameterSpace { + &self.parameters + } + + /// Get a mutable reference to the [ParameterSpace] + pub fn parameters_mut(&mut self) -> &mut ParameterSpace { + &mut self.parameters + } + + /// Get the number of support points, equal to the number of rows in the matrix + pub fn nspp(&self) -> usize { + self.matrix.nrows() + } + + /// Get the parameter names + pub fn param_names(&self) -> Vec { + self.parameters.names() + } + + /// Modify the [Theta::matrix] to only include the rows specified by `indices` + pub(crate) fn filter_indices(&mut self, indices: &[usize]) { + let matrix = self.matrix.to_owned(); + + let new = Mat::from_fn(indices.len(), matrix.ncols(), |r, c| { + *matrix.get(indices[r], c) + }); + + self.matrix = new; + } + + /// Forcibly add a support point to the matrix + pub fn add_point(&mut self, spp: &[f64]) -> Result<()> { + if spp.len() != self.matrix.ncols() { + bail!( + "Support point length ({}) does not match number of parameters ({})", + spp.len(), + self.matrix.ncols() + ); + } + + self.matrix + .resize_with(self.matrix.nrows() + 1, self.matrix.ncols(), |_, i| spp[i]); + Ok(()) + } + + /// Suggest a new support point to add to the matrix + /// The point is only added if it is at least `min_dist` away from all existing support points + /// and within the limits specified by `limits` + pub(crate) fn suggest_point(&mut self, spp: &[f64], min_dist: f64) -> Result<()> { + if self.check_point(spp, min_dist) { + self.add_point(spp)?; + } + Ok(()) + } + + /// Check if a point is at least `min_dist` away from all existing support points + pub(crate) fn check_point(&self, spp: &[f64], min_dist: f64) -> bool { + if self.matrix.nrows() == 0 { + return true; + } + + let limits = self.parameters.finite_ranges(); + + for row_idx in 0..self.matrix.nrows() { + let mut squared_dist = 0.0; + for (i, val) in spp.iter().enumerate() { + let normalized_diff = + (val - self.matrix.get(row_idx, i)) / (limits[i].1 - limits[i].0); + squared_dist += normalized_diff * normalized_diff; + } + let dist = squared_dist.sqrt(); + if dist <= min_dist { + return false; + } + } + true + } + + /// Write the matrix to a CSV file + pub fn write(&self, path: &str) { + let mut writer = csv::Writer::from_path(path).unwrap(); + for row in self.matrix.row_iter() { + writer + .write_record(row.iter().map(|x| x.to_string())) + .unwrap(); + } + } + + /// Write the matrix to a CSV file with weights + pub fn write_with_weights(&self, path: &str, weights: &Weights) -> Result<()> { + if self.nspp() != weights.len() { + bail!( + "Number of support points ({}) does not match number of weights ({})", + self.nspp(), + weights.len() + ); + } + + let mut writer = csv::Writer::from_path(path)?; + + let header: Vec = self + .parameters + .names() + .iter() + .cloned() + .chain(std::iter::once("prob".to_string())) + .collect(); + + writer.write_record(header)?; + + for (row_idx, row) in self.matrix.row_iter().enumerate() { + let mut record: Vec = row.iter().map(|x| x.to_string()).collect(); + record.push(weights[row_idx].to_string()); + writer.write_record(record)?; + } + Ok(()) + } + + /// Write the theta matrix to a CSV writer + /// Each row represents a support point, each column represents a parameter + pub fn to_csv(&self, writer: W) -> Result<()> { + let mut csv_writer = csv::Writer::from_writer(writer); + + for i in 0..self.matrix.nrows() { + let row: Vec = (0..self.matrix.ncols()) + .map(|j| *self.matrix.get(i, j)) + .collect(); + csv_writer.serialize(row)?; + } + + csv_writer.flush()?; + Ok(()) + } + + /// Read theta matrix from a CSV reader + /// Each row represents a support point, each column represents a parameter + /// Note: This only reads the matrix values, not the parameter metadata + pub fn from_csv(reader: R) -> Result { + let mut csv_reader = csv::Reader::from_reader(reader); + let mut rows: Vec> = Vec::new(); + + for result in csv_reader.deserialize() { + let row: Vec = result?; + rows.push(row); + } + + if rows.is_empty() { + bail!("CSV file is empty"); + } + + let nrows = rows.len(); + let ncols = rows[0].len(); + + for (i, row) in rows.iter().enumerate() { + if row.len() != ncols { + bail!("Row {} has {} columns, expected {}", i, row.len(), ncols); + } + } + + let mat = Mat::from_fn(nrows, ncols, |i, j| rows[i][j]); + let parameters = ParameterSpace::::new(); + + Theta::from_parts(mat, parameters) + } + + /// Generate a starting grid of `points` support points over `parameters` + /// using a Sobol sequence and the default seed ([`sampling::DEFAULT_SEED`]). + /// + /// The returned [Theta] carries `parameters`, so the chosen grid is explicit + /// and self-describing. + pub fn sobol(parameters: &ParameterSpace, points: usize) -> Result { + Self::sobol_with_seed(parameters, points, sampling::DEFAULT_SEED) + } + + /// Generate a starting grid over `parameters` using a Sobol sequence with the + /// default number of support points ([`sampling::DEFAULT_POINTS`]) and the + /// default seed ([`sampling::DEFAULT_SEED`]). + pub fn sobol_default(parameters: &ParameterSpace) -> Result { + Self::sobol(parameters, sampling::DEFAULT_POINTS) + } + + /// Like [`Theta::sobol`], with an explicit seed for the quasi-random sequence. + pub fn sobol_with_seed( + parameters: &ParameterSpace, + points: usize, + seed: usize, + ) -> Result { + validate_bounds(parameters)?; + sobol::generate(parameters, points, seed) + } + + /// Generate a starting grid of `points` support points over `parameters` + /// using Latin Hypercube Sampling and the default seed ([`sampling::DEFAULT_SEED`]). + /// + /// The returned [Theta] carries `parameters`, so the chosen grid is explicit + /// and self-describing. + pub fn latin(parameters: &ParameterSpace, points: usize) -> Result { + Self::latin_with_seed(parameters, points, sampling::DEFAULT_SEED) + } + + /// Like [`Theta::latin`], with an explicit seed for the quasi-random sequence. + pub fn latin_with_seed( + parameters: &ParameterSpace, + points: usize, + seed: usize, + ) -> Result { + validate_bounds(parameters)?; + latin::generate(parameters, points, seed) + } + + pub fn from_file( + path: impl AsRef, + parameters: &ParameterSpace, + ) -> Result<(Theta, Option)> { + let path = path.as_ref(); + tracing::info!("Reading prior from {}", path.display()); + let file = File::open(path).context(format!( + "Unable to open the prior file '{}'", + path.display() + ))?; + let mut reader = csv::ReaderBuilder::new() + .has_headers(true) + .from_reader(file); + + let mut parameter_names: Vec = reader + .headers()? + .clone() + .into_iter() + .map(|s| s.trim().to_owned()) + .collect(); + + let prob_index = parameter_names.iter().position(|name| name == "prob"); + if let Some(index) = prob_index { + parameter_names.remove(index); + } + + let random_names: Vec = parameters.names(); + + let mut reordered_indices: Vec = Vec::new(); + for random_name in &random_names { + match parameter_names.iter().position(|name| name == random_name) { + Some(index) => { + let adjusted_index = if let Some(prob_idx) = prob_index { + if index >= prob_idx { + index + 1 + } else { + index + } + } else { + index + }; + reordered_indices.push(adjusted_index); + } + None => bail!("Parameter {} is not present in the CSV file.", random_name), + } + } + + if parameter_names.len() > random_names.len() { + let extra_parameters: Vec<&String> = parameter_names.iter().collect(); + bail!( + "Found parameters in the prior not present in configuration: {:?}", + extra_parameters + ); + } + + let mut theta_values = Vec::new(); + let mut prob_values = Vec::new(); + + for result in reader.records() { + let record = result.unwrap(); + let values: Vec = reordered_indices + .iter() + .map(|&i| record[i].parse::().unwrap()) + .collect(); + theta_values.push(values); + + 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(); + let n_params = random_names.len(); + let theta_values: Vec = theta_values.into_iter().flatten().collect(); + let theta_matrix: Mat = + Mat::from_fn(n_points, n_params, |i, j| theta_values[i * n_params + j]); + + let theta = Theta::from_parts(theta_matrix, parameters.clone())?; + let weights = if !prob_values.is_empty() { + Some(Weights::from_vec(prob_values)) + } else { + None + }; + + Ok((theta, weights)) + } +} + +impl Debug for Theta { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "\nTheta contains {} support points\n", self.nspp())?; + + for name in self.parameters.names().iter() { + write!(f, "\t{}", name)?; + } + writeln!(f)?; + self.matrix.row_iter().enumerate().for_each(|(index, row)| { + write!(f, "{}", index).unwrap(); + for val in row.iter() { + write!(f, "\t{:.2}", val).unwrap(); + } + writeln!(f).unwrap(); + }); + Ok(()) + } +} + +impl Serialize for Theta { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + + let rows: Vec> = (0..self.matrix.nrows()) + .map(|i| { + (0..self.matrix.ncols()) + .map(|j| *self.matrix.get(i, j)) + .collect() + }) + .collect(); + + let mut state = serializer.serialize_struct("Theta", 2)?; + state.serialize_field("matrix", &rows)?; + state.serialize_field("parameters", &self.parameters)?; + state.end() + } +} + +impl<'de> Deserialize<'de> for Theta { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct ThetaSerde { + matrix: Vec>, + parameters: ParameterSpace, + } + + let decoded = ThetaSerde::deserialize(deserializer)?; + + if decoded.matrix.is_empty() { + return Ok(Self { + matrix: Mat::new(), + parameters: decoded.parameters, + }); + } + + let nrows = decoded.matrix.len(); + let ncols = decoded.matrix[0].len(); + for (index, row) in decoded.matrix.iter().enumerate() { + if row.len() != ncols { + return Err(serde::de::Error::custom(format!( + "Row {} has {} columns, expected {}", + index, + row.len(), + ncols + ))); + } + } + + let matrix = Mat::from_fn(nrows, ncols, |i, j| decoded.matrix[i][j]); + Self::from_parts(matrix, decoded.parameters).map_err(serde::de::Error::custom) + } +} + +/// Validates that every parameter has a strictly-ordered, finite bound interval. +fn validate_bounds(parameters: &ParameterSpace) -> Result<()> { + for parameter in parameters.iter() { + if parameter.lower >= parameter.upper { + bail!( + "Parameter '{}' has invalid bounds: [{}, {}]. Lower bound must be less than upper bound.", + parameter.name, + parameter.lower, + parameter.upper + ); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn parameters() -> ParameterSpace { + ParameterSpace::::new() + .add("ke", 0.1, 1.0) + .add("v", 5.0, 50.0) + } + + fn temp_csv_path() -> String { + format!("test_temp_theta_{}.csv", rand::random::()) + } + + #[test] + fn sobol_generates_expected_shape() { + let theta = Theta::sobol_with_seed(¶meters(), 10, 42).unwrap(); + assert_eq!(theta.nspp(), 10); + assert_eq!(theta.matrix().ncols(), 2); + } + + #[test] + fn latin_generates_expected_shape() { + let theta = Theta::latin(¶meters(), 10).unwrap(); + assert_eq!(theta.nspp(), 10); + assert_eq!(theta.matrix().ncols(), 2); + } + + #[test] + fn sampling_rejects_invalid_bounds() { + let bad = ParameterSpace::::new().add("ke", 1.0, 1.0); + let err = Theta::sobol(&bad, 10).unwrap_err(); + assert!(err.to_string().contains("invalid bounds")); + } + + #[test] + fn from_file_parses_weights_and_reorders_columns() { + let path = temp_csv_path(); + fs::write(&path, "v,ke,prob\n10.0,0.5,0.3\n15.0,0.7,0.7\n").unwrap(); + + let (theta, weights) = Theta::from_file(&path, ¶meters()).unwrap(); + let _ = fs::remove_file(&path); + + assert_eq!(theta.nspp(), 2); + assert_eq!(theta.matrix()[(0, 0)], 0.5); + assert_eq!(theta.matrix()[(0, 1)], 10.0); + + let weights = weights.expect("weights should be parsed from prob column"); + assert_eq!(weights.len(), 2); + assert_eq!(weights[0], 0.3); + assert_eq!(weights[1], 0.7); + } + + #[test] + fn from_file_rejects_extra_parameters() { + let path = temp_csv_path(); + fs::write(&path, "ke,v,extra\n0.5,10.0,1.0\n").unwrap(); + + let err = Theta::from_file(&path, ¶meters()).unwrap_err(); + let _ = fs::remove_file(&path); + + assert!(err + .to_string() + .contains("Found parameters in the prior not present in configuration")); + } +} diff --git a/src/structs/weights.rs b/src/estimation/nonparametric/weights.rs similarity index 86% rename from src/structs/weights.rs rename to src/estimation/nonparametric/weights.rs index 483817d48..2ed06f75f 100644 --- a/src/structs/weights.rs +++ b/src/estimation/nonparametric/weights.rs @@ -24,15 +24,12 @@ impl Weights { Self { weights } } - /// Create a new [Weights] instance from a vector of weights. pub fn from_vec(weights: Vec) -> Self { Self { weights: Col::from_fn(weights.len(), |i| weights[i]), } } - /// 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(); @@ -43,27 +40,22 @@ impl Weights { } } - /// Get a reference to the weights. pub fn weights(&self) -> &Col { &self.weights } - /// Get a mutable reference to the weights. pub fn weights_mut(&mut self) -> &mut Col { &mut self.weights } - /// Get the number of weights. pub fn len(&self) -> usize { self.weights.nrows() } - // Check if there are no weights. pub fn is_empty(&self) -> bool { self.len() == 0 } - /// Get a vector representation of the weights. pub fn to_vec(&self) -> Vec { self.weights.iter().cloned().collect() } diff --git a/src/estimation/problem.rs b/src/estimation/problem.rs new file mode 100644 index 000000000..ef66805c2 --- /dev/null +++ b/src/estimation/problem.rs @@ -0,0 +1,378 @@ +use anyhow::{anyhow, Result}; +use pharmsol::{ + AssayErrorModel, AssayErrorModels, Data, Equation, Event, ResidualErrorModel, + ResidualErrorModels, +}; +use std::collections::{BTreeSet, HashSet}; + +use crate::estimation::nonparametric::Theta; +use crate::model::parameter_space::{BoundedParameter, ParameterSpace, UnboundedParameter}; +use crate::model::{EquationMetadataSource, Model, ModelBuilder}; + +pub trait Framework { + type ErrorModels; + /// The prior that seeds the algorithm. + /// + /// For the non-parametric framework this is a [`Theta`] (a discrete prior + /// distribution that also carries the parameter space). For the parametric + /// framework it is the [`ParameterSpace`] of unbounded parameters. + type Prior; +} + +pub struct Parametric; + +impl Framework for Parametric { + type ErrorModels = ResidualErrorModels; + type Prior = ParameterSpace; +} + +pub struct NonParametric; + +impl Framework for NonParametric { + type ErrorModels = AssayErrorModels; + type Prior = Theta; +} + +#[derive(Debug, Clone)] +pub struct EstimationProblem { + pub(crate) model: Model, + pub(crate) data: Data, + pub(crate) error_models: F::ErrorModels, + /// The prior that seeds the algorithm. + /// + /// For the non-parametric framework this is the prior [`Theta`], which also + /// carries the parameter space. The parameter space is therefore not stored + /// separately. + pub(crate) prior: F::Prior, +} + +impl EstimationProblem { + /// Creates a non-parametric estimation problem. + /// + /// The `prior` is a [`Theta`] holding the prior distribution (the initial + /// set of support points) together with the [`ParameterSpace`] it was built + /// from. The parameter space is taken directly from the prior, so there is + /// no separate parameter-declaration step. + pub fn nonparametric( + equation: E, + data: Data, + prior: Theta, + error_models: AssayErrorModels, + ) -> Result { + let model_builder = Model::builder(equation); + + validate_nonparametric_parameters(&model_builder, prior.parameters())?; + + let model = model_builder.build()?; + + validate_nonparametric_error_models(&model, &data, &error_models)?; + + Ok(EstimationProblem { + model, + data, + error_models, + prior, + }) + } +} + +impl EstimationProblem { + /// Begins building a parametric estimation problem. + pub fn parametric(equation: E, data: Data) -> ParametricBuilder { + ParametricBuilder { + model: Model::builder(equation), + data, + parameters: ParameterSpace::::new(), + error_models: Vec::new(), + } + } + + /// Returns the parameter space defined for this problem. + pub fn parameters(&self) -> &ParameterSpace { + &self.prior + } +} + +impl EstimationProblem { + /// Returns the parameter space carried by the prior [`Theta`]. + pub fn parameters(&self) -> &ParameterSpace { + self.prior.parameters() + } +} + +pub struct ParametricBuilder { + model: ModelBuilder, + data: Data, + parameters: ParameterSpace, + error_models: Vec<(String, ResidualErrorModel)>, +} + +impl ParametricBuilder { + pub fn parameter(mut self, parameter: impl Into) -> Self { + self.parameters.push(parameter.into()); + self + } + + pub fn parameters(mut self, parameters: I) -> Self + where + P: Into, + I: IntoIterator, + { + for param in parameters { + self.parameters.push(param.into()); + } + self + } + + pub fn error_model(mut self, name: impl Into, model: ResidualErrorModel) -> Self { + self.error_models.push((name.into(), model)); + self + } +} + +impl ParametricBuilder { + pub fn build(self) -> Result> { + validate_parametric_parameters(&self.model, &self.parameters)?; + validate_parametric_error_models(&self.model, &self.error_models)?; + + let mut all_errors = ResidualErrorModels::new(); + for (name, error_model) in self.error_models { + let outeq = self + .model + .output_index(&name) + .ok_or_else(|| anyhow!("unknown equation output label: {name}"))?; + + all_errors = all_errors.add(outeq, error_model); + } + + Ok(EstimationProblem { + model: self.model.build()?, + data: self.data, + error_models: all_errors, + prior: self.parameters, + }) + } +} + +fn validate_nonparametric_parameters( + model: &ModelBuilder, + parameters: &ParameterSpace, +) -> Result<()> { + if parameters.is_empty() { + anyhow::bail!("at least one parameter is required for non-parametric models"); + } + + for parameter in parameters.iter() { + if !parameter.lower.is_finite() || !parameter.upper.is_finite() { + anyhow::bail!( + "invalid bounds for parameter '{}': bounds must be finite numbers", + parameter.name + ); + } + + if parameter.lower >= parameter.upper { + anyhow::bail!( + "invalid bounds for parameter '{}': lower bound ({}) must be strictly less than upper bound ({})", + parameter.name, + parameter.lower, + parameter.upper + ); + } + } + + let names: Vec = parameters + .iter() + .map(|parameter| parameter.name.clone()) + .collect(); + validate_parameter_declarations(model, &names) +} + +fn validate_parametric_parameters( + model: &ModelBuilder, + parameters: &ParameterSpace, +) -> Result<()> { + if parameters.is_empty() { + anyhow::bail!("at least one parameter is required for parametric models"); + } + + let names: Vec = parameters + .iter() + .map(|parameter| parameter.name.clone()) + .collect(); + validate_parameter_declarations(model, &names) +} + +fn validate_parameter_declarations( + model: &ModelBuilder, + provided_names: &[String], +) -> Result<()> { + let mut seen: HashSet<&str> = HashSet::new(); + let mut duplicates: Vec = Vec::new(); + for name in provided_names { + if !seen.insert(name.as_str()) { + duplicates.push(name.clone()); + } + } + + if !duplicates.is_empty() { + duplicates.sort(); + duplicates.dedup(); + anyhow::bail!( + "duplicate parameter declarations found: {}", + duplicates.join(", ") + ); + } + + let declared = model.parameter_names(); + + let unknown: Vec = provided_names + .iter() + .filter(|name| model.parameter_index(name).is_none()) + .cloned() + .collect(); + if !unknown.is_empty() { + anyhow::bail!( + "unknown parameter name(s): {}. Valid parameters are: {}", + unknown.join(", "), + declared.join(", ") + ); + } + + let provided: HashSet<&str> = provided_names.iter().map(|name| name.as_str()).collect(); + let missing: Vec = declared + .iter() + .filter(|name| !provided.contains(name.as_str())) + .cloned() + .collect(); + + if !missing.is_empty() { + anyhow::bail!("missing parameter declaration(s): {}", missing.join(", ")); + } + + Ok(()) +} + +fn validate_nonparametric_error_models( + model: &Model, + data: &Data, + error_models: &AssayErrorModels, +) -> Result<()> { + // Bind the (label-first) error models to the equation. This resolves and + // validates that every declared output label maps to a valid model output. + let bound = model + .equation + .bind_error_models(error_models) + .map_err(|e| anyhow!("invalid assay error model output(s): {e}"))?; + + // Collect the set of model output indices that are actually observed in the + // data, resolving each observation's output label the same way the simulator + // does (exact name, then the `outeq_` numeric alias). + let mut observed_outputs: BTreeSet = BTreeSet::new(); + let mut unresolved_labels: BTreeSet = BTreeSet::new(); + for subject in data.subjects() { + for occasion in subject.occasions() { + for event in occasion.events() { + if let Event::Observation(obs) = event { + let label = obs.outeq().to_string(); + match resolve_output_index(model, &label) { + Some(outeq) => { + observed_outputs.insert(outeq); + } + None => { + unresolved_labels.insert(label); + } + } + } + } + } + } + + if !unresolved_labels.is_empty() { + let labels: Vec = unresolved_labels.into_iter().collect(); + anyhow::bail!( + "the data references output label(s) that are not defined by the model: {}", + labels.join(", ") + ); + } + + if observed_outputs.is_empty() { + anyhow::bail!("the data contains no observations to fit"); + } + + // Every observed output must have a (non-`None`) assay error model. + for &outeq in &observed_outputs { + let has_model = matches!( + bound.error_model(outeq), + Ok(error_model) if *error_model != AssayErrorModel::None + ); + + if !has_model { + let label = model + .output_name(outeq) + .map(|name| name.to_string()) + .unwrap_or_else(|| outeq.to_string()); + anyhow::bail!( + "no assay error model defined for output '{}' (index {}), which is observed in the data", + label, + outeq + ); + } + } + + Ok(()) +} + +/// Resolves an observation output `label` to a model output index, mirroring the +/// simulator: first by exact output name, then via the `outeq_` numeric alias. +fn resolve_output_index( + model: &Model, + label: &str, +) -> Option { + model.output_index(label).or_else(|| { + if !label.is_empty() && label.bytes().all(|b| b.is_ascii_digit()) { + model.output_index(&format!("outeq_{label}")) + } else { + None + } + }) +} + +fn validate_parametric_error_models( + model: &ModelBuilder, + error_models: &[(String, ResidualErrorModel)], +) -> Result<()> { + if error_models.is_empty() { + anyhow::bail!("at least one residual error model is required"); + } + + validate_error_model_labels(model, error_models.iter().map(|(name, _)| name.as_str())) +} + +fn validate_error_model_labels<'a, E, I>(model: &ModelBuilder, labels: I) -> Result<()> +where + E: Equation + EquationMetadataSource, + I: IntoIterator, +{ + let valid_outputs = model.output_names(); + let mut seen_output_indexes: HashSet = HashSet::new(); + + for name in labels { + let outeq = model.output_index(name).ok_or_else(|| { + anyhow!( + "unknown equation output label: {}. Valid outputs are: {}", + name, + valid_outputs.join(", ") + ) + })?; + + if !seen_output_indexes.insert(outeq) { + anyhow::bail!( + "duplicate error model declaration for output '{}' (index {})", + name, + outeq + ); + } + } + + Ok(()) +} diff --git a/src/estimation/progress.rs b/src/estimation/progress.rs new file mode 100644 index 000000000..1726d7423 --- /dev/null +++ b/src/estimation/progress.rs @@ -0,0 +1,18 @@ +use serde::Serialize; + +use crate::algorithms::Status; + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct NonparametricCycleProgress { + pub cycle: usize, + pub objective: f64, + pub objective_delta: Option, + pub elapsed_ms: u64, + pub status: Status, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum FitProgress { + NonparametricCycle(NonparametricCycleProgress), +} diff --git a/src/lib.rs b/src/lib.rs index 41cbc9af8..1ef54e274 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,25 +1,44 @@ -//! PMcore is a framework for developing and running non-parametric algorithms for population pharmacokinetic modelling +//! PMcore is a framework for developing and running population pharmacokinetic algorithms. //! -//! The framework is designed to be modular and flexible, allowing for easy integration of new algorithms and methods. It is heavily designed around the specifications for Pmetrics, a package for R, and is designed to be used in conjunction with it. However, as a general rust library, it can be used for a wide variety of applications, not limited to pharmacometrics. +//! The structure branch keeps the refactored platform surface together with the baseline +//! non-parametric workflows that existed on `main`. //! -//! # Configuration +//! # Algorithm Types //! -//! PMcore is configured using [routines::settings::Settings], which specifies the settings for the program. +//! ## Non-Parametric Algorithms +//! Represent the population distribution as a discrete set of support points with associated weights. +//! - NPAG (Non-Parametric Adaptive Grid) +//! - NPOD (Non-Parametric Optimal Design) +//! - POSTPROB (Posterior probability reweighting) +//! +//! # Public Interface +//! +//! PMcore centers on the estimation interface in [estimation]. Models are defined in +//! [model], configured with [estimation::EstimationProblem], and then executed with the +//! selected algorithm. //! //! # Data format //! -//! PMcore is heavily linked to [pharmsol], which provides the data structures and routines for handling pharmacokinetic data. The data is stored in a [pharmsol::Data] structure, and can either be read from a CSV file, using [pharmsol::data::parse_pmetrics::read_pmetrics], or created dynamically using the [pharmsol::data::builder::SubjectBuilder]. +//! PMcore is heavily linked to [pharmsol], which provides the data structures and routines for handling +//! pharmacokinetic data. The data is stored in a [pharmsol::Data] structure, and can either be read +//! from a CSV file, using [pharmsol::data::parse_pmetrics::read_pmetrics], or created dynamically +//! using the [pharmsol::data::builder::SubjectBuilder]. //! /// Provides the various algorithms used within the framework -// pub mod algorithms; pub mod algorithms; -/// Routines -pub mod routines; +/// Estimation family boundaries for the new architecture. +pub mod estimation; + +/// Public model-domain types used by the new API. +pub mod model; + +/// Shared result and summary types for the new API. +pub mod results; -// Structures -pub mod structs; +/// Logs +pub mod logs; // Re-export commonly used items pub use anyhow::Result; @@ -30,22 +49,42 @@ pub mod bestdose; /// A collection of commonly used items to simplify imports. pub mod prelude { + pub use super::logs::Logger; pub use super::HashMap; pub use super::Result; pub use crate::algorithms; - pub use crate::algorithms::dispatch_algorithm; pub use crate::algorithms::Algorithm; - pub use crate::routines; - pub use crate::routines::logger; - pub use pharmsol::optimize::effect::get_e2; - pub use pharmsol; + pub use crate::estimation::NonParametric; + pub use crate::estimation::Parametric; + pub use crate::estimation::{ + ErrorModels, EstimationProblem, FitProgress, NonParametricAlgorithm, + NonparametricCycleProgress, NpagConfig, NpmapConfig, NpodConfig, ParametricAlgorithm, + SaemConfig, + }; - pub use crate::routines::initialization::Prior; + pub use crate::model::parameter_space::{ + BoundedParameter, Parameter, ParameterScale, ParameterSpace, UnboundedParameter, + }; - pub use crate::routines::settings::*; - pub use crate::structs::*; + pub use crate::estimation::nonparametric::{ + CycleLog, NPCycle, NPPredictions, NonParametricResult, Posterior, Psi, Theta, Weights, + }; + pub use crate::model::{EquationMetadataSource, ModelMetadata}; + pub use crate::results::{ + FitResult, FitSummary, IndividualSummary, ParameterSummary, PopulationSummary, + }; + // pharmsol: re-export the crate itself and its curated prelude. + pub use pharmsol; + pub use pharmsol::prelude::*; + + // Items required by downstream code that are not part of `pharmsol::prelude`. + pub use pharmsol::equation::{EquationTypes, Predictions}; + pub use pharmsol::optimize::effect::get_e2; + pub use pharmsol::{ODE, SDE}; + + // Organized submodules mirroring pharmsol's grouping. pub mod simulator { pub use pharmsol::prelude::simulator::*; } @@ -56,20 +95,6 @@ pub mod prelude { pub use pharmsol::prelude::models::*; } - //traits - pub use pharmsol::data::*; - pub use pharmsol::equation::Equation; - pub use pharmsol::equation::EquationTypes; - pub use pharmsol::equation::Predictions; - pub use pharmsol::equation::*; - pub use pharmsol::prelude::*; - pub use pharmsol::simulator::*; - pub use pharmsol::ODE; - pub use pharmsol::SDE; - - //macros - pub use pharmsol::fa; - pub use pharmsol::fetch_cov; - pub use pharmsol::fetch_params; - pub use pharmsol::lag; + // macros + pub use pharmsol::{fa, fetch_cov, fetch_params, lag}; } diff --git a/src/logs.rs b/src/logs.rs new file mode 100644 index 000000000..69abca8f4 --- /dev/null +++ b/src/logs.rs @@ -0,0 +1,187 @@ +use std::fs::{create_dir_all, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use tracing::Level; +use tracing_subscriber::fmt; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, Layer, Registry}; + +/// Default directives appended to the level filter. +/// +/// `diffsol` is silenced because it is very verbose at `INFO` and below and +/// rarely useful for end users of PMcore. +const DEFAULT_DIRECTIVES: &str = "diffsol=off"; + +/// Builder for an opinionated `tracing` subscriber. +/// +/// By default the subscriber writes `INFO` level events to stdout and does +/// not write to a file. The subscriber itself does not render timestamps; +/// callers that want elapsed-time information are expected to include it in +/// their log messages, for example using [`format_elapsed`] with a start +/// instant tracked on the algorithm. +#[derive(Debug, Clone)] +pub struct Logger { + level: Level, + stdout: bool, + file: Option, + extra_directives: Option, +} + +impl Default for Logger { + fn default() -> Self { + Self { + level: Level::INFO, + stdout: true, + file: None, + extra_directives: Some(DEFAULT_DIRECTIVES.to_string()), + } + } +} + +impl Logger { + /// Create a new [`Logger`] builder with default settings (stdout at `INFO`). + pub fn new() -> Self { + Self::default() + } + + /// Set the minimum level of events to record. + pub fn level(mut self, level: Level) -> Self { + self.level = level; + self + } + + /// Enable or disable the stdout layer. Enabled by default. + pub fn stdout(mut self, enable: bool) -> Self { + self.stdout = enable; + self + } + + /// Write logs to the given file path. Parent directories are created as + /// needed and the file is truncated on open. + pub fn file>(mut self, path: P) -> Self { + self.file = Some(path.as_ref().to_path_buf()); + self + } + + /// Disable writing logs to a file. + pub fn no_file(mut self) -> Self { + self.file = None; + self + } + + /// Override the additional `EnvFilter` directives appended after the level. + /// + /// Defaults to `"diffsol=off"`. Pass an empty string to disable the + /// default directives entirely. The `RUST_LOG` environment variable, if + /// set, takes precedence over the configured level and directives. + pub fn directives(mut self, directives: impl Into) -> Self { + let directives = directives.into(); + self.extra_directives = if directives.is_empty() { + None + } else { + Some(directives) + }; + self + } + + /// Install the configured subscriber as the global default. + /// + /// Returns an error if a log file is configured but cannot be opened. + /// Silently does nothing if a global subscriber is already installed, + /// which makes the subscriber safe to reuse across back-to-back runs. + pub fn init(self) -> Result<()> { + let env_filter = self.build_env_filter(); + + let file_layer = match self.file.as_deref() { + Some(path) => Some(open_file_layer(path)?), + None => None, + }; + + let stdout_layer = if self.stdout { + Some( + fmt::layer() + .with_writer(std::io::stdout) + .with_ansi(true) + .with_target(false) + .without_time() + .boxed(), + ) + } else { + None + }; + + let _ = Registry::default() + .with(env_filter) + .with(file_layer) + .with(stdout_layer) + .try_init(); + + Ok(()) + } + + fn build_env_filter(&self) -> EnvFilter { + // Honor RUST_LOG when set, otherwise fall back to the configured level + // plus any extra directives. + if std::env::var("RUST_LOG").is_ok() { + return EnvFilter::from_default_env(); + } + + let mut directives = self.level.to_string().to_lowercase(); + if let Some(extra) = &self.extra_directives { + directives.push(','); + directives.push_str(extra); + } + EnvFilter::new(directives) + } +} + +fn open_file_layer( + path: &Path, +) -> Result + Send + Sync + 'static>> +where + S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>, +{ + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + create_dir_all(parent) + .with_context(|| format!("failed to create log directory {:?}", parent))?; + } + } + + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path) + .with_context(|| format!("failed to open log file {:?}", path))?; + + Ok(fmt::layer() + .with_writer(file) + .with_ansi(false) + .without_time() + .boxed()) +} + +/// Format a [`Duration`] as a compact `HHh MMm SSs` string. +/// +/// Intended for embedding elapsed-time information into log messages emitted +/// by an algorithm that tracks its own start instant, e.g.: +/// +/// ```no_run +/// use std::time::Instant; +/// use pmcore::logs::format_elapsed; +/// +/// let start = Instant::now(); +/// // ... do work ... +/// tracing::info!("cycle finished in {}", format_elapsed(start.elapsed())); +/// ``` +pub fn format_elapsed(elapsed: Duration) -> String { + let secs = elapsed.as_secs(); + let hours = secs / 3600; + let minutes = (secs % 3600) / 60; + let seconds = secs % 60; + format!("{:02}h {:02}m {:02}s", hours, minutes, seconds) +} diff --git a/src/model/metadata.rs b/src/model/metadata.rs new file mode 100644 index 000000000..7363ad36a --- /dev/null +++ b/src/model/metadata.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ModelMetadata { + pub name: Option, + pub description: Option, + pub tags: Vec, +} diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 000000000..26b22787e --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1,162 @@ +use anyhow::Result; +use pharmsol::equation::Equation; +use pharmsol::{Analytical, ValidatedModelMetadata, ODE, SDE}; + +pub mod metadata; +pub mod parameter_space; + +pub use metadata::ModelMetadata; +// Re-exporting the new typestate parameter elements for easy access downstream +pub use parameter_space::{ + BoundedParameter, Parameter, ParameterMeta, ParameterScale, ParameterSpace, UnboundedParameter, +}; + +#[derive(Debug, Clone)] +pub struct Model { + pub equation: E, + // Note: No 'parameters' field here! It is now managed by EstimationProblem. + // This struct is ready to hold Covariates or Variability specs if you add them later. +} + +impl Model { + pub fn builder(equation: E) -> ModelBuilder { + ModelBuilder { equation } + } +} + +impl Model { + pub fn parameter_count(&self) -> usize { + self.equation + .equation_metadata() + .map_or(0, |metadata| metadata.parameters().len()) + } + + pub fn parameter_name(&self, index: usize) -> Option<&str> { + self.equation + .equation_metadata() + .and_then(|metadata| metadata.parameters().get(index)) + .map(|parameter| parameter.name()) + } + + pub fn parameter_index(&self, name: &str) -> Option { + self.equation + .equation_metadata() + .and_then(|metadata| metadata.parameter_index(name)) + } + + pub fn output_count(&self) -> usize { + self.equation + .equation_metadata() + .map_or(0, |metadata| metadata.outputs().len()) + } + + pub fn output_name(&self, outeq: usize) -> Option<&str> { + self.equation + .equation_metadata() + .and_then(|metadata| metadata.outputs().get(outeq)) + .map(|output| output.name()) + } + + pub fn output_index(&self, name: &str) -> Option { + self.equation.equation_metadata().and_then(|metadata| { + metadata + .outputs() + .iter() + .position(|output| output.name() == name) + }) + } +} + +pub struct ModelBuilder { + equation: E, +} + +impl ModelBuilder { + pub fn build(self) -> Result> { + Ok(Model { + equation: self.equation, + }) + } +} + +impl ModelBuilder { + pub(crate) fn parameter_index(&self, name: &str) -> Option { + self.equation + .equation_metadata() + .and_then(|metadata| metadata.parameter_index(name)) + } + + pub(crate) fn parameter_names(&self) -> Vec { + self.equation + .equation_metadata() + .map(|metadata| { + metadata + .parameters() + .iter() + .map(|parameter| parameter.name().to_string()) + .collect() + }) + .unwrap_or_default() + } + + pub(crate) fn output_names(&self) -> Vec { + self.equation + .equation_metadata() + .map(|metadata| { + metadata + .outputs() + .iter() + .map(|output| output.name().to_string()) + .collect() + }) + .unwrap_or_default() + } + + pub(crate) fn output_index(&self, name: &str) -> Option { + self.equation.equation_metadata().and_then(|metadata| { + metadata + .outputs() + .iter() + .position(|output| output.name() == name) + }) + } +} + +pub trait EquationMetadataSource: Equation { + fn equation_metadata(&self) -> Option<&ValidatedModelMetadata>; +} + +// Macro for standard pharmsol equations +macro_rules! impl_metadata_opt { + ($($t:ty),+) => { + $(impl EquationMetadataSource for $t { + fn equation_metadata(&self) -> Option<&ValidatedModelMetadata> { + self.metadata() + } + })+ + }; +} +impl_metadata_opt!(ODE, Analytical, SDE); + +// Macro for runtime/JIT models +macro_rules! impl_metadata_some { + ($($t:ty),+) => { + $( + #[cfg(any( + feature = "dsl-jit", + all(feature = "dsl-aot", feature = "dsl-aot-load"), + all(feature = "dsl-wasm", not(all(target_arch = "wasm32", target_os = "unknown"))) + ))] + impl EquationMetadataSource for $t { + fn equation_metadata(&self) -> Option<&ValidatedModelMetadata> { + Some(self.metadata()) + } + } + )+ + }; +} +impl_metadata_some!( + pharmsol::dsl::RuntimeOdeModel, + pharmsol::dsl::RuntimeAnalyticalModel, + pharmsol::dsl::RuntimeSdeModel +); diff --git a/src/model/parameter_space.rs b/src/model/parameter_space.rs new file mode 100644 index 000000000..b92785bea --- /dev/null +++ b/src/model/parameter_space.rs @@ -0,0 +1,258 @@ +use serde::{Deserialize, Serialize}; + +/// Ordered collection of parameters. +/// +/// Use `ParameterSpace` for non-parametric problems and +/// `ParameterSpace` for parametric problems. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct ParameterSpace { + pub items: Vec, +} + +impl ParameterSpace { + pub fn new() -> Self { + Self { items: Vec::new() } + } + + pub fn push(&mut self, item: impl Into) { + self.items.push(item.into()); + } + + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn iter(&self) -> std::slice::Iter<'_, T> { + self.items.iter() + } +} + +impl ParameterSpace { + pub fn names(&self) -> Vec { + self.items.iter().map(|p| p.name().to_string()).collect() + } +} + +/// Helpers for bounded parameter spaces. +impl ParameterSpace { + /// Creates an empty bounded parameter space (for non-parametric problems). + /// + /// Prefer this over `ParameterSpace::::new()` to avoid the + /// turbofish: + /// + /// ```ignore + /// let space = ParameterSpace::bounded() + /// .add("ke", 0.1, 1.0) + /// .add("v", 1.0, 20.0); + /// ``` + pub fn bounded() -> Self { + Self::new() + } + + /// Adds a bounded parameter with the given `name`, `lower`, and `upper` bounds. + #[allow(clippy::should_implement_trait)] + pub fn add(mut self, name: impl Into, lower: f64, upper: f64) -> Self { + self.items.push(BoundedParameter::new(name, lower, upper)); + self + } + + /// Returns `(lower, upper)` for each parameter. + pub fn finite_ranges(&self) -> Vec<(f64, f64)> { + self.items.iter().map(|p| (p.lower, p.upper)).collect() + } +} + +/// Helpers for unbounded parameter spaces. +impl ParameterSpace { + /// Creates an empty unbounded parameter space (for parametric problems). + /// + /// Prefer this over `ParameterSpace::::new()` to avoid + /// the turbofish. + pub fn unbounded() -> Self { + Self::new() + } + + /// Adds an unbounded parameter to the space. + #[allow(clippy::should_implement_trait)] + pub fn add(mut self, parameter: impl Into) -> Self { + self.items.push(parameter.into()); + self + } +} + +impl FromIterator for ParameterSpace { + fn from_iter>(iter: I) -> Self { + Self { + items: iter.into_iter().collect(), + } + } +} + +impl IntoIterator for ParameterSpace { + type Item = T; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.items.into_iter() + } +} + +impl<'a, T> IntoIterator for &'a ParameterSpace { + type Item = &'a T; + type IntoIter = std::slice::Iter<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.items.iter() + } +} + +/// Common metadata exposed by parameter types. +pub trait ParameterMeta { + fn name(&self) -> &str; +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct BoundedParameter { + pub name: String, + pub lower: f64, + pub upper: f64, +} + +impl BoundedParameter { + pub fn new(name: impl Into, lower: f64, upper: f64) -> Self { + Self { + name: name.into(), + lower, + upper, + } + } +} + +impl ParameterMeta for BoundedParameter { + fn name(&self) -> &str { + &self.name + } +} + +/// Converts a bounded parameter into a parametric parameter with logit scaling. +impl From for UnboundedParameter { + fn from(p: BoundedParameter) -> Self { + UnboundedParameter { + name: p.name, + scale: ParameterScale::Logit { + lower: p.lower, + upper: p.upper, + }, + initial: None, + estimate: true, + } + } +} + +/// Parametric parameter with an optional scale transform. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UnboundedParameter { + pub name: String, + pub scale: ParameterScale, + pub initial: Option, + pub estimate: bool, +} + +impl UnboundedParameter { + /// Creates a parameter with an explicit scale. + pub fn new(name: impl Into, scale: ParameterScale) -> Self { + Self { + name: name.into(), + scale, + initial: None, + estimate: true, + } + } + + /// Creates a parameter on identity scale. + pub fn real(name: impl Into) -> Self { + Self::new(name, ParameterScale::Identity) + } + + /// Sets an initial value. + pub fn with_initial(mut self, value: f64) -> Self { + self.initial = Some(value); + self + } +} + +impl ParameterMeta for UnboundedParameter { + fn name(&self) -> &str { + &self.name + } +} + +/// Scale transform for parametric parameters. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +pub enum ParameterScale { + /// Identity transform. + Identity, + /// Log transform. + Log, + /// Logistic transform on `(lower, upper)`. + Logit { lower: f64, upper: f64 }, + /// Probit transform on `(lower, upper)`. + Probit { lower: f64, upper: f64 }, +} + +impl Default for ParameterScale { + fn default() -> Self { + ParameterScale::Identity + } +} + +/// Entry point for building parameter declarations. +/// +/// ```ignore +/// use pmcore::prelude::*; +/// +/// // Non-parametric: only bounded parameters are accepted. +/// builder.parameter(Parameter::bounded("ke", 0.001, 3.0)); +/// +/// // Parametric: pick the scale explicitly. +/// builder.parameter(Parameter::log("ke")); +/// builder.parameter(Parameter::logit("frac", 0.0, 1.0)); +/// builder.parameter(Parameter::bounded("v", 25.0, 250.0)); // mapped to Logit +/// ``` +pub struct Parameter; + +impl Parameter { + /// Creates a bounded parameter. + pub fn bounded(name: impl Into, lower: f64, upper: f64) -> BoundedParameter { + BoundedParameter::new(name, lower, upper) + } + + /// Creates a parametric parameter on identity scale. + pub fn real(name: impl Into) -> UnboundedParameter { + UnboundedParameter::real(name) + } + + /// Creates a parametric parameter with an explicit scale. + pub fn scaled(name: impl Into, scale: ParameterScale) -> UnboundedParameter { + UnboundedParameter::new(name, scale) + } + + /// Creates a parametric parameter on log scale. + pub fn log(name: impl Into) -> UnboundedParameter { + UnboundedParameter::new(name, ParameterScale::Log) + } + + /// Creates a parametric parameter on logit scale. + pub fn logit(name: impl Into, lower: f64, upper: f64) -> UnboundedParameter { + UnboundedParameter::new(name, ParameterScale::Logit { lower, upper }) + } + + /// Creates a parametric parameter on probit scale. + pub fn probit(name: impl Into, lower: f64, upper: f64) -> UnboundedParameter { + UnboundedParameter::new(name, ParameterScale::Probit { lower, upper }) + } +} diff --git a/src/results/fit_result.rs b/src/results/fit_result.rs new file mode 100644 index 000000000..3e8470b52 --- /dev/null +++ b/src/results/fit_result.rs @@ -0,0 +1,63 @@ +use pharmsol::Equation; + +use crate::estimation::nonparametric::NonParametricResult; +use crate::results::{FitSummary, IndividualSummary, PopulationSummary}; + +/// A shared trait for the output of any estimation algorithm. +pub trait FitResult { + fn objf(&self) -> f64; + fn converged(&self) -> bool; + fn summary(&self) -> FitSummary; + fn population_summary(&self) -> PopulationSummary; + fn individual_summaries(&self) -> Vec; +} + +// Placeholder for your future Parametric implementation +#[derive(Debug)] +#[allow(unused)] +//TODO: Implement ParametricResult +pub struct ParametricResult { + _phantom: std::marker::PhantomData, +} + +impl FitResult for ParametricResult { + fn objf(&self) -> f64 { + unimplemented!("Parametric result not yet implemented") + } + fn converged(&self) -> bool { + unimplemented!() + } + fn summary(&self) -> FitSummary { + unimplemented!() + } + fn population_summary(&self) -> PopulationSummary { + unimplemented!() + } + fn individual_summaries(&self) -> Vec { + unimplemented!() + } +} + +use crate::estimation::nonparametric; + +impl FitResult for NonParametricResult { + fn objf(&self) -> f64 { + self.objf() // Assuming the struct has this native method + } + + fn converged(&self) -> bool { + self.converged() // Assuming the struct has this native method + } + + fn summary(&self) -> FitSummary { + nonparametric::fit_summary(self) + } + + fn population_summary(&self) -> PopulationSummary { + nonparametric::population_summary(self) + } + + fn individual_summaries(&self) -> Vec { + nonparametric::individual_summaries(self) + } +} diff --git a/src/results/mod.rs b/src/results/mod.rs new file mode 100644 index 000000000..5a0bed71e --- /dev/null +++ b/src/results/mod.rs @@ -0,0 +1,7 @@ +mod fit_result; + +mod summary; + +pub use fit_result::{FitResult, ParametricResult}; + +pub use summary::{FitSummary, IndividualSummary, ParameterSummary, PopulationSummary}; diff --git a/src/results/summary.rs b/src/results/summary.rs new file mode 100644 index 000000000..c6769f38e --- /dev/null +++ b/src/results/summary.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FitSummary { + pub objective_function: f64, + pub converged: bool, + pub iterations: usize, + pub subject_count: usize, + pub observation_count: usize, + pub parameter_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PopulationSummary { + pub parameters: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ParameterSummary { + pub name: String, + pub mean: f64, + pub median: f64, + pub sd: f64, + pub cv_percent: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct IndividualSummary { + pub id: String, + pub parameter_names: Vec, + pub estimates: Vec, + pub standard_errors: Option>, +} diff --git a/src/routines/condensation/mod.rs b/src/routines/condensation/mod.rs deleted file mode 100644 index d01533b6b..000000000 --- a/src/routines/condensation/mod.rs +++ /dev/null @@ -1,107 +0,0 @@ -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)) -} diff --git a/src/routines/estimation/ipm.rs b/src/routines/estimation/ipm.rs deleted file mode 100644 index fbb1768b2..000000000 --- a/src/routines/estimation/ipm.rs +++ /dev/null @@ -1,517 +0,0 @@ -use crate::structs::psi::Psi; -use crate::structs::weights::Weights; -use anyhow::bail; -use faer::linalg::triangular_solve::solve_lower_triangular_in_place; -use faer::linalg::triangular_solve::solve_upper_triangular_in_place; -use faer::{Col, Mat, Row}; -use rayon::prelude::*; -/// Applies Burke's Interior Point Method (IPM) to solve a convex optimization problem. -/// -/// The objective function to maximize is: -/// f(x) = Σ(log(Σ(ψ_ij * x_j))) for i = 1 to n_sub -/// -/// subject to: -/// 1. x_j ≥ 0 for all j = 1 to n_point, -/// 2. Σ(x_j) = 1, -/// -/// where ψ is an n_sub×n_point matrix with non-negative entries and x is a probability vector. -/// -/// # Arguments -/// -/// * `psi` - A reference to a Psi structure containing the input matrix. -/// -/// # Returns -/// -/// On success, returns a tuple `(weights, obj)` where: -/// - [Weights] contains the optimized weights (probabilities) for each support point. -/// - `obj` is the value of the objective function at the solution. -/// -/// # Errors -/// -/// This function returns an error if any step in the optimization (e.g. Cholesky factorization) -/// fails. -pub fn burke(psi: &Psi) -> anyhow::Result<(Weights, f64)> { - let mut psi = psi.matrix().to_owned(); - - // Ensure all entries are finite and make them non-negative. - psi.row_iter_mut().try_for_each(|row| { - row.iter_mut().try_for_each(|x| { - if !x.is_finite() { - bail!("Input matrix must have finite entries") - } else { - // Coerce negatives to non-negative (could alternatively return an error) - *x = x.abs(); - Ok(()) - } - }) - })?; - - // Let psi be of shape (n_sub, n_point) - let (n_sub, n_point) = psi.shape(); - - // Create unit vectors: - // ecol: ones vector of length n_point (used for sums over points) - // erow: ones row of length n_sub (used for sums over subproblems) - let ecol: Col = Col::from_fn(n_point, |_| 1.0); - let erow: Row = Row::from_fn(n_sub, |_| 1.0); - - // Compute plam = psi · ecol. This gives a column vector of length n_sub. - let mut plam: Col = &psi * &ecol; - let eps: f64 = 1e-8; - let mut sig: f64 = 0.0; - - // Initialize lam (the variable we optimize) as a column vector of ones (length n_point). - let mut lam = ecol.clone(); - - // w = 1 ./ plam, elementwise. - let mut w: Col = Col::from_fn(plam.nrows(), |i| 1.0 / plam.get(i)); - - // ptw = ψᵀ · w, which will be a vector of length n_point. - let mut ptw: Col = psi.transpose() * &w; - - // Use the maximum entry in ptw for scaling (the "shrink" factor). - let ptw_max = ptw.iter().fold(f64::NEG_INFINITY, |acc, &x| x.max(acc)); - let shrink = 2.0 * ptw_max; - lam *= shrink; - plam *= shrink; - w /= shrink; - ptw /= shrink; - - // y = ecol - ptw (a vector of length n_point). - let mut y: Col = &ecol - &ptw; - // r = erow - (w .* plam) (elementwise product; r has length n_sub). - let mut r: Col = Col::from_fn(n_sub, |i| erow.get(i) - w.get(i) * plam.get(i)); - let mut norm_r: f64 = r.iter().fold(0.0, |max, &val| max.max(val.abs())); - - // Compute the duality gap. - let sum_log_plam: f64 = plam.iter().map(|x| x.ln()).sum(); - let sum_log_w: f64 = w.iter().map(|x| x.ln()).sum(); - let mut gap: f64 = (sum_log_w + sum_log_plam).abs() / (1.0 + sum_log_plam); - - // Compute the duality measure mu. - let mut mu = lam.transpose() * &y / n_point as f64; - - let mut psi_inner: Mat = Mat::zeros(psi.nrows(), psi.ncols()); - - let n_threads = faer::get_global_parallelism().degree(); - - let rows = psi.nrows(); - - let mut output: Vec> = (0..n_threads).map(|_| Mat::zeros(rows, rows)).collect(); - - let mut h: Mat = Mat::zeros(rows, rows); - - while mu > eps || norm_r > eps || gap > eps { - let smu = sig * mu; - // inner = lam ./ y, elementwise. - let inner = Col::from_fn(lam.nrows(), |i| lam.get(i) / y.get(i)); - // w_plam = plam ./ w, elementwise (length n_sub). - let w_plam = Col::from_fn(plam.nrows(), |i| plam.get(i) / w.get(i)); - - // Scale each column of psi by the corresponding element of 'inner' - - if psi.ncols() > n_threads * 128 { - psi_inner - .par_col_partition_mut(n_threads) - .zip(psi.par_col_partition(n_threads)) - .zip(inner.par_partition(n_threads)) - .zip(output.par_iter_mut()) - .for_each(|(((mut psi_inner, psi), inner), output)| { - psi_inner - .as_mut() - .col_iter_mut() - .zip(psi.col_iter()) - .zip(inner.iter()) - .for_each(|((col, psi_col), inner_val)| { - col.iter_mut().zip(psi_col.iter()).for_each(|(x, psi_val)| { - *x = psi_val * inner_val; - }); - }); - faer::linalg::matmul::triangular::matmul( - output.as_mut(), - faer::linalg::matmul::triangular::BlockStructure::TriangularLower, - faer::Accum::Replace, - &psi_inner, - faer::linalg::matmul::triangular::BlockStructure::Rectangular, - psi.transpose(), - faer::linalg::matmul::triangular::BlockStructure::Rectangular, - 1.0, - faer::Par::Seq, - ); - }); - - let mut first_iter = true; - for output in &output { - if first_iter { - h.copy_from(output); - first_iter = false; - } else { - h += output; - } - } - } else { - psi_inner - .as_mut() - .col_iter_mut() - .zip(psi.col_iter()) - .zip(inner.iter()) - .for_each(|((col, psi_col), inner_val)| { - col.iter_mut().zip(psi_col.iter()).for_each(|(x, psi_val)| { - *x = psi_val * inner_val; - }); - }); - faer::linalg::matmul::triangular::matmul( - h.as_mut(), - faer::linalg::matmul::triangular::BlockStructure::TriangularLower, - faer::Accum::Replace, - &psi_inner, - faer::linalg::matmul::triangular::BlockStructure::Rectangular, - psi.transpose(), - faer::linalg::matmul::triangular::BlockStructure::Rectangular, - 1.0, - faer::Par::Seq, - ); - } - - for i in 0..h.nrows() { - h[(i, i)] += w_plam[i]; - } - - let uph = match h.llt(faer::Side::Lower) { - Ok(llt) => llt, - Err(_) => { - bail!("Error during Cholesky decomposition. The matrix might not be positive definite. This is usually due to model misspecification or numerical issues.") - } - }; - let uph = uph.L().transpose().to_owned(); - - // smuyinv = smu * (ecol ./ y) - let smuyinv: Col = Col::from_fn(ecol.nrows(), |i| smu * (ecol[i] / y[i])); - - // let smuyinv = smu * (&ecol / &y); - // rhsdw = (erow ./ w) - (psi · smuyinv) - let psi_dot_muyinv: Col = &psi * &smuyinv; - - let rhsdw: Row = Row::from_fn(erow.ncols(), |i| erow[i] / w[i] - psi_dot_muyinv[i]); - - //let rhsdw = (&erow / &w) - psi * &smuyinv; - // Reshape rhsdw into a column vector. - let mut dw = Mat::from_fn(rhsdw.ncols(), 1, |i, _j| *rhsdw.get(i)); - - // let a = rhsdw - // .into_shape((n_sub, 1)) - // .context("Failed to reshape rhsdw").unwrap(); - - // Solve the triangular systems: - - solve_lower_triangular_in_place(uph.transpose().as_ref(), dw.as_mut(), faer::Par::rayon(0)); - - solve_upper_triangular_in_place(uph.as_ref(), dw.as_mut(), faer::Par::rayon(0)); - - // Extract dw (a column vector) from the solution. - let dw = dw.col(0); - - // let dw = dw_aux.column(0); - // Compute dy = - (ψᵀ · dw) - let dy = -(psi.transpose() * dw); - - let inner_times_dy = Col::from_fn(ecol.nrows(), |i| inner[i] * dy[i]); - - let dlam: Row = - Row::from_fn(ecol.nrows(), |i| smuyinv[i] - lam[i] - inner_times_dy[i]); - // let dlam = &smuyinv - &lam - inner.transpose() * &dy; - - // Compute the primal step length alfpri. - let ratio_dlam_lam = Row::from_fn(lam.nrows(), |i| dlam[i] / lam[i]); - //let ratio_dlam_lam = &dlam / &lam; - let min_ratio_dlam = ratio_dlam_lam.iter().cloned().fold(f64::INFINITY, f64::min); - let mut alfpri: f64 = -1.0 / min_ratio_dlam.min(-0.5); - alfpri = (0.99995 * alfpri).min(1.0); - - // Compute the dual step length alfdual. - let ratio_dy_y = Row::from_fn(y.nrows(), |i| dy[i] / y[i]); - // let ratio_dy_y = &dy / &y; - let min_ratio_dy = ratio_dy_y.iter().cloned().fold(f64::INFINITY, f64::min); - let ratio_dw_w = Row::from_fn(dw.nrows(), |i| dw[i] / w[i]); - //let ratio_dw_w = &dw / &w; - let min_ratio_dw = ratio_dw_w.iter().cloned().fold(f64::INFINITY, f64::min); - let mut alfdual = -1.0 / min_ratio_dy.min(-0.5); - alfdual = alfdual.min(-1.0 / min_ratio_dw.min(-0.5)); - alfdual = (0.99995 * alfdual).min(1.0); - - // Update the iterates. - lam += alfpri * dlam.transpose(); - w += alfdual * dw; - y += alfdual * &dy; - - mu = lam.transpose() * &y / n_point as f64; - plam = &psi * &lam; - - // mu = lam.dot(&y) / n_point as f64; - // plam = psi.dot(&lam); - r = Col::from_fn(n_sub, |i| erow.get(i) - w.get(i) * plam.get(i)); - ptw -= alfdual * dy; - - norm_r = r.norm_max(); - let sum_log_plam: f64 = plam.iter().map(|x| x.ln()).sum(); - let sum_log_w: f64 = w.iter().map(|x| x.ln()).sum(); - gap = (sum_log_w + sum_log_plam).abs() / (1.0 + sum_log_plam); - - // Adjust sigma. - if mu < eps && norm_r > eps { - sig = 1.0; - } else { - let candidate1 = (1.0 - alfpri).powi(2); - let candidate2 = (1.0 - alfdual).powi(2); - let candidate3 = (norm_r - mu) / (norm_r + 100.0 * mu); - sig = candidate1.max(candidate2).max(candidate3).min(0.3); - } - } - // Scale lam. - lam /= n_sub as f64; - // Compute the objective function value: sum(ln(psi·lam)). - let obj = (psi * &lam).iter().map(|x| x.ln()).sum(); - // Normalize lam to sum to 1. - let lam_sum: f64 = lam.iter().sum(); - lam = &lam / lam_sum; - - Ok((lam.into(), obj)) -} - -#[cfg(test)] -mod tests { - use super::*; - use approx::assert_relative_eq; - use faer::Mat; - - #[test] - fn test_burke_identity() { - // Test with a small identity matrix - // For an identity matrix, each support point should have equal weight - let n = 100; - let mat = Mat::identity(n, n); - let psi = Psi::from(mat); - - let (lam, _) = burke(&psi).unwrap(); - - // For identity matrix, all lambda values should be equal - let expected = 1.0 / n as f64; - for i in 0..n { - assert_relative_eq!(lam[i], expected, epsilon = 1e-10); - } - - // Check that lambda sums to 1 - assert_relative_eq!(lam.iter().sum::(), 1.0, epsilon = 1e-10); - } - - #[test] - fn test_burke_uniform_square() { - // Test with a matrix of all ones - // This should also result in uniform weights - let n_sub = 10; - let n_point = 10; - let mat = Mat::from_fn(n_sub, n_point, |_, _| 1.0); - let psi = Psi::from(mat); - - let (lam, _) = burke(&psi).unwrap(); - - // Check that lambda sums to 1 - assert_relative_eq!(lam.iter().sum::(), 1.0, epsilon = 1e-10); - - // For uniform matrix, all lambda values should be equal - let expected = 1.0 / n_point as f64; - for i in 0..n_point { - assert_relative_eq!(lam[i], expected, epsilon = 1e-10); - } - } - - #[test] - fn test_burke_uniform_wide() { - // Test with a matrix of all ones - // This should also result in uniform weights - let n_sub = 10; - let n_point = 100; - let mat = Mat::from_fn(n_sub, n_point, |_, _| 1.0); - let psi = Psi::from(mat); - - let (lam, _) = burke(&psi).unwrap(); - - // Check that lambda sums to 1 - assert_relative_eq!(lam.iter().sum::(), 1.0, epsilon = 1e-10); - - // For uniform matrix, all lambda values should be equal - let expected = 1.0 / n_point as f64; - for i in 0..n_point { - assert_relative_eq!(lam[i], expected, epsilon = 1e-10); - } - } - - #[test] - fn test_burke_uniform_long() { - // Test with a matrix of all ones - // This should also result in uniform weights - let n_sub = 100; - let n_point = 10; - let mat = Mat::from_fn(n_sub, n_point, |_, _| 1.0); - let psi = Psi::from(mat); - - let (lam, _) = burke(&psi).unwrap(); - - // Check that lambda sums to 1 - assert_relative_eq!(lam.iter().sum::(), 1.0, epsilon = 1e-10); - - // For uniform matrix, all lambda values should be equal - let expected = 1.0 / n_point as f64; - for i in 0..n_point { - assert_relative_eq!(lam[i], expected, epsilon = 1e-10); - } - } - - #[test] - fn test_burke_with_non_uniform_matrix() { - // Test with a non-uniform matrix - // Create a matrix where one column is clearly better - let n_sub = 3; - let n_point = 4; - let mat = Mat::from_fn(n_sub, n_point, |_, j| if j == 0 { 10.0 } else { 1.0 }); - let psi = Psi::from(mat); - - let (lam, _) = burke(&psi).unwrap(); - - // Check that lambda sums to 1 - assert_relative_eq!(lam.iter().sum::(), 1.0, epsilon = 1e-10); - - // First support point should have highest weight - assert!(lam[0] > lam[1]); - assert!(lam[0] > lam[2]); - assert!(lam[0] > lam[3]); - } - - #[test] - fn test_burke_with_negative_values() { - // The algorithm should handle negative values by taking their absolute value - let n_sub = 2; - let n_point = 3; - let mat = Mat::from_fn( - n_sub, - n_point, - |i, j| if i == 0 && j == 0 { -5.0 } else { 1.0 }, - ); - let psi = Psi::from(mat); - - let result = burke(&psi); - assert!(result.is_ok()); - - let (lam, _) = result.unwrap(); - // Check that lambda sums to 1 - assert_relative_eq!(lam.iter().sum::(), 1.0, epsilon = 1e-10); - - // First support point should have highest weight due to the high absolute value - assert!(lam[0] > lam[1]); - assert!(lam[0] > lam[2]); - } - - #[test] - fn test_burke_with_non_finite_values() { - // The algorithm should return an error for non-finite values - let n_sub = 10; - let n_point = 10; - let mat = Mat::from_fn(n_sub, n_point, |i, j| { - if i == 0 && j == 0 { - f64::NAN - } else { - 1.0 - } - }); - let psi = Psi::from(mat); - - let result = burke(&psi); - assert!(result.is_err()); - } - - #[test] - fn test_burke_large_matrix_parallel_processing() { - // Test with a large matrix to trigger the parallel processing code path - // This should exceed n_threads * 128 threshold - let n_sub = 50; - let n_point = 10000; - - // Create a simple uniform matrix - // The main goal is to test that parallel processing works correctly - let mat = Mat::from_fn(n_sub, n_point, |_i, _j| 1.0); - let psi = Psi::from(mat); - - let result = burke(&psi); - assert!( - result.is_ok(), - "Burke algorithm should succeed with large matrix" - ); - - let (lam, obj) = result.unwrap(); - - // Verify basic mathematical properties of the solution - assert_relative_eq!(lam.iter().sum::(), 1.0, epsilon = 1e-10); - - // All lambda values should be non-negative - for i in 0..n_point { - assert!(lam[i] >= 0.0, "Lambda values should be non-negative"); - } - - // The objective function should be finite - assert!(obj.is_finite(), "Objective function should be finite"); - - // The main test: verify that the parallel processing path was executed - // and produced a valid probability distribution - // For a uniform matrix, we expect roughly uniform weights, but the exact - // distribution depends on the optimization algorithm's convergence - - // Just verify that no single weight dominates excessively (basic sanity check) - let max_weight = lam - .weights() - .iter() - .cloned() - .fold(f64::NEG_INFINITY, f64::max); - assert!( - max_weight < 0.1, - "No single weight should dominate in uniform matrix (max weight: {})", - max_weight - ); - } - - #[test] - fn test_burke_medium_matrix_sequential_processing() { - // Test with a medium-sized matrix that should NOT trigger parallel processing - // This serves as a comparison to ensure both code paths produce similar results - let n_sub = 50; - let n_point = 500; // This should be < n_threads * 128 threshold - - // Use the same pattern as the large matrix test - let mat = Mat::from_fn(n_sub, n_point, |i, j| { - if j % 100 == 0 { - 5.0 + 0.1 * (i as f64) - } else { - 1.0 + 0.01 * (i as f64) + 0.001 * (j as f64) - } - }); - let psi = Psi::from(mat); - - let result = burke(&psi); - assert!( - result.is_ok(), - "Burke algorithm should succeed with medium matrix" - ); - - let (lam, obj) = result.unwrap(); - - // Verify basic properties of the solution - assert_relative_eq!(lam.iter().sum::(), 1.0, epsilon = 1e-10); - - // All lambda values should be non-negative - for i in 0..n_point { - assert!(lam[i] >= 0.0, "Lambda values should be non-negative"); - } - - // The objective function should be finite - assert!(obj.is_finite(), "Objective function should be finite"); - } -} diff --git a/src/routines/estimation/mod.rs b/src/routines/estimation/mod.rs deleted file mode 100644 index b9ad757e7..000000000 --- a/src/routines/estimation/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod ipm; -pub mod qr; diff --git a/src/routines/estimation/qr.rs b/src/routines/estimation/qr.rs deleted file mode 100644 index acc104d26..000000000 --- a/src/routines/estimation/qr.rs +++ /dev/null @@ -1,98 +0,0 @@ -use crate::structs::psi::Psi; -use anyhow::{bail, Result}; -use faer::linalg::solvers::ColPivQr; -use faer::Mat; - -/// Perform a QR decomposition on the Psi matrix -/// -/// Normalizes each row of the matrix to sum to 1 before decomposition. -/// Returns the R matrix from QR decomposition and the column permutation vector. -/// -/// # Arguments -/// * `psi` - The Psi matrix to decompose -/// -/// # Returns -/// * Tuple containing the R matrix (as [faer::Mat]) and permutation vector (as [Vec]) -/// * Error if any row in the matrix sums to zero -pub fn qrd(psi: &Psi) -> Result<(Mat, Vec)> { - let mut mat = psi.matrix().to_owned(); - - // Normalize the rows to sum to 1 - for (index, row) in mat.row_iter_mut().enumerate() { - let row_sum: f64 = row.as_ref().iter().sum(); - - // Check if the row sum is zero - if row_sum.abs() == 0.0 { - bail!("In psi, the row with index {} sums to zero", index); - } - row.iter_mut().for_each(|x| *x /= row_sum); - } - - // Perform column pivoted QR decomposition - let qr: ColPivQr = mat.col_piv_qr(); - - // Extract the R matrix - let r_mat: faer::Mat = qr.R().to_owned(); - - // Get the permutation information - let perm = qr.P().arrays().0.to_vec(); - Ok((r_mat, perm)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_identity() { - // Create a 2x2 identity matrix - let mat: Mat = Mat::identity(10, 10); - let psi = Psi::from(mat); - - // Perform the QR decomposition - let (r_mat, perm) = qrd(&psi).unwrap(); - - // Check that R is an identity matrix - let expected_r_mat: Mat = Mat::identity(10, 10); - assert_eq!(r_mat, expected_r_mat); - - // Check that the permutation is the identity - assert_eq!(perm, vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - } - - #[test] - fn test_with_zero_row_sum() { - // Create a test matrix with a row that sums to zero - let mat = Mat::from_fn(2, 2, |i, j| { - if i == 0 && j == 0 { - 1.0 - } else if i == 0 && j == 1 { - 2.0 - } else { - 0.0 // Row that sums to zero for i == 1 - } - }); - let psi = Psi::from(mat); - - // Perform the QR decomposition - let result = qrd(&psi); - - // Confirm that the function returns an error - assert!(result.is_err(), "Expected an error due to zero row sum"); - } - - #[test] - fn test_empty_matrix() { - // Create an empty Psi - let mat = Mat::::new(); - let psi = Psi::from(mat); - - // Should not panic - let (r_mat, perm) = qrd(&psi).unwrap(); - - // Empty matrix should produce empty results - assert_eq!(r_mat.nrows(), 0); - assert_eq!(r_mat.ncols(), 0); - assert_eq!(perm.len(), 0); - } -} diff --git a/src/routines/expansion/adaptative_grid.rs b/src/routines/expansion/adaptative_grid.rs deleted file mode 100644 index e721aebc5..000000000 --- a/src/routines/expansion/adaptative_grid.rs +++ /dev/null @@ -1,243 +0,0 @@ -use crate::structs::theta::Theta; -use anyhow::Result; -use faer::Row; - -/// Implements the adaptive grid algorithm for support point expansion. -/// -/// This function generates up to 2 new support points in each dimension for each existing support point. -/// New support points are symmetrically placed around the original support point, at a distance of `eps` * (range_max - range_min). -/// If the new support point is too close to an existing support point, or it is outside the given range, it is discarded. -/// -/// # Arguments -/// -/// * `theta` - A mutable reference to a 2D array representing the existing support points. -/// * `eps` - A floating-point value representing the fraction of the range to use for generating new support points. -/// * `ranges` - A slice of tuples representing the range of values for each dimension. -/// * `min_dist` - A floating-point value representing the minimum distance between support points. -/// -/// # Returns -/// -/// A 2D array containing the updated support points after the adaptive grid expansion. -/// -pub fn adaptative_grid( - theta: &mut Theta, - eps: f64, - ranges: &[(f64, f64)], - min_dist: f64, -) -> Result<()> { - let mut candidates = Vec::new(); - - // Collect all points first to avoid borrowing conflicts - for spp in theta.matrix().row_iter() { - for (j, val) in spp.iter().enumerate() { - let l = eps * (ranges[j].1 - ranges[j].0); //abs? - if val + l < ranges[j].1 { - let mut plus = Row::zeros(spp.ncols()); - plus[j] = l; - plus += spp; - candidates.push(plus.iter().copied().collect::>()); - } - if val - l > ranges[j].0 { - let mut minus = Row::zeros(spp.ncols()); - minus[j] = -l; - minus += spp; - candidates.push(minus.iter().copied().collect::>()); - } - } - } - - // Option 1: Check all points against the original theta, then add them - let keep = candidates - .iter() - .filter(|point| theta.check_point(point, min_dist)) - .cloned() - .collect::>(); - - for point in keep { - theta.add_point(point.as_slice())?; - } - - Ok(()) - - // Option 2: Check and add points one by one - // Now add all the points after the immutable borrow is released - //for point in candidates { - // theta.suggest_point(point, min_dist, ranges); - //} -} -/* -#[cfg(test)] -mod tests { - use super::*; - use crate::structs::theta::Theta; - use faer::mat; - - #[test] - fn test_expected() { - let original = Theta::from(mat![[1.0, 10.0]]); - - let ranges = [(0.0, 1.0), (0.0, 10.0)]; - let eps = 0.1; - let min_dist = 0.05; - - let mut theta = original.clone(); - adaptative_grid(&mut theta, eps, &ranges, min_dist); - - let expected = mat![[1.0, 10.0], [0.9, 10.0], [1.0, 9.0]]; - - // Check that both matrices have the same number of rows - assert_eq!( - theta.matrix().nrows(), - expected.nrows(), - "Number of points in theta doesn't match expected" - ); - - // Check that all points in expected are in theta - for i in 0..expected.nrows() { - let expected_point = expected.row(i); - let mut found = false; - - for j in 0..theta.matrix().nrows() { - let theta_point = theta.matrix().row(j); - - // Check if points match (within small epsilon for floating-point comparison) - if (expected_point[0] - theta_point[0]).abs() < 1e-10 - && (expected_point[1] - theta_point[1]).abs() < 1e-10 - { - found = true; - break; - } - } - - assert!( - found, - "Expected point [{}, {}] not found in theta", - expected_point[0], expected_point[1] - ); - } - - // Check that all points in theta are in expected - for i in 0..theta.matrix().nrows() { - let theta_point = theta.matrix().row(i); - let mut found = false; - - for j in 0..expected.nrows() { - let expected_point = expected.row(j); - - // Check if points match (within small epsilon) - if (theta_point[0] - expected_point[0]).abs() < 1e-10 - && (theta_point[1] - expected_point[1]).abs() < 1e-10 - { - found = true; - break; - } - } - - assert!( - found, - "Point [{}, {}] in theta was not expected", - theta_point[0], theta_point[1] - ); - } - } - - #[test] - fn test_basic_expansion() { - // Create initial theta with a single point [0.5, 0.5] - let mut theta = Theta::from(mat![[0.5, 0.5]]); - - // Define ranges for two dimensions - let ranges = [(0.0, 1.0), (0.0, 1.0)]; - - // Set expansion parameters - let eps = 0.1; - let min_dist = 0.05; - - // Apply adaptive grid - adaptative_grid(&mut theta, eps, &ranges, min_dist); - - // Should generate 4 new points around the original: - // [0.6, 0.5], [0.4, 0.5], [0.5, 0.6], [0.5, 0.4] - // Total 5 points including the original - assert_eq!(theta.matrix().nrows(), 5); - - // Verify the original point is preserved - let matrix = theta.matrix(); - let mut has_original = false; - - for i in 0..matrix.nrows() { - let row = matrix.row(i); - if (row[0] - 0.5).abs() < 1e-10 && (row[1] - 0.5).abs() < 1e-10 { - has_original = true; - break; - } - } - assert!(has_original, "Original point should be preserved"); - - // Verify expansion points were created - let expected_points = vec![(0.6, 0.5), (0.4, 0.5), (0.5, 0.6), (0.5, 0.4)]; - for (x, y) in expected_points { - let mut found = false; - for i in 0..matrix.nrows() { - let row = matrix.row(i); - if (row[0] - x).abs() < 1e-10 && (row[1] - y).abs() < 1e-10 { - found = true; - break; - } - } - assert!(found, "Expected point ({}, {}) not found", x, y); - } - } - - #[test] - fn test_boundary_conditions() { - // Create initial theta with points near boundaries - let mut theta = Theta::from(mat![ - [0.05, 0.5], // Near lower boundary in x - [0.95, 0.5], // Near upper boundary in x - [0.5, 0.05], // Near lower boundary in y - [0.5, 0.95], // Near upper boundary in y - ]); - - let ranges = [(0.0, 1.0), (0.0, 1.0)]; - let eps = 0.1; - let min_dist = 0.05; - - // Store original count - let original_count = theta.matrix().nrows(); - - adaptative_grid(&mut theta, eps, &ranges, min_dist); - - // Each point should generate fewer than 4 new points due to boundaries - assert!(theta.matrix().nrows() > original_count); - assert!(theta.matrix().nrows() < original_count + 4 * 4); - - // Verify no points are outside the range - let matrix = theta.matrix(); - for i in 0..matrix.nrows() { - let row = matrix.row(i); - assert!(row[0] >= ranges[0].0 && row[0] <= ranges[0].1); - assert!(row[1] >= ranges[1].0 && row[1] <= ranges[1].1); - } - } - - #[test] - fn test_min_distance_constraint() { - // Create initial theta with close points - let mut theta = Theta::from(mat![ - [0.5, 0.5], - [0.55, 0.5], // Close to first point - ]); - - let ranges = [(0.0, 1.0), (0.0, 10.0)]; - let eps = 0.1; - let min_dist = 0.15; // Large enough to prevent some points from being added - - adaptative_grid(&mut theta, eps, &ranges, min_dist); - - // We should have fewer points than the maximum possible expansion - // due to the minimum distance constraint - assert!(theta.matrix().nrows() < 2 + 2 * 4); - } -} - */ diff --git a/src/routines/expansion/mod.rs b/src/routines/expansion/mod.rs deleted file mode 100644 index a84c84fad..000000000 --- a/src/routines/expansion/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod adaptative_grid; diff --git a/src/routines/initialization/latin.rs b/src/routines/initialization/latin.rs deleted file mode 100644 index 57707ef7b..000000000 --- a/src/routines/initialization/latin.rs +++ /dev/null @@ -1,97 +0,0 @@ -use anyhow::Result; -use faer::Mat; -use rand::prelude::*; -use rand::rngs::StdRng; - -use crate::prelude::Parameters; -use crate::structs::theta::Theta; - -/// Generates an instance of [Theta] using Latin Hypercube Sampling. -/// -/// # Arguments -/// -/// * `parameters` - The [Parameters] struct, which contains the parameters to be sampled. -/// * `points` - The number of points to generate, i.e. the number of rows in the matrix. -/// * `seed` - The seed for the Sobol sequence generator. -/// -/// # Returns -/// -/// [Theta], a structure that holds the support point matrix -/// -pub fn generate(parameters: &Parameters, points: usize, seed: usize) -> Result { - let params: Vec<(String, f64, f64)> = parameters - .iter() - .map(|p| (p.name.clone(), p.lower, p.upper)) - .collect(); - - // Initialize random number generator with the provided seed - let mut rng = StdRng::seed_from_u64(seed as u64); - - // Create and shuffle intervals for each parameter - let mut intervals = Vec::new(); - for _ in 0..params.len() { - let mut param_intervals: Vec = (0..points).map(|i| i as f64).collect(); - param_intervals.shuffle(&mut rng); - intervals.push(param_intervals); - } - - let rand_matrix = Mat::from_fn(points, params.len(), |i, j| { - // Get the interval for this parameter and point - let interval = intervals[j][i]; - let random_offset = rng.random::(); - // Calculate normalized value in [0,1] - let unscaled = (interval + random_offset) / points as f64; - // Scale to parameter range - let (_name, lower, upper) = params.get(j).unwrap(); // Fixed: use j instead of i - lower + unscaled * (upper - lower) - }); - - let theta = Theta::from_parts(rand_matrix, parameters.clone())?; - - Ok(theta) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::prelude::Parameters; - use faer::mat; - - #[test] - fn test_latin_hypercube() { - let params = Parameters::new() - .add("a", 0.0, 1.0) - .add("b", 0.0, 1.0) - .add("c", 0.0, 1.0); - - let theta = generate(¶ms, 10, 22).unwrap(); - - assert_eq!(theta.nspp(), 10); - assert_eq!(theta.matrix().ncols(), 3); - } - - #[test] - fn test_latin_hypercube_values() { - let params = Parameters::new() - .add("a", 0.0, 1.0) - .add("b", 0.0, 1.0) - .add("c", 0.0, 1.0); - - let theta = generate(¶ms, 10, 22).unwrap(); - - let expected = mat![ - [0.9318592685623417, 0.5609665425179973, 0.3351914901515939], // - [0.5470144220416706, 0.13513808559222779, 0.1067962439473777], // - [0.34525902829190547, 0.4636722699673962, 0.9142146621998218], // - [0.24828355387285125, 0.8638104433695395, 0.41653980640777954], // - [0.7642037770085612, 0.6806932027789437, 0.5608053599272136], // - [0.19409389824004936, 0.9378790633419902, 0.6039530631991072], // - [0.04886813284275151, 0.7140428162864041, 0.7855069414226704], // - [0.6987026842780971, 0.32378779989236495, 0.8888807957183007], // - [0.4221279608793599, 0.08001464382386277, 0.20689573661666943], // - [0.8310112718320113, 0.29390050406905127, 0.04806137233953963], // - ]; - - assert_eq!(theta.matrix().to_owned(), expected); - } -} diff --git a/src/routines/initialization/mod.rs b/src/routines/initialization/mod.rs deleted file mode 100644 index fa03c4442..000000000 --- a/src/routines/initialization/mod.rs +++ /dev/null @@ -1,530 +0,0 @@ -use std::fs::File; - -use crate::structs::{theta::Theta, weights::Weights}; -use anyhow::{bail, Context, Result}; -use faer::Mat; -use serde::{Deserialize, Serialize}; - -use crate::routines::settings::Settings; - -pub mod latin; -pub mod sobol; - -/// The sampler used to generate the grid of support points -/// -/// The sampler can be one of the following: -/// -/// - `Sobol`: Generates a Sobol sequence -/// - `Latin`: Generates a Latin hypercube -/// - `File`: Reads the prior distribution from a CSV file -#[derive(Debug, Deserialize, Clone, Serialize)] -pub enum Prior { - Sobol(usize, usize), - Latin(usize, usize), - File(String), - #[serde(skip)] - Theta(Theta), -} - -impl Prior { - pub fn sobol(points: usize, seed: usize) -> Prior { - Prior::Sobol(points, seed) - } - - /// Get the number of initial support points - /// - /// This function returns the number of points for Sobol and Latin samplers, - /// and returns `None` for file-based priors since they do not have a fixed number of points. - /// For custom priors ([Prior::Theta]), it returns the number of support points in the original [Theta] structure. - pub fn points(&self) -> Option { - match self { - Prior::Sobol(points, _) => Some(*points), - Prior::Latin(points, _) => Some(*points), - Prior::File(_) => None, // File-based prior does not have a fixed number of points - Prior::Theta(theta) => Some(theta.nspp()), - } - } - - /// Get the seed used for the random number generator - /// - /// This function returns the seed for Sobol and Latin samplers, - /// and returns `None` for file-based priors since they do not have a fixed seed. - /// For custom priors ([Prior::Theta]), it returns `None` as they do not have a fixed seed. - pub fn seed(&self) -> Option { - match self { - Prior::Sobol(_, seed) => Some(*seed), - Prior::Latin(_, seed) => Some(*seed), - Prior::File(_) => None, // "File-based prior does not have a fixed seed" - Prior::Theta(_) => None, // Custom prior does not have a fixed seed - } - } -} - -impl Default for Prior { - fn default() -> Self { - Prior::Sobol(2028, 22) - } -} - -/// This function generates the grid of support points according to the sampler specified in the [Settings] -pub fn sample_space(settings: &Settings) -> Result { - // Ensure that the parameter ranges are not infinite - for param in settings.parameters().iter() { - if param.lower.is_infinite() || param.upper.is_infinite() { - bail!( - "Parameter '{}' has infinite bounds: [{}, {}]", - param.name, - param.lower, - param.upper - ); - } - - // Ensure that the lower bound is less than the upper bound - if param.lower >= param.upper { - bail!( - "Parameter '{}' has invalid bounds: [{}, {}]. Lower bound must be less than upper bound.", - param.name, - param.lower, - param.upper - ); - } - } - - // Otherwise, parse the sampler type and generate the grid - 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)?.0, - Prior::Theta(ref theta) => { - // If a custom prior is provided, return it directly - return Ok(theta.clone()); - } - }; - Ok(prior) -} - -/// This function reads the prior distribution from a file -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() - .has_headers(true) - .from_reader(file); - - let mut parameter_names: Vec = reader - .headers()? - .clone() - .into_iter() - .map(|s| s.trim().to_owned()) - .collect(); - - // 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); - } - - // Check and reorder parameters to match names in settings.parsed.random - let random_names: Vec = settings.parameters().names(); - - let mut reordered_indices: Vec = Vec::new(); - for random_name in &random_names { - match parameter_names.iter().position(|name| name == random_name) { - Some(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); - } - } - } - - // Check if there are remaining parameters not present in settings.parsed.random - if parameter_names.len() > random_names.len() { - let extra_parameters: Vec<&String> = parameter_names.iter().collect(); - bail!( - "Found parameters in the prior not present in configuration: {:?}", - extra_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(); - let n_params = random_names.len(); - - // Convert nested Vec into a single Vec - let theta_values: Vec = theta_values.into_iter().flatten().collect(); - - let theta_matrix: Mat = - Mat::from_fn(n_points, n_params, |i, j| theta_values[i * n_params + j]); - - let theta = Theta::from_parts(theta_matrix, settings.parameters().clone())?; - - // 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)] -mod tests { - use super::*; - use crate::prelude::*; - use pharmsol::{AssayErrorModel, AssayErrorModels, ErrorPoly}; - use std::fs; - - fn create_test_settings() -> Settings { - let parameters = Parameters::new().add("ke", 0.1, 1.0).add("v", 5.0, 50.0); - - let em = AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0); - let ems = AssayErrorModels::new().add(0, em).unwrap(); - - Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(parameters) - .set_error_models(ems) - .build() - } - - fn create_temp_csv_file(content: &str) -> String { - let temp_path = format!("test_temp_{}.csv", rand::random::()); - fs::write(&temp_path, content).unwrap(); - temp_path - } - - fn cleanup_temp_file(path: &str) { - let _ = fs::remove_file(path); - } - - #[test] - fn test_prior_sobol_creation() { - let prior = Prior::sobol(100, 42); - assert_eq!(prior.points(), Some(100)); - assert_eq!(prior.seed(), Some(42)); - } - - #[test] - fn test_prior_latin_creation() { - let prior = Prior::Latin(50, 123); - assert_eq!(prior.points(), Some(50)); - assert_eq!(prior.seed(), Some(123)); - } - - #[test] - fn test_prior_default() { - let prior = Prior::default(); - assert_eq!(prior.points(), Some(2028)); - assert_eq!(prior.seed(), Some(22)); - } - - #[test] - fn test_prior_file_points() { - let prior = Prior::File("test.csv".to_string()); - assert_eq!(prior.points(), None); - } - - #[test] - fn test_prior_file_seed() { - let prior = Prior::File("test.csv".to_string()); - assert_eq!(prior.seed(), None); - } - - #[test] - fn test_sample_space_sobol() { - let mut settings = create_test_settings(); - settings.set_prior(Prior::sobol(10, 42)); - - let result = sample_space(&settings); - assert!(result.is_ok()); - - let theta = result.unwrap(); - assert_eq!(theta.nspp(), 10); - assert_eq!(theta.matrix().ncols(), 2); - } - - #[test] - fn test_sample_space_latin() { - let mut settings = create_test_settings(); - settings.set_prior(Prior::Latin(15, 123)); - - let result = sample_space(&settings); - assert!(result.is_ok()); - - let theta = result.unwrap(); - assert_eq!(theta.nspp(), 15); - assert_eq!(theta.matrix().ncols(), 2); - } - - #[test] - fn test_sample_space_custom_theta() { - let mut settings = create_test_settings(); - - // Create a custom theta - let parameters = settings.parameters().clone(); - let matrix = faer::Mat::from_fn(3, 2, |i, j| (i + j) as f64); - let custom_theta = Theta::from_parts(matrix, parameters).unwrap(); - - let prior = Prior::Theta(custom_theta.clone()); - settings.set_prior(Prior::Theta(custom_theta.clone())); - - let result = sample_space(&settings); - assert!(result.is_ok()); - - let theta = result.unwrap(); - assert_eq!(theta.nspp(), 3); - assert_eq!(theta.matrix().ncols(), 2); - assert_eq!(theta, custom_theta); - assert!(prior.points() == Some(3)); - } - - #[test] - fn test_sample_space_infinite_bounds_error() { - let parameters = Parameters::new() - .add("ke", f64::NEG_INFINITY, 1.0) // Invalid: infinite lower bound - .add("v", 5.0, 50.0); - - let em = AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0); - let ems = AssayErrorModels::new().add(0, em).unwrap(); - - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(parameters) - .set_error_models(ems) - .build(); - - settings.set_prior(Prior::sobol(10, 42)); - - let result = sample_space(&settings); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("infinite bounds")); - } - - #[test] - fn test_sample_space_invalid_bounds_error() { - let parameters = Parameters::new() - .add("ke", 1.0, 0.5) // Invalid: lower bound >= upper bound - .add("v", 5.0, 50.0); - - let em = AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0); - let ems = AssayErrorModels::new().add(0, em).unwrap(); - - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(parameters) - .set_error_models(ems) - .build(); - - settings.set_prior(Prior::sobol(10, 42)); - - let result = sample_space(&settings); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("invalid bounds")); - } - - #[test] - fn test_parse_prior_valid_file() { - let csv_content = "ke,v\n0.1,10.0\n0.2,15.0\n0.3,20.0\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); - assert!(weights.is_none()); // No prob column, so no weights - - cleanup_temp_file(&temp_path); - } - - #[test] - fn test_parse_prior_with_prob_column() { - let csv_content = "ke,v,prob\n0.1,10.0,0.5\n0.2,15.0,0.3\n0.3,20.0,0.2\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); - - cleanup_temp_file(&temp_path); - } - - #[test] - fn test_parse_prior_missing_parameter() { - let csv_content = "ke\n0.1\n0.2\n0.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_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Parameter v is not present")); - - cleanup_temp_file(&temp_path); - } - - #[test] - fn test_parse_prior_extra_parameters() { - let csv_content = "ke,v,extra_param\n0.1,10.0,1.0\n0.2,15.0,2.0\n0.3,20.0,3.0\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_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Found parameters in the prior not present in configuration")); - - cleanup_temp_file(&temp_path); - } - - #[test] - fn test_parse_prior_nonexistent_file() { - let settings = create_test_settings(); - let file_path = "nonexistent_file.csv".to_string(); - - let result = parse_prior(&file_path, &settings); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Unable to open the prior file")); - } - - #[test] - fn test_parse_prior_reordered_columns() { - let csv_content = "v,ke\n10.0,0.1\n15.0,0.2\n20.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); - 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(); - 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_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"; - let temp_path = create_temp_csv_file(csv_content); - - let mut settings = create_test_settings(); - settings.set_prior(Prior::File(temp_path.clone())); - - let result = sample_space(&settings); - assert!(result.is_ok()); - - let theta = result.unwrap(); - assert_eq!(theta.nspp(), 3); - assert_eq!(theta.matrix().ncols(), 2); - - cleanup_temp_file(&temp_path); - } - - #[test] - fn test_prior_theta_no_seed_panic() { - let parameters = Parameters::new().add("ke", 0.1, 1.0); - let matrix = faer::Mat::from_fn(1, 1, |_, _| 0.5); - let theta = Theta::from_parts(matrix, parameters).unwrap(); - let prior = Prior::Theta(theta); - - assert_eq!(prior.seed(), None, "Theta prior should not have a seed"); - } -} diff --git a/src/routines/initialization/sobol.rs b/src/routines/initialization/sobol.rs deleted file mode 100644 index 674580df8..000000000 --- a/src/routines/initialization/sobol.rs +++ /dev/null @@ -1,103 +0,0 @@ -use crate::structs::theta::Theta; -use anyhow::Result; -use faer::Mat; - -use sobol_burley::sample; - -use crate::prelude::Parameters; - -/// Generates an instance of [Theta] from a Sobol sequence. -/// -/// The sequence samples [0, 1), and the values are scaled to the parameter ranges. -/// -/// # Arguments -/// -/// * `parameters` - The [Parameters] struct, which contains the parameters to be sampled. -/// * `points` - The number of points to generate, i.e. the number of rows in the matrix. -/// * `seed` - The seed for the Sobol sequence generator. -/// -/// # Returns -/// -/// [Theta], a structure that holds the support point matrix -/// -pub fn generate(parameters: &Parameters, points: usize, seed: usize) -> Result { - let seed = seed as u32; - let params: Vec<(String, f64, f64)> = parameters - .iter() - .map(|p| (p.name.clone(), p.lower, p.upper)) - .collect(); - - let rand_matrix = Mat::from_fn(points, params.len(), |i, j| { - let unscaled = sample((i).try_into().unwrap(), j.try_into().unwrap(), seed) as f64; - let (_name, lower, upper) = params.get(j).unwrap(); - lower + unscaled * (upper - lower) - }); - - let theta = Theta::from_parts(rand_matrix, parameters.clone())?; - Ok(theta) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::prelude::Parameters; - - #[test] - fn test_sobol() { - let params = Parameters::new() - .add("a", 0.0, 1.0) - .add("b", 0.0, 1.0) - .add("c", 0.0, 1.0); - - let theta = generate(¶ms, 10, 22).unwrap(); - - assert_eq!(theta.nspp(), 10); - assert_eq!(theta.matrix().ncols(), 3); - } - - #[test] - fn test_sobol_ranges() { - let params = Parameters::new() - .add("a", 0.0, 1.0) - .add("b", 0.0, 1.0) - .add("c", 0.0, 1.0); - - let theta = generate(¶ms, 10, 22).unwrap(); - - theta.matrix().row_iter().for_each(|row| { - row.iter().for_each(|&value| { - assert!(value >= 0.0 && value <= 1.0); - }); - }); - } - - #[test] - fn test_sobol_values() { - use faer::mat; - let params = Parameters::new() - .add("a", 0.0, 1.0) - .add("b", 0.0, 1.0) - .add("c", 0.0, 1.0); - - let theta = generate(¶ms, 10, 22).unwrap(); - - let expected = mat![ - [0.05276215076446533, 0.609707236289978, 0.29471302032470703], // - [0.6993427276611328, 0.4142681360244751, 0.6447571516036987], // - [0.860404372215271, 0.769607663154602, 0.1742185354232788], // - [0.3863574266433716, 0.07018685340881348, 0.9825305938720703], // - [0.989533543586731, 0.19934570789337158, 0.4716176986694336], // - [0.29962968826293945, 0.899970293045044, 0.5400241613388062], // - [0.5577576160430908, 0.6990838050842285, 0.859503984451294], // - [ - 0.19194257259368896, - 0.31645333766937256, - 0.042426824569702150 - ], // - [0.8874167203903198, 0.5214653015136719, 0.5899909734725952], // - [0.35627472400665283, 0.4780532121658325, 0.42954015731811523] // - ]; - - assert_eq!(theta.matrix().to_owned(), expected); - } -} diff --git a/src/routines/logger.rs b/src/routines/logger.rs deleted file mode 100644 index d9ba24e6a..000000000 --- a/src/routines/logger.rs +++ /dev/null @@ -1,101 +0,0 @@ -use std::time::Instant; - -use crate::routines::output::OutputFile; -use crate::routines::settings::Settings; -use anyhow::Result; -use tracing_subscriber::fmt::time::FormatTime; -use tracing_subscriber::fmt::{self}; -use tracing_subscriber::prelude::__tracing_subscriber_SubscriberExt; -use tracing_subscriber::registry::Registry; -use tracing_subscriber::util::SubscriberInitExt; -use tracing_subscriber::EnvFilter; - -/// Setup logging for the library -/// -/// This function sets up logging for the library. It uses the `tracing` crate, and the `tracing-subscriber` crate for formatting. -/// -/// The log level is defined in the configuration file, and defaults to `INFO`. -/// -/// If `log_out` is specifified in the configuration file, a log file is created with the specified name. -/// -/// If not, the log messages are written to stdout. -pub(crate) fn setup_log(settings: &mut Settings) -> Result<()> { - // If neither `stdout` nor `file` are specified, return without setting the subscriber - if !settings.log().stdout && !settings.log().write { - return Ok(()); - } - - // Use the log level defined in configuration file - let log_level = settings.log().level.clone(); - let env_filter = EnvFilter::new(format!("{},diffsol=off", log_level)); - - let timestamper = CompactTimestamp { - start: Instant::now(), - }; - - // Define a registry with that level as an environment filter - let subscriber = Registry::default().with(env_filter); - - // If we do not want output files, we must create the log in the current directory - let outputfile = if !settings.output().write { - let cd = std::env::current_dir()?; - OutputFile::new(&cd.to_string_lossy(), "log.txt")? - } else { - OutputFile::new(&settings.output().path, "log.txt")? - }; - - // Define layer for file - let file_layer = match settings.log().write { - true => { - let layer = fmt::layer() - .with_writer(outputfile.file_owned()) - .with_ansi(false) - .with_timer(timestamper.clone()); - - Some(layer) - } - false => None, - }; - - // Define layer for stdout - let stdout_layer = match settings.log().stdout { - true => { - let layer = fmt::layer() - .with_writer(std::io::stdout) - .with_ansi(true) - .with_target(false) - .with_timer(timestamper.clone()); - - Some(layer) - } - false => None, - }; - - // Combine layers with subscriber - let res = subscriber.with(file_layer).with(stdout_layer).try_init(); - match res { - Ok(_) => {} - Err(e) => tracing::warn!("Failed to initialize logger: {}", e), - } - - Ok(()) -} - -#[derive(Clone)] -struct CompactTimestamp { - start: Instant, -} - -impl FormatTime for CompactTimestamp { - fn format_time( - &self, - w: &mut tracing_subscriber::fmt::format::Writer<'_>, - ) -> Result<(), std::fmt::Error> { - let elapsed = self.start.elapsed(); - let hours = elapsed.as_secs() / 3600; - let minutes = (elapsed.as_secs() % 3600) / 60; - let seconds = elapsed.as_secs() % 60; - - write!(w, "{:02}h {:02}m {:02}s", hours, minutes, seconds) - } -} diff --git a/src/routines/mod.rs b/src/routines/mod.rs deleted file mode 100644 index af25d67e6..000000000 --- a/src/routines/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -// Routines for condensation -pub mod condensation; -// Routines for estimation -pub mod estimation; -// Routines for expansion -pub mod expansion; -// Routines for initialization -pub mod initialization; -// Routines for logging -pub mod logger; -// Routines for output -pub mod output; -// Routines for settings -pub mod settings; diff --git a/src/routines/output/mod.rs b/src/routines/output/mod.rs deleted file mode 100644 index 3835904d2..000000000 --- a/src/routines/output/mod.rs +++ /dev/null @@ -1,796 +0,0 @@ -use crate::algorithms::{Status, StopReason}; -use crate::prelude::*; -use crate::routines::output::cycles::CycleLog; -use crate::routines::output::posterior::Posterior; -use crate::routines::output::predictions::NPPredictions; -use crate::routines::settings::Settings; -use crate::structs::psi::Psi; -use crate::structs::theta::Theta; -use crate::structs::weights::Weights; -use anyhow::{bail, Context, Result}; -use csv::WriterBuilder; -use ndarray::{Array, Array1, Array2, Axis}; -use pharmsol::prelude::data::*; -use pharmsol::prelude::simulator::Equation; -use serde::Serialize; -use std::fs::{create_dir_all, File, OpenOptions}; -use std::path::{Path, PathBuf}; - -pub mod cycles; -pub mod posterior; -pub mod predictions; - -use posterior::posterior; - -/// Defines the result objects from an NPAG run -/// An [NPResult] contains the necessary information to generate predictions and summary statistics -#[derive(Debug, Serialize)] -pub struct NPResult { - #[serde(skip)] - equation: E, - data: Data, - theta: Theta, - psi: Psi, - w: Weights, - objf: f64, - cycles: usize, - status: Status, - settings: Settings, - cyclelog: CycleLog, - predictions: Option, - posterior: Posterior, -} - -#[allow(clippy::too_many_arguments)] -impl NPResult { - /// Create a new NPResult object - /// - /// This will also calculate the [Posterior] structure and add it to the NPResult - pub(crate) fn new( - equation: E, - data: Data, - theta: Theta, - psi: Psi, - w: Weights, - objf: f64, - cycles: usize, - status: Status, - settings: Settings, - cyclelog: CycleLog, - ) -> Result { - // Calculate the posterior probabilities - let posterior = posterior(&psi, &w) - .context("Failed to calculate posterior during initialization of NPResult")?; - - let result = Self { - equation, - data, - theta, - psi, - w, - objf, - cycles, - status, - settings, - cyclelog, - predictions: None, - posterior, - }; - - Ok(result) - } - - pub fn cycles(&self) -> usize { - self.cycles - } - - pub fn objf(&self) -> f64 { - self.objf - } - - pub fn converged(&self) -> bool { - self.status == Status::Stop(StopReason::Converged) - } - - pub fn get_theta(&self) -> &Theta { - &self.theta - } - - pub fn data(&self) -> &Data { - &self.data - } - - pub fn cycle_log(&self) -> &CycleLog { - &self.cyclelog - } - - pub fn settings(&self) -> &Settings { - &self.settings - } - - /// Get the [Psi] structure - pub fn psi(&self) -> &Psi { - &self.psi - } - - /// Get the weights (probabilities) of the support points - pub fn weights(&self) -> &Weights { - &self.w - } - - /// Calculate and store the [NPPredictions] in the [NPResult] - /// - /// This will overwrite any existing predictions stored in the result! - pub fn calculate_predictions(&mut self, idelta: f64, tad: f64) -> Result<()> { - let predictions = NPPredictions::calculate( - &self.equation, - &self.data, - &self.theta, - &self.w, - &self.posterior, - idelta, - tad, - )?; - self.predictions = Some(predictions); - Ok(()) - } - - pub fn write_outputs(&mut self) -> Result<()> { - if self.settings.output().write { - tracing::debug!("Writing outputs to {:?}", self.settings.output().path); - self.settings.write()?; - let idelta: f64 = self.settings.predictions().idelta; - let tad = self.settings.predictions().tad; - self.cyclelog.write(&self.settings)?; - self.write_theta().context("Failed to write theta")?; - self.write_covs().context("Failed to write covariates")?; - self.write_predictions(idelta, tad) - .context("Failed to write predictions")?; - self.write_posterior() - .context("Failed to write posterior")?; - } - Ok(()) - } - - /// Writes the observations and predictions to a single file - pub fn write_obspred(&self) -> Result<()> { - tracing::debug!("Writing observations and predictions..."); - - #[derive(Debug, Clone, Serialize)] - struct Row { - id: String, - time: f64, - outeq: usize, - block: usize, - obs: Option, - pop_mean: f64, - pop_median: f64, - post_mean: f64, - post_median: f64, - } - - let tm = self.theta.matrix(); - let theta = Array2::from_shape_fn((tm.nrows(), tm.ncols()), |(i, j)| tm[(i, j)]); - let w: Array1 = self.w.iter().collect(); - let pm = self.psi.matrix(); - let psi = Array2::from_shape_fn((pm.nrows(), pm.ncols()), |(i, j)| pm[(i, j)]); - - let (post_mean, post_median) = posterior_mean_median(&theta, &psi, &w) - .context("Failed to calculate posterior mean and median")?; - - let (pop_mean, pop_median) = population_mean_median(&theta, &w) - .context("Failed to calculate posterior mean and median")?; - - let subjects = self.data.subjects(); - if subjects.len() != post_mean.nrows() { - bail!( - "Number of subjects: {} and number of posterior means: {} do not match", - subjects.len(), - post_mean.nrows() - ); - } - - let outputfile = OutputFile::new(&self.settings.output().path, "op.csv")?; - let mut writer = WriterBuilder::new() - .has_headers(true) - .from_writer(&outputfile.file); - - for (i, subject) in subjects.iter().enumerate() { - for occasion in subject.occasions() { - let id = subject.id(); - let occ = occasion.index(); - - let subject = Subject::from_occasions(id.clone(), vec![occasion.clone()]); - - // Population predictions - let pop_mean_pred = self - .equation - .simulate_subject(&subject, &pop_mean.to_vec(), None)? - .0 - .get_predictions() - .clone(); - - let pop_median_pred = self - .equation - .simulate_subject(&subject, &pop_median.to_vec(), None)? - .0 - .get_predictions() - .clone(); - - // Posterior predictions - let post_mean_spp: Vec = post_mean.row(i).to_vec(); - let post_mean_pred = self - .equation - .simulate_subject(&subject, &post_mean_spp, None)? - .0 - .get_predictions() - .clone(); - let post_median_spp: Vec = post_median.row(i).to_vec(); - let post_median_pred = self - .equation - .simulate_subject(&subject, &post_median_spp, None)? - .0 - .get_predictions() - .clone(); - assert_eq!( - pop_mean_pred.len(), - pop_median_pred.len(), - "The number of predictions do not match (pop_mean vs pop_median)" - ); - - assert_eq!( - post_mean_pred.len(), - post_median_pred.len(), - "The number of predictions do not match (post_mean vs post_median)" - ); - - assert_eq!( - pop_mean_pred.len(), - post_mean_pred.len(), - "The number of predictions do not match (pop_mean vs post_mean)" - ); - - for (((pop_mean_pred, pop_median_pred), post_mean_pred), post_median_pred) in - pop_mean_pred - .iter() - .zip(pop_median_pred.iter()) - .zip(post_mean_pred.iter()) - .zip(post_median_pred.iter()) - { - let row = Row { - id: id.clone(), - time: pop_mean_pred.time(), - outeq: pop_mean_pred.outeq(), - block: occ, - obs: pop_mean_pred.observation(), - pop_mean: pop_mean_pred.prediction(), - pop_median: pop_median_pred.prediction(), - post_mean: post_mean_pred.prediction(), - post_median: post_median_pred.prediction(), - }; - writer.serialize(row)?; - } - } - } - writer.flush()?; - tracing::debug!( - "Observations with predictions written to {:?}", - &outputfile.relative_path() - ); - Ok(()) - } - - /// Writes theta, which contains the population support points and their associated probabilities - /// Each row is one support point, the last column being probability - pub fn write_theta(&self) -> Result<()> { - tracing::debug!("Writing population parameter distribution..."); - - let theta = &self.theta; - let w: Vec = self.w.to_vec(); - - if w.len() != theta.matrix().nrows() { - bail!( - "Number of weights ({}) and number of support points ({}) do not match.", - w.len(), - theta.matrix().nrows() - ); - } - - let outputfile = OutputFile::new(&self.settings.output().path, "theta.csv") - .context("Failed to create output file for theta")?; - - let mut writer = WriterBuilder::new() - .has_headers(true) - .from_writer(&outputfile.file); - - // Create the headers - let mut theta_header = self.settings.parameters().names(); - theta_header.push("prob".to_string()); - writer.write_record(&theta_header)?; - - // Write contents - for (theta_row, &w_val) in theta.matrix().row_iter().zip(w.iter()) { - let mut row: Vec = theta_row.iter().map(|&val| val.to_string()).collect(); - row.push(w_val.to_string()); - writer.write_record(&row)?; - } - writer.flush()?; - tracing::debug!( - "Population parameter distribution written to {:?}", - &outputfile.relative_path() - ); - Ok(()) - } - - /// Writes the posterior support points for each individual - pub fn write_posterior(&self) -> Result<()> { - tracing::debug!("Writing posterior parameter probabilities..."); - let theta = &self.theta; - - // Calculate the posterior probabilities - let posterior = self.posterior.clone(); - - // Create the output folder if it doesn't exist - let outputfile = match OutputFile::new(&self.settings.output().path, "posterior.csv") { - Ok(of) => of, - Err(e) => { - tracing::error!("Failed to create output file: {}", e); - return Err(e.context("Failed to create output file")); - } - }; - - // Create a new writer - let mut writer = WriterBuilder::new() - .has_headers(true) - .from_writer(&outputfile.file); - - // Create the headers - writer.write_field("id")?; - writer.write_field("point")?; - theta.param_names().iter().for_each(|name| { - writer.write_field(name).unwrap(); - }); - writer.write_field("prob")?; - writer.write_record(None::<&[u8]>)?; - - // Write contents - let subjects = self.data.subjects(); - posterior - .matrix() - .row_iter() - .enumerate() - .for_each(|(i, row)| { - let subject = subjects.get(i).unwrap(); - let id = subject.id(); - - row.iter().enumerate().for_each(|(spp, prob)| { - writer.write_field(id.clone()).unwrap(); - writer.write_field(spp.to_string()).unwrap(); - - theta.matrix().row(spp).iter().for_each(|val| { - writer.write_field(val.to_string()).unwrap(); - }); - - writer.write_field(prob.to_string()).unwrap(); - writer.write_record(None::<&[u8]>).unwrap(); - }); - }); - - writer.flush()?; - tracing::debug!( - "Posterior parameters written to {:?}", - &outputfile.relative_path() - ); - - Ok(()) - } - - /// Writes the predictions - pub fn write_predictions(&mut self, idelta: f64, tad: f64) -> Result<()> { - tracing::debug!("Writing predictions..."); - - self.calculate_predictions(idelta, tad)?; - - let predictions = self - .predictions - .as_ref() - .expect("Predictions should have been calculated, but are of type None."); - - // Write (full) predictions to pred.csv - let outputfile_pred = OutputFile::new(&self.settings.output().path, "pred.csv")?; - let mut writer = WriterBuilder::new() - .has_headers(true) - .from_writer(&outputfile_pred.file); - - // Write each prediction row - for row in predictions.predictions() { - writer.serialize(row)?; - } - - writer.flush()?; - tracing::debug!( - "Predictions written to {:?}", - &outputfile_pred.relative_path() - ); - - Ok(()) - } - - /// Writes the covariates - pub fn write_covs(&self) -> Result<()> { - tracing::debug!("Writing covariates..."); - let outputfile = OutputFile::new(&self.settings.output().path, "covs.csv")?; - let mut writer = WriterBuilder::new() - .has_headers(true) - .from_writer(&outputfile.file); - - // Collect all unique covariate names - let mut covariate_names = std::collections::HashSet::new(); - for subject in self.data.subjects() { - for occasion in subject.occasions() { - let cov = occasion.covariates(); - let covmap = cov.covariates(); - for cov_name in covmap.keys() { - covariate_names.insert(cov_name.clone()); - } - } - } - let mut covariate_names: Vec = covariate_names.into_iter().collect(); - covariate_names.sort(); // Ensure consistent order - - // Write the header row: id, time, block, covariate names - let mut headers = vec!["id", "time", "block"]; - headers.extend(covariate_names.iter().map(|s| s.as_str())); - writer.write_record(&headers)?; - - // Write the data rows - for subject in self.data.subjects() { - for occasion in subject.occasions() { - let cov = occasion.covariates(); - let covmap = cov.covariates(); - - for event in occasion.iter() { - let time = match event { - Event::Bolus(bolus) => bolus.time(), - Event::Infusion(infusion) => infusion.time(), - Event::Observation(observation) => observation.time(), - }; - - let mut row: Vec = Vec::new(); - row.push(subject.id().clone()); - row.push(time.to_string()); - row.push(occasion.index().to_string()); - - // Add covariate values to the row - for cov_name in &covariate_names { - if let Some(cov) = covmap.get(cov_name) { - if let Ok(value) = cov.interpolate(time) { - row.push(value.to_string()); - } else { - row.push(String::new()); - } - } else { - row.push(String::new()); - } - } - - writer.write_record(&row)?; - } - } - } - - writer.flush()?; - tracing::debug!("Covariates written to {:?}", &outputfile.relative_path()); - Ok(()) - } -} - -pub(crate) fn median(data: &[f64]) -> f64 { - let mut data: Vec = data.to_vec(); - data.sort_by(|a, b| a.partial_cmp(b).unwrap()); - - let size = data.len(); - match size { - even if even % 2 == 0 => { - let fst = data.get(even / 2 - 1).unwrap(); - let snd = data.get(even / 2).unwrap(); - (fst + snd) / 2.0 - } - odd => *data.get(odd / 2_usize).unwrap(), - } -} - -fn weighted_median(data: &[f64], weights: &[f64]) -> f64 { - // Ensure the data and weights arrays have the same length - assert_eq!( - data.len(), - weights.len(), - "The length of data and weights must be the same" - ); - assert!( - weights.iter().all(|&x| x >= 0.0), - "Weights must be non-negative, weights: {:?}", - weights - ); - - // Create a vector of tuples (data, weight) - let mut weighted_data: Vec<(f64, f64)> = data - .iter() - .zip(weights.iter()) - .map(|(&d, &w)| (d, w)) - .collect(); - - // Sort the vector by the data values - weighted_data.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); - - // Calculate the cumulative sum of weights - let total_weight: f64 = weights.iter().sum(); - let mut cumulative_sum = 0.0; - - for (i, &(_, weight)) in weighted_data.iter().enumerate() { - cumulative_sum += weight; - - if cumulative_sum == total_weight / 2.0 { - // If the cumulative sum equals half the total weight, average this value with the next - if i + 1 < weighted_data.len() { - return (weighted_data[i].0 + weighted_data[i + 1].0) / 2.0; - } else { - return weighted_data[i].0; - } - } else if cumulative_sum > total_weight / 2.0 { - return weighted_data[i].0; - } - } - - unreachable!("The function should have returned a value before reaching this point."); -} - -pub fn population_mean_median( - theta: &Array2, - w: &Array1, -) -> Result<(Array1, Array1)> { - let w = if w.is_empty() { - tracing::warn!("w.len() == 0, setting all weights to 1/n"); - Array1::from_elem(theta.nrows(), 1.0 / theta.nrows() as f64) - } else { - w.clone() - }; - // Check for compatible sizes - if theta.nrows() != w.len() { - bail!( - "Number of parameters and number of weights do not match. Theta: {}, w: {}", - theta.nrows(), - w.len() - ); - } - - let mut mean = Array1::zeros(theta.ncols()); - let mut median = Array1::zeros(theta.ncols()); - - for (i, (mn, mdn)) in mean.iter_mut().zip(&mut median).enumerate() { - // Calculate the weighted mean - let col = theta.column(i).to_owned() * w.to_owned(); - *mn = col.sum(); - - // Calculate the median - let ct = theta.column(i); - let mut params = vec![]; - let mut weights = vec![]; - for (ti, wi) in ct.iter().zip(w.clone()) { - params.push(*ti); - weights.push(wi); - } - - *mdn = weighted_median(¶ms, &weights); - } - - Ok((mean, median)) -} - -pub fn posterior_mean_median( - theta: &Array2, - psi: &Array2, - w: &Array1, -) -> Result<(Array2, Array2)> { - let mut mean = Array2::zeros((0, theta.ncols())); - let mut median = Array2::zeros((0, theta.ncols())); - - let w = if w.is_empty() { - tracing::warn!("w is empty, setting all weights to 1/n"); - Array1::from_elem(theta.nrows(), 1.0 / theta.nrows() as f64) - } else { - w.clone() - }; - - // Check for compatible sizes - if theta.nrows() != w.len() || theta.nrows() != psi.ncols() || psi.ncols() != w.len() { - bail!("Number of parameters and number of weights do not match, theta.nrows(): {}, w.len(): {}, psi.ncols(): {}", theta.nrows(), w.len(), psi.ncols()); - } - - // Normalize psi to get probabilities of each spp for each id - let mut psi_norm: Array2 = Array2::zeros((0, psi.ncols())); - for (i, row) in psi.axis_iter(Axis(0)).enumerate() { - let row_w = row.to_owned() * w.to_owned(); - let row_sum = row_w.sum(); - let row_norm = if row_sum == 0.0 { - tracing::warn!("Sum of row {} of psi is 0.0, setting that row to 1/n", i); - Array1::from_elem(psi.ncols(), 1.0 / psi.ncols() as f64) - } else { - &row_w / row_sum - }; - psi_norm.push_row(row_norm.view())?; - } - if psi_norm.iter().any(|&x| x.is_nan()) { - dbg!(&psi); - bail!("NaN values found in psi_norm"); - }; - - // Transpose normalized psi to get ID (col) by prob (row) - // let psi_norm_transposed = psi_norm.t(); - - // For each subject.. - for probs in psi_norm.axis_iter(Axis(0)) { - let mut post_mean: Vec = Vec::new(); - let mut post_median: Vec = Vec::new(); - - // For each parameter - for pars in theta.axis_iter(Axis(1)) { - // Calculate the mean - let weighted_par = &probs * &pars; - let the_mean = weighted_par.sum(); - post_mean.push(the_mean); - - // Calculate the median - let median = weighted_median(&pars.to_vec(), &probs.to_vec()); - post_median.push(median); - } - - mean.push_row(Array::from(post_mean.clone()).view())?; - median.push_row(Array::from(post_median.clone()).view())?; - } - - Ok((mean, median)) -} - -/// Contains all the necessary information of an output file -#[derive(Debug)] -pub struct OutputFile { - file: File, - relative_path: PathBuf, -} - -impl OutputFile { - pub fn new(folder: &str, file_name: &str) -> Result { - let relative_path = Path::new(&folder).join(file_name); - - if let Some(parent) = relative_path.parent() { - create_dir_all(parent) - .with_context(|| format!("Failed to create directories for {:?}", parent))?; - } - - let file = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(&relative_path) - .with_context(|| format!("Failed to open file: {:?}", relative_path))?; - - Ok(OutputFile { - file, - relative_path, - }) - } - - pub fn file(&self) -> &File { - &self.file - } - - pub fn file_owned(self) -> File { - self.file - } - - pub fn relative_path(&self) -> &Path { - &self.relative_path - } -} - -#[cfg(test)] -mod tests { - use super::median; - - #[test] - fn test_median_odd() { - let data = vec![1.0, 3.0, 2.0]; - assert_eq!(median(&data), 2.0); - } - - #[test] - fn test_median_even() { - let data = vec![1.0, 2.0, 3.0, 4.0]; - assert_eq!(median(&data), 2.5); - } - - #[test] - fn test_median_single() { - let data = vec![42.0]; - assert_eq!(median(&data), 42.0); - } - - #[test] - fn test_median_sorted() { - let data = vec![5.0, 10.0, 15.0, 20.0, 25.0]; - assert_eq!(median(&data), 15.0); - } - - #[test] - fn test_median_unsorted() { - let data = vec![10.0, 30.0, 20.0, 50.0, 40.0]; - assert_eq!(median(&data), 30.0); - } - - #[test] - fn test_median_with_duplicates() { - let data = vec![1.0, 2.0, 2.0, 3.0, 4.0]; - assert_eq!(median(&data), 2.0); - } - - use super::weighted_median; - - #[test] - fn test_weighted_median_simple() { - let data = vec![1.0, 2.0, 3.0]; - let weights = vec![0.2, 0.5, 0.3]; - assert_eq!(weighted_median(&data, &weights), 2.0); - } - - #[test] - fn test_weighted_median_even_weights() { - let data = vec![1.0, 2.0, 3.0, 4.0]; - let weights = vec![0.25, 0.25, 0.25, 0.25]; - assert_eq!(weighted_median(&data, &weights), 2.5); - } - - #[test] - fn test_weighted_median_single_element() { - let data = vec![42.0]; - let weights = vec![1.0]; - assert_eq!(weighted_median(&data, &weights), 42.0); - } - - #[test] - #[should_panic(expected = "The length of data and weights must be the same")] - fn test_weighted_median_mismatched_lengths() { - let data = vec![1.0, 2.0, 3.0]; - let weights = vec![0.1, 0.2]; - weighted_median(&data, &weights); - } - - #[test] - fn test_weighted_median_all_same_elements() { - let data = vec![5.0, 5.0, 5.0, 5.0]; - let weights = vec![0.1, 0.2, 0.3, 0.4]; - assert_eq!(weighted_median(&data, &weights), 5.0); - } - - #[test] - #[should_panic(expected = "Weights must be non-negative")] - fn test_weighted_median_negative_weights() { - let data = vec![1.0, 2.0, 3.0, 4.0]; - let weights = vec![0.2, -0.5, 0.5, 0.8]; - assert_eq!(weighted_median(&data, &weights), 4.0); - } - - #[test] - fn test_weighted_median_unsorted_data() { - let data = vec![3.0, 1.0, 4.0, 2.0]; - let weights = vec![0.1, 0.3, 0.4, 0.2]; - assert_eq!(weighted_median(&data, &weights), 2.5); - } - - #[test] - fn test_weighted_median_with_zero_weights() { - let data = vec![1.0, 2.0, 3.0, 4.0]; - let weights = vec![0.0, 0.0, 1.0, 0.0]; - assert_eq!(weighted_median(&data, &weights), 3.0); - } -} diff --git a/src/routines/settings.rs b/src/routines/settings.rs deleted file mode 100644 index e03fa1e31..000000000 --- a/src/routines/settings.rs +++ /dev/null @@ -1,601 +0,0 @@ -use crate::algorithms::Algorithm; -use crate::routines::initialization::Prior; -use crate::routines::output::OutputFile; -use anyhow::{bail, Result}; -use pharmsol::prelude::data::AssayErrorModels; - -use serde::{Deserialize, Serialize}; -use serde_json; -use std::fmt::Display; -use std::path::PathBuf; - -/// Contains all settings for PMcore -#[derive(Debug, Deserialize, Clone, Serialize)] -#[serde(deny_unknown_fields)] -pub struct Settings { - /// General configuration settings - pub(crate) config: Config, - /// Parameters to be estimated - pub(crate) parameters: Parameters, - /// Defines the error models and polynomials to be used - pub(crate) errormodels: AssayErrorModels, - /// Configuration for predictions - pub(crate) predictions: Predictions, - /// Configuration for logging - pub(crate) log: Log, - /// Configuration for (optional) prior - pub(crate) prior: Prior, - /// Configuration for the output files - pub(crate) output: Output, - /// Configuration for the convergence criteria - pub(crate) convergence: Convergence, - /// Advanced options, mostly hyperparameters, for the algorithm(s) - pub(crate) advanced: Advanced, -} - -impl Settings { - /// Create a new [SettingsBuilder] - pub fn builder() -> SettingsBuilder { - SettingsBuilder::new() - } - - /* Getters */ - pub fn config(&self) -> &Config { - &self.config - } - - pub fn parameters(&self) -> &Parameters { - &self.parameters - } - - pub fn errormodels(&self) -> &AssayErrorModels { - &self.errormodels - } - - pub fn predictions(&self) -> &Predictions { - &self.predictions - } - - pub fn log(&self) -> &Log { - &self.log - } - - pub fn prior(&self) -> &Prior { - &self.prior - } - - pub fn output(&self) -> &Output { - &self.output - } - pub fn convergence(&self) -> &Convergence { - &self.convergence - } - - pub fn advanced(&self) -> &Advanced { - &self.advanced - } - - /* Setters */ - pub fn set_cycles(&mut self, cycles: usize) { - self.config.cycles = cycles; - } - - pub fn set_algorithm(&mut self, algorithm: Algorithm) { - self.config.algorithm = algorithm; - } - - pub fn set_idelta(&mut self, idelta: f64) { - self.predictions.idelta = idelta; - } - - pub fn set_tad(&mut self, tad: f64) { - self.predictions.tad = tad; - } - - pub fn set_prior(&mut self, prior: Prior) { - self.prior = prior; - } - - pub fn disable_output(&mut self) { - self.output.write = false; - } - - pub fn set_output_path(&mut self, path: impl Into) { - self.output.path = parse_output_folder(path.into()); - } - - pub fn set_log_stdout(&mut self, stdout: bool) { - self.log.stdout = stdout; - } - - pub fn set_write_logs(&mut self, write: bool) { - self.log.write = write; - } - - pub fn set_log_level(&mut self, level: LogLevel) { - self.log.level = level; - } - - pub fn set_progress(&mut self, progress: bool) { - self.config.progress = progress; - } - - pub fn initialize_logs(&mut self) -> Result<()> { - crate::routines::logger::setup_log(self) - } - - /// Writes a copy of the settings to file - /// The is written to output folder specified in the [Output] and is named `settings.json`. - pub fn write(&self) -> Result<()> { - let serialized = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?; - - let outputfile = OutputFile::new(self.output.path.as_str(), "settings.json")?; - let mut file = outputfile.file_owned(); - std::io::Write::write_all(&mut file, serialized.as_bytes())?; - Ok(()) - } -} - -/// General configuration settings -#[derive(Debug, Deserialize, Clone, Serialize)] -#[serde(deny_unknown_fields, default)] -pub struct Config { - /// Maximum number of cycles to run - pub cycles: usize, - /// Denotes the algorithm to use - pub algorithm: Algorithm, - /// Should a progress bar be displayed for the first cycle - /// - /// The progress bar is not written to logs, but is written to stdout. It incurs a minor performance penalty. - pub progress: bool, -} - -impl Default for Config { - fn default() -> Self { - Config { - cycles: 100, - algorithm: Algorithm::NPAG, - progress: true, - } - } -} - -/// Defines a parameter to be estimated -/// -/// In non-parametric algorithms, parameters must be bounded. The lower and upper bounds are defined by the `lower` and `upper` fields, respectively. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] -pub struct Parameter { - pub(crate) name: String, - pub(crate) lower: f64, - pub(crate) upper: f64, -} - -impl Parameter { - /// Create a new parameter - pub fn new(name: impl Into, lower: f64, upper: f64) -> Self { - Self { - name: name.into(), - lower, - upper, - } - } -} - -/// This structure contains information on all [Parameter]s to be estimated -#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] -pub struct Parameters { - pub(crate) parameters: Vec, -} - -impl Parameters { - pub fn new() -> Self { - Parameters { - parameters: Vec::new(), - } - } - - pub fn add(mut self, name: impl Into, lower: f64, upper: f64) -> Parameters { - let parameter = Parameter::new(name, lower, upper); - self.parameters.push(parameter); - self - } - - // Get a parameter by name - pub fn get(&self, name: impl Into) -> Option<&Parameter> { - let name = name.into(); - self.parameters.iter().find(|p| p.name == name) - } - - /// Get the names of the parameters - pub fn names(&self) -> Vec { - self.parameters.iter().map(|p| p.name.clone()).collect() - } - /// Get the ranges of the parameters - /// - /// Returns a vector of tuples, where each tuple contains the lower and upper bounds of the parameter - pub fn ranges(&self) -> Vec<(f64, f64)> { - self.parameters.iter().map(|p| (p.lower, p.upper)).collect() - } - - /// Get the number of parameters - pub fn len(&self) -> usize { - self.parameters.len() - } - - /// Check if the parameters are empty - pub fn is_empty(&self) -> bool { - self.parameters.is_empty() - } - - /// Iterate over the parameters - pub fn iter(&self) -> std::slice::Iter<'_, Parameter> { - self.parameters.iter() - } -} - -impl IntoIterator for Parameters { - type Item = Parameter; - type IntoIter = std::vec::IntoIter; - - fn into_iter(self) -> Self::IntoIter { - self.parameters.into_iter() - } -} - -impl From> for Parameters { - fn from(parameters: Vec) -> Self { - Parameters { parameters } - } -} - -/// This struct contains advanced options and hyperparameters -#[derive(Debug, Deserialize, Clone, Serialize)] -#[serde(deny_unknown_fields, default)] -pub struct Advanced { - /// The minimum distance required between a candidate point and the existing grid (THETA_D) - /// - /// This is general for all non-parametric algorithms - pub min_distance: f64, - /// Maximum number of steps in Nelder-Mead optimization - /// This is used in the [NPOD](crate::algorithms::npod) algorithm, specifically in the [D-optimizer](crate::routines::optimization::d_optimizer) - pub nm_steps: usize, - /// Tolerance (in standard deviations) for the Nelder-Mead optimization - /// - /// This is used in the [NPOD](crate::algorithms::npod) algorithm, specifically in the [D-optimizer](crate::routines::optimization::d_optimizer) - pub tolerance: f64, -} - -impl Default for Advanced { - fn default() -> Self { - Advanced { - min_distance: 1e-4, - nm_steps: 100, - tolerance: 1e-6, - } - } -} - -#[derive(Debug, Deserialize, Clone, Serialize)] -#[serde(deny_unknown_fields, default)] -/// This struct contains the convergence criteria for the algorithm -pub struct Convergence { - /// The objective function convergence criterion for the algorithm - /// - /// The objective function is the negative log likelihood - /// Previously referred to as THETA_G - pub likelihood: f64, - /// The PYL convergence criterion for the algorithm - /// - /// P(Y|L) represents the probability of the observation given its weighted support - /// Previously referred to as THETA_F - pub pyl: f64, - /// Precision convergence criterion for the algorithm - /// - /// The precision variable, sometimes referred to as `eps`, is the distance from existing points in the grid to the candidate point. A candidate point is suggested at a distance of `eps` times the range of the parameter. - /// For example, if the parameter `alpha` has a range of `[0.0, 1.0]`, and `eps` is `0.1`, then the candidate point will be at a distance of `0.1 * (1.0 - 0.0) = 0.1` from the existing grid point(s). - /// Previously referred to as THETA_E - pub eps: f64, -} - -impl Default for Convergence { - fn default() -> Self { - Convergence { - likelihood: 1e-4, - pyl: 1e-2, - eps: 1e-2, - } - } -} - -#[derive(Debug, Deserialize, Clone, Serialize)] -#[serde(deny_unknown_fields, default)] -pub struct Predictions { - /// The interval for which predictions are generated - pub idelta: f64, - /// The time after the last dose for which predictions are generated - /// - /// Predictions will always be generated until the last event (observation or dose) in the data. - /// This setting is used to generate predictions beyond the last event if the `tad` if sufficiently large. - /// This can be useful for generating predictions for a subject who only received a dose, but has no observations. - pub tad: f64, -} - -impl Default for Predictions { - fn default() -> Self { - Predictions { - idelta: 0.12, - tad: 0.0, - } - } -} - -impl Predictions { - /// Validate the prediction settings - pub fn validate(&self) -> Result<()> { - if self.idelta < 0.0 { - bail!("The interval for predictions must be non-negative"); - } - if self.tad < 0.0 { - bail!("The time after dose for predictions must be non-negative"); - } - Ok(()) - } -} - -/// The log level, which can be one of the following: -/// - `TRACE` -/// - `DEBUG` -/// - `INFO` (Default) -/// - `WARN` -/// - `ERROR` -#[derive(Debug, Deserialize, Clone, Serialize, Default)] -pub enum LogLevel { - TRACE, - DEBUG, - #[default] - INFO, - WARN, - ERROR, -} - -impl From for tracing::Level { - fn from(log_level: LogLevel) -> tracing::Level { - match log_level { - LogLevel::TRACE => tracing::Level::TRACE, - LogLevel::DEBUG => tracing::Level::DEBUG, - LogLevel::INFO => tracing::Level::INFO, - LogLevel::WARN => tracing::Level::WARN, - LogLevel::ERROR => tracing::Level::ERROR, - } - } -} - -impl AsRef for LogLevel { - fn as_ref(&self) -> &str { - match self { - LogLevel::TRACE => "trace", - LogLevel::DEBUG => "debug", - LogLevel::INFO => "info", - LogLevel::WARN => "warn", - LogLevel::ERROR => "error", - } - } -} - -impl Display for LogLevel { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_ref()) - } -} - -#[derive(Debug, Deserialize, Clone, Serialize)] -#[serde(deny_unknown_fields, default)] -pub struct Log { - /// The maximum log level to display, as defined by [LogLevel] - /// - /// [LogLevel] is a thin wrapper around `tracing::Level`, but can be serialized - pub level: LogLevel, - /// Should the logs be written to a file - /// - /// If true, a file will be created in the output folder with the name `log.txt`, or, if [Output::write] is false, in the current directory. - pub write: bool, - /// Define if logs should be written to stdout - pub stdout: bool, -} - -impl Default for Log { - fn default() -> Self { - Log { - level: LogLevel::INFO, - write: false, - stdout: true, - } - } -} - -/// Configuration for the output files -#[derive(Debug, Deserialize, Clone, Serialize)] -#[serde(deny_unknown_fields, default)] -pub struct Output { - /// Whether to write the output files - pub write: bool, - /// The (relative) path to write the output files to - pub path: String, -} - -impl Default for Output { - fn default() -> Self { - let path = PathBuf::from("outputs/").to_string_lossy().to_string(); - - Output { write: true, path } - } -} - -pub struct SettingsBuilder { - config: Option, - parameters: Option, - errormodels: Option, - predictions: Option, - log: Option, - prior: Option, - output: Option, - convergence: Option, - advanced: Option, - _marker: std::marker::PhantomData, -} - -// Marker traits for builder states -pub trait AlgorithmDefined {} -pub trait ParametersDefined {} -pub trait ErrorModelDefined {} - -// Implement marker traits for PhantomData states -pub struct InitialState; -pub struct AlgorithmSet; -pub struct ParametersSet; -pub struct ErrorSet; - -// Initial state: no algorithm set yet -impl SettingsBuilder { - pub fn new() -> Self { - SettingsBuilder { - config: None, - parameters: None, - errormodels: None, - predictions: None, - log: None, - prior: None, - output: None, - convergence: None, - advanced: None, - _marker: std::marker::PhantomData, - } - } - - pub fn set_algorithm(self, algorithm: Algorithm) -> SettingsBuilder { - SettingsBuilder { - config: Some(Config { - algorithm, - ..Config::default() - }), - parameters: self.parameters, - errormodels: self.errormodels, - predictions: self.predictions, - log: self.log, - prior: self.prior, - output: self.output, - convergence: self.convergence, - advanced: self.advanced, - _marker: std::marker::PhantomData, - } - } -} - -impl Default for SettingsBuilder { - fn default() -> Self { - SettingsBuilder::new() - } -} - -// Algorithm is set, move to defining parameters -impl SettingsBuilder { - pub fn set_parameters(self, parameters: Parameters) -> SettingsBuilder { - SettingsBuilder { - config: self.config, - parameters: Some(parameters), - errormodels: self.errormodels, - predictions: self.predictions, - log: self.log, - prior: self.prior, - output: self.output, - convergence: self.convergence, - advanced: self.advanced, - _marker: std::marker::PhantomData, - } - } -} - -// Parameters are set, move to defining error model -impl SettingsBuilder { - pub fn set_error_models(self, ems: AssayErrorModels) -> SettingsBuilder { - SettingsBuilder { - config: self.config, - parameters: self.parameters, - errormodels: Some(ems), - predictions: self.predictions, - log: self.log, - prior: self.prior, - output: self.output, - convergence: self.convergence, - advanced: self.advanced, - _marker: std::marker::PhantomData, - } - } -} - -// Error model is set, allow optional settings and final build -impl SettingsBuilder { - pub fn build(self) -> Settings { - Settings { - config: self.config.unwrap(), - parameters: self.parameters.unwrap(), - errormodels: self.errormodels.unwrap(), - predictions: self.predictions.unwrap_or_default(), - log: self.log.unwrap_or_default(), - prior: self.prior.unwrap_or_default(), - output: self.output.unwrap_or_default(), - convergence: self.convergence.unwrap_or_default(), - advanced: self.advanced.unwrap_or_default(), - } - } -} - -fn parse_output_folder(path: String) -> String { - // If the path doesn't contain a "#", just return it as is - if !path.contains("#") { - return path; - } - - // If it does contain "#", perform the incrementation logic - let mut num = 1; - while std::path::Path::new(&path.replace("#", &num.to_string())).exists() { - num += 1; - } - - path.replace("#", &num.to_string()) -} - -#[cfg(test)] - -mod tests { - use pharmsol::{AssayErrorModel, AssayErrorModels, ErrorPoly}; - - use super::*; - use crate::algorithms::Algorithm; - - #[test] - fn test_builder() { - let parameters = Parameters::new().add("Ke", 0.0, 5.0).add("V", 10.0, 200.0); - - let ems = AssayErrorModels::new() - .add( - 0, - AssayErrorModel::Proportional { - gamma: pharmsol::Factor::Variable(5.0), - poly: ErrorPoly::new(0.0, 0.1, 0.0, 0.0), - }, - ) - .unwrap(); - let mut settings = SettingsBuilder::new() - .set_algorithm(Algorithm::NPAG) // Step 1: Define algorithm - .set_parameters(parameters) // Step 2: Define parameters - .set_error_models(ems) - .build(); - - settings.set_cycles(100); - - assert_eq!(settings.config.algorithm, Algorithm::NPAG); - assert_eq!(settings.config.cycles, 100); - assert_eq!(settings.parameters().names(), vec!["Ke", "V"]); - } -} diff --git a/src/structs/mod.rs b/src/structs/mod.rs deleted file mode 100644 index eb24bf1e4..000000000 --- a/src/structs/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod psi; -pub mod theta; -pub mod weights; diff --git a/src/structs/theta.rs b/src/structs/theta.rs deleted file mode 100644 index 37d9f65e7..000000000 --- a/src/structs/theta.rs +++ /dev/null @@ -1,412 +0,0 @@ -use std::fmt::Debug; - -use anyhow::{bail, Result}; -use faer::Mat; -use serde::{Deserialize, Serialize}; - -use crate::{prelude::Parameters, structs::weights::Weights}; - -/// [Theta] is a structure that holds the support points -/// These represent the joint population parameter distribution -/// -/// Each row represents a support points, and each column a parameter -#[derive(Clone, PartialEq)] -pub struct Theta { - matrix: Mat, - parameters: Parameters, -} - -impl Default for Theta { - fn default() -> Self { - Theta { - matrix: Mat::new(), - parameters: Parameters::new(), - } - } -} - -impl Theta { - pub fn new() -> Self { - Theta::default() - } - - /// Create a new [Theta] from a matrix and [Parameters] - /// - /// It is important that the number of columns in the matrix matches the number of parameters - /// in the [Parameters] object - /// - /// The order of parameters in the [Parameters] object should match the order of columns in the matrix - pub fn from_parts(matrix: Mat, parameters: Parameters) -> Result { - if matrix.ncols() != parameters.len() { - bail!( - "Number of columns in matrix ({}) does not match number of parameters ({})", - matrix.ncols(), - parameters.len() - ); - } - - Ok(Theta { matrix, parameters }) - } - - /// Get the matrix containing parameter values - /// - /// The matrix is a 2D array where each row represents a support point, and each column a parameter - pub fn matrix(&self) -> &Mat { - &self.matrix - } - - /// Get a mutable reference to the matrix - pub fn matrix_mut(&mut self) -> &mut Mat { - &mut self.matrix - } - - /// Get the [Parameters] object associated with this [Theta] - pub fn parameters(&self) -> &Parameters { - &self.parameters - } - - /// Get a mutable reference to the [Parameters] object - pub fn parameters_mut(&mut self) -> &mut Parameters { - &mut self.parameters - } - - /// Get the number of support points, equal to the number of rows in the matrix - pub fn nspp(&self) -> usize { - self.matrix.nrows() - } - - /// Get the parameter names - pub fn param_names(&self) -> Vec { - self.parameters.names() - } - - /// Modify the [Theta::matrix] to only include the rows specified by `indices` - pub(crate) fn filter_indices(&mut self, indices: &[usize]) { - let matrix = self.matrix.to_owned(); - - let new = Mat::from_fn(indices.len(), matrix.ncols(), |r, c| { - *matrix.get(indices[r], c) - }); - - self.matrix = new; - } - - /// Forcibly add a support point to the matrix - pub fn add_point(&mut self, spp: &[f64]) -> Result<()> { - if spp.len() != self.matrix.ncols() { - bail!( - "Support point length ({}) does not match number of parameters ({})", - spp.len(), - self.matrix.ncols() - ); - } - - self.matrix - .resize_with(self.matrix.nrows() + 1, self.matrix.ncols(), |_, i| spp[i]); - Ok(()) - } - - /// Suggest a new support point to add to the matrix - /// The point is only added if it is at least `min_dist` away from all existing support points - /// and within the limits specified by `limits` - pub(crate) fn suggest_point(&mut self, spp: &[f64], min_dist: f64) -> Result<()> { - if self.check_point(spp, min_dist) { - self.add_point(spp)?; - } - Ok(()) - } - - /// Check if a point is at least `min_dist` away from all existing support points - pub(crate) fn check_point(&self, spp: &[f64], min_dist: f64) -> bool { - if self.matrix.nrows() == 0 { - return true; - } - - let limits = self.parameters.ranges(); - - for row_idx in 0..self.matrix.nrows() { - let mut squared_dist = 0.0; - for (i, val) in spp.iter().enumerate() { - // Normalized squared difference for this dimension - let normalized_diff = - (val - self.matrix.get(row_idx, i)) / (limits[i].1 - limits[i].0); - squared_dist += normalized_diff * normalized_diff; - } - let dist = squared_dist.sqrt(); - if dist <= min_dist { - return false; // This point is too close to an existing point - } - } - true // Point is sufficiently distant from all existing points - } - - /// Write the matrix to a CSV file - pub fn write(&self, path: &str) { - let mut writer = csv::Writer::from_path(path).unwrap(); - for row in self.matrix.row_iter() { - writer - .write_record(row.iter().map(|x| x.to_string())) - .unwrap(); - } - } - - /// Write the matrix to a CSV file with weights - pub fn write_with_weights(&self, path: &str, weights: &Weights) -> Result<()> { - if self.nspp() != weights.len() { - bail!( - "Number of support points ({}) does not match number of weights ({})", - self.nspp(), - weights.len() - ); - } - - let mut writer = csv::Writer::from_path(path)?; - - let header: Vec = self - .parameters - .names() - .iter() - .cloned() - .chain(std::iter::once("prob".to_string())) - .collect(); - - writer.write_record(header)?; - - for (row_idx, row) in self.matrix.row_iter().enumerate() { - let mut record: Vec = row.iter().map(|x| x.to_string()).collect(); - record.push(weights[row_idx].to_string()); - writer.write_record(record)?; - } - Ok(()) - } - - /// Write the theta matrix to a CSV writer - /// Each row represents a support point, each column represents a parameter - pub fn to_csv(&self, writer: W) -> Result<()> { - let mut csv_writer = csv::Writer::from_writer(writer); - - // Write each row - for i in 0..self.matrix.nrows() { - let row: Vec = (0..self.matrix.ncols()) - .map(|j| *self.matrix.get(i, j)) - .collect(); - csv_writer.serialize(row)?; - } - - csv_writer.flush()?; - Ok(()) - } - - /// Read theta matrix from a CSV reader - /// Each row represents a support point, each column represents a parameter - /// Note: This only reads the matrix values, not the parameter metadata - pub fn from_csv(reader: R) -> Result { - let mut csv_reader = csv::Reader::from_reader(reader); - let mut rows: Vec> = Vec::new(); - - for result in csv_reader.deserialize() { - let row: Vec = result?; - rows.push(row); - } - - if rows.is_empty() { - bail!("CSV file is empty"); - } - - let nrows = rows.len(); - let ncols = rows[0].len(); - - // Verify all rows have the same length - for (i, row) in rows.iter().enumerate() { - if row.len() != ncols { - bail!("Row {} has {} columns, expected {}", i, row.len(), ncols); - } - } - - // Create matrix from rows - let mat = Mat::from_fn(nrows, ncols, |i, j| rows[i][j]); - - // Create empty parameters - user will need to set these separately - let parameters = Parameters::new(); - - Theta::from_parts(mat, parameters) - } -} - -impl Debug for Theta { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Write nspp and nsub - writeln!(f, "\nTheta contains {} support points\n", self.nspp())?; - - // Write the parameter names - for name in self.parameters.names().iter() { - write!(f, "\t{}", name)?; - } - writeln!(f)?; - // Write the matrix - self.matrix.row_iter().enumerate().for_each(|(index, row)| { - write!(f, "{}", index).unwrap(); - for val in row.iter() { - write!(f, "\t{:.2}", val).unwrap(); - } - writeln!(f).unwrap(); - }); - Ok(()) - } -} - -impl Serialize for Theta { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: serde::Serializer, - { - use serde::ser::SerializeSeq; - - let mut seq = serializer.serialize_seq(Some(self.matrix.nrows()))?; - - // Serialize each row as a vector - for i in 0..self.matrix.nrows() { - let row: Vec = (0..self.matrix.ncols()) - .map(|j| *self.matrix.get(i, j)) - .collect(); - seq.serialize_element(&row)?; - } - - seq.end() - } -} - -impl<'de> Deserialize<'de> for Theta { - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - use serde::de::{SeqAccess, Visitor}; - use std::fmt; - - struct ThetaVisitor; - - impl<'de> Visitor<'de> for ThetaVisitor { - type Value = Theta; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a sequence of rows (vectors of f64)") - } - - fn visit_seq(self, mut seq: A) -> std::result::Result - where - A: SeqAccess<'de>, - { - let mut rows: Vec> = Vec::new(); - - while let Some(row) = seq.next_element::>()? { - rows.push(row); - } - - if rows.is_empty() { - return Err(serde::de::Error::custom("Empty matrix not allowed")); - } - - let nrows = rows.len(); - let ncols = rows[0].len(); - - // Verify all rows have the same length - for (i, row) in rows.iter().enumerate() { - if row.len() != ncols { - return Err(serde::de::Error::custom(format!( - "Row {} has {} columns, expected {}", - i, - row.len(), - ncols - ))); - } - } - - // Create matrix from rows - let mat = Mat::from_fn(nrows, ncols, |i, j| rows[i][j]); - - // Create empty parameters - user will need to set these separately - let parameters = Parameters::new(); - - Theta::from_parts(mat, parameters).map_err(serde::de::Error::custom) - } - } - - deserializer.deserialize_seq(ThetaVisitor) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use faer::mat; - - #[test] - fn test_filter_indices() { - // Create a 4x2 matrix with recognizable values - let matrix = mat![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]]; - - let parameters = Parameters::new().add("A", 0.0, 10.0).add("B", 0.0, 10.0); - - let mut theta = Theta::from_parts(matrix, parameters).unwrap(); - - theta.filter_indices(&[0, 3]); - - // Expected result is a 2x2 matrix with filtered rows - let expected = mat![[1.0, 2.0], [7.0, 8.0]]; - - assert_eq!(theta.matrix, expected); - } - - #[test] - fn test_add_point() { - let matrix = mat![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]; - - let parameters = Parameters::new().add("A", 0.0, 10.0).add("B", 0.0, 10.0); - - let mut theta = Theta::from_parts(matrix, parameters).unwrap(); - - theta.add_point(&[7.0, 8.0]).unwrap(); - - let expected = mat![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]]; - - assert_eq!(theta.matrix, expected); - } - - #[test] - fn test_suggest_point() { - let matrix = mat![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]; - let parameters = Parameters::new().add("A", 0.0, 10.0).add("B", 0.0, 10.0); - let mut theta = Theta::from_parts(matrix, parameters).unwrap(); - theta.suggest_point(&[7.0, 8.0], 0.2).unwrap(); - let expected = mat![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]]; - assert_eq!(theta.matrix, expected); - - // Suggest a point that is too close - theta.suggest_point(&[7.1, 8.1], 0.2).unwrap(); - // The point should not be added - assert_eq!(theta.matrix.nrows(), 4); - } - - #[test] - fn test_param_names() { - let matrix = mat![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]; - let parameters = Parameters::new().add("A", 0.0, 10.0).add("B", 0.0, 10.0); - - let theta = Theta::from_parts(matrix, parameters).unwrap(); - let names = theta.param_names(); - assert_eq!(names, vec!["A".to_string(), "B".to_string()]); - } - - #[test] - fn test_set_matrix() { - let matrix = mat![[1.0, 2.0], [3.0, 4.0]]; - let parameters = Parameters::new().add("A", 0.0, 10.0).add("B", 0.0, 10.0); - let mut theta = Theta::from_parts(matrix, parameters).unwrap(); - - let new_matrix = mat![[5.0, 6.0], [7.0, 8.0], [9.0, 10.0]]; - theta.matrix_mut().clone_from(&new_matrix); - - assert_eq!(theta.matrix(), &new_matrix); - } -} diff --git a/tests/bestdose_tests.rs b/tests/bestdose_tests.rs index 145bf11ae..ee4a268b2 100644 --- a/tests/bestdose_tests.rs +++ b/tests/bestdose_tests.rs @@ -1,8 +1,68 @@ use anyhow::Result; -use pmcore::bestdose::{BestDosePosterior, DoseRange, Target}; +use pmcore::bestdose::{BestDoseConfig, BestDosePosterior, BestDoseProblem, DoseRange, Target}; +use pmcore::estimation::nonparametric::{Theta, Weights}; +use pmcore::model::{BoundedParameter, ParameterSpace}; use pmcore::prelude::*; -use pmcore::structs::theta::Theta; -use pmcore::structs::weights::Weights; + +fn pk_parameter_space( + ke_lower: f64, + ke_upper: f64, + v_lower: f64, + v_upper: f64, +) -> ParameterSpace { + ParameterSpace::::new() + .add("ke", ke_lower, ke_upper) + .add("v", v_lower, v_upper) +} + +fn bestdose_config( + params: &ParameterSpace, + error_models: AssayErrorModels, + refinement_cycles: usize, + prediction_interval: f64, +) -> BestDoseConfig { + BestDoseConfig::new(params.clone(), error_models) + .with_refinement_cycles(refinement_cycles) + .with_progress(false) + .with_prediction_interval(prediction_interval) +} + +fn one_compartment_model() -> pharmsol::ODE { + 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; + }, + ) +} + +fn minimal_config() -> BestDoseConfig { + let params = pk_parameter_space(0.001, 3.0, 25.0, 250.0); + let ems = AssayErrorModels::new() + .add( + 0, + AssayErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), + ) + .unwrap(); + bestdose_config(¶ms, ems, 0, 0.12) +} + +fn simple_prior(config: &BestDoseConfig) -> Result<(Theta, Weights)> { + let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { + 0 => 0.3, + 1 => 50.0, + _ => 0.0, + }); + let theta = Theta::from_parts(mat, config.parameter_space().clone())?; + Ok((theta, Weights::uniform(1))) +} /// Test that infusions are properly included in the dose optimization mask /// This test verifies that infusions with amount=0 are treated as optimizable doses @@ -23,21 +83,14 @@ fn test_infusion_mask_inclusion() -> Result<()> { }, ); - let params = Parameters::new().add("ke", 0.1, 0.5).add("v", 40.0, 60.0); + let params = pk_parameter_space(0.1, 0.5, 40.0, 60.0); let ems = AssayErrorModels::new().add( 0, AssayErrorModel::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); + let config = bestdose_config(¶ms, ems.clone(), 0, 0.12); // Create a target subject with an optimizable infusion // Use reasonable target concentrations that match typical PK behavior @@ -54,17 +107,22 @@ fn test_infusion_mask_inclusion() -> Result<()> { 1 => 50.0, // v _ => 0.0, }); - Theta::from_parts(mat, settings.parameters().clone())? + Theta::from_parts(mat, params.clone())? }; let prior_weights = Weights::uniform(1); - // Create BestDose posterior - let posterior = BestDosePosterior::compute( + // Create BestDose problem + let problem = BestDoseProblem::new( &prior_theta, &prior_weights, None, + target.clone(), + None, eq.clone(), - settings.clone(), + DoseRange::new(10.0, 300.0), + 0.5, + config, + Target::Concentration, )?; // Count optimizable doses in the target @@ -85,13 +143,7 @@ fn test_infusion_mask_inclusion() -> Result<()> { ); // Run optimization - it should not panic and should handle infusion - let result = posterior.optimize( - target.clone(), - None, - DoseRange::new(10.0, 300.0), - 0.5, - Target::Concentration, - ); + let result = problem.optimize(); // The optimization should succeed assert!( @@ -138,23 +190,14 @@ fn test_fixed_infusion_preservation() -> Result<()> { }, ); - let params = Parameters::new() - .add("ke", 0.001, 3.0) - .add("v", 25.0, 250.0); + let params = pk_parameter_space(0.001, 3.0, 25.0, 250.0); let ems = AssayErrorModels::new().add( 0, AssayErrorModel::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); + let config = bestdose_config(¶ms, ems.clone(), 0, 0.12); // Create past data with a fixed infusion let past = Subject::builder("test_patient") @@ -174,29 +217,27 @@ fn test_fixed_infusion_preservation() -> Result<()> { 1 => 50.0, _ => 0.0, }); - Theta::from_parts(mat, settings.parameters().clone())? + Theta::from_parts(mat, params.clone())? }; let prior_weights = Weights::uniform(1); // Use current_time to separate past and future - let posterior = BestDosePosterior::compute( + let problem = BestDoseProblem::new( &prior_theta, &prior_weights, Some(past), - eq.clone(), - settings.clone(), - )?; - - let result = posterior.optimize( target, - Some(0.0), // No gap after past (past ends at t=2.0) + Some(2.0), // Current time = 2.0 hours + eq.clone(), DoseRange::new(0.0, 500.0), 0.5, + config, Target::Concentration, )?; - // With time_offset, past doses are concatenated with future target. - // Result should have 2 doses: fixed past infusion + optimized future bolus. + let result = problem.optimize()?; + + // Should only optimize the future bolus, not the past infusion let doses = result.doses(); eprintln!("Optimized doses: {:?}", doses); assert_eq!( @@ -204,11 +245,7 @@ fn test_fixed_infusion_preservation() -> Result<()> { 2, "Should have 2 doses (past infusion + future bolus)" ); - assert!( - (doses[0] - 200.0).abs() < 1e-6, - "Past infusion should remain fixed at 200.0, got {}", - doses[0] - ); + assert_eq!(doses[0], 200.0, "Past infusion dose should be preserved"); assert!(doses[1] > 0.0, "Future bolus dose should be optimized"); Ok(()) @@ -217,6 +254,8 @@ fn test_fixed_infusion_preservation() -> Result<()> { /// 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); @@ -231,19 +270,13 @@ fn test_dose_count_validation() -> Result<()> { }, ); - let params = Parameters::new().add("ke", 0.1, 0.5).add("v", 40.0, 60.0); + let params = pk_parameter_space(0.1, 0.5, 40.0, 60.0); let ems = AssayErrorModels::new().add( 0, AssayErrorModel::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); + let config = bestdose_config(¶ms, ems.clone(), 0, 0.12); // Create target with 2 optimizable doses let target = Subject::builder("test_patient") @@ -259,27 +292,34 @@ fn test_dose_count_validation() -> Result<()> { 1 => 50.0, _ => 0.0, }); - Theta::from_parts(mat, settings.parameters().clone())? + Theta::from_parts(mat, params.clone())? }; let prior_weights = Weights::uniform(1); - let posterior = BestDosePosterior::compute(&prior_theta, &prior_weights, None, eq, settings)?; - - // Optimize with the correct target (2 optimizable doses, 2 observations) - should succeed - let result = posterior.optimize( + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, target, None, + eq, DoseRange::new(10.0, 300.0), 0.5, + config, 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.is_ok(), - "Should succeed with correct target: {:?}", - result.err() + result_correct.is_ok(), + "Should succeed with correct dose count" ); - let result = result?; - assert_eq!(result.doses().len(), 2, "Should have 2 optimized doses"); Ok(()) } @@ -287,6 +327,8 @@ fn test_dose_count_validation() -> Result<()> { /// 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); @@ -301,19 +343,13 @@ fn test_empty_observations_validation() -> Result<()> { }, ); - let params = Parameters::new().add("ke", 0.1, 0.5).add("v", 40.0, 60.0); + let params = pk_parameter_space(0.1, 0.5, 40.0, 60.0); let ems = AssayErrorModels::new().add( 0, AssayErrorModel::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); + let config = bestdose_config(¶ms, ems.clone(), 0, 0.12); // Create target with doses but NO observations let target = Subject::builder("test_patient").bolus(0.0, 0.0, 0).build(); // No observations! @@ -324,20 +360,25 @@ fn test_empty_observations_validation() -> Result<()> { 1 => 50.0, _ => 0.0, }); - Theta::from_parts(mat, settings.parameters().clone())? + Theta::from_parts(mat, params.clone())? }; let prior_weights = Weights::uniform(1); - let posterior = BestDosePosterior::compute(&prior_theta, &prior_weights, None, eq, settings)?; - - // Try to optimize - should fail with no observations - let result = posterior.optimize( + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, target, None, + eq, DoseRange::new(10.0, 300.0), 0.5, + config, 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")); @@ -361,22 +402,14 @@ fn test_basic_auc_mode() -> Result<()> { }, ); - let params = Parameters::new().add("ke", 0.1, 0.5).add("v", 40.0, 60.0); + let params = pk_parameter_space(0.1, 0.5, 40.0, 60.0); let ems = AssayErrorModels::new().add( 0, AssayErrorModel::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); - settings.set_cycles(0); + let config = bestdose_config(¶ms, ems.clone(), 0, 30.0); let target = Subject::builder("test_patient") .bolus(0.0, 0.0, 0) // Optimizable bolus @@ -389,19 +422,24 @@ fn test_basic_auc_mode() -> Result<()> { 1 => 50.0, _ => 0.0, }); - Theta::from_parts(mat, settings.parameters().clone())? + Theta::from_parts(mat, params.clone())? }; let prior_weights = Weights::uniform(1); - let posterior = BestDosePosterior::compute(&prior_theta, &prior_weights, None, eq, settings)?; - - let result = posterior.optimize( + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, target, None, + eq, DoseRange::new(100.0, 2000.0), 0.8, + config, Target::AUCFromZero, - ); + )?; + + let result = problem.optimize(); assert!( result.is_ok(), @@ -446,22 +484,14 @@ fn test_infusion_auc_mode() -> Result<()> { }, ); - let params = Parameters::new().add("ke", 0.1, 0.5).add("v", 40.0, 60.0); + let params = pk_parameter_space(0.1, 0.5, 40.0, 60.0); let ems = AssayErrorModels::new().add( 0, AssayErrorModel::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 - settings.set_cycles(0); + let config = bestdose_config(¶ms, ems.clone(), 0, 30.0); // Create a target with an optimizable infusion and AUC targets let target = Subject::builder("test_patient") @@ -476,21 +506,26 @@ fn test_infusion_auc_mode() -> Result<()> { 1 => 50.0, _ => 0.0, }); - Theta::from_parts(mat, settings.parameters().clone())? + Theta::from_parts(mat, params.clone())? }; let prior_weights = Weights::uniform(1); - // Create BestDose posterior and optimize in AUC mode - let posterior = BestDosePosterior::compute(&prior_theta, &prior_weights, None, eq, settings)?; - - // Run optimization - let result = posterior.optimize( + // Create BestDose problem in AUC mode + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, target, None, + eq, DoseRange::new(100.0, 2000.0), - 0.8, // Higher bias weight typically works better for AUC targets + 0.8, // Higher bias weight typically works better for AUC targets + config, Target::AUCFromZero, // AUC mode! - ); + )?; + + // Run optimization + let result = problem.optimize(); assert!( result.is_ok(), @@ -550,22 +585,14 @@ fn test_multi_outeq_auc_mode() -> Result<()> { }, ); - let params = Parameters::new().add("ke", 0.1, 0.5).add("v", 40.0, 60.0); + let params = pk_parameter_space(0.1, 0.5, 40.0, 60.0); let error_model = AssayErrorModel::additive(ErrorPoly::new(0.0, 5.0, 0.0, 0.0), 0.0); let ems = AssayErrorModels::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(); - settings.set_cycles(0); - settings.set_idelta(30.0); // 30-minute intervals for AUC calculation + let config = bestdose_config(¶ms, ems.clone(), 0, 0.12); // Subject with fixed dose and target observations at multiple outeqs let target = Subject::builder("test") @@ -581,22 +608,26 @@ fn test_multi_outeq_auc_mode() -> Result<()> { 1 => 50.0, // v _ => 0.0, }); - Theta::from_parts(mat, settings.parameters().clone())? + Theta::from_parts(mat, params.clone())? }; let prior_weights = Weights::uniform(1); - let posterior = BestDosePosterior::compute(&prior_theta, &prior_weights, None, eq, settings)?; - - let _result = posterior.optimize( + let _problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, target, None, + eq, DoseRange::new(0.0, 2000.0), 0.5, + config, Target::AUCFromZero, )?; - // Just verify that posterior compute and optimize succeed + // Just verify that problem was created successfully // This tests that cost calculation works with multi-outeq + // (cost is calculated during problem validation) Ok(()) } @@ -620,19 +651,13 @@ fn test_multi_outeq_auc_optimization() -> Result<()> { }, ); - let params = Parameters::new().add("ke", 0.1, 0.5).add("v", 40.0, 60.0); + let params = pk_parameter_space(0.1, 0.5, 40.0, 60.0); let error_model = AssayErrorModel::additive(ErrorPoly::new(0.0, 5.0, 0.0, 0.0), 0.0); let ems = AssayErrorModels::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(); - settings.set_cycles(3); + let config = bestdose_config(¶ms, ems.clone(), 3, 0.12); let target = Subject::builder("test") .bolus(0.0, 0.0, 0) @@ -646,19 +671,24 @@ fn test_multi_outeq_auc_optimization() -> Result<()> { 1 => 50.0, _ => 0.0, }); - Theta::from_parts(mat, settings.parameters().clone())? + Theta::from_parts(mat, params.clone())? }; let prior_weights = Weights::uniform(1); - let posterior = BestDosePosterior::compute(&prior_theta, &prior_weights, None, eq, settings)?; - - let result = posterior.optimize( + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, target, None, + eq, DoseRange::new(0.0, 2000.0), 0.5, + config, Target::AUCFromZero, - ); + )?; + + let result = problem.optimize(); assert!( result.is_ok(), "Multi-outeq AUC optimization failed: {:?}", @@ -705,22 +735,14 @@ fn test_auc_from_zero_single_dose() -> Result<()> { }, ); - let params = Parameters::new().add("ke", 0.2, 0.4).add("v", 40.0, 60.0); + let params = pk_parameter_space(0.2, 0.4, 40.0, 60.0); let ems = AssayErrorModels::new().add( 0, AssayErrorModel::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 + let config = bestdose_config(¶ms, ems.clone(), 0, 10.0); // Target: Single dose, cumulative AUC from 0 to 12h let target = Subject::builder("patient_auc_zero") @@ -734,20 +756,25 @@ fn test_auc_from_zero_single_dose() -> Result<()> { 1 => 50.0, // v _ => 0.0, }); - Theta::from_parts(mat, settings.parameters().clone())? + Theta::from_parts(mat, params.clone())? }; let prior_weights = Weights::uniform(1); - let posterior = BestDosePosterior::compute(&prior_theta, &prior_weights, None, eq, settings)?; - - let result = posterior.optimize( + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, target, None, + eq, DoseRange::new(100.0, 1000.0), 0.8, + config, Target::AUCFromZero, // Cumulative AUC from time 0 )?; + let result = problem.optimize()?; + let doses: Vec = result.doses(); // Verify we got a result @@ -789,22 +816,14 @@ fn test_auc_from_last_dose_maintenance() -> Result<()> { }, ); - let params = Parameters::new().add("ke", 0.2, 0.4).add("v", 40.0, 60.0); + let params = pk_parameter_space(0.2, 0.4, 40.0, 60.0); let ems = AssayErrorModels::new().add( 0, AssayErrorModel::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 config = bestdose_config(¶ms, ems.clone(), 0, 10.0); // Target: Loading dose (fixed) + maintenance dose (optimize) // Target interval AUC from t=12 to t=24 @@ -820,19 +839,24 @@ fn test_auc_from_last_dose_maintenance() -> Result<()> { 1 => 50.0, // v _ => 0.0, }); - Theta::from_parts(mat, settings.parameters().clone())? + Theta::from_parts(mat, params.clone())? }; let prior_weights = Weights::uniform(1); - let posterior = BestDosePosterior::compute(&prior_theta, &prior_weights, None, eq, settings)?; - - let result = posterior.optimize( + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, target, None, + eq, DoseRange::new(50.0, 500.0), 0.8, + config, Target::AUCFromLastDose, // Interval AUC from last dose )?; + + let result = problem.optimize()?; let doses = result.doses(); // Verify we got a result @@ -877,22 +901,14 @@ fn test_auc_modes_comparison() -> Result<()> { }, ); - let params = Parameters::new().add("ke", 0.3, 0.3).add("v", 50.0, 50.0); + let params = pk_parameter_space(0.3, 0.3, 50.0, 50.0); let ems = AssayErrorModels::new().add( 0, AssayErrorModel::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 config = bestdose_config(¶ms, ems.clone(), 0, 10.0); let prior_theta = { let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { @@ -900,7 +916,7 @@ fn test_auc_modes_comparison() -> Result<()> { 1 => 50.0, // v _ => 0.0, }); - Theta::from_parts(mat, settings.parameters().clone())? + Theta::from_parts(mat, params.clone())? }; let prior_weights = Weights::uniform(1); @@ -914,21 +930,20 @@ fn test_auc_modes_comparison() -> Result<()> { .observation(24.0, 100.0, 0) // Target: AUC₀₋₂₄ = 100 .build(); - let posterior_zero = BestDosePosterior::compute( + let problem_zero = BestDoseProblem::new( &prior_theta, &prior_weights, None, - eq.clone(), - settings.clone(), - )?; - - let result_zero = posterior_zero.optimize( target_zero, None, + eq.clone(), DoseRange::new(10.0, 2000.0), 0.8, + config.clone(), Target::AUCFromZero, )?; + + let result_zero = problem_zero.optimize()?; // Extract only the second dose (the optimized one at t=12) let dose_zero = result_zero.doses()[1]; @@ -939,16 +954,20 @@ fn test_auc_modes_comparison() -> Result<()> { .observation(24.0, 100.0, 0) // Target: AUC₁₂₋₂₄ = 100 .build(); - let posterior_last = - BestDosePosterior::compute(&prior_theta, &prior_weights, None, eq, settings)?; - - let result_last = posterior_last.optimize( + let problem_last = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, target_last, None, + eq, DoseRange::new(10.0, 2000.0), 0.8, + config, Target::AUCFromLastDose, )?; + + let result_last = problem_last.optimize()?; // Extract only the second dose (the optimized one at t=12) let dose_last = result_last.doses()[1]; @@ -1006,22 +1025,14 @@ fn test_auc_from_last_dose_multiple_observations() -> Result<()> { }, ); - let params = Parameters::new().add("ke", 0.2, 0.4).add("v", 40.0, 60.0); + let params = pk_parameter_space(0.2, 0.4, 40.0, 60.0); let ems = AssayErrorModels::new().add( 0, AssayErrorModel::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 config = bestdose_config(¶ms, ems.clone(), 0, 10.0); // Multiple doses and observations - each observation measures AUC from its preceding dose let target = Subject::builder("patient_multi") @@ -1037,19 +1048,24 @@ fn test_auc_from_last_dose_multiple_observations() -> Result<()> { 1 => 50.0, // v _ => 0.0, }); - Theta::from_parts(mat, settings.parameters().clone())? + Theta::from_parts(mat, params.clone())? }; let prior_weights = Weights::uniform(1); - let posterior = BestDosePosterior::compute(&prior_theta, &prior_weights, None, eq, settings)?; - - let result = posterior.optimize( + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, target, None, + eq, DoseRange::new(50.0, 500.0), 0.8, + config, Target::AUCFromLastDose, )?; + + let result = problem.optimize()?; let doses: Vec = result.doses(); // Should optimize 2 doses @@ -1100,22 +1116,14 @@ fn test_auc_from_last_dose_no_prior_dose() -> Result<()> { }, ); - let params = Parameters::new().add("ke", 0.2, 0.4).add("v", 40.0, 60.0); + let params = pk_parameter_space(0.2, 0.4, 40.0, 60.0); let ems = AssayErrorModels::new().add( 0, AssayErrorModel::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 config = bestdose_config(¶ms, ems.clone(), 0, 10.0); // Edge case: observation at t=6, but dose is at t=12 (after the observation) let target = Subject::builder("patient_edge") @@ -1129,19 +1137,24 @@ fn test_auc_from_last_dose_no_prior_dose() -> Result<()> { 1 => 50.0, // v _ => 0.0, }); - Theta::from_parts(mat, settings.parameters().clone())? + Theta::from_parts(mat, params.clone())? }; let prior_weights = Weights::uniform(1); - let posterior = BestDosePosterior::compute(&prior_theta, &prior_weights, None, eq, settings)?; - - let result = posterior.optimize( + let problem = BestDoseProblem::new( + &prior_theta, + &prior_weights, + None, target, None, + eq, DoseRange::new(50.0, 500.0), 0.8, + config, Target::AUCFromLastDose, )?; + + let result = problem.optimize()?; let doses: Vec = result.doses(); assert_eq!(doses.len(), 1); @@ -1189,21 +1202,14 @@ fn test_dose_range_bounds_respected() -> Result<()> { }, ); - let params = Parameters::new().add("ke", 0.1, 0.5).add("v", 40.0, 60.0); + let params = pk_parameter_space(0.1, 0.5, 40.0, 60.0); let ems = AssayErrorModels::new().add( 0, AssayErrorModel::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); + let config = bestdose_config(¶ms, ems.clone(), 0, 0.12); // Target with high concentration requiring large dose let target = Subject::builder("test_patient") @@ -1217,23 +1223,27 @@ fn test_dose_range_bounds_respected() -> Result<()> { 1 => 50.0, // v _ => 0.0, }); - Theta::from_parts(mat, settings.parameters().clone())? + Theta::from_parts(mat, params.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 posterior = BestDosePosterior::compute( + let problem = BestDoseProblem::new( &prior_theta, &prior_weights, None, + target.clone(), + None, eq.clone(), - settings.clone(), + dose_range, + 0.0, + config, + Target::Concentration, )?; - let result = - posterior.optimize(target.clone(), None, dose_range, 0.0, Target::Concentration)?; + let result = problem.optimize()?; let doses: Vec = result.doses(); println!("Optimal dose: {:.1} mg", doses[0]); @@ -1262,543 +1272,143 @@ fn test_dose_range_bounds_respected() -> Result<()> { Ok(()) } -// ═════════════════════════════════════════════════════════════════════════════ -// Tests for time_offset behavior -// ═════════════════════════════════════════════════════════════════════════════ - -/// Helper to build a simple one-compartment model used by multiple tests -fn one_compartment_model() -> pharmsol::ODE { - 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; - }, - ) -} - -/// Helper to build minimal settings for tests (no posterior refinement) -fn minimal_settings() -> Settings { - let params = Parameters::new() - .add("ke", 0.001, 3.0) - .add("v", 25.0, 250.0); - let ems = AssayErrorModels::new() - .add( - 0, - AssayErrorModel::additive(ErrorPoly::new(0.0, 0.20, 0.0, 0.0), 0.0), - ) - .unwrap(); - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems) - .build(); - settings.disable_output(); - settings.set_cycles(0); - settings -} - -/// Helper to build a simple prior (single support point) -fn simple_prior(settings: &Settings) -> (Theta, Weights) { - let mat = faer::Mat::from_fn(1, 2, |_r, c| match c { - 0 => 0.3, // ke - 1 => 50.0, // v - _ => 0.0, - }); - let theta = Theta::from_parts(mat, settings.parameters().clone()).unwrap(); - let weights = Weights::uniform(1); - (theta, weights) -} - -/// Test that gap=0 and gap=12 produce different results -/// -/// When time_offset is applied as a gap after the last past event, -/// different gaps change when the future dose is given relative to -/// the past, affecting the PK simulation outcome. #[test] -fn test_time_offset_zero_vs_nonzero_differ() -> Result<()> { +fn test_posterior_accessors() -> Result<()> { let eq = one_compartment_model(); - let settings = minimal_settings(); - let (theta, weights) = simple_prior(&settings); - - // Past data: dose at t=0, observation at t=6 - let past = Subject::builder("patient") - .bolus(0.0, 500.0, 0) - .observation(6.0, 5.0, 0) - .build(); - - let posterior = - BestDosePosterior::compute(&theta, &weights, Some(past), eq.clone(), settings.clone())?; - - // Target: optimizable dose at t=0 (relative), target conc at t=1 (relative) - // Short observation window so residual from past dose matters - let target = Subject::builder("patient") - .bolus(0.0, 0.0, 0) - .observation(1.0, 5.0, 0) // target: 5 mg/L at 1h after the future dose - .build(); + let config = minimal_config(); + let (theta, weights) = simple_prior(&config)?; - // gap=0: target dose at t=6 absolute (right after past), obs at t=7 - // Past dose (500mg at t=0): C(7) = 500/50 * e^(-0.3*7) ≈ 1.22 mg/L residual - let result_gap0 = posterior.optimize( - target.clone(), - Some(0.0), - DoseRange::new(10.0, 1000.0), - 0.5, - Target::Concentration, - )?; + let posterior = BestDosePosterior::compute(&theta, &weights, None, eq, config)?; - // gap=12: target dose at t=18 absolute, obs at t=19 - // Past dose (500mg at t=0): C(19) = 500/50 * e^(-0.3*19) ≈ 0.003 mg/L (negligible) - let result_gap12 = posterior.optimize( - target, - Some(12.0), - DoseRange::new(10.0, 1000.0), - 0.5, - Target::Concentration, - )?; + assert!(posterior.n_support_points() > 0); + assert_eq!( + posterior.theta().matrix().nrows(), + posterior.n_support_points() + ); - let doses_gap0 = result_gap0.doses(); - let doses_gap12 = result_gap12.doses(); + let posterior_sum: f64 = posterior.posterior_weights().iter().sum(); + assert!((posterior_sum - 1.0).abs() < 1e-6); - eprintln!("Gap=0 doses: {:?}", doses_gap0); - eprintln!("Gap=12 doses: {:?}", doses_gap12); - - // With gap=0, there's still significant residual from the past dose (~1.2 mg/L), - // so the optimizer needs less future dose. With gap=12, the past dose is negligible, - // so it needs more future dose. The optimizable doses should differ. - assert!( - (doses_gap0.last().unwrap() - doses_gap12.last().unwrap()).abs() > 1e-3, - "gap=0 and gap=12 must produce different optimizable doses, \ - but got {:.4} vs {:.4}", - doses_gap0.last().unwrap(), - doses_gap12.last().unwrap() - ); + let population_sum: f64 = posterior.population_weights().iter().sum(); + assert!((population_sum - 1.0).abs() < 1e-6); Ok(()) } -/// Test that the first target event lands at last_past_time + gap -/// and subsequent target times are shifted correctly. #[test] -fn test_time_offset_event_placement() -> Result<()> { +fn test_result_accessors_for_two_stage_api() -> Result<()> { let eq = one_compartment_model(); - let settings = minimal_settings(); - let (theta, weights) = simple_prior(&settings); + let config = minimal_config(); + let (theta, weights) = simple_prior(&config)?; - // Past: dose at t=0, observation at t=6 (last event at t=6) - let past = Subject::builder("patient") - .bolus(0.0, 500.0, 0) - .observation(6.0, 5.0, 0) - .build(); - - let posterior = - BestDosePosterior::compute(&theta, &weights, Some(past), eq.clone(), settings.clone())?; - - // Target: dose at t=0, dose at t=12, obs at t=24 (all relative) + let posterior = BestDosePosterior::compute(&theta, &weights, None, eq, config)?; let target = Subject::builder("patient") .bolus(0.0, 0.0, 0) - .bolus(12.0, 0.0, 0) - .observation(24.0, 5.0, 0) + .observation(6.0, 5.0, 0) .build(); - // gap=0: future starts immediately after last past event (t=6) - // effective_offset = 6 + 0 = 6 - let gap = 0.0; let result = posterior.optimize( target, - Some(gap), + None, DoseRange::new(10.0, 500.0), 0.5, Target::Concentration, )?; - // After concatenation we should have: - // past dose at t=0 (fixed 500mg) - // target dose at t=0+6=6 (optimizable) - // target dose at t=12+6=18 (optimizable) - // target obs at t=24+6=30 - - let optimal_subject = result.optimal_subject(); - let mut dose_times = Vec::new(); - let mut obs_times = Vec::new(); - - for occ in optimal_subject.occasions() { - for event in occ.events() { - match event { - Event::Bolus(b) => dose_times.push(b.time()), - Event::Infusion(i) => dose_times.push(i.time()), - Event::Observation(o) => obs_times.push(o.time()), - } - } - } - - eprintln!("Dose times: {:?}", dose_times); - eprintln!("Obs times: {:?}", obs_times); - - // Past dose at t=0 - assert!( - (dose_times[0] - 0.0).abs() < 1e-10, - "First dose (past) should be at t=0, got {}", - dose_times[0] - ); - // First target dose at t = 0 + 6 = 6 - assert!( - (dose_times[1] - 6.0).abs() < 1e-10, - "Second dose should be at t=0+effective_offset=6, got {}", - dose_times[1] - ); - // Second target dose at t = 12 + 6 = 18 - assert!( - (dose_times[2] - 18.0).abs() < 1e-10, - "Third dose should be at t=12+effective_offset=18, got {}", - dose_times[2] - ); - // Observation at t = 24 + 6 = 30 - assert!( - (obs_times[0] - 30.0).abs() < 1e-10, - "Observation should be at t=24+effective_offset=30, got {}", - obs_times[0] + assert_eq!(result.doses().len(), 1); + assert!(result.doses()[0].is_finite()); + assert!(result.objf().is_finite()); + assert!(result.objf() >= 0.0); + assert_eq!( + *result.status(), + pmcore::bestdose::BestDoseStatus::Converged ); + assert!(!result.predictions().predictions().is_empty()); + assert!(result.auc_predictions().is_none()); - // Past dose should remain fixed at 500 - let doses = result.doses(); + let method = result.optimization_method(); assert!( - (doses[0] - 500.0).abs() < 1e-6, - "Past dose should be fixed at 500, got {}", - doses[0] + method == pmcore::bestdose::OptimalMethod::Posterior + || method == pmcore::bestdose::OptimalMethod::Uniform ); Ok(()) } -/// Test that time_offset=None leaves target events unchanged #[test] -fn test_time_offset_none_no_shift() -> Result<()> { +fn test_negative_time_offset_rejected() -> Result<()> { let eq = one_compartment_model(); - let settings = minimal_settings(); - let (theta, weights) = simple_prior(&settings); - - let posterior = - BestDosePosterior::compute(&theta, &weights, None, eq.clone(), settings.clone())?; + let config = minimal_config(); + let (theta, weights) = simple_prior(&config)?; + let posterior = BestDosePosterior::compute(&theta, &weights, None, eq, config)?; let target = Subject::builder("patient") .bolus(0.0, 0.0, 0) - .bolus(12.0, 0.0, 0) - .observation(24.0, 5.0, 0) - .build(); - - let result = posterior.optimize( - target, - None, // No offset - DoseRange::new(10.0, 500.0), - 0.5, - Target::Concentration, - )?; - - let optimal_subject = result.optimal_subject(); - let mut dose_times = Vec::new(); - let mut obs_times = Vec::new(); - - for occ in optimal_subject.occasions() { - for event in occ.events() { - match event { - Event::Bolus(b) => dose_times.push(b.time()), - Event::Infusion(i) => dose_times.push(i.time()), - Event::Observation(o) => obs_times.push(o.time()), - } - } - } - - // Without offset, times should be exactly as specified in target - assert!((dose_times[0] - 0.0).abs() < 1e-10); - assert!((dose_times[1] - 12.0).abs() < 1e-10); - assert!((obs_times[0] - 24.0).abs() < 1e-10); - - Ok(()) -} - -// ═════════════════════════════════════════════════════════════════════════════ -// Tests for multi-target / multi-dose optimization -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test that multiple optimizable doses all get meaningful values -#[test] -fn test_multi_dose_all_optimized() -> Result<()> { - let eq = one_compartment_model(); - let settings = minimal_settings(); - let (theta, weights) = simple_prior(&settings); - - let posterior = - BestDosePosterior::compute(&theta, &weights, None, eq.clone(), settings.clone())?; - - // Two optimizable doses, two target concentrations - let target = Subject::builder("patient") - .bolus(0.0, 0.0, 0) - .bolus(12.0, 0.0, 0) - .observation(6.0, 5.0, 0) // Target 5 mg/L at t=6 - .observation(18.0, 5.0, 0) // Target 5 mg/L at t=18 + .observation(6.0, 5.0, 0) .build(); let result = posterior.optimize( target, - None, + Some(-1.0), DoseRange::new(10.0, 500.0), 0.5, Target::Concentration, - )?; - - let doses = result.doses(); - eprintln!("Multi-dose optimization: {:?}", doses); - - assert_eq!(doses.len(), 2, "Should optimize 2 doses"); - - // Both doses should be meaningful (not collapsed to minimum) - assert!( - doses[0] > 10.0 + 1.0, - "Dose 1 should be above minimum bound, got {}", - doses[0] - ); - assert!( - doses[1] > 10.0 + 1.0, - "Dose 2 should be above minimum bound, got {}", - doses[1] ); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("negative")); + Ok(()) } -/// Test that changing target for dose 2 changes dose 2's result #[test] -fn test_multi_target_second_dose_responds_to_target_change() -> Result<()> { +fn test_time_offset_zero_vs_nonzero_differ() -> Result<()> { let eq = one_compartment_model(); - let settings = minimal_settings(); - let (theta, weights) = simple_prior(&settings); - - let posterior = - BestDosePosterior::compute(&theta, &weights, None, eq.clone(), settings.clone())?; + let config = minimal_config(); + let (theta, weights) = simple_prior(&config)?; - // Scenario A: second target is LOW (2 mg/L) - let target_low = Subject::builder("patient") - .bolus(0.0, 0.0, 0) - .bolus(12.0, 0.0, 0) + let past = Subject::builder("patient") + .bolus(0.0, 500.0, 0) .observation(6.0, 5.0, 0) - .observation(18.0, 2.0, 0) // Low second target .build(); - // Scenario B: second target is HIGH (15 mg/L) - let target_high = Subject::builder("patient") + let posterior = BestDosePosterior::compute(&theta, &weights, Some(past), eq, config)?; + let target = Subject::builder("patient") .bolus(0.0, 0.0, 0) - .bolus(12.0, 0.0, 0) - .observation(6.0, 5.0, 0) - .observation(18.0, 15.0, 0) // High second target + .observation(1.0, 5.0, 0) .build(); - let result_low = posterior.optimize( - target_low, - None, - DoseRange::new(10.0, 1000.0), - 0.5, - Target::Concentration, - )?; - - let result_high = posterior.optimize( - target_high, - None, + let result_gap0 = posterior.optimize( + target.clone(), + Some(0.0), DoseRange::new(10.0, 1000.0), 0.5, Target::Concentration, )?; - let doses_low = result_low.doses(); - let doses_high = result_high.doses(); - - eprintln!("Low second target: doses = {:?}", doses_low); - eprintln!("High second target: doses = {:?}", doses_high); - - // The second dose should be higher when the second target is higher - assert!( - doses_high[1] > doses_low[1], - "Higher second target ({}) should produce higher second dose, \ - but got low={:.2} vs high={:.2}", - 15.0, - doses_low[1], - doses_high[1] - ); - - Ok(()) -} - -// ═════════════════════════════════════════════════════════════════════════════ -// Tests for BestDosePosterior and BestDoseResult API surface -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test BestDosePosterior accessor methods -#[test] -fn test_posterior_accessors() -> Result<()> { - let eq = one_compartment_model(); - let settings = minimal_settings(); - let (theta, weights) = simple_prior(&settings); - - let posterior = - BestDosePosterior::compute(&theta, &weights, None, eq.clone(), settings.clone())?; - - // n_support_points should match the prior (no filtering with 0 cycles and no data) - assert!( - posterior.n_support_points() > 0, - "Posterior should have at least 1 support point" - ); - - // theta() should return a valid Theta with the correct number of rows - assert_eq!( - posterior.theta().matrix().nrows(), - posterior.n_support_points() - ); - - // posterior_weights() should sum to ~1 - let weight_sum: f64 = posterior.posterior_weights().iter().sum(); - assert!( - (weight_sum - 1.0).abs() < 1e-6, - "Posterior weights should sum to 1.0, got {}", - weight_sum - ); - - // population_weights() should also sum to ~1 - let pop_weight_sum: f64 = posterior.population_weights().iter().sum(); - assert!( - (pop_weight_sum - 1.0).abs() < 1e-6, - "Population weights should sum to 1.0, got {}", - pop_weight_sum - ); - - Ok(()) -} - -/// Test BestDoseResult accessor methods -#[test] -fn test_result_accessors() -> Result<()> { - let eq = one_compartment_model(); - let settings = minimal_settings(); - let (theta, weights) = simple_prior(&settings); - - let posterior = - BestDosePosterior::compute(&theta, &weights, None, eq.clone(), settings.clone())?; - - let target = Subject::builder("patient") - .bolus(0.0, 0.0, 0) - .observation(6.0, 5.0, 0) - .build(); - - let result = posterior.optimize( + let result_gap12 = posterior.optimize( target, - None, - DoseRange::new(10.0, 500.0), + Some(12.0), + DoseRange::new(10.0, 1000.0), 0.5, Target::Concentration, )?; - // doses() should return 1 dose - assert_eq!(result.doses().len(), 1); - assert!(result.doses()[0].is_finite()); - - // objf() should be finite and non-negative - assert!(result.objf().is_finite()); - assert!(result.objf() >= 0.0, "Cost should be non-negative"); - - // status() should be Converged (1000 iterations is usually enough for 1D) - assert_eq!( - *result.status(), - pmcore::bestdose::BestDoseStatus::Converged - ); - - // predictions() should have predictions - assert!( - !result.predictions().predictions().is_empty(), - "Predictions should not be empty" - ); - - // optimization_method() should be Posterior or Uniform - let method = result.optimization_method(); - assert!( - method == pmcore::bestdose::OptimalMethod::Posterior - || method == pmcore::bestdose::OptimalMethod::Uniform - ); - - // auc_predictions() should be None for concentration targets - assert!( - result.auc_predictions().is_none(), - "AUC predictions should be None for concentration targets" - ); - - // optimal_subject() should have the optimized dose - let optimal_subj = result.optimal_subject(); - let mut found_dose = false; - for occ in optimal_subj.occasions() { - for event in occ.events() { - if let Event::Bolus(b) = event { - assert!(b.amount() > 0.0, "Optimized dose should be > 0"); - found_dose = true; - } - } - } - assert!( - found_dose, - "Should find at least one dose in optimal subject" - ); - - Ok(()) -} - -/// Test that negative time_offset is rejected -#[test] -fn test_negative_time_offset_rejected() -> Result<()> { - let eq = one_compartment_model(); - let settings = minimal_settings(); - let (theta, weights) = simple_prior(&settings); - - let posterior = - BestDosePosterior::compute(&theta, &weights, None, eq.clone(), settings.clone())?; - - let target = Subject::builder("patient") - .bolus(0.0, 0.0, 0) - .observation(6.0, 5.0, 0) - .build(); - - let result = posterior.optimize( - target, - Some(-1.0), // Negative offset should be rejected - DoseRange::new(10.0, 500.0), - 0.5, - Target::Concentration, - ); + let doses_gap0 = result_gap0.doses(); + let doses_gap12 = result_gap12.doses(); - assert!(result.is_err(), "Negative time_offset should be rejected"); - assert!( - result.unwrap_err().to_string().contains("negative"), - "Error message should mention negative time_offset" - ); + assert!((doses_gap0.last().unwrap() - doses_gap12.last().unwrap()).abs() > 1e-3); Ok(()) } -/// Test that posterior can be reused for multiple optimizations -/// This is the key new feature of the two-stage API #[test] fn test_posterior_reuse() -> Result<()> { let eq = one_compartment_model(); - let settings = minimal_settings(); - let (theta, weights) = simple_prior(&settings); - - // Compute posterior once - let posterior = - BestDosePosterior::compute(&theta, &weights, None, eq.clone(), settings.clone())?; + let config = minimal_config(); + let (theta, weights) = simple_prior(&config)?; - // Optimize with different dose ranges + let posterior = BestDosePosterior::compute(&theta, &weights, None, eq, config)?; let target = Subject::builder("patient") .bolus(0.0, 0.0, 0) .observation(6.0, 5.0, 0) @@ -1820,24 +1430,15 @@ fn test_posterior_reuse() -> Result<()> { Target::Concentration, )?; - // Both should succeed assert!(result_narrow.doses()[0].is_finite()); assert!(result_wide.doses()[0].is_finite()); + assert!(result_wide.objf() <= result_narrow.objf() + 1e-6); - // Wide range should allow a potentially better (lower cost) result - assert!( - result_wide.objf() <= result_narrow.objf() + 1e-6, - "Wider dose range should give equal or better cost: wide={:.6} vs narrow={:.6}", - result_wide.objf(), - result_narrow.objf() - ); - - // Optimize with different bias weights let result_personal = posterior.optimize( target.clone(), None, DoseRange::new(10.0, 500.0), - 0.0, // Full personalization + 0.0, Target::Concentration, )?; @@ -1845,11 +1446,10 @@ fn test_posterior_reuse() -> Result<()> { target, None, DoseRange::new(10.0, 500.0), - 1.0, // Full population weighting + 1.0, Target::Concentration, )?; - // Both should succeed assert!(result_personal.doses()[0].is_finite()); assert!(result_population.doses()[0].is_finite()); diff --git a/tests/ipm_tests.rs b/tests/ipm_tests.rs index efcd3e0e2..795042934 100644 --- a/tests/ipm_tests.rs +++ b/tests/ipm_tests.rs @@ -1,6 +1,6 @@ use anyhow::Result; use faer::Mat; -use pmcore::structs::psi::Psi; +use pmcore::estimation::nonparametric::Psi; /// Test the IPM with a simple 2x2 matrix #[test] @@ -19,7 +19,7 @@ fn test_burke_ipm_simple() -> Result<()> { let psi = Psi::from(mat); // Run Burke's IPM - let result = pmcore::routines::estimation::ipm::burke(&psi); + let result = pmcore::estimation::nonparametric::burke(&psi); // Should succeed assert!(result.is_ok()); @@ -49,16 +49,12 @@ fn test_burke_ipm_simple() -> Result<()> { #[test] fn test_burke_ipm_larger() -> Result<()> { // Create a 5x10 psi matrix with random-like values - let mat = Mat::from_fn(5, 10, |i, j| { - // Generate deterministic "random-like" values - let val = ((i * 7 + j * 13) % 100) as f64 / 100.0 + 0.01; - val - }); + let mat = Mat::from_fn(5, 10, |i, j| ((i * 7 + j * 13) % 100) as f64 / 100.0 + 0.01); let psi = Psi::from(mat); // Run Burke's IPM - let result = pmcore::routines::estimation::ipm::burke(&psi); + let result = pmcore::estimation::nonparametric::burke(&psi); assert!(result.is_ok()); @@ -92,7 +88,7 @@ fn test_burke_ipm_uniform() -> Result<()> { let psi = Psi::from(mat); // Run Burke's IPM - let result = pmcore::routines::estimation::ipm::burke(&psi); + let result = pmcore::estimation::nonparametric::burke(&psi); assert!(result.is_ok()); @@ -131,7 +127,7 @@ fn test_burke_ipm_with_negatives() -> Result<()> { let psi = Psi::from(mat); // Run Burke's IPM - should handle negatives by taking absolute value - let result = pmcore::routines::estimation::ipm::burke(&psi); + let result = pmcore::estimation::nonparametric::burke(&psi); assert!(result.is_ok()); @@ -160,7 +156,7 @@ fn test_burke_ipm_with_infinites() { let psi = Psi::from(mat); // Run Burke's IPM - should fail with infinite values - let result = pmcore::routines::estimation::ipm::burke(&psi); + let result = pmcore::estimation::nonparametric::burke(&psi); assert!(result.is_err(), "Should fail with infinite values"); } @@ -177,7 +173,7 @@ fn test_burke_ipm_with_nan() { let psi = Psi::from(mat); // Run Burke's IPM - should fail with NaN values - let result = pmcore::routines::estimation::ipm::burke(&psi); + let result = pmcore::estimation::nonparametric::burke(&psi); assert!(result.is_err(), "Should fail with NaN values"); } @@ -187,15 +183,13 @@ fn test_burke_ipm_with_nan() { fn test_burke_ipm_high_dimensional() -> Result<()> { // Create a larger matrix (20 subjects, 50 support points) let mat = Mat::from_fn(20, 50, |i, j| { - // Generate deterministic values - let val = ((i * 11 + j * 17) % 1000) as f64 / 1000.0 + 0.001; - val + ((i * 11 + j * 17) % 1000) as f64 / 1000.0 + 0.001 }); let psi = Psi::from(mat); // Run Burke's IPM - let result = pmcore::routines::estimation::ipm::burke(&psi); + let result = pmcore::estimation::nonparametric::burke(&psi); assert!(result.is_ok()); diff --git a/tests/onecomp.rs b/tests/onecomp.rs index 58230aca0..85c6a27be 100644 --- a/tests/onecomp.rs +++ b/tests/onecomp.rs @@ -1,6 +1,14 @@ use anyhow::Result; use pmcore::prelude::*; +fn one_compartment_metadata() -> pharmsol::equation::ModelMetadata { + equation::metadata::new("one_compartment") + .parameters(["ke", "v"]) + .states(["central"]) + .outputs(["0"]) + .route(equation::Route::bolus("0").to_state("central")) +} + #[test] fn test_one_compartment_npag() -> Result<()> { // Create a simple one-compartment model @@ -16,24 +24,11 @@ fn test_one_compartment_npag() -> Result<()> { fetch_params!(p, v); y[0] = x[0] / v; }, - ); - - // Define parameters - let params = Parameters::new().add("ke", 0.1, 1.0).add("v", 1.0, 20.0); - - let em = AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0); - let ems = AssayErrorModels::new().add(0, em).unwrap(); - - // Create settings - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems) - .build(); - - settings.set_prior(Prior::sobol(64, 22)); - - settings.set_cycles(100); + ) + .with_nstates(1) + .with_ndrugs(1) + .with_nout(1) + .with_metadata(one_compartment_metadata())?; // Let known support points let spps: Vec<(f64, f64)> = vec![(0.85, 12.0), (0.52, 5.0), (0.15, 3.0)]; @@ -57,14 +52,27 @@ fn test_one_compartment_npag() -> Result<()> { let data = data::Data::new(subjects); - // Run the algorithm - let mut algorithm = dispatch_algorithm(settings, eq, data)?; - let result = algorithm.fit()?; + let parameters = ParameterSpace::::new() + .add("ke", 0.1, 1.0) + .add("v", 1.0, 20.0); + + let prior = Theta::sobol(¶meters, 100)?; + let error_models = AssayErrorModels::new().add( + "0", + AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0), + )?; + let result = EstimationProblem::nonparametric(eq, data, prior, error_models)? + .fit_with(NonParametricAlgorithm::npag())?; // Check the results - assert_eq!(result.cycles(), 32); + assert_eq!(result.cycles(), 31); assert!(result.objf() - 565.7749 < 0.01); + // The prior is preserved on the result and is distinct from the optimized + // solution (which is condensed to far fewer support points). + assert_eq!(result.prior().nspp(), 100); + assert!(result.get_theta().nspp() < result.prior().nspp()); + Ok(()) } @@ -83,24 +91,11 @@ fn test_one_compartment_npod() -> Result<()> { fetch_params!(p, v); y[0] = x[0] / v; }, - ); - - // Define parameters - let params = Parameters::new().add("ke", 0.1, 1.0).add("v", 1.0, 20.0); - - let em = AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0); - let ems = AssayErrorModels::new().add(0, em).unwrap(); - - // Create settings - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPOD) - .set_parameters(params) - .set_error_models(ems) - .build(); - - settings.set_prior(Prior::sobol(64, 22)); - - settings.set_cycles(100); + ) + .with_nstates(1) + .with_ndrugs(1) + .with_nout(1) + .with_metadata(one_compartment_metadata())?; // Let known support points let spps: Vec<(f64, f64)> = vec![(0.85, 12.0), (0.52, 5.0), (0.15, 3.0)]; @@ -124,9 +119,16 @@ fn test_one_compartment_npod() -> Result<()> { let data = data::Data::new(subjects); - // Run the algorithm - let mut algorithm = dispatch_algorithm(settings, eq, data)?; - let result = algorithm.fit()?; + let parameters = ParameterSpace::::new() + .add("ke", 0.1, 1.0) + .add("v", 1.0, 20.0); + let prior = Theta::sobol_default(¶meters)?; + let error_models = AssayErrorModels::new().add( + "0", + AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0), + )?; + let result = EstimationProblem::nonparametric(eq, data, prior, error_models)? + .fit_with(NonParametricAlgorithm::npod())?; // Check the results assert_eq!(result.cycles(), 11); @@ -150,24 +152,11 @@ fn test_one_compartment_postprob() -> Result<()> { fetch_params!(p, v); y[0] = x[0] / v; }, - ); - - // Define parameters - let params = Parameters::new().add("ke", 0.1, 1.0).add("v", 1.0, 20.0); - - let em = AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0); - let ems = AssayErrorModels::new().add(0, em).unwrap(); - - // Create settings - let mut settings = Settings::builder() - .set_algorithm(Algorithm::POSTPROB) - .set_parameters(params) - .set_error_models(ems) - .build(); - - settings.set_prior(Prior::sobol(64, 22)); - - settings.set_cycles(100); + ) + .with_nstates(1) + .with_ndrugs(1) + .with_nout(1) + .with_metadata(one_compartment_metadata())?; // Let known support points let spps: Vec<(f64, f64)> = vec![(0.85, 12.0), (0.52, 5.0), (0.15, 3.0)]; @@ -191,15 +180,26 @@ fn test_one_compartment_postprob() -> Result<()> { let data = data::Data::new(subjects); - // Run the algorithm - let mut algorithm = dispatch_algorithm(settings, eq, data)?; - let result = algorithm.fit()?; + // Generate a prior distribution to test against + let parameters = ParameterSpace::::new() + .add("ke", 0.1, 1.0) + .add("v", 1.0, 20.0); + + let theta = Theta::sobol(¶meters, 100)?; + + let error_models = AssayErrorModels::new().add( + "0", + AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0), + )?; + let result = EstimationProblem::nonparametric(eq, data, theta.clone(), error_models)? + .fit_with(NonParametricAlgorithm::npmap())?; // Check the results assert_eq!(result.cycles(), 0); - // Should be 64 points in theta (no change in points) - assert_eq!(result.get_theta().nspp(), 64); + // Should be 100 points in theta (no change in points) + assert_eq!(result.get_theta().nspp(), theta.nspp()); Ok(()) } + diff --git a/tests/results_summary_tests.rs b/tests/results_summary_tests.rs new file mode 100644 index 000000000..6edcc8a89 --- /dev/null +++ b/tests/results_summary_tests.rs @@ -0,0 +1,61 @@ +use anyhow::Result; +use pharmsol::{AssayErrorModel, ErrorPoly}; +use pmcore::prelude::*; + +fn simple_equation() -> equation::ODE { + equation::ODE::new( + |x, p, _t, dx, b, _rateiv, _cov| { + fetch_params!(p, ke); + dx[0] = -ke * x[0] + b[0]; + }, + |_p, _t, _cov| lag! {}, + |_p, _t, _cov| fa! {}, + |_p, _t, _cov, _x| {}, + |x, p, _t, _cov, y| { + fetch_params!(p, v); + y[0] = x[0] / v; + }, + ) + .with_nstates(1) + .with_ndrugs(1) + .with_nout(1) + .with_metadata( + equation::metadata::new("results_summary") + .parameters(["ke", "v"]) + .states(["central"]) + .outputs(["0"]) + .route(equation::Route::bolus("0").to_state("central")), + ) + .expect("metadata attachment should validate") +} + +fn simple_data() -> Data { + let subject = Subject::builder("1") + .bolus(0.0, 100.0, 0) + .observation(1.0, 10.0, 0) + .observation(2.0, 8.0, 0) + .build(); + + Data::new(vec![subject]) +} + +#[test] +fn test_nonparametric_fit_result_summary_surface() -> Result<()> { + let assay_error = AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0); + let parameters = ParameterSpace::bounded().add("ke", 0.1, 1.0).add("v", 1.0, 20.0); + let prior = Theta::sobol_default(¶meters)?; + let error_models = AssayErrorModels::new().add("0", assay_error)?; + let result = EstimationProblem::nonparametric(simple_equation(), simple_data(), prior, error_models)? + .fit_with(NonParametricAlgorithm::npag())?; + + let summary = result.summary(); + + assert_eq!(summary.parameter_count, 2); + assert_eq!(summary.subject_count, 1); + assert_eq!(summary.observation_count, 2); + assert_eq!(result.population_summary().parameters.len(), 2); + assert_eq!(result.individual_summaries().len(), 1); + + Ok(()) +} + diff --git a/tests/settings_tests.rs b/tests/settings_tests.rs deleted file mode 100644 index bcb848b4a..000000000 --- a/tests/settings_tests.rs +++ /dev/null @@ -1,213 +0,0 @@ -use anyhow::Result; -use pmcore::prelude::*; - -/// Test basic Settings builder construction -#[test] -fn test_settings_builder_basic() -> Result<()> { - let params = Parameters::new().add("ke", 0.1, 1.0).add("v", 1.0, 20.0); - - let em = AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0); - let ems = AssayErrorModels::new().add(0, em)?; - - let settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems) - .build(); - - // Test getters - assert_eq!(settings.config().algorithm, Algorithm::NPAG); - assert_eq!(settings.parameters().names().len(), 2); - - Ok(()) -} - -/// Test Settings serialization to JSON -#[test] -fn test_settings_serialization() -> Result<()> { - let params = Parameters::new().add("ke", 0.1, 1.0).add("v", 5.0, 15.0); - - let em = AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0); - let ems = AssayErrorModels::new().add(0, em)?; - - let settings = Settings::builder() - .set_algorithm(Algorithm::NPOD) - .set_parameters(params) - .set_error_models(ems) - .build(); - - // Serialize to JSON - let json = serde_json::to_string(&settings)?; - - // Should be valid JSON - assert!(json.contains("\"algorithm\"")); - assert!(json.contains("\"parameters\"")); - - // Deserialize back - let deserialized: Settings = serde_json::from_str(&json)?; - assert_eq!(deserialized.config().algorithm, Algorithm::NPOD); - - Ok(()) -} - -/// Test Settings with different algorithms -#[test] -fn test_settings_algorithms() -> Result<()> { - let params = Parameters::new().add("ke", 0.1, 1.0); - let em = AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0); - let ems = AssayErrorModels::new().add(0, em)?; - - // Test NPAG - let settings_npag = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params.clone()) - .set_error_models(ems.clone()) - .build(); - assert_eq!(settings_npag.config().algorithm, Algorithm::NPAG); - - // Test NPOD - let settings_npod = Settings::builder() - .set_algorithm(Algorithm::NPOD) - .set_parameters(params.clone()) - .set_error_models(ems.clone()) - .build(); - assert_eq!(settings_npod.config().algorithm, Algorithm::NPOD); - - Ok(()) -} - -/// Test Settings setters -#[test] -fn test_settings_setters() -> Result<()> { - let params = Parameters::new().add("ke", 0.1, 1.0); - let em = AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0); - let ems = AssayErrorModels::new().add(0, em)?; - - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems) - .build(); - - // Test set_cycles - settings.set_cycles(50); - assert_eq!(settings.config().cycles, 50); - - // Test set_algorithm - settings.set_algorithm(Algorithm::NPOD); - assert_eq!(settings.config().algorithm, Algorithm::NPOD); - - // Test set_idelta - settings.set_idelta(0.5); - assert_eq!(settings.predictions().idelta, 0.5); - - // Test set_tad - settings.set_tad(24.0); - assert_eq!(settings.predictions().tad, 24.0); - - Ok(()) -} - -/// Test Settings with prior -#[test] -fn test_settings_with_prior() -> Result<()> { - let params = Parameters::new().add("ke", 0.1, 1.0); - let em = AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0); - let ems = AssayErrorModels::new().add(0, em)?; - - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems) - .build(); - - // Set Sobol prior - settings.set_prior(Prior::sobol(100, 42)); - - // Verify prior was set using accessor methods - assert_eq!(settings.prior().points(), Some(100)); - assert_eq!(settings.prior().seed(), Some(42)); - - Ok(()) -} - -/// Test Settings with Latin Hypercube prior -#[test] -fn test_settings_latin_prior() -> Result<()> { - let params = Parameters::new().add("ke", 0.1, 1.0).add("v", 5.0, 15.0); - let em = AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0); - let ems = AssayErrorModels::new().add(0, em)?; - - let mut settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems) - .build(); - - // Set Latin Hypercube prior - settings.set_prior(Prior::Latin(50, 123)); - - // Verify prior was set using accessor methods - assert_eq!(settings.prior().points(), Some(50)); - assert_eq!(settings.prior().seed(), Some(123)); - - Ok(()) -} - -/// Test Parameters functionality -#[test] -fn test_parameters() { - let mut params = Parameters::new(); - - // Add parameters - params = params.add("ke", 0.1, 1.0); - params = params.add("v", 5.0, 20.0); - params = params.add("ka", 0.5, 2.0); - - // Check parameter count - assert_eq!(params.names().len(), 3); - - // Check parameter names - let names = params.names(); - assert!(names.contains(&"ke".to_string())); - assert!(names.contains(&"v".to_string())); - assert!(names.contains(&"ka".to_string())); -} - -/// Test ErrorModels construction -#[test] -fn test_error_models() -> Result<()> { - let em1 = AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0); - let em2 = AssayErrorModel::proportional(ErrorPoly::new(0.0, 0.0, 0.15, 0.0), 2.0); - - let mut ems = AssayErrorModels::new(); - ems = ems.add(0, em1)?; - ems = ems.add(1, em2)?; - - // Should have 2 error models - assert_eq!(ems.len(), 2); - - Ok(()) -} - -/// Test Config accessors -#[test] -fn test_config_accessors() -> Result<()> { - let params = Parameters::new().add("ke", 0.1, 1.0); - let em = AssayErrorModel::additive(ErrorPoly::new(0.0, 0.10, 0.0, 0.0), 2.0); - let ems = AssayErrorModels::new().add(0, em)?; - - let settings = Settings::builder() - .set_algorithm(Algorithm::NPAG) - .set_parameters(params) - .set_error_models(ems) - .build(); - - let config = settings.config(); - - // Test default values - assert_eq!(config.algorithm, Algorithm::NPAG); - assert!(config.cycles > 0); - - Ok(()) -} diff --git a/tests/structs_tests.rs b/tests/structs_tests.rs index ba2b894d9..ae308d430 100644 --- a/tests/structs_tests.rs +++ b/tests/structs_tests.rs @@ -1,6 +1,6 @@ use anyhow::Result; use faer::{Col, Mat}; -use pmcore::structs::{psi::Psi, theta::Theta, weights::Weights}; +use pmcore::estimation::nonparametric::{Psi, Theta, Weights}; use std::io::Cursor; /// Test Psi creation and basic operations