pmcore/algorithms/nonparametric/controller.rs
1//! Step through a fit cycle by cycle, rather than running it all at once.
2//!
3//! [`fit_with`](crate::estimation::EstimationProblem::fit_with) runs to the end and gives back
4//! only the final result. For debugging or third-party monitoring, you can instead drive the fit yourself and inspect the state between cycles.
5//!
6//! - [`FitController`] — hold the fit and advance it one [`step`](FitController::step) at a
7//! time, inspecting state in between.
8//! - [`fit_with_observer`](crate::estimation::EstimationProblem::fit_with_observer) — let it
9//! run, but get a callback after every cycle.
10//!
11//! Both drive the same underlying runner and finish at the same result.
12
13use anyhow::Result;
14use pharmsol::prelude::{data::AssayErrorModels, simulator::Equation};
15
16use crate::algorithms::{NonParametricRunner, Status, StopReason};
17use crate::estimation::nonparametric::{NonParametricResult, Psi, Theta};
18use crate::estimation::{EstimationProblem, NonParametric};
19
20use super::NonParametricAlgorithm;
21
22/// A running fit that you advance one cycle at a time.
23///
24/// It's ready to go as soon as it's built, so the first [`step`](Self::step) runs cycle 1.
25/// The accessors read the current state between steps, and [`finish`](Self::finish) or
26/// [`into_result`](Self::into_result) turn it into a result.
27pub struct FitController<E: Equation + Send + 'static> {
28 runner: Box<dyn NonParametricRunner<E>>,
29}
30
31impl<E: Equation + Send + 'static> FitController<E> {
32 /// Build a controller and initialize it, ready for the first [`step`](Self::step).
33 pub(crate) fn new(
34 algorithm: NonParametricAlgorithm,
35 problem: EstimationProblem<E, NonParametric>,
36 ) -> Result<Self> {
37 let mut runner = algorithm.into_runner(problem)?;
38 runner.initialize()?;
39 Ok(Self { runner })
40 }
41
42 /// Run one cycle and return the new [`Status`].
43 ///
44 /// [`Continue`](Status::Continue) means there's more to do; [`Stop`](Status::Stop) means the
45 /// algorithm is finished and further steps won't change the outcome.
46 pub fn step(&mut self) -> Result<Status> {
47 self.runner.next_cycle()
48 }
49
50 /// Flag the fit as [`Aborted`](StopReason::Aborted) so the result shows an early stop.
51 ///
52 /// This only sets the status; it won't halt a later [`step`](Self::step), which runs a full
53 /// cycle and overwrites it. Call it just before [`into_result`](Self::into_result).
54 pub fn request_stop(&mut self) {
55 self.runner.set_status(Status::Stop(StopReason::Aborted));
56 }
57
58 /// The current cycle number (0 before the first [`step`](Self::step)).
59 pub fn cycle(&self) -> usize {
60 self.runner.cycle()
61 }
62
63 /// The current [`Status`] of the fit.
64 pub fn status(&self) -> &Status {
65 self.runner.status()
66 }
67
68 /// The current support points and weights.
69 pub fn theta(&self) -> &Theta {
70 self.runner.theta()
71 }
72
73 /// The current likelihood matrix.
74 pub fn psi(&self) -> &Psi {
75 self.runner.psi()
76 }
77
78 /// The current log-likelihood.
79 pub fn likelihood(&self) -> f64 {
80 self.runner.likelihood()
81 }
82
83 /// The current negative two log-likelihood (objective function).
84 pub fn n2ll(&self) -> f64 {
85 self.runner.n2ll()
86 }
87
88 /// The current error models (updated when the algorithm optimizes them).
89 pub fn error_models(&self) -> &AssayErrorModels {
90 self.runner.error_models()
91 }
92
93 /// Run the fit to completion and return the result.
94 ///
95 /// Safe to call anytime: if the fit has already stopped, it just returns the result.
96 pub fn finish(mut self) -> Result<NonParametricResult<E>> {
97 while self.status().is_continue() {
98 self.step()?;
99 }
100 self.runner.into_result()
101 }
102
103 /// Turn the current state into a result as-is, without running more cycles.
104 pub fn into_result(self) -> Result<NonParametricResult<E>> {
105 self.runner.into_result()
106 }
107}
108
109/// An observer's verdict after a cycle: keep going, or stop.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum CycleFlow {
112 /// Keep going — the algorithm may still stop on its own.
113 Continue,
114 /// Stop now.
115 Stop,
116}
117
118/// A callback run after every cycle of a fit.
119///
120/// It receives the live [`FitController`] to read the current state, and returns a
121/// [`CycleFlow`] to keep going or bail out. Any `FnMut(&FitController<E>) -> CycleFlow` works,
122/// so you can hand a plain closure to
123/// [`fit_with_observer`](crate::estimation::EstimationProblem::fit_with_observer).
124pub trait FitObserver<E: Equation + Send + 'static> {
125 fn on_cycle(&mut self, controller: &FitController<E>) -> CycleFlow;
126}
127
128impl<E, F> FitObserver<E> for F
129where
130 E: Equation + Send + 'static,
131 F: FnMut(&FitController<E>) -> CycleFlow,
132{
133 fn on_cycle(&mut self, controller: &FitController<E>) -> CycleFlow {
134 self(controller)
135 }
136}
137
138impl<E: Equation + Send + 'static> EstimationProblem<E, NonParametric> {
139 /// Start a fit you drive yourself, one [`step`](FitController::step) at a time.
140 ///
141 /// Takes any non-parametric config (`NpagConfig`, `NpodConfig`, `NpmapConfig`) or a
142 /// [`NonParametricAlgorithm`].
143 ///
144 /// ```no_run
145 /// use pmcore::prelude::*;
146 /// # fn run(problem: EstimationProblem<pmcore::prelude::ODE, NonParametric>) -> Result<()> {
147 /// let mut controller = problem.fit_controller(NpagConfig::new())?;
148 /// while controller.step()?.is_continue() {
149 /// println!("cycle {} | -2LL {:.4}", controller.cycle(), controller.n2ll());
150 /// }
151 /// let result = controller.into_result()?;
152 /// # let _ = result; Ok(())
153 /// # }
154 /// ```
155 pub fn fit_controller(
156 self,
157 algorithm: impl Into<NonParametricAlgorithm>,
158 ) -> Result<FitController<E>> {
159 FitController::new(algorithm.into(), self)
160 }
161
162 /// Run a fit to completion, calling `observer` after every cycle.
163 ///
164 /// The observer sees every cycle, the last one included, and can return
165 /// [`CycleFlow::Stop`] to bail out early. Returns the same result as
166 /// [`fit_with`](Self::fit_with).
167 ///
168 /// ```no_run
169 /// use pmcore::prelude::*;
170 /// # fn run(problem: EstimationProblem<pmcore::prelude::ODE, NonParametric>) -> Result<()> {
171 /// let result = problem.fit_with_observer(NpagConfig::new(), |c: &FitController<_>| {
172 /// println!("cycle {} | -2LL {:.4}", c.cycle(), c.n2ll());
173 /// CycleFlow::Continue
174 /// })?;
175 /// # let _ = result; Ok(())
176 /// # }
177 /// ```
178 pub fn fit_with_observer<O: FitObserver<E>>(
179 self,
180 algorithm: impl Into<NonParametricAlgorithm>,
181 mut observer: O,
182 ) -> Result<NonParametricResult<E>> {
183 let mut controller = self.fit_controller(algorithm)?;
184 loop {
185 let status = controller.step()?;
186 let flow = observer.on_cycle(&controller);
187 // Stop on the algorithm's own criteria, or when the observer asks us to.
188 if status.is_stop() {
189 break;
190 }
191 if flow == CycleFlow::Stop {
192 controller.request_stop();
193 break;
194 }
195 }
196 controller.into_result()
197 }
198}