← All tutorials

On this page

1 Constraining the model 2 Adding training data 3 Cleaning sensor data 4 Regularizers in a PyTorch training loop 5 Common regularization failures 6 References

Regularization and Data Augmentation

Methods that stop a model fitting the noise in its training data instead of the pattern.

A model overfits when training error sinks while test error climbs (Understanding Model Errors). Two families of methods reduce it, constraining the model and adding training data; a third part cleans the data so the model learns the signal, not sensor faults. All act only in training or on the data and are judged on held-out data.

Constrain the modeldropout, L1 and L2
Add training dataaugmentation, class imbalance
Clean the datafiltering, missing channels, fusion

1 Constraining the model

1.1 Dropout

Dropout zeroes each activation with probability p on every training forward pass (one run of an input through the network), so each pass trains a thinned sub-network and no single neuron can carry the prediction; at test time the full network runs. The mask is a Bernoulli draw, a weighted coin flip per neuron that lands on "keep" with probability 1 − p.

Each example gets a fresh mask; there is no state, and the cost is one random draw and one multiply per activation (Srivastava et al. 2014). A larger p regularizes more and underfits when too high; p = 0 turns dropout off, and at p = 1 nothing passes. PyTorch's default is 0.5.

Dropout masks the activation, then rescales the kept units.
ã = (m · a) · 11 − p
a is the activation before the mask, one number per neuron. The mask m is 1 for a kept unit and 0 for a dropped one, so multiplying zeroes the dropped units. Only a share 1 − p survives on average, so the output would shrink by that share; the fraction scales the kept units back up and keeps the expected activation the same. That is why nothing changes at test time, when no unit is dropped. ã (a-tilde) is the result.

Dropout for one layer of 12 activations; a_tilde is ã, and 0.2 is a common rate.

import torch

p = 0.2  # drop chance
a = torch.randn(12)
u = torch.rand_like(a)
m = (u > p).float()  # mask
a_tilde = m * a / (1 - p)  # ã
  1. 1Drop chance p, 12 activations a.
  2. 2Mask m, keep chance 1 − p.
  3. 3Rescale by 1/(1 − p), the dropout equation.

At inference, nothing is dropped: the full network runs, and the training-time rescale already matches its scale. torch.nn.Dropout masks only in training mode; model.train() and model.eval() switch every dropout layer at once.

drop = torch.nn.Dropout(p)
drop.train()
a_train = drop(a)  # masked
drop.eval()
a_test = drop(a)  # equals a
  1. 1Training mode masks and rescales.
  2. 2Evaluation mode passes a unchanged.

Dropout acts like noise injection in the hidden layers: the noise forces features that survive small disturbances. Rates are often small; the original Transformer used 0.1. Stochastic depth skips whole residual blocks (Huang et al. 2016), and DropConnect zeroes single weights instead of units (Wan et al. 2013).

Figure 1. Higher rates drop more units and scale the rest up to keep the dashed average.

1.2 L1 and L2 penalties

A penalty on the weights, added to the loss, makes large weights cost something, so training trades a little fit for smaller weights, and with L1 many exactly zero. It acts at every step on every weight, keeps no state and costs one sum over the weights.

L1 and L2 add the weights' sizes, or their squares, to the loss.
Ltotal = L + λ·Σi |wi|
L1. L is the data loss, how wrong the model is, and λ (lambda) sets how much the penalty counts against it. Σi adds up over every weight i, and the bars take each weight's size without its sign, so every unit of weight costs the same whether the weight is big or small. A larger λ makes weights more expensive, so the fit gives up some data loss to keep them small; λ = 0 removes the penalty, and a very large λ pushes every weight toward zero and underfits. There is no universal default; PyTorch's optimizers default to 0 except AdamW at 0.01.
Ltotal = L + λ·Σi wi2
L2 squares each weight instead. A weight twice as large costs four times as much, so L2 punishes big weights hardest and barely notices small ones, so the model uses many small weights instead of a few large ones.

