4. Perform Sensitivity Analysis on Hyperparameters for a Neural Network

Why Hyperparameter Sensitivity Belongs in the Credibility Case

In a VVUQ workflow, sensitivity analysis answers a specific credibility question: how much of the variability in validation performance is attributable to each hyperparameter, and which ones dominate? This is not the same as hyperparameter tuning. Tuning searches for a good configuration; sensitivity analysis characterizes how the model's validation response behaves across a designed range of configurations so that reviewers can judge whether reported performance is robust or fragile. If a small change in learning rate swings validation accuracy by several points while batch size barely matters, that asymmetry is itself a credibility finding — it tells you where the model is brittle and where uncertainty quantification must concentrate.

This case uses a small, fully specified neural network on a synthetic binary classification task so that every number is reproducible. We run a one-at-a-time (OAT) design to get local, interpretable effects, then a variance-based (Sobol-style) design to apportion output variance across learning rate, batch size, and L2 regularization strength. The two designs answer different questions and are reported together.

Experimental Setup and Fixed Baseline

All runs share the same data, architecture, seed policy, and epoch budget; only the three hyperparameters vary. Fixing everything else is what makes the sensitivity attribution meaningful — if the data split or initialization changed between runs, you would be measuring noise, not hyperparameter effect.

import numpy as np
import torch
import torch.nn as nn
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split

# ---- Fixed experimental conditions ----
SEED = 20240501
N_RUNS_PER_CONFIG = 3          # repeat each config over 3 init seeds to average out init noise
EPOCHS = 40
HIDDEN = 32

def make_data():
    X, y = make_classification(
        n_samples=4000, n_features=20, n_informative=8,
        n_redundant=4, class_sep=0.9, flip_y=0.03, random_state=SEED)
    return train_test_split(X, y, test_size=0.3, stratify=y, random_state=SEED)

X_tr, X_va, y_tr, y_va = make_data()
X_tr = torch.tensor(X_tr, dtype=torch.float32); y_tr = torch.tensor(y_tr, dtype=torch.float32)
X_va = torch.tensor(X_va, dtype=torch.float32); y_va = torch.tensor(y_va, dtype=torch.float32)

def build_model():
    return nn.Sequential(
        nn.Linear(20, HIDDEN), nn.ReLU(),
        nn.Linear(HIDDEN, HIDDEN), nn.ReLU(),
        nn.Linear(HIDDEN, 1))

def run_config(lr, batch_size, weight_decay, init_seed):
    torch.manual_seed(init_seed)
    model = build_model()
    opt = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)
    loss_fn = nn.BCEWithLogitsLoss()
    n = X_tr.shape[0]
    g = torch.Generator().manual_seed(init_seed)
    for _ in range(EPOCHS):
        perm = torch.randperm(n, generator=g)
        for i in range(0, n, batch_size):
            idx = perm[i:i+batch_size]
            opt.zero_grad()
            loss = loss_fn(model(X_tr[idx]).squeeze(-1), y_tr[idx])
            loss.backward(); opt.step()
    with torch.no_grad():
        logits = model(X_va).squeeze(-1)
        preds = (torch.sigmoid(logits) >= 0.5).float()
        acc = (preds == y_va).float().mean().item()
    return acc

def evaluate(lr, batch_size, weight_decay):
    accs = [run_config(lr, batch_size, weight_decay, SEED + k) for k in range(N_RUNS_PER_CONFIG)]
    return float(np.mean(accs)), float(np.std(accs))

The output of interest is validation accuracy, averaged over three initialization seeds per configuration. The per-configuration standard deviation is retained because it separates initialization noise from hyperparameter effect: if the spread across seeds is comparable to the spread across hyperparameter values, the sensitivity result is not credible.

One-at-a-Time Design: Local Effects Around the Baseline

OAT holds two hyperparameters at the baseline and sweeps the third across a defined range. It is cheap, easy to explain, and directly answers "what happens if I nudge this one knob?" Its limitation — it cannot detect interactions — is why the variance-based design follows.

