pmcore/bestdose/
optimization.rs

1//! Stage 2: Dose Optimization
2//!
3//! Implements the dual optimization strategy that compares patient-specific and
4//! population-based approaches to find the best dosing regimen.
5//!
6//! # Dual Optimization Strategy
7//!
8//! The algorithm runs two independent optimizations:
9//!
10//! ## Optimization 1: Posterior Weights (Patient-Specific)
11//!
12//! - Uses refined posterior weights from NPAGFULL11 + NPAGFULL
13//! - Emphasizes parameter values compatible with patient history
14//! - Best when patient has substantial historical data
15//! - Variance term dominates cost function
16//!
17//! ## Optimization 2: Uniform Weights (Population-Based)
18//!
19//! - Treats all posterior support points equally (weight = 1/M)
20//! - Emphasizes population-typical behavior
21//! - More robust when patient history is limited
22//! - Population mean (from prior) influences cost
23//!
24//! ## Selection
25//!
26//! The algorithm compares both results and selects the one with lower cost.
27//! This automatic selection provides robustness across diverse patient scenarios.
28//!
29//! # Optimization Method
30//!
31//! Uses the Nelder-Mead simplex algorithm (derivative-free):
32//! - **Initial simplex**: -20% perturbation from starting doses
33//! - **Max iterations**: 1000
34//! - **Convergence tolerance**: 1e-10 (standard deviation of simplex)
35//!
36//! # See Also
37//!
38//! - [`dual_optimization`]: Main entry point for Stage 2
39//! - [`create_initial_simplex`]: Simplex construction
40//! - [`crate::bestdose::cost::calculate_cost`]: Cost function implementation
41
42use anyhow::Result;
43use argmin::core::{CostFunction, Executor};
44use argmin::solver::neldermead::NelderMead;
45
46use crate::bestdose::cost::calculate_cost;
47use crate::bestdose::predictions::calculate_final_predictions;
48use crate::bestdose::types::{BestDoseProblem, BestDoseResult, BestDoseStatus, OptimalMethod};
49use crate::structs::weights::Weights;
50use pharmsol::prelude::*;
51
52/// Create initial simplex for Nelder-Mead optimization
53///
54/// Constructs a simplex with n+1 vertices in n-dimensional space,
55/// where n is the number of doses to optimize.
56fn create_initial_simplex(initial_point: &[f64]) -> Vec<Vec<f64>> {
57    let n = initial_point.len();
58    let perturbation_percentage = -0.2; // -20% perturbation
59    let mut simplex = Vec::with_capacity(n + 1);
60
61    // First vertex is the initial point
62    simplex.push(initial_point.to_vec());
63
64    // Create n additional vertices by perturbing each dimension
65    for i in 0..n {
66        let mut vertex = initial_point.to_vec();
67        let perturbation = if initial_point[i] == 0.0 {
68            0.00025 // Special case for zero values
69        } else {
70            perturbation_percentage * initial_point[i]
71        };
72        vertex[i] += perturbation;
73        simplex.push(vertex);
74    }
75
76    simplex
77}
78
79/// Implement CostFunction trait for BestDoseProblem
80///
81/// This allows the Nelder-Mead optimizer to evaluate candidate doses.
82impl CostFunction for BestDoseProblem {
83    type Param = Vec<f64>;
84    type Output = f64;
85
86    fn cost(&self, param: &Self::Param) -> Result<Self::Output> {
87        calculate_cost(self, param)
88    }
89}
90
91/// Run single optimization with specified weights
92///
93/// This is a helper for the dual optimization approach.
94///
95/// Only optimizes doses with `amount == 0.0` in the target subject:
96/// - Counts optimizable doses (amount == 0) vs fixed doses (amount > 0)
97/// - Creates a reduced-dimension simplex for optimizable doses only
98/// - Maps optimized doses back to full vector (fixed doses unchanged)
99///
100/// Returns: (optimal_doses, final_cost)
101fn run_single_optimization(
102    problem: &BestDoseProblem,
103    weights: &Weights,
104    method_name: &str,
105) -> Result<(Vec<f64>, f64)> {
106    let min_dose = problem.doserange.min;
107    let max_dose = problem.doserange.max;
108    let target_subject = &problem.target;
109
110    // Get all doses from target subject
111    let all_doses: Vec<f64> = target_subject
112        .iter()
113        .flat_map(|occ| {
114            occ.iter().filter_map(|event| match event {
115                Event::Bolus(bolus) => Some(bolus.amount()),
116                Event::Infusion(infusion) => Some(infusion.amount()),
117                Event::Observation(_) => None,
118            })
119        })
120        .collect();
121
122    // Count optimizable doses (amount == 0)
123    let num_optimizable = all_doses.iter().filter(|&&d| d == 0.0).count();
124    let num_fixed = all_doses.len() - num_optimizable;
125    let num_support_points = problem.theta.matrix().nrows();
126
127    tracing::info!(
128        "  │  {} doses: {} optimizable, {} fixed | {} support points",
129        method_name,
130        num_optimizable,
131        num_fixed,
132        num_support_points
133    );
134
135    // If no doses to optimize, return current doses with zero cost
136    if num_optimizable == 0 {
137        tracing::warn!("  │  ⚠ No doses to optimize (all fixed)");
138        return Ok((all_doses, 0.0));
139    }
140
141    // Create initial simplex for optimizable doses only
142    let initial_guess = (min_dose + max_dose) / 2.0;
143    let initial_point = vec![initial_guess; num_optimizable];
144    let initial_simplex = create_initial_simplex(&initial_point);
145
146    // Create modified problem with the specified weights
147    let mut problem_with_weights = problem.clone();
148    problem_with_weights.posterior = weights.clone();
149
150    // Run Nelder-Mead optimization
151    let solver: NelderMead<Vec<f64>, f64> =
152        NelderMead::new(initial_simplex).with_sd_tolerance(1e-10)?;
153
154    let opt = Executor::new(problem_with_weights, solver)
155        .configure(|state| state.max_iters(1000))
156        .run()?;
157
158    let result = opt.state();
159    let optimized_doses = result.best_param.clone().unwrap();
160    let final_cost = result.best_cost;
161
162    tracing::info!("  │  → Cost: {:.6}", final_cost);
163
164    // Map optimized doses back to full vector
165    // For past/future mode: combine fixed past doses + optimized future doses
166    let mut full_doses = Vec::with_capacity(all_doses.len());
167    let mut opt_idx = 0;
168
169    for &original_dose in all_doses.iter() {
170        if original_dose == 0.0 {
171            // This was a placeholder dose - use optimized value
172            full_doses.push(optimized_doses[opt_idx]);
173            opt_idx += 1;
174        } else {
175            // This was a fixed dose - keep original value
176            full_doses.push(original_dose);
177        }
178    }
179
180    Ok((full_doses, final_cost))
181}
182
183/// Stage 2 & 3: Dual optimization + Final predictions
184///
185/// # Algorithm Flow (Matches Diagram)
186///
187/// ```text
188/// ┌─────────────────────────────────────────────────┐
189/// │ STAGE 2: Dual Optimization                      │
190/// │                                                 │
191/// │  OPTIMIZATION 1: Posterior Weights              │
192/// │    Use NPAGFULL11 posterior probabilities       │
193/// │    → (doses₁, cost₁)                            │
194/// │                                                 │
195/// │  OPTIMIZATION 2: Uniform Weights                │
196/// │    Use equal weights (1/M) for all points       │
197/// │    → (doses₂, cost₂)                            │
198/// │                                                 │
199/// │  SELECTION: Choose min(cost₁, cost₂)            │
200/// │    → (optimal_doses, optimal_cost, method)      │
201/// └────────────┬────────────────────────────────────┘
202///              ↓
203/// ┌─────────────────────────────────────────────────┐
204/// │ STAGE 3: Final Predictions                      │
205/// │                                                 │
206/// │  Calculate predictions with:                    │
207/// │    - Optimal doses from winning optimization    │
208/// │    - Winning weights (posterior or uniform)     │
209/// │                                                 │
210/// │  Return: BestDoseResult                         │
211/// └─────────────────────────────────────────────────┘
212/// ```
213///
214/// This dual optimization ensures robust performance:
215/// - Posterior weights: Best for atypical patients with good data
216/// - Uniform weights: Best for typical patients or limited data
217/// - Automatic selection gives optimal result in both cases
218pub fn dual_optimization(problem: &BestDoseProblem) -> Result<BestDoseResult> {
219    let n_points = problem.theta.matrix().nrows();
220
221    // ═════════════════════════════════════════════════════════════
222    // STAGE 2: Dual Optimization
223    // ═════════════════════════════════════════════════════════════
224    tracing::info!("─────────────────────────────────────────────────────────────");
225    tracing::info!("STAGE 2: Dual Optimization");
226    tracing::info!("─────────────────────────────────────────────────────────────");
227
228    // OPTIMIZATION 1: Posterior weights (patient-specific adaptation)
229    tracing::info!("│");
230    tracing::info!("├─ Optimization 1: Posterior Weights (Patient-Specific)");
231    let (doses1, cost1) = run_single_optimization(problem, &problem.posterior, "Posterior")?;
232
233    // OPTIMIZATION 2: Uniform weights (population robustness)
234    tracing::info!("│");
235    tracing::info!("├─ Optimization 2: Uniform Weights (Population-Based)");
236    let uniform_weights = Weights::uniform(n_points);
237    let (doses2, cost2) = run_single_optimization(problem, &uniform_weights, "Uniform")?;
238
239    // SELECTION: Compare and choose the better result
240    tracing::info!("│");
241    tracing::info!("└─ Selection: Compare Results");
242    tracing::info!("     Posterior cost: {:.6}", cost1);
243    tracing::info!("     Uniform cost:   {:.6}", cost2);
244
245    let (final_doses, final_cost, method, final_weights) = if cost1 <= cost2 {
246        tracing::info!("     → Winner: Posterior (lower cost) ✓");
247        (
248            doses1,
249            cost1,
250            OptimalMethod::Posterior,
251            problem.posterior.clone(),
252        )
253    } else {
254        tracing::info!("     → Winner: Uniform (lower cost) ✓");
255        (doses2, cost2, OptimalMethod::Uniform, uniform_weights)
256    };
257
258    // ═════════════════════════════════════════════════════════════
259    // STAGE 3: Final Predictions
260    // ═════════════════════════════════════════════════════════════
261    tracing::info!("─────────────────────────────────────────────────────────────");
262    tracing::info!("STAGE 3: Final Predictions");
263    tracing::info!("─────────────────────────────────────────────────────────────");
264    tracing::info!(
265        "  Calculating predictions with optimal doses and {} weights",
266        method
267    );
268
269    // Generate target subject with optimal doses
270    let mut optimal_subject = problem.target.clone();
271    let mut dose_number = 0;
272
273    for occasion in optimal_subject.iter_mut() {
274        for event in occasion.iter_mut() {
275            match event {
276                Event::Bolus(bolus) => {
277                    bolus.set_amount(final_doses[dose_number]);
278                    dose_number += 1;
279                }
280                Event::Infusion(infusion) => {
281                    infusion.set_amount(final_doses[dose_number]);
282                    dose_number += 1;
283                }
284                Event::Observation(_) => {}
285            }
286        }
287    }
288
289    let (preds, auc_predictions) =
290        calculate_final_predictions(problem, &final_doses, &final_weights)?;
291
292    tracing::info!("  ✓ Predictions complete");
293    tracing::info!("─────────────────────────────────────────────────────────────");
294
295    Ok(BestDoseResult {
296        optimal_subject,
297        objf: final_cost,
298        status: BestDoseStatus::Converged,
299        preds,
300        auc_predictions,
301        optimization_method: method,
302    })
303}