scirs2-integrate 0.4.2

Numerical integration module for SciRS2 (scirs2-integrate)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
//! Milstein scheme for SDEs (strong order 1.0)
//!
//! The Milstein method improves upon Euler-Maruyama by adding the leading
//! term from the Itô-Taylor expansion. This extra correction term involves
//! the derivative of the diffusion coefficient, capturing the curvature of
//! the diffusion manifold.
//!
//! ## Scalar Scheme (1D, single Brownian motion)
//!
//! For the scalar SDE `dX = f(t, X) dt + g(t, X) dW`:
//!
//! ```text
//! X_{n+1} = X_n + f(t_n, X_n) dt + g(t_n, X_n) ΔW_n
//!           + (1/2) g(t_n, X_n) g'(t_n, X_n) ((ΔW_n)^2 - dt)
//! ```
//!
//! where `g'` is the partial derivative `∂g/∂x`, approximated numerically
//! by a central finite difference.
//!
//! ## Multi-dimensional Case (commutative noise)
//!
//! For the multi-dimensional case with commutative noise conditions
//! (g_ij g_kj' = g_ij' g_kj for all i,k,j), the scheme generalizes to:
//!
//! ```text
//! X_{n+1} = X_n + f(t_n, X_n) dt + g(t_n, X_n) ΔW_n
//!           + (1/2) Σ_j g_j ∂g_j/∂x ((ΔW_j)^2 - dt)
//! ```
//!
//! ## Convergence
//!
//! - **Strong order**: 1.0 (vs 0.5 for EM)
//! - **Weak order**: 1.0 (same as EM)
//!
//! The Milstein scheme doubles the strong convergence order compared to EM,
//! which means it achieves the same path accuracy with √10 larger step sizes.

use crate::error::{IntegrateError, IntegrateResult};
use crate::sde::{compute_n_steps, SdeOptions, SdeProblem, SdeSolution};
use scirs2_core::ndarray::{Array1, Array2};
use scirs2_core::random::prelude::{Normal, Rng, StdRng};
use scirs2_core::Distribution;

/// Step size for computing numerical derivatives of the diffusion coefficient.
const DIFF_H: f64 = 1e-6;

/// Milstein scheme for general (multi-dimensional, commutative noise) SDEs.
///
/// This method achieves **strong convergence order 1.0** by adding the
/// Milstein correction term to the Euler-Maruyama update.
///
/// The diffusion derivatives are approximated via central finite differences:
/// `∂g_j/∂x ≈ (g(t, x + h*e_i, ...) - g(t, x - h*e_i, ...)) / (2h)`
///
/// For non-commutative noise (general multi-dimensional case), this scheme
/// still achieves strong order 1.0 only when the noise is commutative.
/// For non-commutative noise, consider using `srk_strong` in `runge_kutta_sde`.
///
/// # Arguments
///
/// * `prob` - The SDE problem definition
/// * `dt` - Step size
/// * `rng` - Mutable RNG reference
///
/// # Examples
///
/// ```rust
/// use scirs2_integrate::sde::{SdeProblem, SdeSolution};
/// use scirs2_integrate::sde::milstein::milstein;
/// use scirs2_core::ndarray::{array, Array2};
/// use scirs2_core::random::prelude::*;
///
/// // Geometric Brownian Motion (exact Milstein = exact solution)
/// let (mu, sigma) = (0.05_f64, 0.2_f64);
/// let prob = SdeProblem::new(
///     array![1.0_f64], [0.0, 1.0], 1,
///     move |_t, x| array![mu * x[0]],
///     move |_t, x| { let mut g = Array2::zeros((1,1)); g[[0,0]] = sigma*x[0]; g },
/// );
/// let mut rng = seeded_rng(42);
/// let sol = milstein(&prob, 0.01, &mut rng).unwrap();
/// assert!(sol.len() > 1);
/// assert!(sol.x_final().unwrap()[0] > 0.0);
/// ```
pub fn milstein<F, G>(
    prob: &SdeProblem<F, G>,
    dt: f64,
    rng: &mut StdRng,
) -> IntegrateResult<SdeSolution>
where
    F: Fn(f64, &Array1<f64>) -> Array1<f64>,
    G: Fn(f64, &Array1<f64>) -> Array2<f64>,
{
    milstein_with_options(prob, dt, rng, &SdeOptions::default())
}

