@@ -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+ }
0 commit comments