4. 1D Convolutional Neural Network for Crash Pulse Feature Extraction

Why Local Temporal Patterns Matter in Crash Pulses

A crash acceleration pulse is not a smooth curve that can be reconstructed from a handful of global scalars. Its information is concentrated in short, localized events: the initial rise rate at contact, the first deceleration peak, the valley that follows structural buckling, and the secondary oscillations produced by rebound and engine-block interaction. A fully connected baseline that maps [velocity, angle, barrier_type] to a 200-sample curve must implicitly learn where in time each feature belongs, and it typically smooths away exactly the high-frequency structure that matters for occupant injury metrics.

A 1D convolutional network attacks this problem differently. Convolution kernels slide along the time axis and learn translation-equivariant filters: the same detector for a steep leading edge fires wherever that edge occurs. Stacking dilated convolutions grows the receptive field so the network can still see the whole 40 ms pulse, while the parameter count stays far below a dense layer of comparable width. This case builds that model end to end and compares it against the baseline on identical data splits.

Signal Representation and Dataset Contract

Each sample is a scalar parameter vector x ∈ R³ and a target curve y ∈ R^T with T = 200 samples at a 0.2 ms step (40 ms window). The scalar parameters must first be expanded into a time-aligned conditioning signal, otherwise the convolution has nothing to modulate. The standard trick is to broadcast the parameters to length T and concatenate them as extra input channels, giving a tensor of shape (batch, 3, T). The network then learns filters over the joint parameter–time field.

Normalization is not optional here. Acceleration amplitudes span roughly 0–80 g, while velocity is 0–60 km/h and angle is 0–30°. Without per-channel standardization the convolution weights are dominated by the largest-magnitude channel and training diverges or stalls. Fit the scaler on the training split only and reuse it for validation and test.

import numpy as np
import torch
import torch.nn as nn

T = 200
torch.manual_seed(0)

# --- Synthetic teaching dataset (stand-in for a validated crash pulse database) ---
def make_pulse(v, ang, barrier, t):
    # Two-lobe pulse: primary deceleration + rebound oscillation.
    # Parameters are illustrative, not measured crash data.
    t0 = 0.006 + 0.00004 * v
    width = 0.004 + 0.00002 * ang
    amp = 18.0 + 0.55 * v + 0.4 * ang + 6.0 * barrier
    primary = amp * np.exp(-((t - t0) ** 2) / (2 * width ** 2))
    rebound = 0.35 * amp * np.exp(-((t - (t0 + 0.012)) ** 2) / (2 * (1.6 * width) ** 2))
    return primary - rebound

def build_dataset(n=1200):
    rng = np.random.default_rng(42)
    v = rng.uniform(10, 60, n)
    ang = rng.uniform(0, 30, n)
    barrier = rng.integers(0, 2, n).astype(float)   # 0 = rigid, 1 = deformable
    t = np.linspace(0, 0.040, T)
    Y = np.stack([make_pulse(v[i], ang[i], barrier[i], t) for i in range(n)])
    X = np.stack([v, ang, barrier], axis=1)
    return X.astype(np.float32), Y.astype(np.float32)

X, Y = build_dataset()
idx = np.random.permutation(len(X))
tr, va, te = idx[:840], idx[840:1020], idx[1020:]

mu_x, sd_x = X[tr].mean(0), X[tr].std(0) + 1e-8
mu_y, sd_y = Y[tr].mean(), Y[tr].std() + 1e-8
Xn = (X - mu_x) / sd_x
Yn = (Y - mu_y) / sd_y

def to_tensor(x, y):
    # Broadcast scalars to (B, 3, T) conditioning channels
    cond = torch.tensor(x, dtype=torch.float32).unsqueeze(-1).repeat(1, 1, T)
    return cond, torch.tensor(y, dtype=torch.float32)

Architecture: Dilated 1D CNN with Residual Blocks

The design uses four residual blocks with kernel size 5 and dilation rates 1, 2, 4, 8. The receptive field after the stack is 1 + 4·(5−1)·(1+2+4+8) = 241 samples, which exceeds T = 200, so every output position can attend to the full pulse. Each block applies Conv1d → BatchNorm → GELU twice with a skip connection, and a final 1×1 convolution projects to a single output channel that is squeezed to length T.

Dilated 1D CNN: Conditioning Channels → Residual Stack → Pulse Input v, angle, barrier broadcast → (B,3,T) T = 200 Stem Conv1d k=5, 32 ch GELU Residual Stack (4 blocks) Block 1 dilation=1 RF=5 Block 2 dilation=2 RF=21 Block 3 dilation=4 RF=53 Block 4 dilation=8 RF=117 1×1 Conv Head → 1 channel squeeze → (B,T) Cumulative Receptive Field vs. Pulse Length T = 200 samples (40 ms) — fully covered by RF = 241 Predicted Pulse (normalized) g t

Training Loop and Baseline Comparison

The baseline is a two-hidden-layer MLP (256 → 512 units, GELU) mapping the same three scalars to 200 outputs. Both models use the same optimizer, learning-rate schedule, batch size, and epoch budget so the comparison isolates the architectural difference. Loss is MSE on the standardized curve; report both standardized MSE and the physical-scale RMSE in g.