/// Milstein scheme with solver options.
pub fn milstein_with_options<F, G>(
    prob: &SdeProblem<F, G>,
    dt: f64,
    rng: &mut StdRng,
    opts: &SdeOptions,
) -> IntegrateResult<SdeSolution>
where
    F: Fn(f64, &Array1<f64>) -> Array1<f64>,
    G: Fn(f64, &Array1<f64>) -> Array2<f64>,
{
    prob.validate()?;
    let t0 = prob.t_span[0];
    let t1 = prob.t_span[1];
    let n_steps = compute_n_steps(t0, t1, dt, opts.max_steps)?;
    let n_state = prob.dim();
    let m = prob.n_brownian;

    let capacity = if opts.save_all_steps { n_steps + 1 } else { 2 };
    let mut sol = SdeSolution::with_capacity(capacity);
    sol.push(t0, prob.x0.clone());

    let normal = Normal::new(0.0_f64, 1.0_f64)
        .map_err(|e| IntegrateError::ComputationError(format!("Normal dist error: {}", e)))?;

    let mut x = prob.x0.clone();
    let mut t = t0;

    for step in 0..n_steps {
        let dt_actual = if step == n_steps - 1 {
            t1 - t
        } else {
            dt.min(t1 - t)
        };
        if dt_actual <= 0.0 {
            break;
        }
        let sqrt_dt = dt_actual.sqrt();

        // Generate Brownian increments ΔW ~ N(0, dt_actual * I_m)
        let dw: Array1<f64> = Array1::from_shape_fn(m, |_| normal.sample(rng) * sqrt_dt);

        let drift = (prob.f_drift)(t, &x);
        let g_curr = (prob.g_diffusion)(t, &x);

        if drift.len() != n_state || g_curr.nrows() != n_state || g_curr.ncols() != m {
            return Err(IntegrateError::DimensionMismatch(
                "Drift or diffusion output dimension mismatch".to_string(),
            ));
        }

        // Euler-Maruyama term: x + f*dt + g*ΔW
        let em_increment = drift * dt_actual + g_curr.dot(&dw);

        // Milstein correction: (1/2) Σ_j [g_j(·) ∂g/∂x]·e_j * ((ΔW_j)^2 - dt)
        let milstein_corr = compute_milstein_correction(
            t,
            &x,
            &g_curr,
            &dw,
            dt_actual,
            n_state,
            m,
            &prob.g_diffusion,
        )?;

        x = x + em_increment + milstein_corr;
        t += dt_actual;

        if opts.save_all_steps {
            sol.push(t, x.clone());
        }
    }

    if !opts.save_all_steps {
        sol.push(t, x);
    }

    Ok(sol)
}

/// Compute the Milstein correction term:
///
/// ```text
/// correction_i = (1/2) Σ_j Σ_k g_{kj}(t, x) * (∂g_{ij}/∂x_k) * ((ΔW_j)^2 - dt)
/// ```
///
/// The derivative `∂g_{ij}/∂x_k` is approximated by a central finite difference.
fn compute_milstein_correction<G>(
    t: f64,
    x: &Array1<f64>,
    g_curr: &Array2<f64>,
    dw: &Array1<f64>,
    dt: f64,
    n_state: usize,
    m: usize,
    g_diffusion: &G,
) -> IntegrateResult<Array1<f64>>
where
    G: Fn(f64, &Array1<f64>) -> Array2<f64>,
{
    let mut correction = Array1::<f64>::zeros(n_state);

    for j in 0..m {
        let dw_j = dw[j];
        let iterated_factor = 0.5 * (dw_j * dw_j - dt);

        // Compute g_j(x) · ∂g_{·j}/∂x via finite differences
        // For each state component k, we perturb x_k and measure ∂g_{ij}/∂x_k
        // g_{kj} is the (k,j) element of the diffusion matrix
        // The Milstein term for component i from Brownian motion j is:
        //   (1/2) * [g(t,x) * (∂g^{·j}/∂x)_j] * ((ΔW_j)^2 - dt)
        // where (∂g^{·j}/∂x)_j means the derivative of the j-th column of g
        // dotted with the j-th column of g.
        //
        // Specifically: corr_i += (1/2) * Σ_k g_{kj} * (dg_{ij}/dx_k) * ((ΔW_j)^2 - dt)

        let mut column_correction = Array1::<f64>::zeros(n_state);
        for k in 0..n_state {
            let h = DIFF_H * (1.0 + x[k].abs());
            let mut x_plus = x.clone();
            let mut x_minus = x.clone();
            x_plus[k] += h;
            x_minus[k] -= h;

            let g_plus = g_diffusion(t, &x_plus);
            let g_minus = g_diffusion(t, &x_minus);

            // dg_{ij}/dx_k for all i
            for i in 0..n_state {
                let dg_ij_dxk = (g_plus[[i, j]] - g_minus[[i, j]]) / (2.0 * h);
                column_correction[i] += g_curr[[k, j]] * dg_ij_dxk;
            }
        }

        correction = correction + column_correction * iterated_factor;
    }

    Ok(correction)
}

