cds2.sde
Stochastic differential equations: Euler-Maruyama and Milstein ensembles.
Stochastic differential equation ensemble solvers.
SciPy integrates ODEs (solve_ivp) and quadratures (quad) but has no
stochastic integrator: a dW term is not a parameter flip on a
deterministic solver, because Ito calculus changes what convergence even
means (strong vs weak order, and the Ito-Stratonovich choice). Users
otherwise reach for unmaintained packages or pull in a full autodiff stack.
This module solves diagonal-noise systems
dy = drift(y, t) dt + diffusion(y, t) dW
with Euler-Maruyama (strong order 1/2) and Milstein (strong order 1), both as ensembles: every path is advanced together as one array operation, so the cost of 10,000 trajectories is one vectorised step per time point rather than 10,000 sequential integrations.
Drift and diffusion are called with a stacked state of shape
(n_paths, dim) and must return the same shape. Anything written with
NumPy operators satisfies this automatically::
def drift(y, t): return 0.05 * y # works for (dim,) and (n, dim)
def diffusion(y, t): return 0.20 * y
That single convention is what keeps this module honest — it neither probes callables nor silently falls back to per-path loops, so a shape mistake surfaces as an error instead of a slow path.
SdeEnsemble
dataclass
Ensemble of SDE trajectories.
Attributes:
t: Time grid, shape (n_steps + 1,).
paths: Trajectories, shape (n_paths, n_steps + 1, dim).
method: Solver name, "euler" or "milstein".
dt: Achieved step size. May be slightly below the requested dt
because the grid is made to land exactly on t_span[1].
n_paths: Number of trajectories.
seed: Seed used, or None when the generator was unseeded.
Source code in src/cds2/sde.py
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 | |
terminal
property
Final state of every path, shape (n_paths, dim).
sde_euler_maruyama(drift, diffusion, y0, t_span, dt, n_paths=1024, seed=None)
Euler-Maruyama ensemble for dy = drift dt + diffusion dW.
Strong order 1/2, weak order 1. Each dimension is driven by its own independent Wiener process (diagonal noise).
Args:
drift: drift(y, t) with y of shape (n_paths, dim),
returning the same shape.
diffusion: diffusion(y, t), same convention.
y0: Initial state, shape (dim,), shared by every path.
t_span: (t0, t1) with t0 < t1.
dt: Target step size. The achieved step is
(t1 - t0) / ceil((t1 - t0) / dt), never larger.
n_paths: Number of trajectories to advance together.
seed: Seed for the Wiener increments; None for a fresh stream.
Returns:
The :class:SdeEnsemble.
Raises:
TypeError: If drift or diffusion is not callable.
ValueError: If y0, t_span, dt or n_paths is invalid,
or a callable returns the wrong shape.
FloatingPointError: If the state becomes non-finite.
Example:
Geometric Brownian motion, where E[y_T] = y_0 exp(mu T) is known
in closed form and so serves as a check on the solver::
>>> import numpy as np
>>> ens = sde_euler_maruyama(
... lambda y, t: 0.05 * y,
... lambda y, t: 0.20 * y,
... y0=[100.0], t_span=(0.0, 1.0), dt=1e-3,
... n_paths=20000, seed=7,
... )
>>> bool(abs(ens.terminal.mean() - 100.0 * np.exp(0.05)) < 1.0)
True
Source code in src/cds2/sde.py
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 | |
sde_milstein(drift, diffusion, y0, t_span, dt, n_paths=1024, seed=None, jacobian=None)
Milstein ensemble: Euler-Maruyama plus the 0.5 b b' (dW^2 - dt) term.
Strong order 1 for diagonal noise, against 1/2 for Euler-Maruyama. The gain is in pathwise accuracy; both schemes have weak order 1, so an expectation like a European option price converges at the same rate while a path-dependent quantity (barrier crossing, maximum drawdown) does not.
Args:
drift: drift(y, t), ensemble convention as in
:func:sde_euler_maruyama.
diffusion: diffusion(y, t), same convention.
y0: Initial state, shape (dim,).
t_span: (t0, t1) with t0 < t1.
dt: Target step size.
n_paths: Number of trajectories.
seed: Seed for the Wiener increments.
jacobian: Diagonal of d diffusion / d y, same call convention.
When None it is approximated by central differences, which
costs 2 * dim extra evaluations per step and limits accuracy
to about 1e-7 relative — pass it explicitly when known.
Returns:
The :class:SdeEnsemble.
Raises:
TypeError: If drift, diffusion or jacobian is not callable.
ValueError: On invalid arguments or a wrong return shape.
FloatingPointError: If the state becomes non-finite.
Example:
For b(y) = sigma * y the derivative is just sigma, so the
analytic Jacobian is available and the finite-difference step can be
skipped::
>>> ens = sde_milstein(
... lambda y, t: 0.05 * y,
... lambda y, t: 0.20 * y,
... y0=[100.0], t_span=(0.0, 1.0), dt=1e-3,
... n_paths=4096, seed=7,
... jacobian=lambda y, t: 0.20 * np.ones_like(y),
... )
>>> ens.method
'milstein'
Source code in src/cds2/sde.py
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 | |
ensemble_stats(ensemble, quantiles=None)
Summarise an ensemble across paths at every time point.
Args:
ensemble: Result of :func:sde_euler_maruyama or :func:sde_milstein.
quantiles: Levels in the open interval (0, 1). Defaults to
(0.05, 0.5, 0.95).
Returns:
Mapping with "t", "mean", "std" and "quantiles". Each
statistic has shape (n_steps + 1, dim), or (n_steps + 1,) when
dim == 1 so that a scalar SDE plots without squeezing.
Raises:
TypeError: If ensemble is not an :class:SdeEnsemble.
ValueError: If any quantile lies outside (0, 1).
Example: >>> ens = sde_euler_maruyama( ... lambda y, t: -y, lambda y, t: 0.1 * np.ones_like(y), ... y0=[1.0], t_span=(0.0, 1.0), dt=1e-2, n_paths=512, seed=1, ... ) >>> stats = ensemble_stats(ens, quantiles=[0.5]) >>> stats["mean"].shape == ens.t.shape True
Source code in src/cds2/sde.py
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 | |