2. Baseline Neural Network for Crash Pulse Prediction

Problem framing: from three scalars to a 200-point pulse

This baseline case builds the simplest model that can still be called a response-curve predictor: a fully connected feedforward network that consumes a small vector of impact parameters and emits the entire deceleration–time history in one shot. The input is a fixed-length vector [velocity_kph, angle_deg, barrier_onehot(3)] — five numbers after one-hot encoding the barrier type (rigid_wall, pole, offset_deformable). The output is a fixed-length vector of T = 200 samples spanning 0–200 ms at 1 ms resolution, expressed in g. Because the output length is fixed and the mapping is memoryless, an MLP is a legitimate baseline: it establishes the error floor that the later sequence models must beat, and it exposes immediately whether the input parameters carry enough information to reconstruct pulse shape at all.

Two design decisions dominate everything downstream. First, the output must be normalized per-channel, because raw g values span roughly 0–60 with a long tail; unnormalized targets make the MSE gradient scale wildly across samples. Second, the pulse is smooth and correlated in time, so plain MSE on raw samples is a weak objective — it rewards matching the mean level and tolerates phase error. We therefore train on MSE but evaluate on both MSE and a shape-aware metric (peak acceleration error and peak-time error), which is what an occupant-safety engineer actually reads.

Architecture and data contract

The network is deliberately small: 5 → 128 → 256 → 256 → 200, with GELU activations, dropout 0.1 between hidden layers, and a linear output head. Parameter count is about 105k — small enough to train on a laptop CPU in minutes, large enough to fit a few hundred pulses. The data contract is the part that must be pinned down before any training run:

ItemSpecificationRationale
Input featuresvelocity_kph (16–64), angle_deg (0–30), barrier one-hot (3)Scalars available at test setup time
Feature scalingvelocity ÷ 64, angle ÷ 30; one-hot unchangedKeeps all inputs in [0,1]
Targeta(t), 200 samples, 1 ms spacing, in gFixed length enables a dense head
Target scalingPer-dataset mean/std over all samples and channelsStabilizes MSE gradient
Split70/15/15 train/val/test, stratified by barrier typePrevents barrier-type leakage

A subtle but important point: the target normalization statistics must be computed on the training split only and then applied unchanged to validation and test. Recomputing per split leaks information and inflates validation scores.

Reference implementation

The following is a self-contained PyTorch implementation. It assumes the cleaned dataset from the previous stage is available as a single .npz with arrays X (N×5), Y (N×200), and barrier (N, integer 0–2). Nothing else is imported from earlier chapters.

import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader

torch.manual_seed(0)
rng = np.random.default_rng(0)

# ---- 1. Load and split ------------------------------------------------
d = np.load("crash_pulses_clean.npz")
X, Y, barrier = d["X"].astype(np.float32), d["Y"].astype(np.float32), d["barrier"]

idx = rng.permutation(len(X))
n_tr, n_va = int(0.70 * len(X)), int(0.15 * len(X))
tr, va, te = idx[:n_tr], idx[n_tr:n_tr + n_va], idx[n_tr + n_va:]

# ---- 2. Normalize (fit on train only) ---------------------------------
x_mean, x_std = X[tr].mean(0), X[tr].std(0) + 1e-8
y_mean, y_std = Y[tr].mean(), Y[tr].std() + 1e-8

def prep(i):
    return torch.from_numpy((X[i] - x_mean) / x_std), \
           torch.from_numpy((Y[i] - y_mean) / y_std)

train_ds = TensorDataset(*[torch.stack(v) for v in zip(*[prep(i) for i in tr])])
val_ds   = TensorDataset(*[torch.stack(v) for v in zip(*[prep(i) for i in va])])
train_dl = DataLoader(train_ds, batch_size=32, shuffle=True)
val_dl   = DataLoader(val_ds, batch_size=64)