HyperparameterBaselineOAT sweep valuesRationale for range
Learning rate1e-31e-4, 3e-4, 1e-3, 3e-3, 1e-2Log-spaced across two decades around Adam's typical range
Batch size6416, 32, 64, 128, 256Powers of two; keeps epoch count fixed so steps/epoch changes
L2 weight decay1e-40, 1e-5, 1e-4, 1e-3, 1e-2Includes zero to test whether regularization helps at all
baseline = dict(lr=1e-3, batch_size=64, weight_decay=1e-4)
sweeps = {
    "lr":           [1e-4, 3e-4, 1e-3, 3e-3, 1e-2],
    "batch_size":   [16, 32, 64, 128, 256],
    "weight_decay": [0.0, 1e-5, 1e-4, 1e-3, 1e-2],
}

oat_results = {}
for name, values in sweeps.items():
    rows = []
    for v in values:
        cfg = dict(baseline); cfg[name] = v
        mean, sd = evaluate(**cfg)
        rows.append((v, mean, sd))
    oat_results[name] = rows
    print(f"\nOAT sweep: {name}")
    for v, mean, sd in rows:
        print(f"  {name}={v:<8} val_acc={mean:.4f}  (seed sd={sd:.4f})")

Read the OAT output as a local response curve. A steep monotonic segment indicates a hyperparameter the model is sensitive to in that region; a flat segment indicates insensitivity. The seed standard deviation printed alongside each point is the noise floor — any OAT difference smaller than roughly twice that floor should not be interpreted as a real effect.

Report OAT effects as local sensitivities around the stated baseline. They do not generalize to other regions of hyperparameter space, and they cannot reveal interactions such as "learning rate matters only when weight decay is high." State the baseline explicitly in any credibility report.

Variance-Based Design: Apportioning Output Variability

The variance-based design treats each hyperparameter as a random input with a defined distribution and estimates how much of the variance in validation accuracy each one explains. We use a Saltelli-style sampling scheme to estimate first-order Sobol indices (individual contribution) and total-order indices (individual plus interaction contribution).

from scipy.stats import qmc

# Sample in normalized [0,1]^3, then map to physical ranges (log for lr & wd)
BOUNDS = {
    "lr":           (1e-4, 1e-2),
    "batch_size":   (16, 256),
    "weight_decay": (1e-5, 1e-2),
}
D = 3
N_BASE = 64   # base sample size; total model runs = N_BASE*(D+2)*N_RUNS_PER_CONFIG

def to_physical(u):
    lr  = 10 ** (np.log10(BOUNDS["lr"][0]) + u[0]*(np.log10(BOUNDS["lr"][1])-np.log10(BOUNDS["lr"][0])))
    bs  = int(round(2 ** (np.log2(BOUNDS["batch_size"][0]) + u[1]*(np.log2(BOUNDS["batch_size"][1])-np.log2(BOUNDS["batch_size"][0])))))
    wd  = 10 ** (np.log10(BOUNDS["weight_decay"][0]) + u[2]*(np.log10(BOUNDS["weight_decay"][1])-np.log10(BOUNDS["weight_decay"][0])))
    return lr, bs, wd

sampler = qmc.LatinHypercube(d=D, seed=SEED)
A = sampler.random(N_BASE)
B = sampler.random(N_BASE)
AB = []
for i in range(D):
    ABi = A.copy(); ABi[:, i] = B[:, i]
    AB.append(ABi)

def f(u):
    lr, bs, wd = to_physical(u)
    return evaluate(lr, bs, wd)[0]

yA  = np.array([f(u) for u in A])
yB  = np.array([f(u) for u in B])
yAB = [np.array([f(u) for u in ABi]) for i in range(D)]

varY = np.var(np.concatenate([yA, yB]), ddof=1)
names = ["lr", "batch_size", "weight_decay"]
S1, ST = {}, {}
for i, name in enumerate(names):
    S1[name] = float(np.mean(yB * (yAB[i] - yA)) / varY)
    ST[name] = float(0.5 * np.mean((yA - yAB[i])**2) / varY)

