loading model…
Example · aircraft
design sweep · α = 4° · one surrogate call each
surrogatemodels

Put it to work.

In one lineMillisecond answers turn design studies, sensitivity analysis and real-time tools into routine work.

You will learn to
  • Name the four main uses of a trained surrogate
  • Propagate input scatter through a surrogate by Monte Carlo
  • Rank inputs by their effect with Sobol sensitivity indices
Before this

Limits and trust

Trained and validated, a surrogate is a few kilobytes to a few gigabytes of weights that answer in milliseconds. That speed changes how engineering is done:

Design explorationSweep thousands of variants in the time one CFD run takes, and find the promising region before spending compute.
OptimisationPut the surrogate inside an optimiser. A differentiable surrogate also gives gradients with respect to the design.
UncertaintyPropagate manufacturing tolerances or flight-condition scatter by Monte Carlo, far too slow with a solver in the loop.
Real timeInteractive design tools and digital twins that keep up with a slider, or with sensors on the real asset.

Monte Carlo through the surrogate

Real inputs scatter: the angle of attack varies with gusts and trim, the sweep with manufacturing tolerance. To know how much the lift scatters in return, draw many input samples from their distributions, evaluate each, and look at the spread of the outputs. This is Monte Carlo propagation. It needs tens of thousands of evaluations: about eleven years of a one-hour solver for 100,000 samples, seconds to minutes with a neural surrogate on a GPU (a few milliseconds for the simple formula in the code below).

Which input matters: Sobol indices

Sensitivity analysis asks which inputs drive the output's variation over the whole design range. The first-order Sobol index of an input is the share of the output variance it causes on its own; the total index adds its interactions with the other inputs. Indices near zero mark inputs you could fix without losing much. Estimating them also takes thousands of evaluations, which a surrogate makes routine. (Sobol indices and the Sobol sequence of sampling share the name of the same mathematician but are different tools: one measures sensitivity, the other spreads samples.)

NumPy and SciPy · Monte Carlo and Sobol indices through a surrogate
import time
import numpy as np
from scipy import stats

def surrogate(alpha, sweep):
    # Stands in for model.predict: the lift coefficient.
    s = np.cos(np.radians(sweep))
    return 0.09 * (alpha + 1.5) * s**1.5

# Monte Carlo: how much does CL scatter when alpha and
# sweep vary a little around a design point?
rng = np.random.default_rng(0)
t = time.perf_counter()
alpha = rng.normal(4.0, 0.3, 100_000)  # flight scatter
sweep = rng.normal(28.0, 0.5, 100_000) # tolerance
cl = surrogate(alpha, sweep)
dt = time.perf_counter() - t
print(f"CL = {cl.mean():.3f} +/- {cl.std():.3f}, "
      f"95 % above {np.quantile(cl, 0.05):.3f} "
      f"({dt:.3f} s)")

# Sobol indices: which input drives the variance of CL
# over the whole design box?
res = stats.sobol_indices(
    func=lambda x: surrogate(x[0], x[1]),  # x: (2, n)
    n=2**14,
    # alpha uniform in [-2, 8], sweep in [20, 36]
    dists=[stats.uniform(-2, 10), stats.uniform(20, 16)],
    rng=rng,
)
print("first order", res.first_order.round(3))
print("total", res.total_order.round(3))

News on this site shows the same uses at scale: PhysicsNeMo in NVIDIA's Agent Toolkit and PhysicsX's Series C.

Check yourself

Why is Monte Carlo with 100,000 samples routine with a surrogate and not with the solver?

Each surrogate call costs microseconds to milliseconds, so 10⁵ samples take seconds. With a solver at an hour per run it would take over eleven years.

A first-order Sobol index of 0.9 for α means what?

About 90 % of the output variance over the chosen input ranges comes from α acting alone.

Next lessonChoosing the next simulation


Unit 8 · Design loops/Use itAdvanced

Run the solver where it helps most.

In one lineRun the solver where the surrogate is unsure or a better design may hide.

You will learn to
  • Run a sequential design loop: fit, choose, simulate, refit
  • Choose the next run by uncertainty or by expected improvement
  • Decide when to stop adding runs

A space-filling design (sampling) spreads runs evenly before anything is known. Once a first surrogate exists, it can say where the next run would teach it most. Choosing runs one batch at a time this way is sequential design, or active learning.

