1use std::path::Path;
2
3use pharmsol::Equation;
4use serde::Serialize;
5
6use crate::algorithms::Status;
7use crate::estimation::nonparametric::{CycleLog, NPPredictions, Posterior, Psi, Theta, Weights};
8
9use pharmsol::{AssayErrorModels, Data};
10
11#[derive(Debug)]
13pub struct NonParametricResult<E: Equation> {
14 equation: E,
15 data: Data,
16 error_models: AssayErrorModels,
17 prior: Theta,
18 theta: Theta,
19 psi: Psi,
20 weights: Weights,
21 objf: f64,
22 cycles: usize,
23 status: Status,
24 cyclelog: CycleLog,
25}
26
27impl<E: Equation> NonParametricResult<E> {
28 #[allow(clippy::too_many_arguments)]
29 pub(crate) fn new(
30 equation: E,
31 data: Data,
32 error_models: AssayErrorModels,
33 prior: Theta,
34 theta: Theta,
35 psi: Psi,
36 weights: Weights,
37 objf: f64,
38 cycles: usize,
39 status: Status,
40 cyclelog: CycleLog,
41 ) -> anyhow::Result<Self> {
42 Ok(Self {
43 equation,
44 data,
45 error_models,
46 prior,
47 theta,
48 psi,
49 weights,
50 objf,
51 cycles,
52 status,
53 cyclelog,
54 })
55 }
56
57 pub fn cycles(&self) -> usize {
58 self.cycles
59 }
60
61 pub fn objf(&self) -> f64 {
62 self.objf
63 }
64
65 pub fn converged(&self) -> bool {
66 self.status.converged()
67 }
68
69 pub fn get_theta(&self) -> &Theta {
70 &self.theta
71 }
72
73 pub fn prior(&self) -> &Theta {
78 &self.prior
79 }
80
81 pub fn data(&self) -> &Data {
82 &self.data
83 }
84
85 pub fn equation(&self) -> &E {
86 &self.equation
87 }
88
89 pub fn cycle_log(&self) -> &CycleLog {
90 &self.cyclelog
91 }
92
93 pub fn chain<A>(self, algorithm: A) -> anyhow::Result<NonParametricResult<E>>
106 where
107 A: crate::algorithms::Algorithm<
108 E,
109 crate::estimation::NonParametric,
110 Output = NonParametricResult<E>,
111 >,
112 E: crate::model::EquationMetadataSource,
113 {
114 crate::estimation::EstimationProblem::nonparametric(
115 self.equation,
116 self.data,
117 self.theta,
118 self.error_models,
119 )?
120 .fit_with(algorithm)
121 }
122
123 pub fn psi(&self) -> &Psi {
124 &self.psi
125 }
126
127 pub fn weights(&self) -> &Weights {
128 &self.weights
129 }
130
131 pub fn error_models(&self) -> &AssayErrorModels {
132 &self.error_models
133 }
134
135 pub fn posterior(&self) -> anyhow::Result<Posterior> {
139 Posterior::calculate(&self.psi, &self.weights)
140 }
141
142 pub fn predictions(&self, idelta: f64, tad: f64) -> anyhow::Result<NPPredictions> {
146 let posterior = self.posterior()?;
147 self.predictions_with(&posterior, idelta, tad)
148 }
149
150 fn predictions_with(
153 &self,
154 posterior: &Posterior,
155 idelta: f64,
156 tad: f64,
157 ) -> anyhow::Result<NPPredictions> {
158 NPPredictions::calculate(
159 &self.equation,
160 &self.data,
161 &self.theta,
162 &self.weights,
163 posterior,
164 idelta,
165 tad,
166 )
167 }
168
169 pub fn write_theta(&self, path: &Path) -> anyhow::Result<()> {
170 use anyhow::bail;
171 use csv::WriterBuilder;
172
173 tracing::debug!("Writing population parameter distribution...");
174
175 let w: Vec<f64> = self.weights.to_vec();
176 if w.len() != self.theta.matrix().nrows() {
177 bail!(
178 "Number of weights ({}) and number of support points ({}) do not match.",
179 w.len(),
180 self.theta.matrix().nrows()
181 );
182 }
183
184 crate::estimation::nonparametric::create_parent_dir(path)?;
185 let file = std::fs::File::create(path)?;
186 let mut writer = WriterBuilder::new().has_headers(true).from_writer(file);
187
188 let mut theta_header = self.theta.param_names().clone();
189 theta_header.push("prob".to_string());
190 writer.write_record(&theta_header)?;
191
192 for (theta_row, &w_val) in self.theta.matrix().row_iter().zip(w.iter()) {
193 let mut row: Vec<String> = theta_row.iter().map(|&val| val.to_string()).collect();
194 row.push(w_val.to_string());
195 writer.write_record(&row)?;
196 }
197 writer.flush()?;
198 Ok(())
199 }
200
201 pub fn write_posterior(&self, path: &Path) -> anyhow::Result<()> {
202 let posterior = self.posterior()?;
203 self.write_posterior_with(path, &posterior)
204 }
205
206 fn write_posterior_with(&self, path: &Path, posterior: &Posterior) -> anyhow::Result<()> {
207 use csv::WriterBuilder;
208
209 tracing::debug!("Writing posterior parameter probabilities...");
210
211 crate::estimation::nonparametric::create_parent_dir(path)?;
212 let file = std::fs::File::create(path)?;
213 let mut writer = WriterBuilder::new().has_headers(true).from_writer(file);
214
215 writer.write_field("id")?;
216 writer.write_field("point")?;
217 self.theta.param_names().iter().for_each(|name| {
218 writer.write_field(name).unwrap();
219 });
220 writer.write_field("prob")?;
221 writer.write_record(None::<&[u8]>)?;
222
223 let subjects = self.data.subjects();
224 posterior
225 .matrix()
226 .row_iter()
227 .enumerate()
228 .for_each(|(i, row)| {
229 let subject = subjects.get(i).unwrap();
230 let id = subject.id();
231
232 row.iter().enumerate().for_each(|(spp, prob)| {
233 writer.write_field(id.clone()).unwrap();
234 writer.write_field(spp.to_string()).unwrap();
235
236 self.theta.matrix().row(spp).iter().for_each(|val| {
237 writer.write_field(val.to_string()).unwrap();
238 });
239
240 writer.write_field(prob.to_string()).unwrap();
241 writer.write_record(None::<&[u8]>).unwrap();
242 });
243 });
244
245 writer.flush()?;
246 Ok(())
247 }
248
249 pub fn write_covariates(&self, path: &Path) -> anyhow::Result<()> {
250 use csv::WriterBuilder;
251 use pharmsol::Event;
252
253 tracing::debug!("Writing covariates...");
254 crate::estimation::nonparametric::create_parent_dir(path)?;
255 let file = std::fs::File::create(path)?;
256 let mut writer = WriterBuilder::new().has_headers(true).from_writer(file);
257
258 let mut covariate_names = std::collections::HashSet::new();
259 for subject in self.data.subjects() {
260 for occasion in subject.occasions() {
261 let covmap = occasion.covariates().covariates();
262 for cov_name in covmap.keys() {
263 covariate_names.insert(cov_name.clone());
264 }
265 }
266 }
267 let mut covariate_names: Vec<String> = covariate_names.into_iter().collect();
268 covariate_names.sort();
269
270 let mut headers = vec!["id", "time", "block"];
271 headers.extend(covariate_names.iter().map(|s| s.as_str()));
272 writer.write_record(&headers)?;
273
274 for subject in self.data.subjects() {
275 for occasion in subject.occasions() {
276 let covmap = occasion.covariates().covariates();
277
278 for event in occasion.iter() {
279 let time = match event {
280 Event::Bolus(bolus) => bolus.time(),
281 Event::Infusion(infusion) => infusion.time(),
282 Event::Observation(observation) => observation.time(),
283 };
284
285 let mut row: Vec<String> = Vec::new();
286 row.push(subject.id().clone());
287 row.push(time.to_string());
288 row.push(occasion.index().to_string());
289
290 for cov_name in &covariate_names {
291 if let Some(cov) = covmap.get(cov_name) {
292 if let Ok(value) = cov.interpolate(time) {
293 row.push(value.to_string());
294 } else {
295 row.push(String::new());
296 }
297 } else {
298 row.push(String::new());
299 }
300 }
301
302 writer.write_record(&row)?;
303 }
304 }
305 }
306
307 writer.flush()?;
308 Ok(())
309 }
310
311 pub fn write_cycles(&self, path: &Path) -> anyhow::Result<()> {
313 self.cyclelog.write(path)
314 }
315
316 pub fn write_predictions(&self, path: &Path, idelta: f64, tad: f64) -> anyhow::Result<()> {
322 let predictions = self.predictions(idelta, tad)?;
323 predictions.write(path)
324 }
325
326 pub fn write_json(&self, path: &Path, idelta: f64, tad: f64) -> anyhow::Result<()> {
333 let posterior = self.posterior()?;
334 let predictions = self.predictions_with(&posterior, idelta, tad)?;
335 self.write_json_with(path, &posterior, &predictions)
336 }
337
338 fn write_json_with(
339 &self,
340 path: &Path,
341 posterior: &Posterior,
342 predictions: &NPPredictions,
343 ) -> anyhow::Result<()> {
344 tracing::debug!("Writing result as JSON...");
345
346 crate::estimation::nonparametric::create_parent_dir(path)?;
347
348 let view = NonParametricResultJson {
349 data: &self.data,
350 error_models: &self.error_models,
351 prior: &self.prior,
352 theta: &self.theta,
353 psi: &self.psi,
354 weights: &self.weights,
355 objf: self.objf,
356 cycles: self.cycles,
357 status: &self.status,
358 cyclelog: &self.cyclelog,
359 posterior,
360 predictions,
361 };
362
363 let file = std::fs::File::create(path)?;
364 serde_json::to_writer_pretty(file, &view)?;
365 Ok(())
366 }
367
368 pub fn write_outputs(
383 &self,
384 directory: impl AsRef<Path>,
385 idelta: f64,
386 tad: f64,
387 ) -> anyhow::Result<()> {
388 let dir = directory.as_ref();
389 std::fs::create_dir_all(dir)?;
390
391 let posterior = self.posterior()?;
393 let predictions = self.predictions_with(&posterior, idelta, tad)?;
394
395 self.write_theta(&dir.join("theta.csv"))?;
396 self.write_posterior_with(&dir.join("posterior.csv"), &posterior)?;
397 predictions.write(&dir.join("pred.csv"))?;
398 self.write_covariates(&dir.join("covs.csv"))?;
399 self.write_cycles(&dir.join("cycles.csv"))?;
400 self.write_json_with(&dir.join("result.json"), &posterior, &predictions)?;
401
402 tracing::info!("Results written to {}", dir.display());
403 Ok(())
404 }
405}
406
407#[derive(Serialize)]
411struct NonParametricResultJson<'a> {
412 data: &'a Data,
413 error_models: &'a AssayErrorModels,
414 prior: &'a Theta,
415 theta: &'a Theta,
416 psi: &'a Psi,
417 weights: &'a Weights,
418 objf: f64,
419 cycles: usize,
420 status: &'a Status,
421 cyclelog: &'a CycleLog,
422 posterior: &'a Posterior,
423 predictions: &'a NPPredictions,
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use crate::algorithms::nonparametric::{NpagConfig, NpodConfig};
430 use crate::estimation::EstimationProblem;
431 use crate::model::ParameterSpace;
432 use pharmsol::equation::metadata;
433 use pharmsol::prelude::data::{AssayErrorModel, AssayErrorModels};
434 use pharmsol::{ErrorPoly, SubjectBuilderExt};
435
436 fn minimal_ode() -> pharmsol::ODE {
437 pharmsol::equation::ODE::new(
438 |x, p, _t, dx, b, _rateiv, _cov| {
439 let ke = p[0];
440 dx[0] = -ke * x[0] + b[0];
441 },
442 |_p, _t, _cov| pharmsol::lag! {},
443 |_p, _t, _cov| pharmsol::fa! {},
444 |_p, _t, _cov, _x| {},
445 |x, p, _t, _cov, y| {
446 let v = p[1];
447 y[0] = x[0] / v;
448 },
449 )
450 .with_nstates(1)
451 .with_ndrugs(1)
452 .with_nout(1)
453 .with_metadata(
454 metadata::new("chain_test")
455 .parameters(["ke", "v"])
456 .states(["central"])
457 .outputs(["0"])
458 .route(pharmsol::equation::Route::bolus("0").to_state("central")),
459 )
460 .expect("metadata should validate")
461 }
462
463 fn minimal_data() -> pharmsol::Data {
464 let subject = pharmsol::Subject::builder("1")
465 .bolus(0.0, 100.0, 0)
466 .observation(1.0, 10.0, 0)
467 .observation(2.0, 8.0, 0)
468 .build();
469 pharmsol::Data::new(vec![subject])
470 }
471
472 #[test]
473 fn chain_npag_to_npod_preserves_support_points() {
474 let ode = minimal_ode();
475 let data = minimal_data();
476 let params = ParameterSpace::bounded()
477 .add("ke", 0.001, 3.0)
478 .add("v", 25.0, 250.0);
479 let prior = Theta::sobol_default(¶ms).unwrap();
480 let err = AssayErrorModels::new()
481 .add(
482 "0",
483 AssayErrorModel::additive(ErrorPoly::new(0.0, 0.5, 0.0, 0.0), 0.0),
484 )
485 .unwrap();
486
487 let r1 = EstimationProblem::nonparametric(ode, data, prior, err)
488 .unwrap()
489 .fit_with(NpagConfig::new().max_cycles(2))
490 .unwrap();
491
492 let n_spp = r1.get_theta().nspp();
493 assert!(n_spp > 0, "NPAG should produce support points");
494
495 let r2 = r1.chain(NpodConfig::new().max_cycles(1)).unwrap();
497
498 assert!(r2.get_theta().nspp() > 0);
499 assert_eq!(r2.data().subjects().len(), 1);
500 assert_eq!(r2.cycles(), 1);
501 }
502
503 #[test]
504 fn chain_npag_to_npag_maintains_or_improves_objf() {
505 let ode = minimal_ode();
506 let data = minimal_data();
507 let params = ParameterSpace::bounded()
508 .add("ke", 0.001, 3.0)
509 .add("v", 25.0, 250.0);
510 let prior = Theta::sobol_with_seed(¶ms, 5, 42).unwrap();
511 let err = AssayErrorModels::new()
512 .add(
513 "0",
514 AssayErrorModel::additive(ErrorPoly::new(0.0, 0.5, 0.0, 0.0), 0.0),
515 )
516 .unwrap();
517
518 let r1 = EstimationProblem::nonparametric(ode, data, prior, err)
519 .unwrap()
520 .fit_with(NpagConfig::new().max_cycles(5))
521 .unwrap();
522
523 let objf1 = r1.objf();
524
525 let r2 = r1.chain(NpagConfig::new().max_cycles(3)).unwrap();
527
528 assert!(
530 r2.objf() <= objf1 + 0.5,
531 "OBJF regressed: {} -> {}",
532 objf1,
533 r2.objf()
534 );
535 }
536
537 #[test]
538 fn chain_npag_to_npod_with_unconverged_result() {
539 let ode = minimal_ode();
540 let data = minimal_data();
541 let params = ParameterSpace::bounded()
542 .add("ke", 0.001, 3.0)
543 .add("v", 25.0, 250.0);
544 let prior = Theta::sobol_with_seed(¶ms, 5, 42).unwrap();
545 let err = AssayErrorModels::new()
546 .add(
547 "0",
548 AssayErrorModel::additive(ErrorPoly::new(0.0, 0.5, 0.0, 0.0), 0.0),
549 )
550 .unwrap();
551
552 let r1 = EstimationProblem::nonparametric(ode, data, prior, err)
554 .unwrap()
555 .fit_with(NpagConfig::new().max_cycles(1))
556 .unwrap();
557
558 let r2 = r1.chain(NpodConfig::new().max_cycles(1));
560 assert!(
561 r2.is_ok(),
562 "Chaining from unconverged result should succeed"
563 );
564 }
565}