# ---- 3. Model ---------------------------------------------------------
class PulseMLP(nn.Module):
    def __init__(self, n_in=5, n_out=200):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_in, 128), nn.GELU(), nn.Dropout(0.1),
            nn.Linear(128, 256), nn.GELU(), nn.Dropout(0.1),
            nn.Linear(256, 256), nn.GELU(),
            nn.Linear(256, n_out),
        )
    def forward(self, x):
        return self.net(x)

model = PulseMLP()
opt = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)
loss_fn = nn.MSELoss()

# ---- 4. Training loop -------------------------------------------------
best_val, patience, wait = float("inf"), 20, 0
for epoch in range(200):
    model.train()
    for xb, yb in train_dl:
        opt.zero_grad()
        loss = loss_fn(model(xb), yb)
        loss.backward()
        opt.step()

    model.eval()
    with torch.no_grad():
        v = sum(loss_fn(model(xb), yb).item() for xb, yb in val_dl) / len(val_dl)
    if v < best_val - 1e-5:
        best_val, wait = v, 0
        torch.save(model.state_dict(), "pulse_mlp_best.pt")
    else:
        wait += 1
        if wait >= patience:
            print(f"early stop at epoch {epoch}, best val MSE {best_val:.5f}")
            break

Two implementation details matter for reproducibility. The zip(*[prep(i) for i in tr]) idiom stacks the per-sample tensors into two contiguous tensors; if your dataset is large, replace it with a custom Dataset that normalizes on the fly. And the early-stopping patience of 20 epochs is calibrated for this small model — with a larger network or a noisier dataset, raise it to 40–50 before concluding the model has plateaued.

What the baseline actually learns

The figure below shows the information path and the two failure modes that dominate this architecture. The left branch is the forward pass; the right branch is the diagnostic view you should inspect after training. The key insight is that an MLP has no notion of time — it treats the 200 output slots as independent regression targets that happen to be correlated. That is exactly why it can reproduce the overall pulse envelope but tends to smear the sharp leading edge and the rebound trough.

Baseline MLP: forward path and diagnostic view Impact parameters velocity_kph angle_deg barrier one-hot (3) → 5-dim vector Dense 128 GELU · Drop 0.1 Dense 256 GELU · Drop 0.1 Dense 256 GELU Linear head Output: 200 samples a(t), 0–200 ms, 1 ms denormalized to g no temporal structure in the head Diagnostic view: predicted vs. reference pulse time (ms) a (g) reference pulse MLP prediction Typical baseline errors • leading-edge slope underestimated (peak smeared) • peak-time error 3–8 ms on high-velocity cases • rebound trough flattened toward the mean peak a: ref 52.1 g / pred 47.6 g

Evaluation and acceptance criteria

Do not judge this model by training loss alone. After loading pulse_mlp_best.pt, run the test split through the model, denormalize with y_mean/y_std, and compute the following on each pulse:

  • Curve MSE in g² — the optimization target, useful for tracking convergence.
  • Peak acceleration error |max(a_pred) − max(a_ref)| in g; a baseline MLP typically lands in the 3–8 g band on unseen cases.
  • Peak-time error |argmax(a_pred) − argmax(a_ref)| in ms; values above 10 ms indicate the model is not resolving the leading edge.
  • Pulse-duration error — first time the signal falls below 5 g after the peak.

A reasonable acceptance gate for this baseline is: median peak-acceleration error below 10% of the reference peak, and median peak-time error below 8 ms. If the model clears both, the input parameters carry enough signal and the later sequence models have a meaningful floor to beat. If it fails, the problem is almost always one of three things — target normalization computed on the full dataset, a learning rate too high for the small batch size, or a barrier one-hot that was accidentally dropped during feature assembly. Check the input tensor shape first: it must be exactly (N, 5).

Save the normalization statistics (x_mean, x_std, y_mean, y_std) alongside the checkpoint. Every later model in this course must use the same statistics, otherwise cross-model comparisons are meaningless.
The MLP output is not constrained to be non-negative or physically smooth. On out-of-distribution inputs (e.g., velocity far outside the training range) it can emit oscillatory or negative deceleration. Do not deploy this baseline for anything other than benchmarking; clamp and smooth only for visualization, never for metric computation.