//! Augmented Neural ODEs implementation //! //! Augmented Neural ODEs increase the expressivity of Neural ODEs by adding //! extra dimensions to the state space. This helps overcome limitations of //! standard Neural ODEs in modeling complex dynamics. //! //! Based on "Augmented Neural ODEs" (Dupont et al., 2019) use crate::prelude::*; use super::{ODEFunc, NeuralODE, NeuralODEConfig, Result, NeuralODEError}; /// Configuration for augmented Neural ODEs #[derive(Debug, Clone)] pub struct AugmentationConfig { /// Number of original dimensions pub original_dim: usize, /// Number of augmented dimensions to add pub augmented_dim: usize, /// Initialization strategy for augmented dimensions pub init_strategy: AugmentationInit, /// Whether to include augmented dimensions in output pub return_augmented: bool, } /// Initialization strategies for augmented dimensions #[derive(Debug, Clone)] pub enum AugmentationInit { /// Initialize to zeros Zeros, /// Initialize to small random values SmallRandom { scale: f32 }, /// Initialize using a linear transformation of the original state Linear { matrix: Tensor }, /// Initialize to constant values Constant { value: f32 }, } impl Default for AugmentationConfig { fn default() -> Self { Self { original_dim: 0, // Must be set explicitly augmented_dim: 1, init_strategy: AugmentationInit::SmallRandom { scale: 0.01 }, return_augmented: false, } } } /// Augmented Neural ODE that adds extra dimensions to increase expressivity pub struct AugmentedNeuralODE { /// Underlying Neural ODE operating on augmented state neural_ode: NeuralODE, /// Augmentation configuration config: AugmentationConfig, /// Device for computations device: Device, } impl AugmentedNeuralODE { /// Create a new Augmented Neural ODE /// /// # Arguments /// - `dynamics`: ODE dynamics function for the augmented system /// - `ode_config`: Configuration for the underlying Neural ODE /// - `original_dim`: Number of original state dimensions /// - `augmented_dim`: Number of extra dimensions to add /// - `device`: Device for tensor operations pub fn new( dynamics: Box, ode_config: NeuralODEConfig, original_dim: usize, augmented_dim: usize, device: &Device, ) -> Result { let augmentation_config = AugmentationConfig { original_dim, augmented_dim, init_strategy: AugmentationInit::SmallRandom { scale: 0.01 }, return_augmented: false, }; let neural_ode = NeuralODE::new(dynamics, ode_config, device)?; Ok(Self { neural_ode, config: augmentation_config, device: device.clone(), }) } /// Create with custom augmentation configuration pub fn with_config( dynamics: Box, ode_config: NeuralODEConfig, augmentation_config: AugmentationConfig, device: &Device, ) -> Result { let neural_ode = NeuralODE::new(dynamics, ode_config, device)?; Ok(Self { neural_ode, config: augmentation_config, device: device.clone(), }) } /// Augment the initial state with extra dimensions fn augment_state(&self, y0: &Tensor) -> Result { let batch_size = if y0.ndim() == 2 { Some(y0.shape()[0]) } else { None }; let original_shape = if let Some(bs) = batch_size { [bs, self.config.original_dim] } else { [self.config.original_dim] }; // Verify original state has correct dimensions let expected_shape = if batch_size.is_some() { &original_shape[..] } else { &original_shape[1..] }; if y0.shape() != expected_shape { return Err(NeuralODEError::InvalidInput(format!( "Expected original state shape {:?}, got {:?}", expected_shape, y0.shape() ))); } // Create augmented dimensions let augmented_part = match &self.config.init_strategy { AugmentationInit::Zeros => { if let Some(bs) = batch_size { Tensor::zeros([bs, self.config.augmented_dim], &self.device)? } else { Tensor::zeros([self.config.augmented_dim], &self.device)? } } AugmentationInit::SmallRandom { scale } => { let shape = if let Some(bs) = batch_size { [bs, self.config.augmented_dim] } else { [self.config.augmented_dim] }; Tensor::randn(shape, &self.device)?.mul_scalar(*scale)? } AugmentationInit::Linear { matrix } => { // Apply linear transformation to original state if let Some(_bs) = batch_size { y0.matmul(&matrix.t()?)? } else { matrix.matmul(y0)? } } AugmentationInit::Constant { value } => { let shape = if let Some(bs) = batch_size { [bs, self.config.augmented_dim] } else { [self.config.augmented_dim] }; Tensor::full(shape, *value, &self.device)? } }; // Concatenate original and augmented parts let augmented_state = if batch_size.is_some() { Tensor::cat(&[y0.clone(), augmented_part], 1)? } else { Tensor::cat(&[y0.clone(), augmented_part], 0)? }; Ok(augmented_state) } /// Extract original dimensions from augmented state fn extract_original(&self, augmented_state: &Tensor) -> Result { if augmented_state.ndim() == 1 { // Single trajectory: [total_dim] -> [original_dim] augmented_state.slice(0, 0..self.config.original_dim) } else if augmented_state.ndim() == 2 { if augmented_state.shape()[0] == self.config.original_dim + self.config.augmented_dim { // Single time series: [time_steps, total_dim] -> [time_steps, original_dim] augmented_state.slice(1, 0..self.config.original_dim) } else { // Batch: [batch_size, total_dim] -> [batch_size, original_dim] augmented_state.slice(1, 0..self.config.original_dim) } } else if augmented_state.ndim() == 3 { // Batch time series: [batch_size, time_steps, total_dim] -> [batch_size, time_steps, original_dim] augmented_state.slice(2, 0..self.config.original_dim) } else { Err(NeuralODEError::InvalidInput(format!( "Unsupported tensor dimensionality: {}", augmented_state.ndim() ))) } } /// Forward pass with augmented state pub fn forward(&self, y0: &Tensor, t_span: &[f32]) -> Result { // Augment initial state let augmented_y0 = self.augment_state(y0)?; // Solve ODE in augmented space let augmented_solution = self.neural_ode.forward(&augmented_y0, t_span)?; // Return either full augmented state or just original dimensions if self.config.return_augmented { Ok(augmented_solution) } else { self.extract_original(&augmented_solution) } } /// Forward pass for batch of initial conditions pub fn forward_batch(&self, y0_batch: &Tensor, t_span: &[f32]) -> Result { if y0_batch.ndim() != 2 { return Err(NeuralODEError::InvalidInput( "Batch input must be 2D [batch_size, original_dim]".to_string() )); } // Augment batch let augmented_y0_batch = self.augment_state(y0_batch)?; // Solve in augmented space let augmented_solution = self.neural_ode.forward_batch(&augmented_y0_batch, t_span)?; // Extract original dimensions if self.config.return_augmented { Ok(augmented_solution) } else { self.extract_original(&augmented_solution) } } /// Get all parameters from underlying Neural ODE pub fn parameters(&self) -> Vec { self.neural_ode.parameters() } /// Get configuration pub fn config(&self) -> &AugmentationConfig { &self.config } /// Set whether to return augmented dimensions in output pub fn set_return_augmented(&mut self, return_augmented: bool) { self.config.return_augmented = return_augmented; } /// Get the underlying Neural ODE pub fn inner(&self) -> &NeuralODE { &self.neural_ode } /// Evaluate dynamics in augmented space pub fn evaluate_augmented_dynamics(&self, t: f32, augmented_y: &Tensor) -> Result { self.neural_ode.evaluate_dynamics(t, augmented_y) } /// Compare expressivity with standard Neural ODE /// /// This runs both augmented and standard versions and returns metrics pub fn compare_with_standard( &self, standard_dynamics: Box, y0: &Tensor, t_span: &[f32], ) -> Result { // Run augmented version let augmented_result = self.forward(y0, t_span)?; // Run standard version let standard_config = NeuralODEConfig::default(); // Use same config as augmented let standard_ode = NeuralODE::new(standard_dynamics, standard_config, &self.device)?; let standard_result = standard_ode.forward(y0, t_span)?; // Compute comparison metrics let mse = augmented_result.sub(&standard_result)?.pow_scalar(2.0)?.mean()?; let max_diff = augmented_result.sub(&standard_result)?.abs()?.max()?; let mse_val = mse.to_scalar::()?; let max_diff_val = max_diff.to_scalar::()?; Ok(ExpressionComparison { mse: mse_val, max_absolute_diff: max_diff_val, augmented_trajectory: augmented_result, standard_trajectory: standard_result, }) } } /// Results of comparing augmented vs standard Neural ODE #[derive(Debug)] pub struct ExpressionComparison { /// Mean squared error between trajectories pub mse: f32, /// Maximum absolute difference pub max_absolute_diff: f32, /// Trajectory from augmented Neural ODE pub augmented_trajectory: Tensor, /// Trajectory from standard Neural ODE pub standard_trajectory: Tensor, } /// Wrapper that automatically augments any ODEFunc pub struct AugmentedODEFunc { inner: F, original_dim: usize, augmented_dim: usize, } impl AugmentedODEFunc { pub fn new(inner: F, original_dim: usize, augmented_dim: usize) -> Self { Self { inner, original_dim, augmented_dim, } } } impl ODEFunc for AugmentedODEFunc { fn forward(&self, t: f32, y: &Tensor) -> Result { // Extract original dimensions let y_original = if y.ndim() == 1 { y.slice(0, 0..self.original_dim)? } else { y.slice(1, 0..self.original_dim)? }; // Apply original dynamics to original dimensions let dy_original = self.inner.forward(t, &y_original)?; // For augmented dimensions, we can use various strategies // Here we use a simple approach: augmented dimensions decay towards zero let y_augmented = if y.ndim() == 1 { y.slice(0, self.original_dim..)? } else { y.slice(1, self.original_dim..)? }; let dy_augmented = y_augmented.mul_scalar(-0.1)?; // Slow decay // Combine derivatives let dy_combined = if y.ndim() == 1 { Tensor::cat(&[dy_original, dy_augmented], 0)? } else { Tensor::cat(&[dy_original, dy_augmented], 1)? }; Ok(dy_combined) } fn parameters(&self) -> Vec { self.inner.parameters() } fn is_autonomous(&self) -> bool { self.inner.is_autonomous() } fn name(&self) -> String { format!("Augmented({})", self.inner.name()) } } #[cfg(all(test, feature = "disabled_tests"))] mod tests { use super::*; use crate::neural_ode::ode_func::LinearDynamics; #[test] fn test_augmentation_config() { let config = AugmentationConfig::default(); assert_eq!(config.original_dim, 0); assert_eq!(config.augmented_dim, 1); assert!(!config.return_augmented); match config.init_strategy { AugmentationInit::SmallRandom { scale } => assert_eq!(scale, 0.01), _ => panic!("Expected SmallRandom initialization"), } } #[test] fn test_augmented_neural_ode_creation() { let device = Device::cpu(); let dynamics = LinearDynamics::decay(1.0, 4, device.clone()).unwrap(); // 4D total (2 original + 2 augmented) let ode_config = NeuralODEConfig::default(); let augmented_ode = AugmentedNeuralODE::new( Box::new(dynamics), ode_config, 2, // original_dim 2, // augmented_dim &device, ).unwrap(); assert_eq!(augmented_ode.config.original_dim, 2); assert_eq!(augmented_ode.config.augmented_dim, 2); } #[test] fn test_state_augmentation() { let device = Device::cpu(); let dynamics = LinearDynamics::decay(1.0, 3, device.clone()).unwrap(); // 3D total let ode_config = NeuralODEConfig::default(); let augmented_ode = AugmentedNeuralODE::new( Box::new(dynamics), ode_config, 2, // original_dim 1, // augmented_dim &device, ).unwrap(); let y0 = Tensor::ones([2], &device).unwrap(); let augmented_y0 = augmented_ode.augment_state(&y0).unwrap(); assert_eq!(augmented_y0.shape(), &[3]); // Should be [original_dim + augmented_dim] let aug_slice = augmented_y0.to_vec::().unwrap(); // First two should be original state assert_eq!(aug_slice[0], 1.0); assert_eq!(aug_slice[1], 1.0); // Third should be small random value (approximately 0 for small scale) assert!(aug_slice[2].abs() < 0.1); } #[test] fn test_batch_augmentation() { let device = Device::cpu(); let dynamics = LinearDynamics::decay(1.0, 4, device.clone()).unwrap(); // 4D total let ode_config = NeuralODEConfig::default(); let augmented_ode = AugmentedNeuralODE::new( Box::new(dynamics), ode_config, 2, // original_dim 2, // augmented_dim &device, ).unwrap(); let y0_batch = Tensor::ones([3, 2], &device).unwrap(); // Batch of 3 let augmented_y0_batch = augmented_ode.augment_state(&y0_batch).unwrap(); assert_eq!(augmented_y0_batch.shape(), &[3, 4]); // [batch_size, total_dim] } #[test] fn test_original_extraction() { let device = Device::cpu(); let dynamics = LinearDynamics::decay(1.0, 3, device.clone()).unwrap(); let ode_config = NeuralODEConfig::default(); let augmented_ode = AugmentedNeuralODE::new( Box::new(dynamics), ode_config, 2, // original_dim 1, // augmented_dim &device, ).unwrap(); // Test extraction from time series: [time_steps, total_dim] let augmented_solution = Tensor::randn(&[5, 3], &device).unwrap(); let original_part = augmented_ode.extract_original(&augmented_solution).unwrap(); assert_eq!(original_part.shape(), &[5, 2]); // [time_steps, original_dim] } #[test] fn test_augmented_forward() { let device = Device::cpu(); let dynamics = LinearDynamics::decay(1.0, 3, device.clone()).unwrap(); let ode_config = NeuralODEConfig::default(); let augmented_ode = AugmentedNeuralODE::new( Box::new(dynamics), ode_config, 2, // original_dim 1, // augmented_dim &device, ).unwrap(); let y0 = Tensor::ones([2], &device).unwrap(); let t_span = vec![0.0, 0.5, 1.0]; let result = augmented_ode.forward(&y0, &t_span).unwrap(); // Should return only original dimensions assert_eq!(result.shape(), &[3, 2]); // [time_steps, original_dim] let result_slice = result.to_vec::().unwrap(); // Should start with initial conditions assert!((result_slice[0] - 1.0).abs() < 1e-6); assert!((result_slice[1] - 1.0).abs() < 1e-6); } #[test] fn test_return_augmented_flag() { let device = Device::cpu(); let dynamics = LinearDynamics::decay(1.0, 3, device.clone()).unwrap(); let ode_config = NeuralODEConfig::default(); let mut augmented_ode = AugmentedNeuralODE::new( Box::new(dynamics), ode_config, 2, // original_dim 1, // augmented_dim &device, ).unwrap(); let y0 = Tensor::ones([2], &device).unwrap(); let t_span = vec![0.0, 1.0]; // Test with return_augmented = false (default) let result_original = augmented_ode.forward(&y0, &t_span).unwrap(); assert_eq!(result_original.shape(), &[2, 2]); // Original dimensions only // Test with return_augmented = true augmented_ode.set_return_augmented(true); let result_augmented = augmented_ode.forward(&y0, &t_span).unwrap(); assert_eq!(result_augmented.shape(), &[2, 3]); // All dimensions } #[test] fn test_different_initialization_strategies() { let device = Device::cpu(); // Test zeros initialization let config_zeros = AugmentationConfig { original_dim: 2, augmented_dim: 2, init_strategy: AugmentationInit::Zeros, return_augmented: true, }; let dynamics = LinearDynamics::decay(1.0, 4, device.clone()).unwrap(); let ode_config = NeuralODEConfig::default(); let augmented_ode = AugmentedNeuralODE::with_config( Box::new(dynamics), ode_config, config_zeros, &device, ).unwrap(); let y0 = Tensor::ones([2], &device).unwrap(); let augmented_y0 = augmented_ode.augment_state(&y0).unwrap(); let aug_slice = augmented_y0.to_vec::().unwrap(); assert_eq!(aug_slice[0], 1.0); // Original assert_eq!(aug_slice[1], 1.0); // Original assert_eq!(aug_slice[2], 0.0); // Augmented (zeros) assert_eq!(aug_slice[3], 0.0); // Augmented (zeros) // Test constant initialization let config_const = AugmentationConfig { original_dim: 2, augmented_dim: 1, init_strategy: AugmentationInit::Constant { value: 0.5 }, return_augmented: true, }; let dynamics2 = LinearDynamics::decay(1.0, 3, device.clone()).unwrap(); let augmented_ode2 = AugmentedNeuralODE::with_config( Box::new(dynamics2), NeuralODEConfig::default(), config_const, &device, ).unwrap(); let augmented_y0_2 = augmented_ode2.augment_state(&y0).unwrap(); let aug_slice_2 = augmented_y0_2.to_vec::().unwrap(); assert_eq!(aug_slice_2[2], 0.5); // Should be constant value } #[test] fn test_augmented_ode_func_wrapper() { let device = Device::cpu(); let inner_dynamics = LinearDynamics::decay(1.0, 2, device.clone()).unwrap(); let augmented_dynamics = AugmentedODEFunc::new(inner_dynamics, 2, 1); let y = Tensor::ones([3], &device).unwrap(); // [2 original + 1 augmented] let dy_dt = augmented_dynamics.forward(0.0, &y).unwrap(); assert_eq!(dy_dt.shape(), &[3]); let dy_slice = dy_dt.to_vec::().unwrap(); // Original dimensions should follow inner dynamics: -1 * y assert!((dy_slice[0] + 1.0).abs() < 1e-6); assert!((dy_slice[1] + 1.0).abs() < 1e-6); // Augmented dimension should decay slowly: -0.1 * y assert!((dy_slice[2] + 0.1).abs() < 1e-6); } #[test] fn test_parameters_passthrough() { let device = Device::cpu(); let dynamics = LinearDynamics::decay(1.0, 3, device.clone()).unwrap(); let ode_config = NeuralODEConfig::default(); let augmented_ode = AugmentedNeuralODE::new( Box::new(dynamics), ode_config, 2, 1, &device, ).unwrap(); let params = augmented_ode.parameters(); assert!(!params.is_empty()); // Should have parameters from underlying dynamics } }