The loop

  1. Run an initial space-filling design, a few runs per input dimension.
  2. Fit a surrogate that knows its uncertainty: a Gaussian process, or an ensemble.
  3. Score a large set of candidate designs with an acquisition function, and pick the best.
  4. Run the solver there, add the result, refit. Stop when the budget is spent or the score stops improving.

What to ask for

  • Uncertainty sampling: pick the candidate with the largest σ. It improves the model everywhere, which is what you want for a general-purpose surrogate.
  • Expected improvement: pick the candidate where the chance of beating the best design so far, weighted by how much, is highest. It balances exploiting good regions against exploring uncertain ones, and it is the usual choice for Bayesian optimisation.
EI(x) = (f* − μ) · Φ(z) + σ · φ(z)
f*: the best value so far (minimising); μ, σ: the GP mean and standard deviation at x; z = (f* − μ)/σ; Φ, φ: the standard normal CDF and density.
scikit-learn · uncertainty sampling with a Gaussian process
import numpy as np
from scipy.stats import qmc
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import RBF, ConstantKernel

def solver(X):
    # Stands in for one expensive CFD run per row.
    a, s = X[:, 0], X[:, 1]
    return np.sin(3 * a) * np.cos(2 * s) + 0.5 * a

rng = np.random.default_rng(0)
lhs = qmc.LatinHypercube(d=2, rng=rng)
X = lhs.random(8)               # 8 initial runs
y = solver(X)
X_cand = lhs.random(2000)       # designs we could run next
X_test = rng.random((500, 2))
kernel = ConstantKernel() * RBF([0.3, 0.3])

for step in range(12):          # 12 more runs, one by one
    gp = GaussianProcessRegressor(kernel, normalize_y=True)
    gp.fit(X, y)
    mean, std = gp.predict(X_cand, return_std=True)
    # The candidate where the model is least sure.
    x_next = X_cand[[std.argmax()]]
    X = np.vstack([X, x_next])
    y = np.append(y, solver(x_next))
    err = gp.predict(X_test) - solver(X_test)
    rmse = np.sqrt(np.mean(err**2))
    print(step, len(y), round(rmse, 3), round(std.max(), 3))

In this run the test RMSE falls from 0.046 to 0.001 as 12 runs are added, and the largest σ falls with it: σ is a usable stopping signal. For expected improvement, batches and constraints, use BoTorch rather than writing your own.

In practice

  • Batches. On a cluster you run 10 or 50 simulations at once. Picking the top 10 σ puts them side by side; batch methods add one point, pretend its result is known, update σ, and pick the next.
  • Neural surrogates. Use the spread of a 5-member ensemble as σ. Retraining five networks per round is the main cost; fine-tune from the previous round instead of starting over.
  • When it does not help. If the response is smooth and the budget is large, a good space-filling design is nearly as good and much simpler. Active learning pays when runs are expensive and the interesting behaviour is local.

Further on this site: Active operator learning chooses simulations for neural operators with cheap uncertainty estimates; PhysicsNeMo added active-learning workflows in its 26.05 release.

Check yourself

What is the difference between uncertainty sampling and expected improvement?

Uncertainty sampling improves the model everywhere (pick the largest σ). Expected improvement looks for a better design (balance a good predicted value against σ).

Why choose a batch of runs rather than one at a time on a cluster?

Runs are cheaper in parallel. Batch methods pick points that are uncertain and spread out, so the batch does not cluster at one spot.

Next lessonOptimising on a surrogate


Unit 8 · Design loops/Use itAdvanced

Follow the gradient, then check the answer.

In one lineFollow the surrogate’s gradients to a better design, then re-simulate it: optimisers exploit errors.

You will learn to
  • Optimise design parameters through a frozen surrogate with torch.optim
  • Keep the design inside the training box
  • Explain why optimisers find surrogate errors, and how to guard against it
  • Verify the winner with the solver

A neural surrogate is differentiable. Backpropagation (backpropagation and autograd) gives the gradient of the loss with respect to the weights; the same machinery gives the gradient of the prediction with respect to the inputs. If the inputs are design parameters, that gradient says how to change the design to improve it, at the cost of one backward pass. Solvers can give such gradients too, through adjoint methods, but each costs about as much as another simulation.