L2 produces shrinkage: the weights get smaller but stay non-zero. With Adam, use AdamW, which applies it as weight decay in the optimizer step.

L1 regularization uses absolute values instead and produces sparsity.

Each penalty pushes on a weight with its gradient.
∂∂wi (λ·wi2) = 2λ·wi
The stacked ∂ fraction is the slope of the penalty as wi changes. For L2 the push is proportional to the weight: a big weight is pulled back hard and a small one gently, so the push fades as the weight shrinks and the weight approaches zero without reaching it.
∂∂wi (λ·|wi|) = λ·sign(wi)
sign(wi) is +1 for a positive weight and −1 for a negative one; the slope of |wi| is undefined at zero. The L1 push has the same size whatever the weight, so a small weight is pushed toward zero as hard as a big one, and many weights land exactly at zero, the corners in Figure 2.

Read as a constraint, the weights stay inside a ball (L2) or a diamond (L1) around the origin, and the solution is where the loss contours (rings of equal loss) first touch it.

L2 ballw1w2best fitlimitanswerboth weightsstay nonzeroL1 diamondw1w2best fitlimitanswerone weight hitsexactly zerogray rings: equal loss

Figure 2. L2 keeps both weights nonzero; L1 hits the contours at a corner, one weight zero.

2 Adding training data

Regularization cuts the estimation error, the error from too little data for the model's flexibility (Understanding Model Errors). More varied training data also cuts it, leaving less room to memorize.

2.1 Data augmentation for wearables

A gesture recorded with the wristband twisted is still the same gesture, so a twisted copy is a new example with a true label; each augmentation in the table mimics one real source of variation. The same label-keeping edits make the positive pairs of Contrastive Learning.

Augmentations are drawn fresh each time a training window is loaded, so every epoch sees a new copy; they touch training data only, keep no state, and cost data-loader time, not model time.

augmentationwhat it mimics
channel dropoutlost contact or a misaligned sensor
channel shufflingthe band shifting along the arm
spatial mixingthe displacement blur of a shifted band
rotationthe band worn twisted or at another angle
random scalingstrap tightness or skin-electrode impedance (how much the skin contact holds back the signal)
temporal jitterthe delay that altered placement introduces
cross-modal maskinga missing sensor type
handforearmxzyrollyawpitch
Figure 3. The band's axes; roll, pitch and yaw turn around x, y and z.

Twisting the band keeps x and rotates y and z, so the augmentation rotates the readings.

A general rotation multiplies one rotation per axis together and applies it to each sensor vector.
v′ = Rz(ψ) Ry(θ) Rx(φ) v
The rotated reading v′ (v-prime) is the original reading v, a 3D vector in the band's x, y, z axes, multiplied by three rotation matrices. Rx(φ) turns by the roll angle φ (phi) around x, Ry(θ) by the pitch angle θ (theta) around y, and Rz(ψ) by the yaw angle ψ (psi) around z. Each matrix only turns the vector, so its length, the strength of the motion, stays the same. The matrices apply right to left, roll first and yaw last, and the order matters because turns about different axes do not commute.

SciPy rotates a window by a fresh small random roll, pitch and yaw; acc_rotated keeps the shape [time, 3].

import numpy as np
import scipy.spatial.transform
rng = np.random.default_rng()
shape = (200, 3)  # [t, 3]
acc = rng.normal(size=shape)
# roll, pitch, yaw in degrees
deg = rng.uniform(-15, 15, 3)
rad = np.radians(deg)
st = scipy.spatial.transform
euler = st.Rotation.from_euler
rot = euler("xyz", rad)
acc_rotated = rot.apply(acc)
  1. 1One window, 200 readings on three axes.
  2. 2Roll, pitch and yaw within 15 degrees.
  3. 3Rotate every reading.

Channel dropout and shuffling, each into a new array.

