Skip to content

Commit ba9ada2

Browse files
authored
feat: Chain fit methods using result (#284)
* Makes it possible to directly chain different algorithms, or the same algorithms with different configurations.
1 parent 3ce9ad8 commit ba9ada2

10 files changed

Lines changed: 818 additions & 12 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,6 @@ lcov.info
4747
Fortran/
4848
paper/
4949
docs/
50-
examples/**/outputs/
50+
examples/**/outputs/
51+
examples/iov_synthetic/
52+
examples/iov_*.rs

examples/chain_algorithms.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//! Algorithm chaining: NPAG(10) → NPOD(5) on a simple 1-compartment IV bolus model.
2+
//!
3+
//! Demonstrates `NonParametricResult::chain()`.
4+
//! Uses inline synthetic data to keep the example self-contained.
5+
6+
use pharmsol::SubjectBuilderExt;
7+
use pmcore::prelude::*;
8+
9+
fn main() -> Result<()> {
10+
let eq = ode! {
11+
name: "chain_example",
12+
params: [ke, v],
13+
states: [central],
14+
outputs: [outeq_1],
15+
routes: [bolus(input_1) -> central],
16+
diffeq: |x, _t, dx| {
17+
dx[central] = -ke * x[central];
18+
},
19+
out: |x, _t, y| {
20+
y[outeq_1] = x[central] / v;
21+
},
22+
};
23+
24+
let subject1 = Subject::builder("1")
25+
.bolus(0.0, 100.0, 1)
26+
.observation(1.0, 8.0, 1)
27+
.observation(2.0, 5.0, 1)
28+
.observation(4.0, 2.0, 1)
29+
.build();
30+
31+
let subject2 = Subject::builder("2")
32+
.bolus(0.0, 80.0, 1)
33+
.observation(1.0, 6.0, 1)
34+
.observation(2.0, 4.0, 1)
35+
.observation(4.0, 1.5, 1)
36+
.build();
37+
38+
let data = Data::new(vec![subject1, subject2]);
39+
40+
let parameters = ParameterSpace::bounded()
41+
.add("ke", 0.001, 3.0)
42+
.add("v", 5.0, 50.0);
43+
let prior = Theta::sobol(&parameters, 100)?;
44+
let error_models = AssayErrorModels::new().add(
45+
"outeq_1",
46+
AssayErrorModel::additive(ErrorPoly::new(0.0, 0.5, 0.0, 0.0), 0.0),
47+
)?;
48+
49+
let result = EstimationProblem::nonparametric(eq, data, prior, error_models)?
50+
.fit_with(NpagConfig::new().max_cycles(10))?
51+
.chain(NpodConfig::new().max_cycles(5))?;
52+
53+
println!(
54+
"Chained NPAG→NPOD: OBJF = {:.2}, {} support points, {} total cycles",
55+
result.objf(),
56+
result.get_theta().nspp(),
57+
result.cycles(),
58+
);
59+
60+
Ok(())
61+
}

src/algorithms/nonparametric/npod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ impl<E: Equation + Send + 'static> NPOD<E> {
9595
equation,
9696
psi: Psi::new(),
9797
prior: theta.clone(),
98-
theta: theta,
98+
theta,
9999
lambda: Weights::default(),
100100
w: Weights::default(),
101101
last_objf: -1e30,

src/estimation/error_models.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ impl ErrorModels {
1919
}
2020
}
2121

