pmcore/bestdose/cost.rs
1//! Cost function calculation for BestDose optimization
2//!
3//! Implements the hybrid cost function that trades off hitting the target on
4//! average against the spread of outcomes across the parameter distribution.
5//! Also enforces dose-range constraints through penalty-based bounds checking.
6//!
7//! # Cost Function
8//!
9//! Everything is computed from a single distribution over parameters — the
10//! support points and their probability weights `w` (see `BestDoseObjective`).
11//! Let `p[i,j]` be the prediction for support point `i` at observation `j`, and
12//! `t[j]` the target.
13//!
14//! ```text
15//! Cost = {
16//! (1-λ) × Variance + λ × Bias², if doses within bounds
17//! 1e12 + violation² × 1e6, if any dose violates bounds
18//! }
19//! ```
20//!
21//! ## Variance term — expected squared error
22//!
23//! ```text
24//! Variance = Σᵢ wᵢ Σⱼ (t[j] - p[i,j])² = E_w[(t - p)²]
25//! ```
26//!
27//! ## Bias term — squared error of the mean prediction
28//!
29//! ```text
30//! Bias² = Σⱼ (t[j] - ȳ[j])², where ȳ[j] = Σᵢ wᵢ p[i,j] (the weighted mean)
31//! ```
32//!
33//! ## Bias weight parameter (λ)
34//!
35//! Using the decomposition `E_w[(t-p)²] = (t - E_w[p])² + Var_w(p)`, the cost
36//! simplifies to:
37//!
38//! ```text
39//! Cost = (t - E_w[p])² + (1-λ) · Var_w(p)
40//! ```
41//!
42//! So λ controls how strongly the *spread* of predicted outcomes across the
43//! distribution is penalized:
44//!
45//! - `λ = 0.0`: minimize the full expected squared error — hit the target on
46//! average **and** keep the prediction spread small (robust across all
47//! plausible parameter values).
48//! - `λ = 1.0`: only the weighted-mean prediction has to hit the target; the
49//! spread is ignored.
50//! - `0 < λ < 1`: interpolates the variance penalty.
51//!
52//! Note: λ is independent of whether `w` is a population distribution or a
53//! patient-specific posterior — that choice is made upstream by the caller.
54//!
55//! # Implementation Notes
56//!
57//! The cost function handles both concentration and AUC targets:
58//! - **Concentration**: Simulates model at observation times directly
59//! - **AUC**: Generates dense time grid and calculates AUC via trapezoidal rule
60//!
61//! See `evaluate` for the main implementation.
62
63use anyhow::Result;
64
65use crate::bestdose::predictions::{
66 calculate_auc_at_times, calculate_dense_times, calculate_interval_auc_per_observation,
67};
68use crate::bestdose::types::{Achievement, BestDoseObjective, Target};
69use pharmsol::prelude::*;
70use pharmsol::Equation;
71use pharmsol::Predictions;
72
73/// Cost together with the per-observation target achievements at a candidate
74/// dose regimen.
75pub(crate) struct Evaluation {
76 pub cost: f64,
77 pub achievements: Vec<Achievement>,
78}
79
80/// Calculate cost function for a candidate dose regimen
81///
82/// This is the core objective function minimized by the Nelder-Mead optimizer.
83///
84/// # Arguments
85///
86/// * `problem` - The [`BestDoseObjective`] containing the model, distribution,
87/// target, and optimization settings
88/// * `candidate_doses` - Dose amounts to evaluate (only for optimizable doses)
89///
90/// # Returns
91///
92/// The cost value `(1-λ) × Variance + λ × Bias²` for the candidate doses.
93/// Lower cost indicates a better match to targets.
94///
95/// # Dose Masking
96///
97/// Only doses with `amount == 0.0` in the target subject are considered optimizable.
98/// Doses with non-zero amounts remain fixed at their specified values.
99///
100/// The `candidate_doses` parameter contains only the optimizable doses, which are
101/// substituted into the target subject before simulation.
102///
103/// # Cost Function Details
104///
105/// Both terms are computed from the single distribution `w` (support points and
106/// their weights). For each support point the model is simulated with the
107/// candidate doses to obtain `p[i,j]`.
108///
109/// - **Variance** (expected squared error): `Σᵢ wᵢ Σⱼ (t[j] - p[i,j])²`
110/// - **Bias²** (error of the weighted mean): `Σⱼ (t[j] - Σᵢ wᵢ p[i,j])²`
111///
112/// `λ` (`problem.bias_weight`) trades between them; equivalently
113/// `Cost = (t - E_w[p])² + (1-λ)·Var_w(p)`, so `λ` sets how much predictive
114/// spread across the distribution is penalized.
115///
116/// ## Target Types
117///
118/// - **Concentration** ([`Target::Concentration`]): predictions are
119/// concentrations at observation times.
120/// - **AUC** ([`Target::AUCFromZero`] / [`Target::AUCFromLastDose`]):
121/// predictions are AUC values computed via the trapezoidal rule on a dense
122/// time grid (controlled by the prediction interval).
123///
124/// # Errors
125///
126/// Returns an error if:
127/// - Model simulation fails
128/// - Prediction length doesn't match observation count
129/// - AUC calculation fails (for AUC targets)
130pub(crate) fn calculate_cost<E: Equation>(
131 problem: &BestDoseObjective<E>,
132 candidate_doses: &[f64],
133) -> Result<f64> {
134 Ok(evaluate(problem, candidate_doses)?.cost)
135}
136
137/// Evaluate a candidate dose regimen, returning both the cost and the expected
138/// achieved value at each target observation.
139pub(crate) fn evaluate<E: Equation>(
140 problem: &BestDoseObjective<E>,
141 candidate_doses: &[f64],
142) -> Result<Evaluation> {
143 // Validate candidate_doses length matches expected optimizable dose count
144 let expected_optimizable = problem
145 .target
146 .occasions()
147 .iter()
148 .flat_map(|occ| occ.events())
149 .filter(|event| match event {
150 Event::Bolus(b) => b.amount() == 0.0,
151 Event::Infusion(inf) => inf.amount() == 0.0,
152 _ => false,
153 })
154 .count();
155
156 if candidate_doses.len() != expected_optimizable {
157 return Err(anyhow::anyhow!(
158 "Dose count mismatch: received {} candidate doses but expected {} optimizable doses",
159 candidate_doses.len(),
160 expected_optimizable
161 ));
162 }
163
164 // Check bounds and return penalty if violated
165 // This constrains the Nelder-Mead optimizer to search within the specified DoseRange
166 let min_dose = problem.doserange.min;
167 let max_dose = problem.doserange.max;
168
169 for &dose in candidate_doses {
170 if dose < min_dose || dose > max_dose {
171 // Return a large penalty cost to push the optimizer back into bounds
172 // The penalty grows quadratically with distance from the nearest bound
173 let violation = if dose < min_dose {
174 min_dose - dose
175 } else {
176 dose - max_dose
177 };
178 return Ok(Evaluation {
179 cost: 1e12 + violation.powi(2) * 1e6,
180 achievements: Vec::new(),
181 });
182 }
183 }
184
185 // Build target subject with candidate doses
186 let mut target_subject = problem.target.clone();
187 let mut optimizable_dose_number = 0; // Index into candidate_doses
188
189 for occasion in target_subject.iter_mut() {
190 for event in occasion.iter_mut() {
191 match event {
192 Event::Bolus(bolus) => {
193 // Only update if this dose is optimizable (amount == 0)
194 if bolus.amount() == 0.0 {
195 bolus.set_amount(candidate_doses[optimizable_dose_number]);
196 optimizable_dose_number += 1;
197 }
198 // If not optimizable (amount > 0), keep original amount
199 }
200 Event::Infusion(infusion) => {
201 // Only update if this dose is optimizable (amount == 0)
202 if infusion.amount() == 0.0 {
203 infusion.set_amount(candidate_doses[optimizable_dose_number]);
204 optimizable_dose_number += 1;
205 }
206 // If not optimizable (amount > 0), keep original amount
207 }
208 Event::Observation(_) => {}
209 }
210 }
211 }
212
213 // Extract target values and observation times
214 let obs_times: Vec<f64> = target_subject
215 .occasions()
216 .iter()
217 .flat_map(|occ| occ.events())
218 .filter_map(|event| match event {
219 Event::Observation(obs) => Some(obs.time()),
220 _ => None,
221 })
222 .collect();
223
224 // Validate that target has observations
225 if obs_times.is_empty() {
226 return Err(anyhow::anyhow!(
227 "Target subject has no observations. At least one observation is required for dose optimization."
228 ));
229 }
230
231 let obs_vec: Vec<f64> = target_subject
232 .occasions()
233 .iter()
234 .flat_map(|occ| occ.events())
235 .filter_map(|event| match event {
236 Event::Observation(obs) => obs.value(),
237 _ => None,
238 })
239 .collect();
240
241 let obs_outeqs: Vec<usize> = target_subject
242 .occasions()
243 .iter()
244 .flat_map(|occ| occ.events())
245 .filter_map(|event| match event {
246 Event::Observation(obs) => Some(obs.outeq_index().unwrap_or(0)),
247 _ => None,
248 })
249 .collect();
250
251 let n_obs = obs_vec.len();
252
253 // Accumulators
254 let mut variance = 0.0_f64; // Expected squared error E[(target - pred)²]
255 let mut y_bar = vec![0.0_f64; n_obs]; // Weighted-mean predictions
256
257 // Both cost terms are computed from the single distribution weights.
258 for (row, prob) in problem
259 .theta
260 .matrix()
261 .row_iter()
262 .zip(problem.weights.iter())
263 {
264 let spp = row.iter().copied().collect::<Vec<f64>>();
265
266 // Get predictions based on target type
267 let preds_i: Vec<f64> = match problem.target_type {
268 Target::Concentration => {
269 // Simulate at observation times only
270 let pred = problem
271 .eq
272 .simulate_subject_dense(&target_subject, &spp, None)?;
273 pred.0
274 .get_predictions()
275 .iter()
276 .map(|p| p.prediction())
277 .collect()
278 }
279 Target::AUCFromZero => {
280 // For AUC: simulate at dense time grid and calculate cumulative AUC
281 let idelta = problem.prediction_interval;
282 let start_time = 0.0; // Future starts at 0
283 let end_time = obs_times.last().copied().unwrap_or(0.0);
284
285 // Generate dense time grid
286 let dense_times = calculate_dense_times(start_time, end_time, &obs_times, idelta);
287
288 // Create temporary subject with dense time points for simulation
289 let subject_id = target_subject.id().to_string();
290 let mut builder = Subject::builder(&subject_id);
291
292 // Add all doses from original subject
293 for occasion in target_subject.occasions() {
294 for event in occasion.events() {
295 match event {
296 Event::Bolus(bolus) => {
297 builder =
298 builder.bolus(bolus.time(), bolus.amount(), bolus.input());
299 }
300 Event::Infusion(infusion) => {
301 builder = builder.infusion(
302 infusion.time(),
303 infusion.amount(),
304 infusion.input(),
305 infusion.duration(),
306 );
307 }
308 Event::Observation(_) => {} // Skip original observations
309 }
310 }
311 }
312
313 // Collect observations with (time, outeq) pairs to preserve original order
314 let obs_time_outeq: Vec<(f64, usize)> = target_subject
315 .occasions()
316 .iter()
317 .flat_map(|occ| occ.events())
318 .filter_map(|event| match event {
319 Event::Observation(obs) => Some(
320 obs.outeq_index()
321 .map(|outeq| (obs.time(), outeq))
322 .ok_or_else(|| {
323 anyhow::anyhow!(
324 "BestDose AUC calculations require numeric observation output labels; got `{}`",
325 obs.outeq()
326 )
327 }),
328 ),
329 _ => None,
330 })
331 .collect::<Result<Vec<_>>>()?;
332
333 let mut unique_outeqs: Vec<usize> =
334 obs_time_outeq.iter().map(|(_, outeq)| *outeq).collect();
335 unique_outeqs.sort();
336 unique_outeqs.dedup();
337
338 // Add observations at dense times (with dummy values for timing only)
339 for outeq in unique_outeqs.iter() {
340 for &t in &dense_times {
341 builder = builder.missing_observation(t, *outeq);
342 }
343 }
344
345 let dense_subject = builder.build();
346
347 // Simulate at dense times
348 let pred = problem
349 .eq
350 .simulate_subject_dense(&dense_subject, &spp, None)?;
351 let dense_predictions_with_outeq = pred.0.get_predictions();
352
353 // Group predictions by outeq using the Prediction struct
354 let mut outeq_predictions: std::collections::HashMap<usize, Vec<f64>> =
355 std::collections::HashMap::new();
356
357 for prediction in dense_predictions_with_outeq {
358 outeq_predictions
359 .entry(prediction.outeq())
360 .or_default()
361 .push(prediction.prediction());
362 }
363
364 // Calculate AUC for each outeq separately
365 let mut outeq_aucs: std::collections::HashMap<usize, Vec<f64>> =
366 std::collections::HashMap::new();
367
368 for &outeq in unique_outeqs.iter() {
369 let outeq_preds = outeq_predictions.get(&outeq).ok_or_else(|| {
370 anyhow::anyhow!("Missing predictions for outeq {}", outeq)
371 })?;
372
373 // Get observation times for this outeq only
374 let outeq_obs_times: Vec<f64> = obs_time_outeq
375 .iter()
376 .filter(|(_, o)| *o == outeq)
377 .map(|(t, _)| *t)
378 .collect();
379
380 // Calculate AUC at observation times for this outeq
381 let aucs = calculate_auc_at_times(&dense_times, outeq_preds, &outeq_obs_times);
382 outeq_aucs.insert(outeq, aucs);
383 }
384
385 // Build final AUC vector in original observation order
386 let mut result_aucs = Vec::with_capacity(obs_time_outeq.len());
387 let mut outeq_counters: std::collections::HashMap<usize, usize> =
388 std::collections::HashMap::new();
389
390 for (_, outeq) in obs_time_outeq.iter() {
391 let aucs = outeq_aucs
392 .get(outeq)
393 .ok_or_else(|| anyhow::anyhow!("Missing AUC for outeq {}", outeq))?;
394
395 let counter = outeq_counters.entry(*outeq).or_insert(0);
396 if *counter < aucs.len() {
397 result_aucs.push(aucs[*counter]);
398 *counter += 1;
399 } else {
400 return Err(anyhow::anyhow!(
401 "AUC index out of bounds for outeq {}",
402 outeq
403 ));
404 }
405 }
406
407 result_aucs
408 }
409 Target::AUCFromLastDose => {
410 // For interval AUC: simulate at dense time grid and calculate AUC from last dose
411 let idelta = problem.prediction_interval;
412 let end_time = obs_times.last().copied().unwrap_or(0.0);
413
414 // Generate dense time grid from 0 to end_time (need full grid for intervals)
415 let dense_times = calculate_dense_times(0.0, end_time, &obs_times, idelta);
416
417 // Create temporary subject with dense time points for simulation
418 let subject_id = target_subject.id().to_string();
419 let mut builder = Subject::builder(&subject_id);
420
421 // Add all doses from original subject
422 for occasion in target_subject.occasions() {
423 for event in occasion.events() {
424 match event {
425 Event::Bolus(bolus) => {
426 builder =
427 builder.bolus(bolus.time(), bolus.amount(), bolus.input());
428 }
429 Event::Infusion(infusion) => {
430 builder = builder.infusion(
431 infusion.time(),
432 infusion.amount(),
433 infusion.input(),
434 infusion.duration(),
435 );
436 }
437 Event::Observation(_) => {} // Skip original observations
438 }
439 }
440 }
441
442 // Collect observations with (time, outeq) pairs to preserve original order
443 let obs_time_outeq: Vec<(f64, usize)> = target_subject
444 .occasions()
445 .iter()
446 .flat_map(|occ| occ.events())
447 .filter_map(|event| match event {
448 Event::Observation(obs) => Some(
449 obs.outeq_index()
450 .map(|outeq| (obs.time(), outeq))
451 .ok_or_else(|| {
452 anyhow::anyhow!(
453 "BestDose AUC calculations require numeric observation output labels; got `{}`",
454 obs.outeq()
455 )
456 }),
457 ),
458 _ => None,
459 })
460 .collect::<Result<Vec<_>>>()?;
461
462 let mut unique_outeqs: Vec<usize> =
463 obs_time_outeq.iter().map(|(_, outeq)| *outeq).collect();
464 unique_outeqs.sort();
465 unique_outeqs.dedup();
466
467 // Add observations at dense times
468 for outeq in unique_outeqs.iter() {
469 for &t in &dense_times {
470 builder = builder.missing_observation(t, *outeq);
471 }
472 }
473
474 let dense_subject = builder.build();
475
476 // Simulate at dense times
477 let pred = problem
478 .eq
479 .simulate_subject_dense(&dense_subject, &spp, None)?;
480 let dense_predictions_with_outeq = pred.0.get_predictions();
481
482 // Group predictions by outeq
483 let mut outeq_predictions: std::collections::HashMap<usize, Vec<f64>> =
484 std::collections::HashMap::new();
485
486 for prediction in dense_predictions_with_outeq {
487 outeq_predictions
488 .entry(prediction.outeq())
489 .or_default()
490 .push(prediction.prediction());
491 }
492
493 // Calculate interval AUC for each outeq separately
494 let mut outeq_aucs: std::collections::HashMap<usize, Vec<f64>> =
495 std::collections::HashMap::new();
496
497 for &outeq in unique_outeqs.iter() {
498 let outeq_preds = outeq_predictions.get(&outeq).ok_or_else(|| {
499 anyhow::anyhow!("Missing predictions for outeq {}", outeq)
500 })?;
501
502 // Get observation times for this outeq only
503 let outeq_obs_times: Vec<f64> = obs_time_outeq
504 .iter()
505 .filter(|(_, o)| *o == outeq)
506 .map(|(t, _)| *t)
507 .collect();
508
509 // Calculate interval AUC at observation times for this outeq
510 let aucs = calculate_interval_auc_per_observation(
511 &target_subject,
512 &dense_times,
513 outeq_preds,
514 &outeq_obs_times,
515 );
516 outeq_aucs.insert(outeq, aucs);
517 }
518
519 // Build final AUC vector in original observation order
520 let mut result_aucs = Vec::with_capacity(obs_time_outeq.len());
521 let mut outeq_counters: std::collections::HashMap<usize, usize> =
522 std::collections::HashMap::new();
523
524 for (_, outeq) in obs_time_outeq.iter() {
525 let aucs = outeq_aucs
526 .get(outeq)
527 .ok_or_else(|| anyhow::anyhow!("Missing AUC for outeq {}", outeq))?;
528
529 let counter = outeq_counters.entry(*outeq).or_insert(0);
530 if *counter < aucs.len() {
531 result_aucs.push(aucs[*counter]);
532 *counter += 1;
533 } else {
534 return Err(anyhow::anyhow!(
535 "AUC index out of bounds for outeq {}",
536 outeq
537 ));
538 }
539 }
540
541 result_aucs
542 }
543 };
544
545 if preds_i.len() != n_obs {
546 return Err(anyhow::anyhow!(
547 "prediction length ({}) != observation length ({})",
548 preds_i.len(),
549 n_obs
550 ));
551 }
552
553 // Calculate variance term: weighted by the distribution probability
554 let mut sumsq_i = 0.0_f64;
555 for (j, &obs_val) in obs_vec.iter().enumerate() {
556 let pj = preds_i[j];
557 let se = (obs_val - pj).powi(2);
558 sumsq_i += se;
559 // Weighted-mean prediction
560 y_bar[j] += prob * pj;
561 }
562
563 variance += prob * sumsq_i; // Weighted by the distribution
564 }
565
566 // Bias term: squared error of the weighted-mean prediction.
567 let mut bias = 0.0_f64;
568 for (j, &obs_val) in obs_vec.iter().enumerate() {
569 bias += (obs_val - y_bar[j]).powi(2);
570 }
571
572 // Cost = (1-λ)×Variance + λ×Bias² ≡ Bias² + (1-λ)·Var_w(pred).
573 // λ sets how much predictive spread across the distribution is penalized:
574 // λ=0 minimizes the full expected squared error; λ=1 only aims the mean.
575 let cost = (1.0 - problem.bias_weight) * variance + problem.bias_weight * bias;
576
577 // Expected achieved value at each observation is the weighted-mean prediction.
578 let achievements = obs_times
579 .iter()
580 .zip(obs_outeqs.iter())
581 .zip(obs_vec.iter())
582 .zip(y_bar.iter())
583 .map(|(((&time, &outeq), &target), &achieved)| Achievement {
584 time,
585 outeq,
586 target,
587 achieved,
588 })
589 .collect();
590
591 Ok(Evaluation { cost, achievements })
592}
593
594#[cfg(test)]
595mod tests {
596 use super::calculate_cost;
597 use crate::bestdose::types::{BestDoseObjective, DoseRange, Target};
598 use crate::estimation::nonparametric::{Theta, Weights};
599 use crate::model::{BoundedParameter, ParameterSpace};
600 use pharmsol::prelude::*;
601
602 fn one_compartment() -> pharmsol::ODE {
603 equation::ODE::new(
604 |x, p, _t, dx, b, _rateiv, _cov| {
605 fetch_params!(p, ke, _v);
606 dx[0] = -ke * x[0] + b[0];
607 },
608 |_p, _, _| lag! {},
609 |_p, _, _| fa! {},
610 |_p, _t, _cov, _x| {},
611 |x, p, _t, _cov, y| {
612 fetch_params!(p, _ke, v);
613 y[0] = x[0] / v;
614 },
615 )
616 }
617
618 fn single_point_theta() -> Theta {
619 let params = ParameterSpace::<BoundedParameter>::new()
620 .add("ke", 0.1, 0.5)
621 .add("v", 40.0, 60.0);
622 let mat = faer::Mat::from_fn(1, 2, |_r, c| if c == 0 { 0.3 } else { 50.0 });
623 Theta::from_parts(mat, params).unwrap()
624 }
625
626 fn problem_with(target: Subject) -> BestDoseObjective<pharmsol::ODE> {
627 BestDoseObjective {
628 target,
629 target_type: Target::Concentration,
630 theta: single_point_theta(),
631 weights: Weights::uniform(1),
632 eq: one_compartment(),
633 doserange: DoseRange::new(10.0, 300.0),
634 bias_weight: 0.5,
635 prediction_interval: 0.12,
636 }
637 }
638
639 #[test]
640 fn dose_count_mismatch_is_rejected() {
641 // Two optimizable doses, so a single candidate dose must be rejected.
642 let target = Subject::builder("test_patient")
643 .bolus(0.0, 0.0, 0)
644 .bolus(6.0, 0.0, 0)
645 .observation(2.0, 5.0, 0)
646 .observation(8.0, 3.0, 0)
647 .build();
648 let problem = problem_with(target);
649
650 let wrong = calculate_cost(&problem, &[100.0]);
651 assert!(wrong.is_err(), "wrong dose count should fail");
652 assert!(wrong.unwrap_err().to_string().contains("mismatch"));
653
654 let correct = calculate_cost(&problem, &[100.0, 150.0]);
655 assert!(correct.is_ok(), "correct dose count should succeed");
656 }
657
658 #[test]
659 fn empty_observations_are_rejected() {
660 let target = Subject::builder("test_patient").bolus(0.0, 0.0, 0).build();
661 let problem = problem_with(target);
662
663 let result = calculate_cost(&problem, &[100.0]);
664 assert!(result.is_err(), "no observations should fail");
665 assert!(result.unwrap_err().to_string().contains("no observations"));
666 }
667}