Skip to main content

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}