Training¶
Every run is three YAML files — data, model, train — and nothing else. Unknown keys are rejected rather than ignored, so a config always describes what the run actually did.
Running¶
make train-joint # both heads, every corpus (the union experiment)
make train-blink-presence # blink presence, video corpora only
make train-eye-state # eye state, still-image corpora
make train-cnn # BlinkCNN
make train-lint # BlinkLinT
make train-linmult # BlinkLinMulT
make train-single DB=rn30 # one corpus, trained and evaluated alone
make per-dataset # every per-corpus arm
make ablation # the four architectures on identical data
make mlflow-ui # inspect the runs
Any config value can be overridden without editing a file:
make train-joint ARGS="--set data.strategy_kwargs.temperature=3.0"
make train-joint ARGS="--set train.optimizer.lr=1e-4 --set data.batch_size=16"
Or call the CLI directly for full control:
uv run python -m blinklinmult.train.cli \
--data config/data/all.yaml \
--model config/model/blinklinmult.yaml \
--train config/train/joint.yaml
Configurations¶
config/data/ — which corpora, and how they are mixed.
Config |
Corpora |
Use |
|---|---|---|
|
TalkingFace, RN15, RN30, HUST-LEBW, MPEblink |
the union experiment; the sequence models |
|
CEW, MRL-Eye + video corpora as frames |
eye state; the frame-wise model |
|
RN15 + RN30 |
the aggregate RN arm |
|
one corpus |
the per-dataset arms |
|
RN30, CEW |
CI and smoke runs |
config/model/ — the three shipped models plus one baseline:
Config |
Model |
Inputs |
|---|---|---|
|
BlinkCNN |
one eye crop |
|
BlinkLinT |
|
|
BlinkLinMulT |
the above and |
The two masks of BlinkLinMulT are independent by design: a usable eye crop can still defeat the iris landmarker, so a frame can be valid for the image branch and invalid for the feature branch.
config/train/ — joint, blink_presence, eye_state, smoke.
Not every combination is valid, and the invalid ones fail at config load rather
than after an hour of training. family: cnn with the blink-presence task is
rejected: a per-frame CNN has no sequence model and cannot express the task.
The analysis window¶
window_seconds: 1.5 is one knob for the whole run; each corpus derives its own
frame count from its rate (30 fps → 45 frames, 15 fps → 22). It matches
MPEblink’s 36-frame test clip, so the numbers are readable beside theirs. Every corpus then
spans the same real duration, OmniLoader pads the shorter ones and masks the
padding, and nothing is resampled.
make train-joint ARGS="--set data.window_seconds=1.0" # every corpus re-derives
A joint run mixing RN15 and RN30 resolves to the longer frame count, so RN15 samples arrive roughly half padding. That is intended — the frames genuinely are not there.
Mixing the corpora¶
The corpora differ in size by three orders of magnitude — MRL-Eye has ~85 000
stills, TalkingFace is one recording. Proportional mixing would make a batch
almost entirely MRL, so the default is temperature: 2.0, which samples by
sqrt(size): the large corpora stay dominant without erasing the small ones.
Raise the temperature towards uniform, lower it towards proportional.
strategy: temperature # proportional | temperature | annealed_temperature
strategy_kwargs: # | fixed | round_robin
temperature: 2.0
Each strategy accepts only its own arguments; passing another’s fails at setup with a message naming the offending key.
Losses and metrics¶
Losses are masked. Every loss reduces over validly-supervised positions only. That is not an optimisation — it is what makes joint training correct. The mean is taken over valid positions, so a batch’s loss does not depend on how many of its samples came from a corpus that annotates the target, and a batch with no valid position for a head contributes exactly zero to it with the gradient path preserved, so DDP does not deadlock waiting for a rank that skipped a parameter.
focal is the default for blink presence: sampled continuously, the
overwhelming majority of frames are easy negatives whose aggregate gradient
drowns out the few frames where the eye is actually closing. bce is the
default for eye state, which is roughly balanced in CEW and MRL.
Training and evaluation are windowed identically — a 50%-overlapping sweep
of every split, described in Data. That is what makes focal the
right default rather than a precaution: roughly 10% of training windows contain
a blink, the same proportion the test split holds, so a threshold calibrated on
validation transfers to test. 1.x balanced the training split to 50% positive,
which made the loss easier to optimise and the operating point meaningless.
F1 is the primary metric, not accuracy. A model predicting “no blink” everywhere scores above 95% accuracy on a continuously-sampled recording, which says nothing about whether it detects blinks. Precision and recall are reported alongside because the two failure modes have different costs, and average precision because it is threshold-free.
Training is eye-wise; reporting is frame-wise. Two families of scores are logged:
{split}/{target}/f1 per eye — what the loss sees
{split}/{target}/frame_max/f1 per frame — THE reported number
{split}/{target}/frame_mean/f1 the alternative rule, for comparison
{split}/mean_f1 mean frame-level F1; drives selection
{split}/mean_eye_f1 mean per-eye F1, as a diagnostic
max is the primary rule: a blink is a frame-level event that occurred, so if
one eye is occluded while the other is plainly closing, max recovers it where
mean halves the score. The aggregation runs on validation as well as test,
so valid/mean_f1 and test/mean_f1 are directly comparable and model
selection optimises the number the benchmark reports.
Each run also writes test_per_dataset.json: the test split scored separately
per contributing corpus. One aggregate F1 can hide a model that has learned
MRL-Eye’s stills and fails on RN30’s video entirely.
Optimization¶
An ImageNet-pretrained backbone and a randomly-initialised transformer do not
want the same step size — fine-tuning the backbone at the transformer’s rate
destroys the pretrained features within a few hundred steps. backbone_lr
therefore splits the parameters into two groups, and the one-cycle schedule
keeps each group’s own peak rather than flattening them to a single max_lr.
optimizer:
lr: 0.001 # transformer and heads
backbone_lr: 0.0001 # the pretrained CNN
scheduler: onecycle
Set backbone_lr: null to use one rate for everything.
Choosing the backbone¶
convnext_femto is the default. It was chosen by measurement, not by reputation,
and the numbers below are the justification.
The constraint is the crop size. Eye patches are 64x64, and every
ImageNet backbone reduces its input by 32, so a 64px crop collapses to a 2x2
feature map before pooling — four cells to describe an eye. backbone_wide_stem
(on by default) removes one stride-2 reduction to recover 4x4. It discards no
pretrained parameter, but the layers downstream then see a feature map at a
sampling rate they were not trained on, so it expects fine-tuning rather than
freezing.
Measured results¶
CEW eye state, three seeds per backbone, config/data/single_cew.yaml with
config/train/eye_state.yaml unchanged. GPU time is one 26-crop batch — a
13-frame window times two eyes — on an otherwise idle Apple MPS device. Measure
it idle or not at all: the same backbone timed 13 ms free and 23 ms while
another run held the GPU, which is enough to reorder the column.
backbone |
year |
params |
GPU |
accuracy |
F1 |
precision |
recall |
|---|---|---|---|---|---|---|---|
|
2019 |
4.0M |
35 ms |
96.72 ±1.09 |
96.64 ±1.13 |
96.67 ±1.63 |
96.65 ±2.16 |
|
2022 |
4.8M |
14 ms |
96.63 ±0.97 |
96.49 ±1.06 |
98.12 ±0.15 |
94.92 ±2.08 |
|
2016 |
4.8M |
42 ms |
95.27 ±0.08 |
95.11 ±0.07 |
96.19 ±0.72 |
94.06 ±0.66 |
|
2016 |
7.0M |
45 ms |
94.38 ±0.00 |
94.21 ±0.05 |
94.95 ±0.76 |
93.49 ±0.83 |
|
2023 |
3.5M |
32 ms |
92.23 ±1.23 |
92.34 ±1.13 |
89.22 ±1.94 |
95.69 ±0.29 |
|
2018 |
1.3M |
7 ms |
88.81 ±0.49 |
88.89 ±0.51 |
86.36 ±0.35 |
91.57 ±0.72 |
|
2024 |
2.5M |
6 ms |
87.83 ±0.58 |
88.27 ±0.60 |
83.45 ±0.34 |
93.68 ±1.00 |
Read the precision column, not the accuracy column. The fast mobile
backbones match DenseNet’s recall — mobilenetv4_conv_small even beats it,
93.68 against 93.49 — and lose entirely on precision, 83.45 against 94.95.
They over-predict “closed”. For blink detection that is the expensive
direction, because a false closure becomes a phantom blink. Selecting a backbone
on inference cost alone picks exactly the wrong one.
efficientnet_b0 and convnext_femto are tied on accuracy: 0.09pp apart
against ±1 seed spreads. Cost is what separates them, and ConvNeXt is 2.5x
faster with the tightest precision in the table. They differ in character, so
the choice is reversible on purpose — ConvNeXt leans precise (98.1 / 94.9),
EfficientNet balanced (96.7 / 96.7). Prefer EfficientNet if missing a blink ever
costs more than inventing one.
densenet121_truncated drops DenseNet’s last dense block, which holds 31% of
its parameters and runs on a 2x2 map at this crop size. It beats the full
network it was cut from — 95.27 against 94.38, on 4.79M parameters instead of
6.95M — so that block is not merely idle here, it is harmful: 2.16M parameters
fine-tuned on 3.5k images cost more in overfitting than they return. The cut is
free in the sense that matters, since every remaining tensor is bit-identical to
the pretrained checkpoint. It is still not competitive with either leader, and
is kept as evidence rather than as a recommendation.
Note also that 121 is the smallest pretrained DenseNet in existence — torchvision and timm ship 121/161/169/201 and the rest are larger — which is why a smaller one had to be cut rather than downloaded.
Caveats¶
Still images only. CEW is single frames, so this ranking measures appearance and nothing temporal. The backbone feeds LinT and LinMulT in the models that matter, and a backbone that wins on stills need not win as a sequence encoder.
The validation split is 626 crops, and it drives both early stopping and checkpoint selection. That is the likeliest source of every ±1 spread here.
Weight tags are pinned in
TIMM_WEIGHTS(blinklinmult/train/model.py) rather than left to timm’s defaults, which move between releases.
Any backbone in BACKBONES can be selected without editing a file:
make train-eye-state ARGS="--set model.backbone=efficientnet_b0"
What a run produces¶
Under results/<task>/:
checkpoints/— best by F1, best by loss, and last. Full state, so a run resumes with--resume.metrics_test.json— the headline test metrics.test_predictions_<target>.csv— per-position probability, target, and validity, so threshold tuning and significance testing never need the model re-run.test_per_dataset.json— per-corpus breakdown.test_precision_recall.png,time.json.
Everything except the checkpoints is logged to MLflow as artifacts, alongside the flattened config, the library versions, the commit, and each dataset file’s hash and embedded builder SHA — two runs over “the same six corpora” are not comparable if one of them was rebuilt, and nothing else would reveal that.
Smoke testing¶
make train-smoke # one batch through every stage, then exit
This is what to run after changing anything in the data or model layer; it exercises the whole chain in seconds.