import numpy as np
rng = np.random.default_rng()
x = rng.normal(size=(8, 500))
n_ch, p = len(x), 0.1
# channel dropout: zero rows
keep = rng.random(n_ch) > p
x_drop = x * keep[:, None]
# shuffle: swap rows i, i + 1
i = rng.integers(n_ch - 1)
rows = [i, i + 1]
x_shuf = x.copy()
x_shuf[rows] = x[rows[::-1]]
  1. 1One window, 8 channels by 500 samples.
  2. 2Zero each channel with chance p.
  3. 3Swap two neighbouring channels.

Random scaling and jitter; wider ranges regularize more, until they make signals no real wearer would record.

# strap tightness per channel
u = rng.random((n_ch, 1))
gain = 0.8 + 0.4 * u
x_scaled = x * gain
# jitter: shift ≤ 20 samples
k = rng.integers(-20, 21)
x_jit = np.roll(x, k, axis=1)
  1. 1Per-channel gain from 0.8 to 1.2.
  2. 2Shift by up to 20 samples.

2.2 Class imbalance

If 95 of 100 windows are rest, always guessing rest scores 95 percent and catches nothing: class imbalance makes the model overfit to easy, frequent samples. You can oversample minority classes, undersample majority classes, or augment minority classes more. SMOTE (Synthetic Minority Over-sampling Technique, Chawla et al. 2002) oversamples without duplicating: it finds a nearby minority sample with k-NN (k-Nearest Neighbors) and interpolates a new one between them.

SMOTE places a new minority sample at a random point on the line to a neighbor.
xnew = xi + u · (xnn − xi)
The new sample xnew starts at a minority sample xi and moves a share u of the way toward xnn, one of its nearest minority neighbors. u is drawn between 0 and 1, so the new point always lies between two real minority examples: it fills in the region the class already occupies instead of copying a point. A far neighbor would put samples in unlikely places, which is why SMOTE uses the nearest ones.
new sample, on the linebetween two neighborsminority classmajority class
Figure 4. SMOTE puts a new minority sample on the line to a nearby minority neighbor.

Class-weighted loss weights the minority class higher; balanced batch sampling gives each batch (a group of examples processed together) roughly equal classes. In the code 95 labels are common and 5 rare, and w is the inverse class frequency, used by the loss or the sampler, not both.

from torch import nn, tensor
import torch.utils.data as tud
labels = [0] * 95 + [1] * 5
y_train = tensor(labels)
counts = y_train.bincount()
w = counts.sum() / counts
# option 1: weighted loss
loss_fn = nn.CrossEntropyLoss(w)
# option 2: balanced batches
WRS = tud.WeightedRandomSampler
n = len(y_train)
sampler = WRS(w[y_train], n)
  1. 1Inverse-frequency class weights.
  2. 2Option 1, weights to the loss.
  3. 3Option 2, per-sample weights to the sampler.

