Files
rustytorch/demos/rtx-bioheat/python/pytorch_bioheat_reference.py
T
2026-03-04 00:08:42 +00:00

724 lines
22 KiB
Python

#!/usr/bin/env python3
"""
PyTorch Reference Implementation for Pennes Bioheat PINN
This script provides a reference implementation of the Physics-Informed Neural Network
for solving the Pennes Bioheat Equation, used to benchmark against RustyTorch++.
Pennes Bioheat Equation:
rho*c * dT/dt = k * laplacian(T) + omega_b*rho_b*c_b*(T_a - T) + Q_m + Q_s
Where:
- T: Temperature field T(x,y,z,t)
- rho, c: Tissue density and specific heat
- k: Thermal conductivity
- omega_b: Blood perfusion rate
- T_a: Arterial blood temperature (37C)
- Q_m: Metabolic heat generation
- Q_s: External heat source (ablation probe)
Usage:
python pytorch_bioheat_reference.py [--device cuda|cpu] [--steps 1000]
"""
import argparse
import time
from dataclasses import dataclass
from typing import Tuple, Optional, Dict, Any
import numpy as np
import torch
import torch.nn as nn
# =============================================================================
# Tissue Properties
# =============================================================================
@dataclass
class TissueProperties:
"""Physical properties for biological tissue."""
name: str
density: float # kg/m^3
specific_heat: float # J/(kg*K)
conductivity: float # W/(m*K)
perfusion: float # 1/s (blood perfusion rate)
metabolic_heat: float # W/m^3
@classmethod
def liver(cls) -> 'TissueProperties':
return cls(
name="Liver",
density=1060.0,
specific_heat=3600.0,
conductivity=0.512,
perfusion=0.0064,
metabolic_heat=420.0
)
@classmethod
def kidney(cls) -> 'TissueProperties':
return cls(
name="Kidney",
density=1050.0,
specific_heat=3890.0,
conductivity=0.544,
perfusion=0.0083,
metabolic_heat=380.0
)
@classmethod
def tumor(cls) -> 'TissueProperties':
return cls(
name="Tumor",
density=1040.0,
specific_heat=3800.0,
conductivity=0.55,
perfusion=0.002, # Lower perfusion = hotter
metabolic_heat=500.0
)
@dataclass
class BloodProperties:
"""Physical properties for blood."""
density: float = 1060.0 # kg/m^3
specific_heat: float = 3770.0 # J/(kg*K)
arterial_temp: float = 37.0 # Celsius
# =============================================================================
# PINN Network Architecture
# =============================================================================
class FourierFeatures(nn.Module):
"""Learnable Fourier Feature encoding for positional encoding."""
def __init__(self, in_features: int, num_features: int, scale: float = 1.0):
super().__init__()
self.num_features = num_features
# Initialize frequency matrix B ~ N(0, scale^2)
self.B = nn.Parameter(torch.randn(in_features, num_features) * scale)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Args:
x: Input coordinates [batch, in_features]
Returns:
Fourier features [batch, 2*num_features] (cos and sin)
"""
# x @ B: [batch, num_features]
proj = 2 * np.pi * (x @ self.B)
return torch.cat([torch.cos(proj), torch.sin(proj)], dim=-1)
class BioheatPINN(nn.Module):
"""
Physics-Informed Neural Network for Pennes Bioheat Equation.
Network architecture:
Input: (x, y, z, t) -> Fourier Features -> MLP -> Temperature T
"""
def __init__(
self,
fourier_features: int = 64,
fourier_scale: float = 2.0,
hidden_layers: list = None,
activation: str = 'tanh'
):
super().__init__()
if hidden_layers is None:
hidden_layers = [128, 128, 128, 64]
# Fourier feature encoding for 4D input (x, y, z, t)
self.fourier = FourierFeatures(4, fourier_features, fourier_scale)
# MLP layers
layers = []
in_dim = 2 * fourier_features # cos + sin features
for hidden_dim in hidden_layers:
layers.append(nn.Linear(in_dim, hidden_dim))
if activation == 'tanh':
layers.append(nn.Tanh())
elif activation == 'swish':
layers.append(nn.SiLU())
elif activation == 'gelu':
layers.append(nn.GELU())
in_dim = hidden_dim
# Output layer (temperature, no activation)
layers.append(nn.Linear(in_dim, 1))
self.mlp = nn.Sequential(*layers)
# Initialize weights using Xavier
self._init_weights()
def _init_weights(self):
for m in self.mlp.modules():
if isinstance(m, nn.Linear):
nn.init.xavier_uniform_(m.weight)
nn.init.zeros_(m.bias)
def forward(self, x: torch.Tensor, y: torch.Tensor,
z: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
"""
Forward pass: predict temperature at given coordinates.
Args:
x, y, z: Spatial coordinates [batch]
t: Time coordinate [batch]
Returns:
Temperature prediction [batch, 1]
"""
coords = torch.stack([x, y, z, t], dim=-1)
features = self.fourier(coords)
return self.mlp(features)
def predict(self, coords: torch.Tensor) -> torch.Tensor:
"""Predict temperature from stacked coordinates [batch, 4]."""
features = self.fourier(coords)
return self.mlp(features)
# =============================================================================
# Physics Loss Computation
# =============================================================================
class PennesBioheatLoss:
"""
Computes physics-informed loss for Pennes Bioheat Equation.
Loss = L_physics + lambda_bc * L_boundary + lambda_ic * L_initial
"""
def __init__(
self,
tissue: TissueProperties,
blood: BloodProperties = None,
probe_power: float = 20.0,
probe_position: Tuple[float, float, float] = (0.0, 0.0, 0.0),
probe_radius: float = 0.005,
domain_bounds: Tuple[float, float, float] = (0.1, 0.1, 0.1),
lambda_bc: float = 1.0,
lambda_ic: float = 1.0,
):
self.tissue = tissue
self.blood = blood or BloodProperties()
self.probe_power = probe_power
self.probe_position = torch.tensor(probe_position)
self.probe_radius = probe_radius
self.domain_bounds = domain_bounds
self.lambda_bc = lambda_bc
self.lambda_ic = lambda_ic
# Precompute constants
self.rho_c = tissue.density * tissue.specific_heat
self.k = tissue.conductivity
self.perfusion_coeff = (
tissue.perfusion * self.blood.density * self.blood.specific_heat
)
self.T_arterial = self.blood.arterial_temp
self.Q_m = tissue.metabolic_heat
def heat_source(self, x: torch.Tensor, y: torch.Tensor,
z: torch.Tensor) -> torch.Tensor:
"""Gaussian heat source from ablation probe."""
dx = x - self.probe_position[0]
dy = y - self.probe_position[1]
dz = z - self.probe_position[2]
r_sq = dx**2 + dy**2 + dz**2
# Gaussian distribution
sigma = self.probe_radius
amplitude = self.probe_power / ((2 * np.pi * sigma**2) ** 1.5)
return amplitude * torch.exp(-r_sq / (2 * sigma**2))
def physics_residual(
self,
model: BioheatPINN,
x: torch.Tensor,
y: torch.Tensor,
z: torch.Tensor,
t: torch.Tensor,
) -> torch.Tensor:
"""
Compute PDE residual: rho*c*dT/dt - k*laplacian(T) - perfusion - Q_m - Q_s
Uses automatic differentiation to compute derivatives.
"""
# Enable gradients for coordinates
x = x.requires_grad_(True)
y = y.requires_grad_(True)
z = z.requires_grad_(True)
t = t.requires_grad_(True)
# Forward pass
T = model(x, y, z, t)
# First derivatives
grad_outputs = torch.ones_like(T)
dT_dx = torch.autograd.grad(T, x, grad_outputs, create_graph=True)[0]
dT_dy = torch.autograd.grad(T, y, grad_outputs, create_graph=True)[0]
dT_dz = torch.autograd.grad(T, z, grad_outputs, create_graph=True)[0]
dT_dt = torch.autograd.grad(T, t, grad_outputs, create_graph=True)[0]
# Second derivatives (Laplacian)
d2T_dx2 = torch.autograd.grad(dT_dx, x, grad_outputs, create_graph=True)[0]
d2T_dy2 = torch.autograd.grad(dT_dy, y, grad_outputs, create_graph=True)[0]
d2T_dz2 = torch.autograd.grad(dT_dz, z, grad_outputs, create_graph=True)[0]
laplacian_T = d2T_dx2 + d2T_dy2 + d2T_dz2
# Heat source
Q_s = self.heat_source(x, y, z)
# PDE residual
# rho*c*dT/dt = k*laplacian(T) + perfusion*(T_a - T) + Q_m + Q_s
time_term = self.rho_c * dT_dt
diffusion_term = self.k * laplacian_T
perfusion_term = self.perfusion_coeff * (self.T_arterial - T.squeeze())
source_term = self.Q_m + Q_s
residual = time_term - diffusion_term - perfusion_term - source_term
return residual
def boundary_loss(
self,
model: BioheatPINN,
x: torch.Tensor,
y: torch.Tensor,
z: torch.Tensor,
t: torch.Tensor,
) -> torch.Tensor:
"""Dirichlet boundary condition: T = T_body at boundaries."""
T = model(x, y, z, t)
T_body = self.T_arterial # Body temperature
return torch.mean((T.squeeze() - T_body) ** 2)
def initial_loss(
self,
model: BioheatPINN,
x: torch.Tensor,
y: torch.Tensor,
z: torch.Tensor,
) -> torch.Tensor:
"""Initial condition: T(x,y,z,0) = T_body everywhere."""
t = torch.zeros_like(x)
T = model(x, y, z, t)
T_body = self.T_arterial
return torch.mean((T.squeeze() - T_body) ** 2)
def compute_loss(
self,
model: BioheatPINN,
collocation_points: Dict[str, torch.Tensor],
boundary_points: Dict[str, torch.Tensor],
initial_points: Dict[str, torch.Tensor],
) -> Tuple[torch.Tensor, Dict[str, float]]:
"""
Compute total PINN loss.
Returns:
total_loss: Combined loss tensor
loss_dict: Dictionary with individual loss components
"""
# Physics loss at collocation points
residual = self.physics_residual(
model,
collocation_points['x'],
collocation_points['y'],
collocation_points['z'],
collocation_points['t'],
)
physics_loss = torch.mean(residual ** 2)
# Boundary loss
bc_loss = self.boundary_loss(
model,
boundary_points['x'],
boundary_points['y'],
boundary_points['z'],
boundary_points['t'],
)
# Initial condition loss
ic_loss = self.initial_loss(
model,
initial_points['x'],
initial_points['y'],
initial_points['z'],
)
# Total loss
total_loss = physics_loss + self.lambda_bc * bc_loss + self.lambda_ic * ic_loss
loss_dict = {
'total': total_loss.item(),
'physics': physics_loss.item(),
'boundary': bc_loss.item(),
'initial': ic_loss.item(),
}
return total_loss, loss_dict
# =============================================================================
# Training Loop
# =============================================================================
class BioheatTrainer:
"""Trainer for Pennes Bioheat PINN."""
def __init__(
self,
model: BioheatPINN,
loss_fn: PennesBioheatLoss,
device: torch.device,
learning_rate: float = 1e-3,
num_collocation: int = 4096,
num_boundary: int = 1024,
num_initial: int = 1024,
domain_bounds: Tuple[float, float, float] = (0.1, 0.1, 0.1),
t_end: float = 600.0,
):
self.model = model.to(device)
self.loss_fn = loss_fn
self.device = device
self.optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
self.num_collocation = num_collocation
self.num_boundary = num_boundary
self.num_initial = num_initial
self.domain_bounds = domain_bounds
self.t_end = t_end
self.loss_history = []
self.step_count = 0
def sample_collocation_points(self) -> Dict[str, torch.Tensor]:
"""Sample random collocation points in domain."""
lx, ly, lz = self.domain_bounds
x = (torch.rand(self.num_collocation) - 0.5) * lx
y = (torch.rand(self.num_collocation) - 0.5) * ly
z = (torch.rand(self.num_collocation) - 0.5) * lz
t = torch.rand(self.num_collocation) * self.t_end
return {
'x': x.to(self.device),
'y': y.to(self.device),
'z': z.to(self.device),
't': t.to(self.device),
}
def sample_boundary_points(self) -> Dict[str, torch.Tensor]:
"""Sample points on domain boundary."""
lx, ly, lz = self.domain_bounds
n = self.num_boundary // 6 # 6 faces
points_x, points_y, points_z = [], [], []
# Sample from each face
for face_val, coord_idx in [
(-lx/2, 0), (lx/2, 0), # x faces
(-ly/2, 1), (ly/2, 1), # y faces
(-lz/2, 2), (lz/2, 2), # z faces
]:
if coord_idx == 0:
x = torch.full((n,), face_val)
y = (torch.rand(n) - 0.5) * ly
z = (torch.rand(n) - 0.5) * lz
elif coord_idx == 1:
x = (torch.rand(n) - 0.5) * lx
y = torch.full((n,), face_val)
z = (torch.rand(n) - 0.5) * lz
else:
x = (torch.rand(n) - 0.5) * lx
y = (torch.rand(n) - 0.5) * ly
z = torch.full((n,), face_val)
points_x.append(x)
points_y.append(y)
points_z.append(z)
x = torch.cat(points_x)
y = torch.cat(points_y)
z = torch.cat(points_z)
t = torch.rand(len(x)) * self.t_end
return {
'x': x.to(self.device),
'y': y.to(self.device),
'z': z.to(self.device),
't': t.to(self.device),
}
def sample_initial_points(self) -> Dict[str, torch.Tensor]:
"""Sample points at t=0 for initial condition."""
lx, ly, lz = self.domain_bounds
x = (torch.rand(self.num_initial) - 0.5) * lx
y = (torch.rand(self.num_initial) - 0.5) * ly
z = (torch.rand(self.num_initial) - 0.5) * lz
return {
'x': x.to(self.device),
'y': y.to(self.device),
'z': z.to(self.device),
}
def train_step(self) -> Dict[str, float]:
"""Execute one training step."""
self.model.train()
self.optimizer.zero_grad()
# Sample points
collocation = self.sample_collocation_points()
boundary = self.sample_boundary_points()
initial = self.sample_initial_points()
# Compute loss
loss, loss_dict = self.loss_fn.compute_loss(
self.model, collocation, boundary, initial
)
# Backprop
loss.backward()
self.optimizer.step()
self.step_count += 1
loss_dict['step'] = self.step_count
self.loss_history.append(loss_dict)
return loss_dict
def train(self, num_steps: int, verbose: bool = True) -> list:
"""Train for multiple steps."""
losses = []
for i in range(num_steps):
loss_dict = self.train_step()
losses.append(loss_dict)
if verbose and (i + 1) % 100 == 0:
print(f"Step {self.step_count}: "
f"total={loss_dict['total']:.4e}, "
f"physics={loss_dict['physics']:.4e}, "
f"boundary={loss_dict['boundary']:.4e}")
return losses
# =============================================================================
# Benchmarking
# =============================================================================
def benchmark_pytorch(
device: str = 'cuda',
num_steps: int = 1000,
fourier_features: int = 64,
hidden_layers: list = None,
num_collocation: int = 4096,
) -> Dict[str, Any]:
"""
Run benchmark for PyTorch Bioheat PINN.
Returns:
Dictionary with timing and performance metrics
"""
if hidden_layers is None:
hidden_layers = [128, 128, 128, 64]
# Setup device
if device == 'cuda' and not torch.cuda.is_available():
print("CUDA not available, falling back to CPU")
device = 'cpu'
device = torch.device(device)
print(f"Running benchmark on: {device}")
# Create model
model = BioheatPINN(
fourier_features=fourier_features,
hidden_layers=hidden_layers,
)
# Count parameters
num_params = sum(p.numel() for p in model.parameters())
print(f"Model parameters: {num_params:,}")
# Create loss function
tissue = TissueProperties.liver()
loss_fn = PennesBioheatLoss(
tissue=tissue,
probe_power=20.0,
probe_position=(0.0, 0.0, 0.0),
)
# Create trainer
trainer = BioheatTrainer(
model=model,
loss_fn=loss_fn,
device=device,
num_collocation=num_collocation,
)
# Warmup
print("Warming up...")
for _ in range(10):
trainer.train_step()
if device.type == 'cuda':
torch.cuda.synchronize()
# Benchmark training
print(f"Benchmarking {num_steps} training steps...")
start_time = time.perf_counter()
trainer.train(num_steps, verbose=True)
if device.type == 'cuda':
torch.cuda.synchronize()
training_time = time.perf_counter() - start_time
steps_per_second = num_steps / training_time
# Benchmark inference
print("Benchmarking inference...")
model.eval()
# Create inference grid (32^3 = 32768 points)
grid_size = 32
x = torch.linspace(-0.05, 0.05, grid_size)
y = torch.linspace(-0.05, 0.05, grid_size)
z = torch.linspace(-0.05, 0.05, grid_size)
xx, yy, zz = torch.meshgrid(x, y, z, indexing='ij')
coords = torch.stack([
xx.flatten(),
yy.flatten(),
zz.flatten(),
torch.full((grid_size**3,), 300.0), # t = 300s
], dim=-1).to(device)
# Warmup inference
with torch.no_grad():
for _ in range(5):
_ = model.predict(coords)
if device.type == 'cuda':
torch.cuda.synchronize()
# Time inference
num_inference_runs = 100
start_time = time.perf_counter()
with torch.no_grad():
for _ in range(num_inference_runs):
_ = model.predict(coords)
if device.type == 'cuda':
torch.cuda.synchronize()
inference_time = time.perf_counter() - start_time
inference_ms = (inference_time / num_inference_runs) * 1000
# Memory usage
if device.type == 'cuda':
peak_memory_mb = torch.cuda.max_memory_allocated() / (1024 ** 2)
else:
peak_memory_mb = 0.0
# Final loss
final_loss = trainer.loss_history[-1]['total']
results = {
'device': str(device),
'num_parameters': num_params,
'num_steps': num_steps,
'training_time_sec': training_time,
'steps_per_second': steps_per_second,
'inference_grid_size': grid_size ** 3,
'inference_mean_ms': inference_ms,
'peak_memory_mb': peak_memory_mb,
'final_loss': final_loss,
}
return results
def print_benchmark_results(results: Dict[str, Any]):
"""Pretty print benchmark results."""
print("\n" + "=" * 60)
print("PyTorch Bioheat PINN Benchmark Results")
print("=" * 60)
print(f"Device: {results['device']}")
print(f"Parameters: {results['num_parameters']:,}")
print(f"Training steps: {results['num_steps']}")
print("-" * 60)
print(f"Training time: {results['training_time_sec']:.2f} sec")
print(f"Steps/second: {results['steps_per_second']:.1f}")
print(f"Inference (32^3): {results['inference_mean_ms']:.2f} ms")
if results['peak_memory_mb'] > 0:
print(f"Peak GPU memory: {results['peak_memory_mb']:.1f} MB")
print(f"Final loss: {results['final_loss']:.4e}")
print("=" * 60)
# =============================================================================
# Main
# =============================================================================
def main():
parser = argparse.ArgumentParser(
description='PyTorch Reference Implementation for Pennes Bioheat PINN'
)
parser.add_argument(
'--device', type=str, default='cuda',
choices=['cuda', 'cpu'],
help='Device to run on (default: cuda)'
)
parser.add_argument(
'--steps', type=int, default=1000,
help='Number of training steps (default: 1000)'
)
parser.add_argument(
'--fourier-features', type=int, default=64,
help='Number of Fourier features (default: 64)'
)
parser.add_argument(
'--collocation', type=int, default=4096,
help='Number of collocation points (default: 4096)'
)
parser.add_argument(
'--output', type=str, default=None,
help='Output JSON file for results'
)
args = parser.parse_args()
results = benchmark_pytorch(
device=args.device,
num_steps=args.steps,
fourier_features=args.fourier_features,
num_collocation=args.collocation,
)
print_benchmark_results(results)
if args.output:
import json
with open(args.output, 'w') as f:
json.dump(results, f, indent=2)
print(f"\nResults saved to: {args.output}")
if __name__ == '__main__':
main()