Quant Memo
Core

SciPy Optimize for Model Calibration

scipy.optimize turns model calibration into a numerical search: give it a function that scores how wrong a guess is, and it hunts for the parameters that make that score smallest.

Prerequisites: Gradient Descent, Big-O Complexity

A volatility model has a few free parameters — say, a long-run variance and a mean-reversion speed. You know what prices the model should produce if those parameters were right, and you can see what prices the market actually shows. Calibration means adjusting the parameters until the model's prices line up with the market's as closely as possible. Doing that by hand — nudge a number, re-check, nudge again — does not scale past two parameters. scipy.optimize automates the nudging.

The idea: turn "fit the model" into "minimize a number"

You write one function, usually called the objective or loss, that takes a candidate parameter vector and returns a single number: how bad that guess is. For calibration this is almost always a sum of squared errors between model output and market data. Small number, good fit. Zero, perfect fit. scipy.optimize.minimize is handed that function plus a starting guess, and it searches parameter space for the vector that makes the objective smallest.

θ^=argminθi(model(θ,xi)marketi)2\hat{\theta} = \arg\min_{\theta} \sum_i \big(\text{model}(\theta, x_i) - \text{market}_i\big)^2

In words: find the parameter vector θ\theta that minimizes the sum, over every data point ii, of the squared gap between what the model predicts and what was actually observed. The optimizer never sees your model directly — it only sees the number the objective function hands back, and it repeatedly tweaks θ\theta to make that number smaller.

Under the hood, most methods ('BFGS', 'L-BFGS-B', 'Nelder-Mead') either estimate the local slope of the objective and step downhill, or — for Nelder-Mead — probe nearby points without needing derivatives at all. 'L-BFGS-B' additionally supports box constraints (bounds=), which matters because most financial parameters must stay in a sensible range: a volatility can't be negative, a mean-reversion speed can't be zero or below.

Worked example 1: fitting a straight line by hand

Suppose "the market" gives you three points that should lie on y=mx+cy = mx + c: (1,2.1)(1, 2.1), (2,3.9)(2, 3.9), (3,6.2)(3, 6.2). Start with a guess m=1,c=1m=1, c=1.

import numpy as np
from scipy.optimize import minimize

x = np.array([1, 2, 3])
y_market = np.array([2.1, 3.9, 6.2])

def objective(params):
    m, c = params
    y_model = m * x + c
    return np.sum((y_model - y_market) ** 2)   # sum of squared errors

result = minimize(objective, x0=[1.0, 1.0], method="BFGS")
print(result.x)          # ~[2.05, -0.03]
print(result.fun)        # residual sum of squares, ~0.007

At the start, m=1,c=1m=1, c=1 gives model values 2,3,42, 3, 4 against market 2.1,3.9,6.22.1, 3.9, 6.2 — errors of 0.1,0.9,2.2-0.1, -0.9, -2.2, squared and summed to about 5.95.9. BFGS estimates the local gradient of that error surface, steps toward lower error, and repeats. It converges near m2.05,c0.03m \approx 2.05, c \approx -0.03, which nearly reproduces the roughly-linear jump of about 2 per step in the market data, with a tiny residual of about 0.007 because the data isn't perfectly linear.

market points vs starting guess vs fitted line (1, 2.1)(2, 3.9)(3, 6.2) start (m=1) fitted
The fitted line passes close to all three points; the starting guess was far too shallow. BFGS's downhill steps closed that gap by adjusting slope and intercept together.

Worked example 2: calibrating a mean-reverting model with bounds

A simplified model says variance reverts to a long-run level θ\theta at speed κ\kappa: model(t)=v0eκt+θ(1eκt)\text{model}(t) = v_0 e^{-\kappa t} + \theta(1 - e^{-\kappa t}). Given observed variances at a few maturities, calibrate κ\kappa and θ\theta, keeping both positive.

def objective(params, t, v_observed, v0):
    kappa, theta = params
    v_model = v0 * np.exp(-kappa * t) + theta * (1 - np.exp(-kappa * t))
    return np.sum((v_model - v_observed) ** 2)

t = np.array([0.25, 1.0, 2.0, 5.0])
v_observed = np.array([0.045, 0.040, 0.038, 0.036])
v0 = 0.05

result = minimize(
    objective, x0=[1.0, 0.035], args=(t, v_observed, v0),
    method="L-BFGS-B", bounds=[(1e-4, None), (1e-4, None)],
)

args= passes the fixed data alongside the parameters being searched; bounds= stops the optimizer from ever trying a negative κ\kappa or θ\theta, which would make the model nonsensical even though the raw math wouldn't complain.

objective value vs parameter guess start minimum found a local dip could trap a bad starting guess
Each optimizer step moves to a lower point on the error surface. A single downhill run only guarantees the nearest dip, not the true lowest point of the whole surface — this is why the starting guess matters.

scipy.optimize.minimize needs one thing from you: a function from parameters to a single "badness" score. Calibration is just minimizing that score. bounds= keeps parameters financially sensible, and args= passes through fixed data the objective needs but isn't optimizing over.

What this means in practice

Complexity depends on the method: gradient-based methods (BFGS, L-BFGS-B) typically need on the order of tens of objective evaluations per parameter to converge, each evaluation costing whatever it costs to run the model once. Nelder-Mead needs no gradient but converges slower and scales poorly past ~10 parameters. For anything with more than a handful of parameters or an expensive-to-evaluate model, gradient-based methods with analytic or automatic-differentiation gradients (jac=) beat finite-difference gradients by an order of magnitude in speed.

minimize finds a local minimum, not necessarily the global one — the objective surface for a real pricing model can have multiple dips. A bad starting guess can converge to a plausible-looking but wrong calibration that silently passes review. Always sanity-check the fitted parameters against known ranges, and for genuinely multi-modal surfaces use a global method first (scipy.optimize.differential_evolution or multiple random restarts) before polishing with a local method.

Where it shows up

Interviewers in a quant-dev round may ask you to set up a least-squares objective and call minimize correctly — get the shape of x0, the use of args, and constraint handling right. In production, this exact pattern calibrates volatility surfaces, term-structure models, and risk-factor models daily: the objective function wraps a pricing routine, the market quotes are the target, and the fitted parameters feed straight into the day's risk and pricing systems.

Related concepts

Practice in interviews

Further reading

  • SciPy documentation, scipy.optimize.minimize
  • Nocedal & Wright, Numerical Optimization (ch. 3, 10)
ShareTwitterLinkedIn