/// Efficient scalar (1D state, 1 Brownian motion) Milstein scheme.
///
/// For the scalar SDE `dX = f(t, X) dt + g(t, X) dW`, this avoids matrix
/// operations entirely and uses a simple scalar finite-difference approximation
/// for the Milstein correction.
///
/// The update is:
/// ```text
/// X_{n+1} = X_n + f dt + g ΔW + (1/2) g g' ((ΔW)^2 - dt)
/// ```
///
/// where `g' = ∂g/∂x` is approximated by central finite difference.
///
/// **Strong convergence order**: 1.0
///
/// # Arguments
///
/// * `f_drift` - Scalar drift `f(t, x) -> f64`
/// * `g_diff` - Scalar diffusion `g(t, x) -> f64`
/// * `x0` - Initial condition
/// * `t_span` - Time interval [t0, t1]
/// * `dt` - Step size
/// * `rng` - RNG reference
///
/// # Examples
///
/// ```rust
/// use scirs2_integrate::sde::milstein::scalar_milstein;
/// use scirs2_core::random::prelude::*;
///
/// // Geometric Brownian Motion: dS = μ S dt + σ S dW
/// let (mu, sigma, s0) = (0.05, 0.2, 100.0_f64);
/// let mut rng = seeded_rng(42);
/// let sol = scalar_milstein(
///     |_t, x| mu * x,
///     |_t, x| sigma * x,
///     s0, [0.0, 1.0], 0.01, &mut rng,
/// ).unwrap();
/// assert!(sol.x_final().unwrap()[0] > 0.0);
/// ```
pub fn scalar_milstein<Fd, Gd>(
    f_drift: Fd,
    g_diff: Gd,
    x0: f64,
    t_span: [f64; 2],
    dt: f64,
    rng: &mut StdRng,
) -> IntegrateResult<SdeSolution>
where
    Fd: Fn(f64, f64) -> f64,
    Gd: Fn(f64, f64) -> f64,
{
    scalar_milstein_with_options(f_drift, g_diff, x0, t_span, dt, rng, &SdeOptions::default())
}

/// Scalar Milstein scheme with solver options.
pub fn scalar_milstein_with_options<Fd, Gd>(
    f_drift: Fd,
    g_diff: Gd,
    x0: f64,
    t_span: [f64; 2],
    dt: f64,
    rng: &mut StdRng,
    opts: &SdeOptions,
) -> IntegrateResult<SdeSolution>
where
    Fd: Fn(f64, f64) -> f64,
    Gd: Fn(f64, f64) -> f64,
{
    if t_span[0] >= t_span[1] {
        return Err(IntegrateError::InvalidInput(format!(
            "t_span must satisfy t0 < t1, got [{}, {}]",
            t_span[0], t_span[1]
        )));
    }
    let n_steps = compute_n_steps(t_span[0], t_span[1], dt, opts.max_steps)?;
    let t0 = t_span[0];
    let t1 = t_span[1];

    let capacity = if opts.save_all_steps { n_steps + 1 } else { 2 };
    let mut sol = SdeSolution::with_capacity(capacity);
    sol.push(t0, scirs2_core::ndarray::array![x0]);

    let normal = Normal::new(0.0_f64, 1.0_f64)
        .map_err(|e| IntegrateError::ComputationError(format!("Normal dist error: {}", e)))?;

    let mut x = x0;
    let mut t = t0;

    for step in 0..n_steps {
        let dt_actual = if step == n_steps - 1 {
            t1 - t
        } else {
            dt.min(t1 - t)
        };
        if dt_actual <= 0.0 {
            break;
        }
        let sqrt_dt = dt_actual.sqrt();
        let dw = normal.sample(rng) * sqrt_dt;

        let f_val = f_drift(t, x);
        let g_val = g_diff(t, x);

        // Numerical derivative g' = ∂g/∂x via central difference
        let h = DIFF_H * (1.0 + x.abs());
        let dg_dx = (g_diff(t, x + h) - g_diff(t, x - h)) / (2.0 * h);

        // Milstein update: x + f*dt + g*dW + (1/2)*g*g'*((dW)^2 - dt)
        x += f_val * dt_actual + g_val * dw + 0.5 * g_val * dg_dx * (dw * dw - dt_actual);
        t += dt_actual;

        if opts.save_all_steps {
            sol.push(t, scirs2_core::ndarray::array![x]);
        }
    }

    if !opts.save_all_steps {
        sol.push(t, scirs2_core::ndarray::array![x]);
    }

    Ok(sol)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sde::SdeProblem;
    use scirs2_core::ndarray::{array, Array2};
    use scirs2_core::random::prelude::seeded_rng;

    fn make_gbm(
        mu: f64,
        sigma: f64,
        s0: f64,
    ) -> SdeProblem<
        impl Fn(f64, &Array1<f64>) -> Array1<f64>,
        impl Fn(f64, &Array1<f64>) -> Array2<f64>,
    > {
        SdeProblem::new(
            array![s0],
            [0.0, 1.0],
            1,
            move |_t, x| array![mu * x[0]],
            move |_t, x| {
                let mut g = Array2::zeros((1, 1));
                g[[0, 0]] = sigma * x[0];
                g
            },
        )
    }

    /// For GBM, the Milstein scheme with scalar g_j = sigma*x gives:
    /// g g' = sigma*x * sigma = sigma^2 * x
    /// So the correction exactly captures the Itô-Stratonovich difference,
    /// and for larger dt the Milstein solution is more accurate than EM.
    #[test]
    fn test_milstein_gbm_positive() {
        let prob = make_gbm(0.05, 0.2, 100.0);
        let mut rng = seeded_rng(42);
        let sol = milstein(&prob, 0.01, &mut rng).expect("milstein should succeed");
        // GBM should stay positive
        for xi in &sol.x {
            assert!(xi[0] > 0.0, "GBM should stay positive");
        }
    }

    #[test]
    fn test_milstein_solution_length() {
        let prob = make_gbm(0.05, 0.2, 1.0);
        let mut rng = seeded_rng(0);
        let sol = milstein(&prob, 0.1, &mut rng).expect("milstein should succeed");
        assert_eq!(sol.len(), 11);
    }

    #[test]
    fn test_scalar_milstein_basic() {
        let mut rng = seeded_rng(42);
        let sol = scalar_milstein(
            |_t, x| 0.1 * x,
            |_t, x| 0.3 * x,
            1.0,
            [0.0, 1.0],
            0.01,
            &mut rng,
        )
        .expect("scalar_milstein should succeed");
        assert!(!sol.is_empty());
        assert!(sol.x_final().expect("solution has state")[0] > 0.0);
    }

    /// Compare strong accuracy: Milstein should have lower error than EM on average
    /// over an ensemble for a path-dependent quantity.
    /// We check that Milstein weakly approximates GBM mean at least as well as EM.
    #[test]
    fn test_milstein_vs_em_weak_mean() {
        let mu = 0.1_f64;
        let sigma = 0.2_f64;
        let s0 = 1.0_f64;
        let t1 = 1.0_f64;
        let dt = 0.01;
        let analytic = s0 * (mu * t1).exp();
        let n_paths = 300;

        let mut sum_milstein = 0.0;
        for seed in 0..n_paths as u64 {
            let prob = make_gbm(mu, sigma, s0);
            let mut rng = seeded_rng(seed + 1000);
            let sol = milstein(&prob, dt, &mut rng).expect("milstein should succeed");
            sum_milstein += sol.x_final().expect("solution has state")[0];
        }
        let mean_milstein = sum_milstein / n_paths as f64;
        let rel_err = (mean_milstein - analytic).abs() / analytic;
        assert!(
            rel_err < 0.05,
            "Milstein mean {:.4} vs analytic {:.4}, rel_err {:.4}",
            mean_milstein,
            analytic,
            rel_err
        );
    }

    #[test]
    fn test_milstein_invalid_tspan() {
        let prob = make_gbm(0.05, 0.2, 1.0);
        // Override with bad t_span manually by creating a bad problem
        let x0 = array![1.0_f64];
        let bad_prob = SdeProblem::new(
            x0,
            [1.0, 0.0],
            1,
            |_t, x| array![0.05 * x[0]],
            |_t, x| {
                let mut g = Array2::zeros((1, 1));
                g[[0, 0]] = 0.2 * x[0];
                g
            },
        );
        let mut rng = seeded_rng(0);
        assert!(milstein(&bad_prob, 0.01, &mut rng).is_err());
    }

    #[test]
    fn test_scalar_milstein_length() {
        let mut rng = seeded_rng(7);
        let sol = scalar_milstein(
            |_t, x| 0.05 * x,
            |_t, x| 0.2 * x,
            1.0,
            [0.0, 1.0],
            0.1,
            &mut rng,
        )
        .expect("scalar_milstein should succeed");
        assert_eq!(sol.len(), 11);
    }
}