Stochastic Partial Differential Equations
Stochastic partial differential equations (SPDEs) extend PDEs by incorporating random
forcing terms, modeling systems where both spatial structure and noise play essential
roles. They appear in turbulence modeling, phase-field dynamics, population genetics,
surface growth, and financial mathematics. Numra provides the numra-spde crate which
combines the Method of Lines (MOL) spatial discretization from numra-pde with the
SDE solvers from numra-sde, yielding a unified framework for solving SPDEs numerically.
Mathematical Background
Section titled “Mathematical Background”An SPDE in Ito form is written as:
where:
- is a spatial differential operator (the deterministic drift), such as the Laplacian ,
- is the diffusion coefficient controlling noise intensity,
- is space-time noise — a generalized random process.
Additive vs. Multiplicative Noise
Section titled “Additive vs. Multiplicative Noise”| Noise Type | Diffusion | Physical Meaning |
|---|---|---|
| Additive | independent of | External random forcing (e.g., thermal fluctuations) |
| Multiplicative | Noise intensity depends on state (e.g., concentration-dependent noise) |
Additive noise is simpler to analyze and compute. Many physical systems have additive
noise, and Numra marks it with the is_additive() method to enable optimizations.
Noise Regularity
Section titled “Noise Regularity”A fundamental issue with SPDEs is the regularity of the noise term. Space-time white noise is so rough that solutions may not exist in the classical sense for some equations. The noise regularity determines which equations are well-posed:
| Spatial Dimension | White Noise | Colored/Trace-Class Noise |
|---|---|---|
| 1D | Solutions exist for heat equation | Solutions exist for broader class |
| 2D | Solutions may not exist (need renormalization) | Well-posed with sufficient regularity |
| 3D | Generally ill-posed with white noise | Requires strong trace-class condition |
In practice, spatial discretization on a grid with spacing acts as a natural
regularization, cutting off spatial frequencies above . Numra’s numra-spde
crate operates at this discretized level.
The SpdeSystem Trait
Section titled “The SpdeSystem Trait”Every SPDE in Numra implements the SpdeSystem trait:
pub trait SpdeSystem<S: Scalar> { /// Spatial dimension (1D, 2D, etc.) fn dim(&self) -> usize { 1 }
/// Evaluate the deterministic spatial operator L[u]. fn drift(&self, t: S, u: &[S], du: &mut [S], grid: &Grid1D<S>);
/// Evaluate the noise coefficient sigma(u). fn diffusion(&self, t: S, u: &[S], sigma: &mut [S], grid: &Grid1D<S>);
/// Noise correlation structure. fn noise_correlation(&self) -> NoiseCorrelation<S> { NoiseCorrelation::White }
/// Whether the noise is additive (sigma independent of u). fn is_additive(&self) -> bool { true }}The drift method evaluates the spatial differential operator (e.g., the diffusion
Laplacian) at each interior grid point. The diffusion method returns the noise
intensity at each grid point. Both receive the spatial Grid1D for computing finite
difference stencils.
Convenience Constructor: Spde1D
Section titled “Convenience Constructor: Spde1D”For simple cases, the Spde1D struct lets you define an SPDE from closures:
use numra_spde::system::Spde1D;
let spde = Spde1D::new( |t, u, du, grid| { // Drift: heat equation Laplacian let dx = grid.dx_uniform(); let n = u.len(); for i in 0..n { let u_left = if i == 0 { 0.0 } else { u[i - 1] }; let u_right = if i == n - 1 { 0.0 } else { u[i + 1] }; du[i] = 0.01 * (u_left - 2.0 * u[i] + u_right) / (dx * dx); } }, |_t, _u, sigma, _grid| { for s in sigma.iter_mut() { *s = 0.1; // Constant additive noise } },);Noise Correlation Types
Section titled “Noise Correlation Types”Numra supports three spatial noise correlation structures via NoiseCorrelation:
White Noise (Default)
Section titled “White Noise (Default)”use numra_spde::NoiseCorrelation;
let correlation: NoiseCorrelation<f64> = NoiseCorrelation::White;Spatially uncorrelated: each grid point receives an independent Wiener increment. This is the simplest and cheapest option. The noise at grid point is independent of the noise at grid point for .
Internally, this uses NoiseType::Diagonal from numra-sde, so the diffusion
buffer has size (one entry per interior grid point).
Colored Noise (Exponential Spatial Correlation)
Section titled “Colored Noise (Exponential Spatial Correlation)”use numra_spde::NoiseCorrelation;
let correlation: NoiseCorrelation<f64> = NoiseCorrelation::Colored { correlation_length: 0.2,};Spatially correlated with an exponential correlation function:
where is the correlation_length. Nearby grid points receive correlated
noise, producing smoother spatial noise patterns. The implementation pre-computes a
Cholesky factorization of the correlation matrix , then generates
correlated increments as where is a vector of
independent standard normal samples and .
Guidelines for choosing correlation_length:
- : Nearly white noise; use
Whitefor efficiency. - : Typical choice for smooth spatial patterns.
- : Nearly uniform noise across the domain.
Trace-Class Noise (Spectral Representation)
Section titled “Trace-Class Noise (Spectral Representation)”use numra_spde::NoiseCorrelation;
let correlation: NoiseCorrelation<f64> = NoiseCorrelation::TraceClass { n_modes: 10, decay_rate: 2.0,};Expands the noise in eigenfunctions of the Laplacian (Q-Wiener process):
where are the eigenvalues and are the sinusoidal basis functions. Each is an independent scalar Wiener process.
The decay_rate controls how quickly higher modes are suppressed:
decay_rate = 1.0: Slow decay, rough noisedecay_rate = 2.0: Moderate decay, smooth noisedecay_rate = 4.0: Fast decay, very smooth noise
Trace-class noise is computationally efficient when n_modes is much smaller than the
number of grid points, because only n_modes independent Wiener processes are needed
(rather than one per grid point).
Comparison of Noise Types
Section titled “Comparison of Noise Types”| Property | White | Colored | Trace-Class |
|---|---|---|---|
| Spatial correlation | None | Exponential | Spectral decay |
| Smoothness | Rough | Smooth | Smooth (tunable) |
| Wiener processes | |||
| Setup cost | Cholesky | basis | |
| Per-step cost | |||
| Physical model | Thermal fluctuations | Correlated forcing | Q-Wiener process |
The MolSdeSolver
Section titled “The MolSdeSolver”The MolSdeSolver converts the SPDE into a system of SDEs using the Method of Lines,
then solves the resulting SDE system using solvers from numra-sde. The process is:
- Spatial discretization: The user provides the drift function that computes using finite differences on the grid.
- Noise construction: The solver builds a
MolNoiseConfigfrom theNoiseCorrelation, pre-computing Cholesky factors or spectral bases as needed. - SDE wrapping: The discretized SPDE is wrapped as an
SdeSystem(viaMolSdeWrapper), mapping theSpdeSystemtrait methods toSdeSystem::driftandSdeSystem::diffusion. - Time integration: The wrapped system is solved by Euler-Maruyama or Milstein.
SDE Methods
Section titled “SDE Methods”Numra provides two time-stepping methods via SpdeMethod:
use numra_spde::SpdeMethod;
SpdeMethod::EulerMaruyama // Default: strong order 0.5SpdeMethod::Milstein // Strong order 1.0 (multiplicative noise)For additive noise, Milstein automatically falls back to Euler-Maruyama since the correction term vanishes.
Solver Options
Section titled “Solver Options”use numra_spde::SpdeOptions;
let opts = SpdeOptions::default() .dt(0.001) // Time step .n_output(100) // Number of output time points .method(SpdeMethod::EulerMaruyama) .seed(42) // Random seed for reproducibility .adaptive(false); // Fixed-step (default)| Option | Default | Description |
|---|---|---|
dt | 0.001 | Time step size |
n_output | 100 | Number of output time points (subsampled from all steps) |
method | EulerMaruyama | SDE time-stepping method |
seed | None (entropy) | Random seed for reproducibility |
adaptive | false | Enable adaptive time stepping |
rtol | 1e-3 | Relative tolerance (adaptive only) |
atol | 1e-6 | Absolute tolerance (adaptive only) |
dt_min | 1e-10 | Minimum step size (adaptive only) |
dt_max | 0.1 | Maximum step size (adaptive only) |
Adaptive Time Stepping
Section titled “Adaptive Time Stepping”When adaptive(true) is set, the solver uses step-doubling for error estimation:
each step is taken once with step size and twice with using independent
Wiener increments. The difference between the two results estimates the local error.
The step size controller uses a conservative exponent of (appropriate for Euler-Maruyama’s weak order 1 with step-doubling error estimation):
let opts = SpdeOptions::default() .adaptive(true) .dt(0.001) .rtol(1e-3) .atol(1e-6) .dt_max(0.01) .seed(42);Adaptive stepping adds overhead from the extra half-steps and noise generation, so it is most beneficial when the dynamics have multiple time scales.
Example: Stochastic Heat Equation
Section titled “Example: Stochastic Heat Equation”The stochastic heat equation models thermal diffusion with random forcing:
with Dirichlet boundary conditions .
use numra_spde::{SpdeSystem, SpdeSolver, SpdeOptions, MolSdeSolver, NoiseCorrelation};use numra_pde::Grid1D;
struct StochasticHeat { alpha: f64, sigma: f64,}
impl SpdeSystem<f64> for StochasticHeat { fn drift(&self, _t: f64, u: &[f64], du: &mut [f64], grid: &Grid1D<f64>) { let dx = grid.dx_uniform(); let n = u.len(); for i in 0..n { let u_left = if i == 0 { 0.0 } else { u[i - 1] }; let u_right = if i == n - 1 { 0.0 } else { u[i + 1] }; du[i] = self.alpha * (u_left - 2.0 * u[i] + u_right) / (dx * dx); } }
fn diffusion(&self, _t: f64, _u: &[f64], sigma: &mut [f64], _grid: &Grid1D<f64>) { for s in sigma.iter_mut() { *s = self.sigma; } }}
let system = StochasticHeat { alpha: 0.01, sigma: 0.1 };let grid = Grid1D::uniform(0.0_f64, 1.0, 21);
// Initial condition: sin(pi*x) at interior pointslet u0: Vec<f64> = grid.interior_points().iter() .map(|&x| (std::f64::consts::PI * x).sin()) .collect();
let options = SpdeOptions::default() .dt(0.0001) .n_output(50) .seed(42);
let result = MolSdeSolver::solve(&system, 0.0, 0.1, &u0, &grid, &options) .expect("Solve failed");
// Access solutionlet u_final = result.y_final().unwrap();println!("Solution at t = {:.3}:", result.t_final().unwrap());for (i, &val) in u_final.iter().enumerate() { println!(" x_{} = {:.6}", i, val);}Zero-Noise Limit
Section titled “Zero-Noise Limit”When , the SPDE reduces to the deterministic PDE, and the solver produces identical results regardless of the random seed. This is a useful sanity check.
Example: Multiplicative Noise
Section titled “Example: Multiplicative Noise”For multiplicative noise where the noise intensity depends on the field value:
use numra_spde::{SpdeSystem, SpdeSolver, SpdeOptions, SpdeMethod, MolSdeSolver};use numra_pde::Grid1D;
struct MultiplicativeNoise { alpha: f64, sigma: f64,}
impl SpdeSystem<f64> for MultiplicativeNoise { fn drift(&self, _t: f64, u: &[f64], du: &mut [f64], grid: &Grid1D<f64>) { let dx = grid.dx_uniform(); let n = u.len(); for i in 0..n { let u_left = if i == 0 { 0.0 } else { u[i - 1] }; let u_right = if i == n - 1 { 0.0 } else { u[i + 1] }; du[i] = self.alpha * (u_left - 2.0 * u[i] + u_right) / (dx * dx); } }
fn diffusion(&self, _t: f64, u: &[f64], sigma: &mut [f64], _grid: &Grid1D<f64>) { for i in 0..u.len() { sigma[i] = self.sigma * u[i]; // Noise scales with field value } }
fn is_additive(&self) -> bool { false }}
let system = MultiplicativeNoise { alpha: 0.01, sigma: 0.1 };let grid = Grid1D::uniform(0.0_f64, 1.0, 51);let u0: Vec<f64> = grid.interior_points().iter() .map(|&x| (std::f64::consts::PI * x).sin()) .collect();
// Use Milstein for better accuracy with multiplicative noiselet options = SpdeOptions::default() .dt(0.0001) .method(SpdeMethod::Milstein) .n_output(50) .seed(42);
let result = MolSdeSolver::solve(&system, 0.0, 0.1, &u0, &grid, &options) .expect("Solve failed");Example: Colored Noise
Section titled “Example: Colored Noise”Using spatially correlated noise to model smooth random forcing:
use numra_spde::{SpdeSystem, SpdeSolver, SpdeOptions, MolSdeSolver, NoiseCorrelation};use numra_pde::Grid1D;
struct ColoredHeat { alpha: f64, sigma: f64, correlation_length: f64,}
impl SpdeSystem<f64> for ColoredHeat { fn drift(&self, _t: f64, u: &[f64], du: &mut [f64], grid: &Grid1D<f64>) { let dx = grid.dx_uniform(); let n = u.len(); for i in 0..n { let u_left = if i == 0 { 0.0 } else { u[i - 1] }; let u_right = if i == n - 1 { 0.0 } else { u[i + 1] }; du[i] = self.alpha * (u_left - 2.0 * u[i] + u_right) / (dx * dx); } }
fn diffusion(&self, _t: f64, _u: &[f64], sigma: &mut [f64], _grid: &Grid1D<f64>) { for s in sigma.iter_mut() { *s = self.sigma; } }
fn noise_correlation(&self) -> NoiseCorrelation<f64> { NoiseCorrelation::Colored { correlation_length: self.correlation_length, } }}
let system = ColoredHeat { alpha: 0.01, sigma: 0.1, correlation_length: 0.2,};let grid = Grid1D::uniform(0.0_f64, 1.0, 21);let u0: Vec<f64> = grid.interior_points().iter() .map(|&x| (std::f64::consts::PI * x).sin()) .collect();
let options = SpdeOptions::default() .dt(0.0001) .n_output(20) .seed(42);
let result = MolSdeSolver::solve(&system, 0.0, 0.01, &u0, &grid, &options) .expect("Solve failed");Example: Trace-Class Noise
Section titled “Example: Trace-Class Noise”Using a Q-Wiener process with spectral decay for mathematically well-posed noise:
use numra_spde::{SpdeSystem, SpdeSolver, SpdeOptions, MolSdeSolver, NoiseCorrelation};use numra_pde::Grid1D;
struct TraceClassHeat { alpha: f64, sigma: f64, n_modes: usize, decay_rate: f64,}
impl SpdeSystem<f64> for TraceClassHeat { fn drift(&self, _t: f64, u: &[f64], du: &mut [f64], grid: &Grid1D<f64>) { let dx = grid.dx_uniform(); let n = u.len(); for i in 0..n { let u_left = if i == 0 { 0.0 } else { u[i - 1] }; let u_right = if i == n - 1 { 0.0 } else { u[i + 1] }; du[i] = self.alpha * (u_left - 2.0 * u[i] + u_right) / (dx * dx); } }
fn diffusion(&self, _t: f64, _u: &[f64], sigma: &mut [f64], _grid: &Grid1D<f64>) { for s in sigma.iter_mut() { *s = self.sigma; } }
fn noise_correlation(&self) -> NoiseCorrelation<f64> { NoiseCorrelation::TraceClass { n_modes: self.n_modes, decay_rate: self.decay_rate, } }}
let system = TraceClassHeat { alpha: 0.01, sigma: 0.1, n_modes: 5, // Only 5 Wiener processes instead of n decay_rate: 2.0, // Higher modes suppressed as k^(-2)};let grid = Grid1D::uniform(0.0_f64, 1.0, 51);let u0: Vec<f64> = grid.interior_points().iter() .map(|&x| (std::f64::consts::PI * x).sin()) .collect();
let options = SpdeOptions::default() .dt(0.0001) .n_output(20) .seed(42);
let result = MolSdeSolver::solve(&system, 0.0, 0.01, &u0, &grid, &options) .expect("Solve failed");Monte Carlo Ensemble Simulation
Section titled “Monte Carlo Ensemble Simulation”For computing statistics (expected fields, variance fields, confidence intervals),
SpdeEnsemble runs multiple trajectories with different noise realizations:
use numra_spde::{SpdeSystem, SpdeOptions, MolSdeSolver, SpdeEnsemble};use numra_pde::Grid1D;
// ... define StochasticHeat as above ...
let system = StochasticHeat { alpha: 0.01, sigma: 0.05 };let grid = Grid1D::uniform(0.0_f64, 1.0, 21);let u0: Vec<f64> = grid.interior_points().iter() .map(|&x| (std::f64::consts::PI * x).sin()) .collect();
let options = SpdeOptions::default() .dt(0.001) .n_output(10) .seed(100);
// Run 100 trajectories with different noise realizationslet results = SpdeEnsemble::solve( &system, 0.0, 0.01, &u0, &grid, &options, 100).expect("Ensemble solve failed");
// Compute mean and standard deviation fieldslet mean = SpdeEnsemble::mean(&results);let std = SpdeEnsemble::std(&results, &mean);
// mean[i][j] = mean field at time i, grid point j// std[i][j] = standard deviation at time i, grid point jlet n_times = mean.len();println!("Mean field at final time:");for (j, &val) in mean[n_times - 1].iter().enumerate() { println!(" x_{}: mean = {:.6}, std = {:.6}", j, val, std[n_times - 1][j]);}Each trajectory receives a unique seed derived from the base seed (seed + i), ensuring
both reproducibility and statistical independence. Standard deviation uses Bessel’s
correction (dividing by ).
Working with Results
Section titled “Working with Results”SpdeResult provides access to the solution at all output time points:
let result = MolSdeSolver::solve(&system, t0, tf, &u0, &grid, &options)?;
// Check successassert!(result.success);
// Final fieldlet u_final = result.y_final().unwrap();
// Final timelet t_final = result.t_final().unwrap();
// Field at a specific output indexlet u_i = result.y_at(5);
// All time pointslet times = &result.t;
// Spatial grid (stored with result)let grid_ref = &result.grid;
// Statisticsprintln!("Steps taken: {}", result.stats.n_steps);println!("Drift evaluations: {}", result.stats.n_drift);println!("Diffusion evaluations: {}", result.stats.n_diffusion);println!("Rejected steps: {}", result.stats.n_reject);Note that the result contains subsampled output: the solver takes many internal steps
but only stores n_output time points (plus the final point). This controls memory
usage for fine-grid problems.
Choosing a Solver Configuration
Section titled “Choosing a Solver Configuration”| Scenario | Method | Noise | Adaptive |
|---|---|---|---|
| Quick exploration | EulerMaruyama | White | No |
| Multiplicative noise, accuracy needed | Milstein | Any | No |
| Smooth random forcing | EulerMaruyama | Colored or TraceClass | No |
| Multi-scale dynamics | EulerMaruyama | Any | Yes |
| Ensemble statistics | EulerMaruyama | Any | No (fixed step for reproducibility) |
Practical Considerations
Section titled “Practical Considerations”Step Size and Stability
Section titled “Step Size and Stability”The stochastic heat equation with explicit time stepping requires:
for the deterministic part to be stable. The noise term adds further constraints. A good starting point is , which provides a safety margin. With adaptive stepping enabled, the solver handles this automatically.
Grid Resolution and Noise Scaling
Section titled “Grid Resolution and Noise Scaling”Space-time white noise on a discrete grid has variance proportional to . This means that refining the spatial grid increases the noise intensity at each point. The physical interpretation depends on the model:
- For renormalized models, the noise coefficient should scale with to keep the solution statistics independent of grid resolution.
- For discrete models (e.g., interacting particle systems), the grid-dependent scaling is the correct physical behavior.
Computational Cost
Section titled “Computational Cost”The cost per time step depends on the noise type:
| Component | White Noise | Colored Noise | Trace-Class |
|---|---|---|---|
| Drift evaluation | |||
| Noise generation | |||
| Memory |
For fine grids with colored noise, the Cholesky-based correlation step dominates. If this becomes a bottleneck, consider using trace-class noise with a moderate number of modes, which scales as with .
Reproducibility
Section titled “Reproducibility”With a fixed seed, the solver produces identical trajectories:
let opts = SpdeOptions::default().dt(0.001).seed(42);let r1 = MolSdeSolver::solve(&system, 0.0, 0.01, &u0, &grid, &opts)?;let r2 = MolSdeSolver::solve(&system, 0.0, 0.01, &u0, &grid, &opts)?;
// r1 and r2 produce identical resultslet y1 = r1.y_final().unwrap();let y2 = r2.y_final().unwrap();for (a, b) in y1.iter().zip(y2.iter()) { assert!((a - b).abs() < 1e-10);}Relationship to Other Numra Crates
Section titled “Relationship to Other Numra Crates”The SPDE solver bridges two existing Numra crates:
| Crate | Role in SPDE Solving |
|---|---|
numra-pde | Provides Grid1D for spatial discretization |
numra-sde | Provides EulerMaruyama, Milstein, and WienerProcess for time stepping |
numra-spde | Wraps SpdeSystem as SdeSystem via MOL, manages noise correlation |
When , the SPDE reduces to a deterministic PDE. For that case, using
numra-pde with MOLSystem and an adaptive ODE solver from numra-ode is more
efficient, since ODE solvers provide higher-order accuracy and adaptive error control.