22-
impl Into<AssayErrorModels> for ErrorModels {
23-
fn into(self) -> AssayErrorModels {
24-
self.models().clone()
22+
impl From<ErrorModels> for AssayErrorModels {
23+
fn from(val: ErrorModels) -> Self {
24+
val.models().clone()
2525
}
2626
}

src/estimation/nonparametric/result.rs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,36 @@ impl<E: Equation> NonParametricResult<E> {
8989
&self.cyclelog
9090
}
9191

92+
/// Chain this result into a new fit with a different algorithm.
93+
///
94+
/// Uses the optimized theta as the prior for the new run. Keeps the same
95+
/// equation, data, and error models. Consumes `self`.
96+
///
97+
/// # Example
98+
///
99+
/// ```ignore
100+
/// let result = problem
101+
/// .fit_with(NpagConfig::new().max_cycles(100))?
102+
/// .chain(NpodConfig::new().max_cycles(50))?;
103+
/// ```
104+
pub fn chain<A>(self, algorithm: A) -> anyhow::Result<NonParametricResult<E>>
105+
where
106+
A: crate::algorithms::Algorithm<
107+
E,
108+
crate::estimation::NonParametric,
109+
Output = NonParametricResult<E>,
110+
>,
111+
E: crate::model::EquationMetadataSource,
112+
{
113+
crate::estimation::EstimationProblem::nonparametric(
114+
self.equation,
115+
self.data,
116+
self.theta,
117+
self.error_models,
118+
)?
119+
.fit_with(algorithm)
120+
}
121+
92122
pub fn psi(&self) -> &Psi {
93123
&self.psi
94124
}
@@ -262,3 +292,144 @@ impl<E: Equation> NonParametricResult<E> {
262292
Ok(())
263293
}
264294
}
295+
296+
#[cfg(test)]
297+
mod tests {
298+
use super::*;
299+
use crate::algorithms::nonparametric::{NpagConfig, NpodConfig};
300+
use crate::estimation::EstimationProblem;
301+
use crate::model::ParameterSpace;
302+
use pharmsol::equation::metadata;
303+
use pharmsol::prelude::data::{AssayErrorModel, AssayErrorModels};
304+
use pharmsol::{ErrorPoly, SubjectBuilderExt};
305+
306+
fn minimal_ode() -> pharmsol::ODE {
307+
pharmsol::equation::ODE::new(
308+
|x, p, _t, dx, b, _rateiv, _cov| {
309+
let ke = p[0];
310+
dx[0] = -ke * x[0] + b[0];
311+
},
312+
|_p, _t, _cov| pharmsol::lag! {},
313+
|_p, _t, _cov| pharmsol::fa! {},
314+
|_p, _t, _cov, _x| {},
315+
|x, p, _t, _cov, y| {
316+
let v = p[1];
317+
y[0] = x[0] / v;
318+
},
319+
)
320+
.with_nstates(1)
321+
.with_ndrugs(1)
322+
.with_nout(1)
323+
.with_metadata(
324+
metadata::new("chain_test")
325+
.parameters(["ke", "v"])
326+
.states(["central"])
327+
.outputs(["0"])
328+
.route(pharmsol::equation::Route::bolus("0").to_state("central")),
329+
)
330+
.expect("metadata should validate")
331+
}
332+
333+
fn minimal_data() -> pharmsol::Data {
334+
let subject = pharmsol::Subject::builder("1")
335+
.bolus(0.0, 100.0, 0)
336+
.observation(1.0, 10.0, 0)
337+
.observation(2.0, 8.0, 0)
338+
.build();
339+
pharmsol::Data::new(vec![subject])
340+
}
341+
342+
#[test]
343+
fn chain_npag_to_npod_preserves_support_points() {
344+
let ode = minimal_ode();
345+
let data = minimal_data();
346+
let params = ParameterSpace::bounded()
347+
.add("ke", 0.001, 3.0)
348+
.add("v", 25.0, 250.0);
349+
let prior = Theta::sobol_default(&params).unwrap();
350+
let err = AssayErrorModels::new()
351+
.add(
352+
"0",
353+
AssayErrorModel::additive(ErrorPoly::new(0.0, 0.5, 0.0, 0.0), 0.0),
354+
)
355+
.unwrap();
356+
357+
let r1 = EstimationProblem::nonparametric(ode, data, prior, err)
358+
.unwrap()
359+
.fit_with(NpagConfig::new().max_cycles(2))
360+
.unwrap();
361+
362+
let n_spp = r1.get_theta().nspp();
363+
assert!(n_spp > 0, "NPAG should produce support points");
364+
365+
// Chain into NPOD — should complete without error
366+
let r2 = r1.chain(NpodConfig::new().max_cycles(1)).unwrap();
367+
368+
assert!(r2.get_theta().nspp() > 0);
369+
assert_eq!(r2.data().subjects().len(), 1);
370+
assert_eq!(r2.cycles(), 1);
371+
}
372+
373+
#[test]
374+
fn chain_npag_to_npag_maintains_or_improves_objf() {
375+
let ode = minimal_ode();
376+
let data = minimal_data();
377+
let params = ParameterSpace::bounded()
378+
.add("ke", 0.001, 3.0)
379+
.add("v", 25.0, 250.0);
380+
let prior = Theta::sobol_with_seed(&params, 5, 42).unwrap();
381+
let err = AssayErrorModels::new()
382+
.add(
383+
"0",
384+
AssayErrorModel::additive(ErrorPoly::new(0.0, 0.5, 0.0, 0.0), 0.0),
385+
)
386+
.unwrap();
387+
388+
let r1 = EstimationProblem::nonparametric(ode, data, prior, err)
389+
.unwrap()
390+
.fit_with(NpagConfig::new().max_cycles(5))
391+
.unwrap();
392+
393+
let objf1 = r1.objf();
394+
395+
// Chain into another NPAG run
396+
let r2 = r1.chain(NpagConfig::new().max_cycles(3)).unwrap();
397+
398+
// Second run should not regress significantly
399+
assert!(
400+
r2.objf() <= objf1 + 0.5,
401+
"OBJF regressed: {} -> {}",
402+
objf1,
403+
r2.objf()
404+
);
405+
}
406+
407+
#[test]
408+
fn chain_npag_to_npod_with_unconverged_result() {
409+
let ode = minimal_ode();
410+
let data = minimal_data();
411+
let params = ParameterSpace::bounded()
412+
.add("ke", 0.001, 3.0)
413+
.add("v", 25.0, 250.0);
414+
let prior = Theta::sobol_with_seed(&params, 5, 42).unwrap();
415+
let err = AssayErrorModels::new()
416+
.add(
417+
"0",
418+
AssayErrorModel::additive(ErrorPoly::new(0.0, 0.5, 0.0, 0.0), 0.0),
419+
)
420+
.unwrap();
421+
422+
// Run only 1 cycle — will be unconverged
423+
let r1 = EstimationProblem::nonparametric(ode, data, prior, err)
424+
.unwrap()
425+
.fit_with(NpagConfig::new().max_cycles(1))
426+
.unwrap();
427+
428+
// Chaining from unconverged should still work
429+
let r2 = r1.chain(NpodConfig::new().max_cycles(1));
430+
assert!(
431+
r2.is_ok(),
432+
"Chaining from unconverged result should succeed"
433+
);
434+
}
435+
}

src/estimation/nonparametric/theta.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,61 @@ impl Theta {
144144
true
145145
}
146146

147+
/// Create a new Theta with an additional parameter column.
148+
///
149+
/// All existing rows keep their values. The new column is filled with
150+
/// `initial_value` for every row. Returns a new `Theta` — does not
151+
/// mutate in place.
152+
///
153+
/// # Errors
154+
///
155+
/// - `name` already exists in the parameter space
156+
/// - `lower` or `upper` are non-finite
157+
/// - `lower >= upper`
158+
pub fn with_added_parameter(
159+
&self,
160+
name: &str,
161+
lower: f64,
162+
upper: f64,
163+
initial_value: f64,
164+
) -> Result<Theta> {
165+
// Validate uniqueness
166+
if self.parameters().iter().any(|p| p.name.as_str() == name) {
167+
bail!("parameter '{}' already exists in theta", name);
168+
}
169+
170+
// Validate bounds
171+
if !lower.is_finite() || !upper.is_finite() {
172+
bail!(
173+
"bounds must be finite for parameter '{}': [{}, {}]",
174+
name,
175+
lower,
176+
upper
177+
);
178+
}
179+
if lower >= upper {
180+
bail!(
181+
"lower bound ({}) must be strictly less than upper bound ({}) for parameter '{}'",
182+
lower,
183+
upper,
184+
name
185+
);
186+
}
187+
188+
let (nrows, ncols) = (self.matrix().nrows(), self.matrix().ncols());
189+
let new_matrix = faer::Mat::from_fn(nrows, ncols + 1, |r, c| {
190+
if c < ncols {
191+
self.matrix()[(r, c)]
192+
} else {
193+
initial_value
194+
}
195+
});
196+
197+
let new_params = self.parameters().clone().add(name, lower, upper);
198+
199+
Theta::from_parts(new_matrix, new_params)
200+
}
201+
147202
/// Write the matrix to a CSV file
148203
pub fn write(&self, path: &str) {
149204
let mut writer = csv::Writer::from_path(path).unwrap();

0 commit comments

Comments
 (0)