Files
rustytorch/demos/rtx-hemodynamics/src/training.rs
T
2026-03-04 00:08:42 +00:00

423 lines
12 KiB
Rust

//! Training loop for hemodynamics PINN
//!
//! Implements the training procedure including:
//! - Physics loss (Navier-Stokes residuals)
//! - Data loss (velocity observations)
//! - Boundary condition loss
use crate::boundary::{BoundaryEnforcer, BoundaryLoss};
use crate::config::PinnConfig;
use crate::navier_stokes::NavierStokesResidual;
use crate::network::VesselPinn;
use crate::vessel::VesselSdf;
/// PINN Trainer for hemodynamics
#[derive(Debug)]
pub struct Trainer {
/// Configuration
config: PinnConfig,
/// Vessel geometry
vessel: VesselSdf,
/// Navier-Stokes residual computer
ns: NavierStokesResidual,
/// Boundary enforcer
boundary: BoundaryEnforcer,
/// Training history
history: TrainingHistory,
}
impl Trainer {
/// Creates a new trainer
pub fn new(config: PinnConfig, vessel: VesselSdf) -> Result<Self, String> {
let ns = NavierStokesResidual::new(config.fluid(), true);
let boundary = BoundaryEnforcer::poiseuille_inlet(
0.1, // Inlet velocity
0.0, // Outlet pressure
vessel.clone(),
)?;
Ok(Self {
config,
vessel,
ns,
boundary,
history: TrainingHistory::new(),
})
}
/// Returns the configuration
#[must_use]
pub const fn config(&self) -> &PinnConfig {
&self.config
}
/// Returns the training history
#[must_use]
pub const fn history(&self) -> &TrainingHistory {
&self.history
}
/// Computes total loss for a model
#[must_use]
pub fn compute_loss(&self, model: &VesselPinn, time: f64) -> TotalLoss {
let physics_loss = self.compute_physics_loss(model, time);
let boundary_loss = self.compute_boundary_loss(model, time);
let total = self.config.physics_weight * physics_loss.total
+ self.config.boundary_weight * boundary_loss.total;
TotalLoss {
total,
physics: physics_loss,
boundary: boundary_loss,
}
}
/// Computes physics loss (Navier-Stokes residuals)
fn compute_physics_loss(&self, model: &VesselPinn, time: f64) -> PhysicsLoss {
let collocation = self.vessel.sample_interior(
self.config.num_collocation_points,
42, // Seed for reproducibility
);
let eps = 1e-5;
let mut residuals = Vec::with_capacity(collocation.len());
for point in &collocation {
let (u, v, _p) = model.forward(point.x, point.y, time);
let grads = model.gradients(point.x, point.y, time, eps);
let grads2 = model.second_gradients(point.x, point.y, time, eps);
let (rx, ry, rc) = self.ns.all_residuals(
u,
v,
0.0,
0.0, // Steady state
grads.du_dx,
grads.du_dy,
grads.dv_dx,
grads.dv_dy,
grads2.d2u_dx2,
grads2.d2u_dy2,
grads2.d2v_dx2,
grads2.d2v_dy2,
grads.dp_dx,
grads.dp_dy,
);
residuals.push((rx, ry, rc));
}
let (momentum_x, momentum_y, continuity) = self.ns.loss_components(&residuals);
let total = self.ns.physics_loss(&residuals);
PhysicsLoss {
total,
momentum_x,
momentum_y,
continuity,
}
}
/// Computes boundary condition loss
fn compute_boundary_loss(&self, model: &VesselPinn, time: f64) -> BoundaryLoss {
let (inlet_points, outlet_points, wall_points) = self.boundary.sample_boundary_points(
self.config.num_boundary_points / 4,
self.config.num_boundary_points / 4,
self.config.num_boundary_points / 2,
);
let inlet_preds = model.forward_batch(&inlet_points, time);
let outlet_preds = model.forward_batch(&outlet_points, time);
let wall_preds = model.forward_batch(&wall_points, time);
self.boundary.boundary_loss(
&inlet_points,
&inlet_preds,
&outlet_points,
&outlet_preds,
&wall_points,
&wall_preds,
time,
)
}
/// Records a training step in history
pub fn record_step(&mut self, epoch: usize, loss: &TotalLoss, lr: f64) {
self.history.add_entry(TrainingEntry {
epoch,
total_loss: loss.total,
physics_loss: loss.physics.total,
boundary_loss: loss.boundary.total,
momentum_x_loss: loss.physics.momentum_x,
momentum_y_loss: loss.physics.momentum_y,
continuity_loss: loss.physics.continuity,
learning_rate: lr,
});
}
/// Checks convergence based on loss history
#[must_use]
pub fn is_converged(&self, tolerance: f64, window: usize) -> bool {
self.history.is_converged(tolerance, window)
}
}
/// Total loss combining physics and boundary losses
#[derive(Debug, Clone, Copy)]
pub struct TotalLoss {
/// Total weighted loss
pub total: f64,
/// Physics loss components
pub physics: PhysicsLoss,
/// Boundary loss components
pub boundary: BoundaryLoss,
}
/// Physics loss components
#[derive(Debug, Clone, Copy)]
pub struct PhysicsLoss {
/// Total physics loss
pub total: f64,
/// X-momentum residual loss
pub momentum_x: f64,
/// Y-momentum residual loss
pub momentum_y: f64,
/// Continuity residual loss
pub continuity: f64,
}
/// Training history for monitoring
#[derive(Debug, Clone, Default)]
pub struct TrainingHistory {
/// Training entries
entries: Vec<TrainingEntry>,
}
impl TrainingHistory {
/// Creates empty history
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Adds an entry to history
pub fn add_entry(&mut self, entry: TrainingEntry) {
self.entries.push(entry);
}
/// Returns all entries
#[must_use]
pub fn entries(&self) -> &[TrainingEntry] {
&self.entries
}
/// Returns the latest entry
#[must_use]
pub fn latest(&self) -> Option<&TrainingEntry> {
self.entries.last()
}
/// Returns the best (minimum) total loss
#[must_use]
pub fn best_loss(&self) -> f64 {
self.entries
.iter()
.map(|e| e.total_loss)
.fold(f64::INFINITY, f64::min)
}
/// Checks if loss has converged
#[must_use]
pub fn is_converged(&self, tolerance: f64, window: usize) -> bool {
if self.entries.len() < window {
return false;
}
let recent: Vec<f64> = self
.entries
.iter()
.rev()
.take(window)
.map(|e| e.total_loss)
.collect();
let mean = recent.iter().sum::<f64>() / window as f64;
let variance: f64 = recent.iter().map(|&l| (l - mean).powi(2)).sum::<f64>() / window as f64;
let std_dev = variance.sqrt();
std_dev / mean < tolerance
}
/// Returns loss values for plotting
#[must_use]
pub fn loss_curve(&self) -> Vec<(usize, f64)> {
self.entries
.iter()
.map(|e| (e.epoch, e.total_loss))
.collect()
}
}
/// Single training entry
#[derive(Debug, Clone, Copy)]
pub struct TrainingEntry {
/// Epoch number
pub epoch: usize,
/// Total loss
pub total_loss: f64,
/// Physics loss
pub physics_loss: f64,
/// Boundary loss
pub boundary_loss: f64,
/// X-momentum loss
pub momentum_x_loss: f64,
/// Y-momentum loss
pub momentum_y_loss: f64,
/// Continuity loss
pub continuity_loss: f64,
/// Learning rate
pub learning_rate: f64,
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_trainer() -> Trainer {
let config = PinnConfig::default()
.with_layers(2)
.with_hidden_dim(16)
.with_collocation_points(100)
.with_boundary_points(50);
let vessel = VesselSdf::straight(0.1, 0.005).unwrap();
Trainer::new(config, vessel).unwrap()
}
#[test]
fn test_trainer_creation() {
let trainer = create_test_trainer();
assert_eq!(trainer.config().num_collocation_points, 100);
}
#[test]
fn test_compute_loss() {
let trainer = create_test_trainer();
let model = VesselPinn::new(trainer.config().clone());
let loss = trainer.compute_loss(&model, 0.0);
assert!(loss.total.is_finite());
assert!(loss.total >= 0.0);
assert!(loss.physics.total >= 0.0);
assert!(loss.boundary.total >= 0.0);
}
#[test]
fn test_training_history() {
let mut history = TrainingHistory::new();
history.add_entry(TrainingEntry {
epoch: 0,
total_loss: 1.0,
physics_loss: 0.5,
boundary_loss: 0.5,
momentum_x_loss: 0.2,
momentum_y_loss: 0.2,
continuity_loss: 0.1,
learning_rate: 0.001,
});
history.add_entry(TrainingEntry {
epoch: 1,
total_loss: 0.5,
physics_loss: 0.25,
boundary_loss: 0.25,
momentum_x_loss: 0.1,
momentum_y_loss: 0.1,
continuity_loss: 0.05,
learning_rate: 0.001,
});
assert_eq!(history.entries().len(), 2);
assert!((history.best_loss() - 0.5).abs() < f64::EPSILON);
assert_eq!(history.latest().unwrap().epoch, 1);
}
#[test]
fn test_convergence_check() {
let mut history = TrainingHistory::new();
// Add entries with decreasing loss
for i in 0..10 {
history.add_entry(TrainingEntry {
epoch: i,
total_loss: 1.0 / (i + 1) as f64,
physics_loss: 0.0,
boundary_loss: 0.0,
momentum_x_loss: 0.0,
momentum_y_loss: 0.0,
continuity_loss: 0.0,
learning_rate: 0.001,
});
}
// Should not be converged with high variance
assert!(!history.is_converged(0.01, 5));
// Add stable entries
for i in 10..20 {
history.add_entry(TrainingEntry {
epoch: i,
total_loss: 0.1,
physics_loss: 0.0,
boundary_loss: 0.0,
momentum_x_loss: 0.0,
momentum_y_loss: 0.0,
continuity_loss: 0.0,
learning_rate: 0.001,
});
}
// Should be converged now
assert!(history.is_converged(0.01, 5));
}
#[test]
fn test_loss_curve() {
let mut history = TrainingHistory::new();
for i in 0..5 {
history.add_entry(TrainingEntry {
epoch: i,
total_loss: (5 - i) as f64,
physics_loss: 0.0,
boundary_loss: 0.0,
momentum_x_loss: 0.0,
momentum_y_loss: 0.0,
continuity_loss: 0.0,
learning_rate: 0.001,
});
}
let curve = history.loss_curve();
assert_eq!(curve.len(), 5);
assert_eq!(curve[0], (0, 5.0));
assert_eq!(curve[4], (4, 1.0));
}
#[test]
fn test_record_step() {
let mut trainer = create_test_trainer();
let model = VesselPinn::new(trainer.config().clone());
let loss = trainer.compute_loss(&model, 0.0);
trainer.record_step(0, &loss, 0.001);
assert_eq!(trainer.history().entries().len(), 1);
assert_eq!(trainer.history().latest().unwrap().epoch, 0);
}
}