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}