The weights are computed once from the training labels; a weighted loss applies them every step, a weighted sampler every epoch (PyTorch's CrossEntropyLoss and WeightedRandomSampler). SMOTE runs once, on the training split only.

commonrarecommon, flaggedrare, caughtflagged as rare

Figure 5. A larger w catches more rare windows and flags more common ones.

Focal loss (Lin et al. 2017) down-weights the easy examples the model already classifies confidently, so the hard ones dominate the loss:

Focal loss is cross-entropy scaled down for confident examples.
FL = −(1 − pt)γ log pt
pt is the probability the model gives the true class, and −log pt is the usual cross-entropy, large when pt is small. The factor in front shrinks the loss of examples the model already gets right: a confident example has pt near 1, so the factor is near 0 and it barely counts, while a misclassified one keeps nearly its full loss. A larger γ (gamma) down-weights easy examples harder, and γ = 0 gives back plain cross-entropy; a very large γ ignores almost every example the model already gets right. The focal loss paper used γ = 2.

3 Cleaning sensor data

Wearable signals arrive noisy, drifting and sometimes missing, and a model fed raw signals can learn the sensor faults.

3.1 Preprocessing sensory data

A powerline hum on every channel and a slow zero-level drift are removed by a little signal processing.

Filtering runs on each recording before the model, the same for training, test and live data; the z-score keeps only a mean and a spread per channel of one person's recording.

stepwhat it removes
band-pass filterlow-frequency drift and high-frequency noise, keeping one band
notch filterone narrow band, the powerline hum, leaving the frequencies around it
rectify and envelopethe sign and the fast oscillation: absolute value, then a low-pass, traces how strong the signal is
z-score per channel, per person or sessionamplitude differences across people and sessions: subtract that recording's average, divide by its spread

filtfilt runs each filter forward and backward, so nothing shifts in time; the band-pass is 4th order, its edges fractions of the Nyquist rate nyq (half the 1000 Hz sampling rate).

import numpy as np
from scipy.signal import butter
from scipy.signal import filtfilt
from scipy.signal import iirnotch
fs, hum = 1000, 60  # Hz
x = np.random.randn(8, 2000)
nyq = fs / 2
wn = (20 / nyq, 450 / nyq)
b, a = butter(4, wn, "bandpass")
x_band = filtfilt(b, a, x)
b, a = iirnotch(hum, 30, fs)
clean = filtfilt(b, a, x_band)
  1. 1Sampling rate, hum frequency, 8 channels.
  2. 2Band-pass 20 to 450 Hz, both directions.
  3. 3Notch out the 60 Hz hum.

The envelope and the per-person z-score; z keeps the shape [channels, time].

# rectify, low-pass at 5 Hz
b, a = butter(4, 5, fs=fs)
rect = np.abs(clean)
env = filtfilt(b, a, rect)
# z-score per channel
mu = env.mean(axis=1)[:, None]
sd = env.std(axis=1)[:, None]
z = (env - mu) / (sd + 1e-8)
  1. 1Envelope, low-pass at 5 Hz.
  2. 2Z-score per channel.

Features are then hand-made in time and frequency, or learned by the model.

3.2 Missing data and fusing modalities

Bluetooth packet loss or sweat can leave missing channels. A channel with no spread, or at the top of the range (5 here) more than half the time, gets the mean of the good channels, and the mask bad goes to the model, so it learns to ignore the missing channels.

import numpy as np
x = np.random.randn(8, 500)
x[3] = 0.0  # a flat channel
flat = x.std(axis=1) < 1e-6
pegged = np.abs(x) >= 5.0
share = pegged.mean(axis=1)
bad = flat | (share > 0.5)
fill = x[~bad].mean(axis=0)
x_fill = x.copy()
x_fill[bad] = fill
  1. 18 channels, one flat.
  2. 2Bad if flat, or at its maximum over half the time.
  3. 3Bad channels get the good ones' mean.

The check runs on every window, in training and live, with no state; the mask travels with the window.

Combining modalities lets one sensor cover for another. An encoder compresses a signal into features; a decoder predicts from them.

One shared encoderABconcatone encoderdecoderSeparate encoders, then concatenateABencoder Aencoder BconcatdecoderCross-modal attentionABencoder Aencoder Bquerykeys, valuesattentiondecoder
Figure 6. Fusing sensors A and B with one shared encoder, concatenation or attention.

A shared encoder needs the sensors aligned in time and scale; separate encoders do not. Very late fusion, one model per sensor with only the outputs joined, does not solve missing signals.

With cross-modal attention (Cross-Attention), one sensor's features query the other's, so the model learns how much to trust each and down-weights a missing or noisy one. A gives the queries, B the keys and values; fused has A's shape, w says how much each A step used each B step, and off marks B steps to ignore, in the default [time, batch, features] layout.

import torch
from torch import nn
# [time, batch, features]
A = torch.randn(50, 2, 32)
B = torch.randn(20, 2, 32)
off = torch.zeros(2, 20).bool()
off[:, 10:] = True  # B drops
MHA = nn.MultiheadAttention
attn = MHA(32, num_heads=4)
# 4th argument: the B mask
fused, w = attn(A, B, B, off)
  1. 1Features for A and B; B's last 10 steps missing.
  2. 2Attention with 4 heads.
  3. 3A queries B, masked steps skipped.

If every B step in a row is masked, that row can come out as NaN; one fix is an always-valid token in B.

Foundation-style models such as SensorLM (Zhang et al. 2025) pretrain one encoder on a large cohort (Self-Supervised Learning); a small labeled calibration set from a new person then fine-tunes lightweight per-person heads.

4 Regularizers in a PyTorch training loop

Augmentation lives in the data pipeline, dropout in the model, weight decay in the optimizer step and class weights in the loss. The first snippet builds three once: a classifier net with dropout, AdamW, and a loss weighting a rare third class 5 times higher.

import torch
from torch import nn
net = nn.Sequential()
net.append(nn.Linear(8, 64))
net.append(nn.ReLU())
net.append(nn.Dropout(p=0.5))
net.append(nn.Linear(64, 3))
# AdamW default decay: 0.01
ps = net.parameters()
opt = torch.optim.AdamW(ps)
w = torch.tensor([1., 1., 5.])
loss_fn = nn.CrossEntropyLoss(w)
  1. 1Classifier with dropout.
  2. 2AdamW, which carries weight decay.
  3. 3Loss weighting the rare class 5 times.

The second runs one step on 32 rows; jitter stands in for augmentation (really in the dataset's __getitem__), and net.train() and net.eval() switch dropout on and off.

x = torch.randn(32, 8)
noise = torch.randn_like(x)
x = x + 0.05 * noise  # jitter
y = torch.randint(3, (32,))
net.train()  # dropout on
loss = loss_fn(net(x), y)
opt.zero_grad()
loss.backward()
opt.step()  # also decays
net.eval()  # dropout off
  1. 1One batch, jittered (data pipeline).
  2. 2Dropout on, weighted loss.
  3. 3Backward and step, which decays the weights.
  4. 4Dropout off before evaluation.

None costs much: dropout is one mask per forward pass and an identity in eval mode, weight decay one multiply per weight per step, class weights almost nothing, and augmentation CPU time in the loader every epoch. The dropout paper kept hidden units with probability 0.5 and inputs with 0.8. The focal loss paper used a gamma of 2 and an alpha of 0.25, where α weights the rare positive class against 1 − α, so raising it counts rare examples more; these are the defaults of torchvision's sigmoid_focal_loss. Dropout is torch.nn.Dropout.

5 Common regularization failures

symptomlikely causefix
Test predictions change between runs on the same inputdropout still on at test timemodel.eval() before evaluating
The augmented model gets worsean augmentation changes the label (a rotation that turns one gesture into another)keep only transforms that leave the label true
The rare class is now over-predictedclass weights and a balanced sampler used togetheruse one of the two
The test score looks too good with SMOTEsynthetic samples made before the train and test split land near test pointssplit first, oversample the training part only

Training must still lower the loss; the size and direction of each step is the optimizer's job, on Optimizers from SGD to Adam.

6 References

  1. Chawla, N. V., et al. (2002). SMOTE: Synthetic Minority Over-sampling Technique. Journal of Artificial Intelligence Research.
  2. Wan, L., et al. (2013). Regularization of Neural Networks using DropConnect. ICML.
  3. Srivastava, N., et al. (2014). Dropout: a simple way to prevent neural networks from overfitting. Journal of Machine Learning Research.
  4. Huang, G., et al. (2016). Deep Networks with Stochastic Depth. ECCV. arXiv:1603.09382.
  5. Vaswani, A., et al. (2017). Attention Is All You Need. NeurIPS. (dropout rate 0.1, section 5.4)
  6. Lin, T.-Y., et al. (2017). Focal Loss for Dense Object Detection. ICCV. arXiv:1708.02002.
  7. Zhang, Y., et al. (2025). SensorLM: Learning the Language of Wearable Sensors. arXiv:2506.09108. (the foundation-style models passage)
  8. PyTorch documentation, torch.nn.CrossEntropyLoss and torch.utils.data.WeightedRandomSampler.
  9. PyTorch documentation, torch.nn.Dropout; torchvision documentation, torchvision.ops.sigmoid_focal_loss.