class ResBlock(nn.Module):
    def __init__(self, ch, dilation):
        super().__init__()
        self.conv1 = nn.Conv1d(ch, ch, 5, padding=2 * dilation, dilation=dilation)
        self.conv2 = nn.Conv1d(ch, ch, 5, padding=2 * dilation, dilation=dilation)
        self.norm1 = nn.BatchNorm1d(ch)
        self.norm2 = nn.BatchNorm1d(ch)
        self.act = nn.GELU()

    def forward(self, x):
        h = self.act(self.norm1(self.conv1(x)))
        h = self.norm2(self.conv2(h))
        return self.act(h + x)

class CNN1D(nn.Module):
    def __init__(self, in_ch=3, ch=32, T=200):
        super().__init__()
        self.stem = nn.Sequential(nn.Conv1d(in_ch, ch, 5, padding=2), nn.GELU())
        self.blocks = nn.Sequential(*[ResBlock(ch, d) for d in (1, 2, 4, 8)])
        self.head = nn.Conv1d(ch, 1, 1)
        self.T = T

    def forward(self, cond):
        h = self.blocks(self.stem(cond))
        return self.head(h).squeeze(1)

class MLPBaseline(nn.Module):
    def __init__(self, T=200):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(3, 256), nn.GELU(),
            nn.Linear(256, 512), nn.GELU(),
            nn.Linear(512, T),
        )
    def forward(self, x):
        return self.net(x)

def train(model, cond_tr, y_tr, cond_va, y_va, epochs=120, lr=1e-3):
    opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4)
    sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)
    loss_fn = nn.MSELoss()
    for ep in range(epochs):
        model.train()
        perm = torch.randperm(len(y_tr))
        for i in range(0, len(y_tr), 32):
            b = perm[i:i+32]
            opt.zero_grad()
            pred = model(cond_tr[b])
            loss = loss_fn(pred, y_tr[b])
            loss.backward()
            opt.step()
        sched.step()
    model.eval()
    with torch.no_grad():
        va_pred = model(cond_va)
        return loss_fn(va_pred, y_va).item()

cond_tr, y_tr = to_tensor(Xn[tr], Yn[tr])
cond_va, y_va = to_tensor(Xn[va], Yn[va])
cond_te, y_te = to_tensor(Xn[te], Yn[te])

cnn = CNN1D()
mlp = MLPBaseline()

cnn_val = train(cnn, cond_tr, y_tr, cond_va, y_va)
mlp_val = train(mlp, torch.tensor(Xn[tr]), y_tr, torch.tensor(Xn[va]), y_va)

with torch.no_grad():
    cnn_te = ((cnn(cond_te) - y_te) ** 2).mean().item()
    mlp_te = ((mlp(torch.tensor(Xn[te])) - y_te) ** 2).mean().item()

print(f"CNN  val={cnn_val:.4f}  test={cnn_te:.4f}")
print(f"MLP  val={mlp_val:.4f}  test={mlp_te:.4f}")
print(f"CNN test RMSE (g) = {(cnn_te ** 0.5) * sd_y:.2f}")
print(f"MLP test RMSE (g) = {(mlp_te ** 0.5) * sd_y:.2f}")

Reading the Result and Diagnosing Failure Modes

On this synthetic two-lobe pulse family, the CNN typically reaches a lower validation MSE than the MLP because the residual stack can represent the sharp leading edge and the rebound valley as separate localized filters, whereas the MLP must approximate both with dense weights that smear across the whole output vector. The expected qualitative signature is that the MLP under-predicts peak amplitude and over-smooths the valley, while the CNN tracks both lobes. Treat these as expected behaviors of the architecture on this data family, not as measured benchmark numbers.

If the CNN does not outperform the baseline, check these in order:

  • Conditioning broadcast missing. Passing (B,3) directly into Conv1d raises a shape error or silently treats the three scalars as a length-3 sequence. Confirm the input is (B,3,T).
  • Receptive field too small. Dropping the dilation-8 block reduces RF to 117, and the network can no longer see the rebound lobe from the leading edge. Verify the RF arithmetic against T.
  • Normalization leakage. Fitting the scaler on the full dataset inflates validation scores. Refit on tr only.
  • BatchNorm with tiny batches. Batch size 32 is fine; below 8 the running statistics become noisy and validation loss oscillates.
  • Learning-rate too high for the residual stack. If training loss spikes after epoch 5, drop lr to 3e-4 before changing the architecture.
A quick sanity check before full training: run one forward pass and confirm the output shape is (B, 200) and that the untrained model's output variance is non-zero. A constant output usually means the final 1×1 convolution collapsed or the input channels were all zero after normalization.
The pulse generator in this case is a synthetic stand-in built to expose the architectural difference. Do not report its RMSE values as crash-simulation accuracy. When moving to a validated pulse database, re-fit mu_x, sd_x, mu_y, sd_y on that database's training split and re-check the receptive field against the actual sampling rate and window length.