Quick start¶
sweepx.opt exposes every optimizer through PyTorch's standard
torch.optim.Optimizer interface, so the entire library can be adopted by
swapping a single line.
Setup used on these pages¶
The code on the sweepx.opt pages runs after this block: the forward model
of Getting started, its data for a model with a
faster box as obs, and a constant 2000 m/s starting model.
import numpy as np, torch
import sweepx.opt
from sweep.equations import Acoustic
from sweep.propagator.torch import PropTorch
from sweep.signal import ricker
dev = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
solver = PropTorch(Acoustic(device=dev), shape=(96, 128), dh=10.0, dt=1e-3, device=dev)
wavelet = ricker(np.arange(1200) * 1e-3 - 0.12, f=10.0).astype(np.float32)
sources = np.array([[64, 2]], dtype=np.int64)
receivers = np.array([[[ix, 4] for ix in range(0, 128, 2)]], dtype=np.int64)
vp_true = torch.full((96, 128), 2000.0, device=dev) # (nz, nx)
vp_true[48:58, 32:96] = 2300.0
with torch.no_grad():
obs = solver(wavelet, sources, receivers, models=[vp_true])
init_vp = torch.full((96, 128), 2000.0, device=dev) # starting model
inv_vp = init_vp.clone().requires_grad_(True)
cfg = {"lr": 10.0, "epochs": 5}
The time step is 1 ms instead of the 2 ms of Getting started, so that the
solver stays stable up to 5500 m/s, the upper velocity bound of the examples:
with dh=10.0 and dt=2e-3 it is stable at 3000 m/s and returns NaN at
3300 m/s. sweep does not check the stability (CFL) condition, so a model or a
trial step beyond it gives NaN data rather than an error.
The drop-in replacement¶
Any PyTorch FWI loop already looks like this:
import torch
optimizer = torch.optim.Adam([inv_vp], lr=cfg["lr"], eps=1e-22)
for epoch in range(cfg["epochs"]):
optimizer.zero_grad()
syn = solver(wavelet, sources, receivers, models=[inv_vp])
loss = (syn - obs).pow(2).sum()
loss.backward()
optimizer.step()
For an optimizer whose step() works without a closure (Adam-FWI, Lion,
Shampoo, SOAP, Steepest Descent without a line search), change only the
constructor:
AdamFWI keeps the meaning of lr (about the change per step, m/s here).
Steepest Descent's lr multiplies the raw gradient instead; see
step sizes below.
Optimizers that need a closure¶
L-BFGS, L-BFGS-B, NCG, Steepest Descent with line_search="strong_wolfe"
and SAM evaluate the loss more than once per step. Wrap the loss block in a
function and pass it to step():
optimizer = sweepx.opt.optim.LBFGS([inv_vp], initial_step=20.0) # first trial: 20 m/s
def closure():
optimizer.zero_grad()
syn = solver(wavelet, sources, receivers, models=[inv_vp])
loss = (syn - obs).pow(2).sum()
loss.backward()
return loss
for epoch in range(cfg["epochs"]):
optimizer.step(closure)
The Newton-type optimizers differ: TruncatedNewton and TrustRegion take a
loss_fn that returns the loss without calling backward(),
TruncatedGaussNewton takes a residual_fn that returns the residual, and
all three need a solver that can be differentiated twice (sweep's
impl='eager' without torch.compile). See
Truncated Newton,
Truncated Gauss-Newton and
Trust-Region.
Step sizes are in parameter units¶
The gradient of this misfit with respect to a velocity in m/s is small: at
init_vp its largest entry is about 7e-4, while one float32 step at
2000 m/s is 1.2e-4 m/s. A step parameter that multiplies the raw gradient
needs a matching scale:
- Adam-FWI, Lion, SOAP normalize the update per coordinate (SOAP in a
rotated basis);
lris roughly the change per step in parameter units (m/s here). Shampoo normalizes the update as a matrix (its first step islrtimes an orthogonal factor), so per-cell changes are smaller thanlr. - Steepest Descent without a line search steps
lr * P g, solris in (m/s) / (gradient units), and it must also stay below the stability limit set by the stiffest part of the model; see Steepest Descent. - L-BFGS, L-BFGS-B, NCG and Steepest Descent with a line search start
each line search at
alpha_init * d, withdbuilt from the raw gradient.initial_step=sizes the first trial in parameter units instead. Without it, on this setup every NCG or Steepest Descent step moves the model by less than 0.01 m/s (five steps change the misfit by less than 0.1 %) and L-BFGS wastes its first step the same way; each such search emits aRuntimeWarning(it stopped atalpha_maxor left the parameters unchanged). - TrustRegion (
initial_radius,max_radius) and SAM (rho) take Euclidean norms over all parameters, in parameter units. - Truncated Newton / Gauss-Newton solve for the step itself, so their
alpha_init=1is already in parameter units.
With bounds and a preconditioner¶
bounds=(lo, hi) keeps every iterate, and every line-search trial, inside
the box. The pseudo-Hessian preconditioner
needs illumination maps, which sweep's native backend (impl='c', the
default on a GPU) fills during backward() once compute_illumination is
on. Refresh the preconditioner in the closure, right after backward(), so
every step direction uses the maps of the model it starts from:
import sweepx.opt
precond = sweepx.opt.precond.PseudoHessianPrecond(eps=1e-5)
solver.compute_illumination = True
optimizer = sweepx.opt.optim.LBFGS(
[inv_vp],
history_size=10,
bounds=(1500.0, 5500.0), # m/s velocity bounds
preconditioner=precond,
initial_step=20.0,
restart_every=20,
)
def closure():
optimizer.zero_grad()
syn = solver(wavelet, sources, receivers, models=[inv_vp])
loss = (syn - obs).pow(2).sum()
loss.backward()
S, R = solver.source_illumination, solver.receiver_illumination
precond.update([inv_vp], source_illum=S / S.max(), receiver_illum=R / R.max())
return loss
for epoch in range(cfg["epochs"]):
optimizer.step(closure)
The optimizer overview lists every algorithm and
what its step() takes.