Skip to content

Smooth offline predictions with a centred vote (0.780 -> 0.823 on held-out data) - #110

Merged
gbradham merged 1 commit into
mainfrom
feat/offline-smoothing
Aug 6, 2026
Merged

Smooth offline predictions with a centred vote (0.780 -> 0.823 on held-out data)#110
gbradham merged 1 commit into
mainfrom
feat/offline-smoothing

Conversation

@gbradham

@gbradham gbradham commented Aug 6, 2026

Copy link
Copy Markdown
Member

Scoring a recording was using the live smoother. The live smoother is causal because a live overlay has no choice — it can only vote over labels it has already emitted. Scoring a file on disk has the frames after each one available, and was declining to read them.

What that cost

A causal vote lags by half its window, so at every bout boundary it spends that long still reporting the previous behavior — and bout boundaries are where the errors already are.

Measured on eight held-out sessions against a 4-class model (macro F1, clean frames):

smoothing macro F1 on transition frames
none 0.780 0.756
causal (live smoother) 0.797 0.743
centred 0.823 0.802

On transition frames the causal vote scores below no smoothing at all. That is the lag showing up directly.

Per class, centred vs raw: dig 0.758 → 0.829, grooming 0.677 → 0.711, investigate 0.767 → 0.812, locomote 0.917 → 0.939.

The obvious hazard didn't materialise

A vote wide enough to help should erase bouts shorter than itself. It doesn't — predicted bout counts fall towards the true ones while recall holds:

class bouts predicted (true) bout F1@50
raw centred raw centred
dig 94 (47) 57 0.664 0.771
grooming 49 (26) 27 0.559 0.698
investigate 117 (59) 73 0.719 0.790
locomote 81 (50) 60 0.844 0.902

Raw output was over-segmenting roughly two to one. What the vote removes is flicker, not short bouts.

Where the default window comes from

One median bout. Bouts here run median 22 frames, p75 29, at 30 fps — and macro F1 sits on a plateau from 21 to 31 that is flat to within 0.002. So the number comes from the bout-length distribution rather than from whichever value happened to score best, and the plateau being that wide is the evidence it isn't tuned to one test set. Past ~37 it falls away as real bouts start getting absorbed.

Both entry points opt in

classify_pose_data promises its rows match the streaming path's, and a centred vote deliberately breaks that promise. So it takes an explicit offline_smooth_window and leaves the guarantee intact at its default — the parity tests pass untouched.

evaluate_model defaults to off too, because an unsmoothed score is the one comparable to cross-validation. It records the window it used, so a smoothed macro_f1 in a report can't be mistaken for a raw one.

evaluate_model also had to start predicting whole sessions when smoothing is requested: it scored only the annotated rows, which are scattered islands in a recording, and voting across those would pool labels seconds apart. The unsmoothed path still predicts just the annotated rows, so nothing gets slower for callers who didn't ask.

Verification

evaluate_model(model16, 8 held-out sessions, smooth_window=25)0.8229, against 0.7796 unsmoothed — reproducing the throwaway analysis to four decimals, which is the check that the library path and the experiment agree.

2886 passed, 1 skipped; ruff and black clean.

Not included

The GUI Apply tab doesn't expose this yet — ApplyWorker still threads only the causal smooth_window. Wiring it through is a small follow-up, but it's a UI decision (default on or off for a run someone launches) that seemed worth separating from the measurement.

Scoring a recording was using the live smoother, and the live smoother is
causal because a live overlay has no choice: it can only vote over labels it
has already emitted. Scoring a file on disk has the frames after each one
available and was declining to read them.

That costs more than it sounds. A causal vote lags by half its window, so at
every bout boundary it spends that long still reporting the previous
behavior -- and bout boundaries are where the errors already are. Measured on
eight held-out sessions against a 4-class model, macro F1 went 0.780 raw,
0.797 causal, 0.823 centred. On transition frames specifically the causal
vote scored *below* no smoothing at all, 0.743 against 0.756, which is the
lag showing up directly.

The obvious hazard is that a vote wide enough to help erases bouts shorter
than itself. It did not: predicted bout counts fell towards the true ones
(117 investigate bouts against 59 real, down to 73) while recall at 50%
overlap held. What the vote removes is flicker, not short bouts -- raw output
was over-segmenting roughly two to one.

The default window is one median bout. Bouts here run median 22 frames and
p75 29 at 30 fps, and macro F1 sits on a plateau from 21 to 31 that is flat
to within 0.002 -- so the number comes from the bout-length distribution
rather than from whichever value scored best, and the plateau being that wide
is the evidence it is not tuned to one test set. Past roughly 37 it falls
away as real bouts start being absorbed.

Both entry points opt in rather than switching under callers.
classify_pose_data promises its rows match the streaming path's, and a
centred vote deliberately breaks that promise, so it takes an explicit
offline_smooth_window and leaves the guarantee intact at its default; the
parity tests still pass untouched. evaluate_model defaults to off too,
because an unsmoothed score is the one comparable to cross-validation, and it
records the window it used so a smoothed macro_f1 in a report cannot be
mistaken for a raw one.

evaluate_model also had to start predicting whole sessions when smoothing is
asked for. It scored only the annotated rows, which are scattered islands in
a recording -- voting across those would pool labels seconds apart. The
unsmoothed path still predicts just the annotated rows, so nothing gets
slower for callers who did not ask.

Verified end to end: evaluate_model(model16, 8 held-out sessions,
smooth_window=25) reports 0.8229 against 0.7796 unsmoothed, reproducing the
throwaway analysis to four decimals.
@gbradham
gbradham merged commit f63a7be into main Aug 6, 2026
@gbradham
gbradham deleted the feat/offline-smoothing branch August 6, 2026 18:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant