Files
optimiz-rs/src/optimization/generative_calibration_hooks.rs
T
ThotDjehuty d6b6018b9c release(v2.0.0-alpha.1): top-level reorg + bsde/pde/stochastic_control + mean_field/agent_based/inference/optimization
Phase 1 (top-level reorg):
  - matrix_riccati promoted to crate root via re-export
  - new top-level groups: bsde, pde, stochastic_control,
    agent_based, inference, optimization

Phase 2 (bsde):
  - theta_scheme: linear-BSDE theta-scheme
  - deep_bsde_bridge: ConditionalExpectation trait + driver

Phase 3 (pde):
  - fokker_planck: 1D forward FP with conservative central FD
  - hjb_multid: explicit n-D HJB on Cartesian grid (d <= 3)
  - elliptic_fd: 2D Poisson SOR solver

Phase 4 (stochastic_control):
  - optimal_switching: Snell envelope backward induction
  - pontryagin: 1D LQR Riccati shooting
  - two_sided_intensity_control: bilateral intensity control

Phase 5/6 (controls):
  - optimal_control::quadratic_impact_control (closed-form Riccati)
  - stochastic_control::two_sided_intensity_control

Phase 7 (mean_field + agent_based):
  - mean_field::mckean_vlasov: interacting-particle Euler scheme
  - agent_based::mod: generic interacting-agent simulator

Phase 8 (inference + optimization):
  - inference::robust_drift: Huber IRLS drift estimator
  - optimization::generative_calibration_hooks: GenerativeSampler trait
    + Gaussian MMD + finite-diff calibration step

Tests: 38 NEW tests, all passing (165/170 lib total; the 5 pre-existing
failures predate v1.1 and are tracked separately).

Versions bumped: Cargo 2.0.0-alpha.1, pyproject 2.0.0a1.

Deferred to subsequent v2.0.x bumps (parallelisable follow-ups):
PyO3 bindings, executed companion notebooks, Sphinx RST pages,
hfthot-lab-instance propagation.
2026-05-12 12:02:07 +02:00

128 lines
4.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Generative calibration hooks
//! ==============================
//!
//! Provides a [`GenerativeSampler`] trait wrapping any external
//! parameterised sampler `θ ↦ X^θ_1, ..., X^θ_M` and a Maximum Mean
//! Discrepancy (MMD) loss with a Gaussian kernel
//!
//! ```text
//! MMD²(P, Q) = (1/M²) Σ_{i,j} k(x_i, x_j) + (1/N²) Σ_{i,j} k(y_i, y_j)
//! - (2/(MN)) Σ_{i,j} k(x_i, y_j)
//! ```
//!
//! and a one-step finite-difference calibration routine that returns a new
//! parameter vector with a centred-difference gradient descent update.
use crate::core::{OptimizrError, Result};
pub trait GenerativeSampler {
/// Draw `n_samples` from the distribution parameterised by `theta`.
fn sample(&self, theta: &[f64], n_samples: usize, seed: u64) -> Result<Vec<f64>>;
}
#[derive(Clone, Debug)]
pub struct MmdLoss {
/// Gaussian kernel bandwidth (σ > 0).
pub sigma: f64,
}
fn gaussian_kernel(x: f64, y: f64, sigma: f64) -> f64 {
let d = (x - y) / sigma;
(-0.5 * d * d).exp()
}
pub fn mmd_distance(x: &[f64], y: &[f64], loss: &MmdLoss) -> Result<f64> {
if loss.sigma <= 0.0 {
return Err(OptimizrError::InvalidParameter("sigma > 0".into()));
}
if x.is_empty() || y.is_empty() {
return Err(OptimizrError::EmptyData);
}
let m = x.len();
let n = y.len();
let mut s_xx = 0.0;
for i in 0..m { for j in 0..m { s_xx += gaussian_kernel(x[i], x[j], loss.sigma); } }
let mut s_yy = 0.0;
for i in 0..n { for j in 0..n { s_yy += gaussian_kernel(y[i], y[j], loss.sigma); } }
let mut s_xy = 0.0;
for i in 0..m { for j in 0..n { s_xy += gaussian_kernel(x[i], y[j], loss.sigma); } }
let mmd2 = s_xx / (m * m) as f64 + s_yy / (n * n) as f64 - 2.0 * s_xy / (m * n) as f64;
Ok(mmd2.max(0.0).sqrt())
}
pub fn calibration_step<S: GenerativeSampler>(
sampler: &S,
target: &[f64],
theta: &[f64],
loss: &MmdLoss,
learning_rate: f64,
finite_diff_eps: f64,
n_samples: usize,
seed: u64,
) -> Result<Vec<f64>> {
let p = theta.len();
if p == 0 {
return Err(OptimizrError::InvalidParameter("theta is empty".into()));
}
let mut grad = vec![0.0f64; p];
for k in 0..p {
let mut tp = theta.to_vec();
let mut tm = theta.to_vec();
tp[k] += finite_diff_eps;
tm[k] -= finite_diff_eps;
let xp = sampler.sample(&tp, n_samples, seed)?;
let xm = sampler.sample(&tm, n_samples, seed)?;
let lp = mmd_distance(&xp, target, loss)?;
let lm = mmd_distance(&xm, target, loss)?;
grad[k] = (lp - lm) / (2.0 * finite_diff_eps);
}
let new_theta: Vec<f64> = theta.iter().zip(grad.iter()).map(|(&t, &g)| t - learning_rate * g).collect();
Ok(new_theta)
}
#[cfg(test)]
mod tests {
use super::*;
/// MMD between two identical samples is zero.
#[test]
fn mmd_self_distance_is_zero() {
let x: Vec<f64> = (0..50).map(|i| i as f64 / 10.0).collect();
let d = mmd_distance(&x, &x, &MmdLoss { sigma: 1.0 }).unwrap();
assert!(d.abs() < 1e-10);
}
/// MMD between samples shifted by 5σ is large (positive).
#[test]
fn mmd_increases_with_shift() {
let x: Vec<f64> = (0..50).map(|i| i as f64 / 10.0).collect();
let y: Vec<f64> = x.iter().map(|v| v + 5.0).collect();
let loss = MmdLoss { sigma: 1.0 };
let d_self = mmd_distance(&x, &x, &loss).unwrap();
let d_shift = mmd_distance(&x, &y, &loss).unwrap();
assert!(d_shift > d_self + 0.5);
}
/// Calibration step decreases MMD on a trivially identifiable scalar
/// shift problem `sample(θ) = θ + i/10`.
struct ShiftSampler;
impl GenerativeSampler for ShiftSampler {
fn sample(&self, theta: &[f64], n: usize, _seed: u64) -> Result<Vec<f64>> {
let t = theta[0];
Ok((0..n).map(|i| t + i as f64 / 10.0).collect())
}
}
#[test]
fn calibration_step_descends() {
let s = ShiftSampler;
let target: Vec<f64> = (0..30).map(|i| 1.0 + i as f64 / 10.0).collect();
let loss = MmdLoss { sigma: 1.0 };
let theta = vec![0.0];
let l0 = mmd_distance(&s.sample(&theta, 30, 0).unwrap(), &target, &loss).unwrap();
let new_theta = calibration_step(&s, &target, &theta, &loss, 1.0, 1e-2, 30, 0).unwrap();
let l1 = mmd_distance(&s.sample(&new_theta, 30, 0).unwrap(), &target, &loss).unwrap();
assert!(l1 < l0, "loss did not decrease: {l0} → {l1}");
}
}