A Physics-Informed Neural Network (PINN) is a neural architecture that embeds known physical or financial laws directly into its training process.
Instead of learning purely from data, the PINN minimizes a composite loss function that enforces the governing partial differential equation (PDE) — in this case, the Black–Scholes equation — as a soft constraint.
This approach allows the network to:
- Learn the structure of option prices under no-arbitrage conditions,
- Generalize better than purely data-driven models, and
- Provide continuous, differentiable pricing surfaces across strike, time, and volatility domains.
For a European-style call option with price
subject to the terminal condition at maturity
for strike price
The PINN learns a function
The total training loss combines three key components:
where:
-
PDE residual loss enforces the Black–Scholes differential constraint:
\mathcal{L}_{\text{PDE}} = \mathbb{E}\left[\left( V_t + \tfrac{1}{2}\sigma^2 S^2 V_{SS} + r S V_S - r V \right)^2\right] -
Terminal loss enforces the payoff condition
$V(S, T) = \max(S-K, 0)$ -
Data loss (optional) fits observed market or synthetic prices
Training is performed using stochastic gradient descent (e.g., Adam), with all spatial and temporal derivatives computed via PyTorch’s autograd.
For American-style options, early exercise introduces a free boundary condition:
and the PDE becomes an inequality-constrained problem (a complementarity formulation):
In the PINN framework, this condition is typically handled by:
- Adding a penalty term that enforces
$V \geq \text{payoff}$ , - Or training two subnetworks (one for the continuation region, one for the exercise region),
- Or using a ReLU barrier to ensure nonnegative exercise premium.
Support for this is currently in development.
After training, we visualize the PINN's predictions against analytic Black–Scholes solutions (for European-style options) under a constant volatility assumption:
plt.plot(S_grid, V_true, label="Black–Scholes (analytic)")
plt.plot(S_grid, V_pred, '--', label="PINN prediction")
plt.xlabel("Underlying Price S")
plt.ylabel("Option Price V(S, t)")
plt.legend()
plt.grid(True)This section describes how the repository implements a Physics-Informed Neural Network (PINN) for European-style option pricing, and explains the key pieces of the reference implementation (model, losses, training loop, and evaluation).
The model approximates the option pricing function $$ V(S, t; K, r, \sigma) $$ where:
Sis the underlying asset price (spot),tis the current time (measured in years),Kis the option strike,ris the risk-free rate,σis volatility (constant or per-sample).
We train a neural network V̂_θ that takes (scaled) inputs and outputs the option price.
The Black–Scholes PDE (used for the PDE residual term) is rendered using the math fenced block below:
The PINN minimizes the mean-square of this PDE residual over randomly sampled collocation points (S, t) in the training domain.
The overall loss is a weighted sum of three terms:
-
PDE residual loss
Mean squared PDE residual across collocation points:L_pde = mean( (V_t + 0.5 * sigma^2 * S^2 * V_SS + r*S*V_S - r*V)^2 ) -
Terminal (payoff) loss
Enforce the terminal condition at maturityT:$$V(S, T) = \max(S - K, 0)$$ Practically:
L_term = mean( (V(S, T) - max(S - K, 0))**2 )
-
Data loss (optional)
Fit observed market prices (or synthetic BS prices) at sampled(S, t):L_data = mean( (V_pred - market_price)**2 )
Final loss:
Loss = λ_pde * L_pde + λ_term * L_term + λ_data * L_data
Below are the important functions and design choices from the reference implementation.
A fully connected feed-forward network that accepts scaled inputs (S_scaled, t_scaled, sigma_scaled) and outputs the scalar price:
class PINN(nn.Module):
def __init__(self, input_dim=3, width=64, depth=3):
super().__init__()
layers = []
layers.append(nn.Linear(input_dim, width))
layers.append(nn.Tanh())
for _ in range(depth - 1):
layers.append(nn.Linear(width, width))
layers.append(nn.Tanh())
layers.append(nn.Linear(width, 1))
self.net = nn.Sequential(*layers)
def forward(self, S, t, sigma):
x = torch.cat([S, t, sigma], dim=1) # (N, 3)
return self.net(x) # (N, 1)Note: scaling (S/K and t/T) is applied before passing inputs to the network. Scaling stabilizes training.
We compute derivatives using torch.autograd.grad. The residual function returns the PDE residual for a batch of (S,t):
def pde_residual(model, S, t, sigma, r):
S_req = S.clone().detach().requires_grad_(True)
t_req = t.clone().detach().requires_grad_(True)
sigma_in = sigma.clone().detach()
V = model(S_req_scaled, t_req_scaled, sigma_scaled) # model expects scaled inputs
V_S = torch.autograd.grad(V, S_req, grad_outputs=torch.ones_like(V), create_graph=True)[0]
V_t = torch.autograd.grad(V, t_req, grad_outputs=torch.ones_like(V), create_graph=True)[0]
V_SS = torch.autograd.grad(V_S, S_req, grad_outputs=torch.ones_like(V_S), create_graph=True)[0]
residual = V_t + 0.5 * (sigma_in ** 2) * (S_req ** 2) * V_SS + r * S_req * V_S - r * V
return residualIn practice, the code wraps the model so that raw physical variables S and t are scaled internally before being passed to the neural net.
Evaluate the network at t = T and penalize deviation from the intrinsic payoff:
def terminal_loss(model, S_terminal, K, r, sigma, T):
t_T = torch.full_like(S_terminal, T)
V_pred_T = model(S_terminal_scaled, t_T_scaled, sigma_scaled)
payoff = torch.maximum(S_terminal - K, torch.zeros_like(S_terminal))
return torch.mean( (V_pred_T - payoff)**2 )The training loop alternates the computation of the three loss terms and backpropagates the weighted sum:
for epoch in range(n_epochs):
optimizer.zero_grad()
# collocation samples (S_coll, t_coll)
residual = pde_residual(wrapped_model, S_coll, t_coll, sigma_coll, r)
L_pde = torch.mean(residual**2)
# terminal loss
L_term = terminal_loss(wrapped_model_T, S_terminal, K_term, r, sigma_term, T_maturity)
# optional data loss
V_pred_data = model(S_data_scaled, t_data_scaled, sigma_data_scaled)
L_data = torch.mean( (V_pred_data - price_data)**2 )
loss = pde_weight * L_pde + term_weight * L_term + data_weight * L_data
loss.backward()
optimizer.step()Practical notes:
- Use a relatively large
term_weight(terminal loss) to ensure the network respects the payoff. - Train first with
Adam, optionally fine-tune withLBFGSfor better convergence. - Sample collocation points broadly across the
Sandtdomain (e.g.,S ∈ [0, 3K]andt ∈ [0, T]).
After training:
- Evaluate
V̂on a gridS_grid × t_gridand compare to analytic Black–Scholes results (whenσis constant) to validate correctness. - Typical plots:
V_PINN(S, t=0)vs. analyticV_BS(S, t=0).- Training history (total loss and components).
- Residual heatmap
|PDE_residual(S,t)|.
Example plotting code snippet:
# Evaluate on a grid at t = 0
S_grid = torch.linspace(0.01, 2*K_val, 300).view(-1,1).to(device)
t_zero = torch.zeros_like(S_grid).to(device)
# scale inputs and run model...
V_pred = model(S_grid_scaled, t_zero_scaled, sigma_grid_scaled).cpu().numpy().flatten()
# analytic Black–Scholes
V_true = bs_price_call_torch(S_grid.cpu(), K_grid.cpu(), t_zero.cpu(), T_maturity, r_val, sigma_const).cpu().numpy().flatten()
plt.plot(S_grid.cpu().numpy(), V_true, label='Black–Scholes (analytic)')
plt.plot(S_grid.cpu().numpy(), V_pred, '--', label='PINN prediction')
plt.xlabel('Underlying price S')
plt.ylabel('Option price V(S,t=0)')
plt.legend(); plt.grid(True)To use actual option-market data:
- Build tensors of
(S, t, K, sigma, market_price)where:S= current spot (or historical spot when price snapshot was taken)t= current time (or the time corresponding to the market price), or time-to-maturitysigma= implied volatility (optional — can be used as input) or leave as constantmarket_price= mid-price (or other observed price)
- Replace the mock
S_data, t_data, K_data, sigma_data, price_dataused in the demo with your actual tensors (all shaped(N,1)). - Keep the PDE residual and terminal loss components — they act as regularizers that enforce no-arbitrage structure.
- Scaling: Always scale
Sandtbefore passing to the network (e.g.S_scaled = S/K,t_scaled = t/T). This makes training stable. - Small maturities: For extremely small time-to-maturity, guard against numerical issues in the analytic price and in
tau = T - t(clamp witheps). - Weights: Tune
λ_pde,λ_term, andλ_data. If the network reproduces market prices but violates the PDE, increaseλ_pde. - Local volatility: To learn a
σ(S,t), replacesigmaas an input with a small subnetworksigma_net(S_scaled, t_scaled)that outputs a positive volatility (e.g. viasoftplus). Train both subnets jointly with the PDE residual.