Optimise the inputs, not the weights

The recipe turns the training loop inside out. Freeze the network. Make the design a tensor with requires_grad=True and hand it to an optimiser. The loss is the objective to minimise, for example −CL/CD to maximise lift-to-drag. After each step, clamp the design back into the training box.

PyTorch · optimise α and Λ through a frozen surrogate
import torch
from torch import nn

torch.manual_seed(0)

def solver(d):
    # Stands in for one CFD run: returns (CL, CD).
    a, s = d[..., 0], d[..., 1]
    cl = 0.09 * (a + 1.5) * torch.cos(torch.deg2rad(s))
    cd = 0.008 + 0.002 * (s / 30) ** 2 + 0.045 * cl**2
    return torch.stack([cl, cd], -1)

# 1. A small surrogate trained on 45 runs inside the box.
lo, hi = torch.tensor([-2.0, 20.0]), torch.tensor([8.0, 36.0])
X = lo + (hi - lo) * torch.rand(45, 2)
scale = torch.tensor([1.0, 20.0])  # CL, CD similar sizes
net = nn.Sequential(nn.Linear(2, 64), nn.SiLU(),
                    nn.Linear(64, 64), nn.SiLU(),
                    nn.Linear(64, 2))
def surrogate(d):
    return net((d - lo) / (hi - lo)) / scale
opt = torch.optim.Adam(net.parameters(), lr=3e-3)
for _ in range(3000):
    opt.zero_grad()
    loss = nn.functional.mse_loss(surrogate(X) * scale,
                                  solver(X) * scale)
    loss.backward()
    opt.step()
net.requires_grad_(False)          # freeze the surrogate

# 2. Optimise the design: alpha and sweep are now
# the parameters being trained.
design = torch.tensor([2.0, 30.0], requires_grad=True)
opt = torch.optim.Adam([design], lr=0.05)
for _ in range(500):
    cl, cd = surrogate(design)
    loss = -cl / cd                # maximise lift-to-drag
    opt.zero_grad()
    loss.backward()
    opt.step()
    with torch.no_grad():
        design.clamp_(lo, hi)      # stay inside the box

# 3. Re-simulate the winner before believing it.
with torch.no_grad():
    cl, cd = surrogate(design)
    cl_s, cd_s = solver(design)
print(design.detach().numpy().round(2))
print(round(float(cl / cd), 2), round(float(cl_s / cd_s), 2))

A typical run ends near α ≈ 3.7°, Λ = 20°, with a predicted L/D of about 25 and the solver agreeing to within about 0.1. Note where the optimum landed: on the edge of the box, at the smallest sweep allowed. That is common, and it is exactly where the surrogate has the fewest neighbouring runs.

Optimisers find the surrogate's mistakes

An optimiser searches for the input with the best predicted value. If the surrogate is wrong somewhere in a favourable direction, the optimiser will find that place, because it looks better than anything real. The more freedom the design has, the more such places exist. Guards, from simplest to strongest:

  • Bounds and a trust region. Keep the design inside the training box, and limit each optimisation round to a small region around known runs.
  • Penalise uncertainty. Optimise the predicted value plus k times the ensemble spread (for a minimisation), so uncertain regions look worse rather than better.
  • Robust objectives. Optimise the worst case inside a conformal interval (uncertainty), so the chosen design is good even if the surrogate is off by its calibrated error.
  • Close the loop. Simulate the optimum, add it to the data, retrain, optimise again: active learning aimed at the optimum.

Gradient-free optimisers (differential evolution, genetic algorithms, Bayesian optimisation) work with any surrogate, including non-differentiable ones, and escape local optima more easily; with a surrogate their many evaluations are affordable. Gradients win when there are tens or hundreds of design variables.

Further on this site: Robust predict-then-optimize builds conformal bounds into the optimisation; CANTO optimises CAD parameters through a surrogate and verifies every design with CFD, which is the subject of learning straight from CAD.

Check yourself

In the code, which tensors have requires_grad=True during the design loop?

Only the design (α and Λ). The network's weights are frozen; the gradient flows through the network to its inputs.

The optimum sits on the edge of the training box. Why be careful?

Edges are where the surrogate has the fewest neighbouring runs and the largest error. Run the solver there, add the run to the data and re-optimise.

Next unit · The frontierPretrained physics models