Smooth offline predictions with a centred vote (0.780 -> 0.823 on held-out data) - #110
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):
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:
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_datapromises its rows match the streaming path's, and a centred vote deliberately breaks that promise. So it takes an explicitoffline_smooth_windowand leaves the guarantee intact at its default — the parity tests pass untouched.evaluate_modeldefaults to off too, because an unsmoothed score is the one comparable to cross-validation. It records the window it used, so a smoothedmacro_f1in a report can't be mistaken for a raw one.evaluate_modelalso 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 —
ApplyWorkerstill threads only the causalsmooth_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.