print("\nVariance-based sensitivity (Sobol-style):")
for name in names:
    print(f"  {name:<14} S1={S1[name]:+.3f}  ST={ST[name]:+.3f}")

Interpretation rules for the credibility report:

  • S1 is the fraction of output variance explained by that hyperparameter alone. Values near 0 mean the hyperparameter is not a dominant driver in the sampled region.
  • ST is the fraction explained by that hyperparameter including all its interactions. A large gap between ST and S1 signals strong interaction — the hyperparameter's effect depends on the others.
  • If the sum of S1 is well below 1 while the sum of ST is near or above 1, interactions dominate and OAT alone would have been misleading.
  • Negative S1 estimates are sampling artifacts and should be reported as approximately zero, not as a real negative contribution.
The variance-based indices are only as credible as the input distributions. If you sample learning rate uniformly in linear space instead of log space, the high end dominates the sample and the index reflects that sampling choice, not the model. Document the sampling distributions alongside the indices.

Reading the Two Designs Together

One-at-a-Time (local) Learning rate (log sweep) 1e-4 1e-2 steep → sensitive Batch size flat → insensitive Weight decay mild optimum Baseline: lr=1e-3, bs=64, wd=1e-4 Noise floor = 2× seed sd combine Variance-based (global) Sobol indices (illustrative) 1.0 0.5 0 lr batch wd S1 (first-order) ST (total-order) ST − S1 gap ⇒ interaction strength Σ S1 ≪ 1 ⇒ interactions dominate

The two panels are complementary, not redundant. OAT tells you the shape of the local response — where the model is steep and where it is flat — which is what a reviewer needs to judge whether the reported operating point sits on a cliff edge. The variance-based indices tell you how to allocate credibility effort: the hyperparameter with the largest total-order index is the one whose uncertainty most needs to be characterized, and a large ST−S1 gap warns that OAT results alone would understate that hyperparameter's influence.

Deliverable: Sensitivity Summary for the Credibility Report

The following table is the artifact to hand to a reviewer. It records the design, the sampling distributions, and the interpretation, so the sensitivity claim is auditable rather than asserted.

FieldValue to record
Output metricValidation accuracy, mean over 3 init seeds
Fixed conditionsData split seed, architecture, 40 epochs, Adam optimizer
OAT baselinelr=1e-3, batch_size=64, weight_decay=1e-4
OAT rangeslr 1e-4–1e-2 (log), batch 16–256, wd 0–1e-2
Variance designLatin hypercube, N=64 base, D=3, Saltelli A/B/AB scheme
Input distributionslr log-uniform 1e-4–1e-2; batch log-uniform 16–256; wd log-uniform 1e-5–1e-2
Noise floor2× mean seed standard deviation across configurations
Dominant hyperparameterReport the one with largest ST; note S1 and ST−S1 gap
Credibility statementWhether OAT and variance-based conclusions agree; flag any interaction-driven divergence

Verification Checks Before Reporting

  • Noise floor respected: confirm that OAT differences you call "effects" exceed twice the seed standard deviation; otherwise report them as within noise.
  • Sampling documented: the variance-based indices must be accompanied by the exact input distributions, since the indices are conditional on them.
  • Index sanity: S1 values should be non-negative after accounting for sampling error; ST should be ≥ S1 for each hyperparameter. Violations indicate too few base samples or a bug in the AB construction.
  • Reproducibility: rerunning with a different sampler seed should shift indices by less than the reported uncertainty; a large shift means N_BASE is too small.
  • Consistency between designs: if OAT shows a hyperparameter as flat but its ST is large, an interaction is present — investigate before concluding insensitivity.

When these checks pass, the sensitivity result is a defensible credibility input: it identifies which hyperparameters dominate validation variability, quantifies the local response shape around the operating point, and separates genuine hyperparameter effect from initialization noise. That is the evidence a reviewer needs to decide whether the model's reported performance is robust or contingent on a narrow configuration.