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.
In words: find the parameter vector that minimizes the sum, over every data point , 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 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 : , , . Start with a guess .
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, gives model values against market — errors of , squared and summed to about . BFGS estimates the local gradient of that error surface, steps toward lower error, and repeats. It converges near , 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.
Worked example 2: calibrating a mean-reverting model with bounds
A simplified model says variance reverts to a long-run level at speed : . Given observed variances at a few maturities, calibrate and , 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 or , which would make the model nonsensical even though the raw math wouldn't complain.
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)