Repository navigation
Sleep staging: onset from the band inside V2, a deep-latency guard, and an Asleep/Woke row that matches the hypnogram - #20
Merged
Conversation
…ng after The band latency trim relabelled the lead-in and tail as wake after SleepStagerV2 had already staged the whole gravity window. V2's pass 1 reads sleep onset from its own hypnogram and measures the REM-latency guard from it, so on a night with a long lead-in the guard was anchored up to two hours before the band's onset and spent before sleep began. On a banked 5/MG night whose session opened 2 h 13 min before the band's onset, REM appeared 31 min after the real onset. The relabelling also cut straight from wake into whatever V2 had staged there, which the awake row forbids. SleepStager.bandSleepWindow now returns the span the trim keeps, and detectSleep passes it to SleepStagerV2.stageSession as sleepWindow. Epochs outside it are forced to wake inside the Viterbi lattice, so pass 1's onset, the REM guard and the wake -> light entry all follow the band. Emissions and normalisation are unchanged. The trim still runs afterwards (V1 relies on it) and finds nothing left to relabel. The window is part of the V2 cache key. PhysioNet sleep-accel, PSG hypnogram standing in for the band (31 subjects): kappa 0.413 -> 0.414, first-REM latency MAE 47.4 -> 44.0 min against relabelling afterwards. Inert without a band. On six 5/MG nights TST moves by at most 2 min; REM latency on the two long-lead-in nights moves from 31 and 51 min to 137 and 246 min.
SleepStagerV2 staged deep from the first sleep epoch. Descent into N3 runs through N1 and N2, but the recipe's deep terms (the HR-flatness gate and the early-night cycle prior) are strongest at onset itself. On PhysioNet sleep-accel it staged deep within 5 min of onset for 6 of 30 subjects (1 of 30 in the PSG truth), 12 of 30 once a band window fixes the onset, and at minute 0 on every one of six banked 5/MG nights. The deep emission now carries a latency guard: 3.0 log-odds at onset, decaying linearly to 0 at 20 min, measured from the same onset and applied in the same second pass as the REM-latency guard. The magnitude is the REM guard's. Twenty minutes sits inside the classic first-cycle descent and is where staged first-deep latency is unbiased against PSG on sleep-accel (-3.3 min, from -12.5). Kappa rises across the whole 10-25 min grid, so the value was chosen for the unbiased latency, not the kappa. Shipped sleep-accel baseline (31 subjects): kappa 0.363 -> 0.373, deep F1 0.498 -> 0.523, pooled deep bias +5.17 -> +3.57 pp; REM and wake unchanged. With the band window: kappa 0.414 -> 0.434. On the six 5/MG nights first deep moves from minute 0 to 10-28 min, deep falls 1-3 points to 15-27 % of sleep, TST is unchanged. The frozen golden hypnogram gains 8 min of light before its first deep run; every later boundary is unchanged. Tools/SleepPSG's port carries the guard (port validation passes) and adds a guard-off variant.
…d bounds The Sleep tab's Asleep/Woke row printed the session's in-bed start and end. Since the band latency trim, a banked 5/MG night scores the lying-awake lead-in and tail as wake inside those bounds, so the row and the hypnogram under it disagreed: one night's row printed an in-bed start 2 h 7 min before the hypnogram's first sleep epoch as "Asleep", and another printed a "Woke" time 29 min after its last sleep epoch. Night.sleepOnsetTs / finalWakeTs are the start of the first and the end of the last non-wake interval of the night's real timeline, and the row reads them. A night with no real timeline (imported totals) keeps its bounds. The edit sheet the row opens still edits the in-bed bounds, so its fields are now labelled Bedtime (existing, translated) and Got up (new, nine locales) instead of Asleep and Woke, keeping one name per time.
Every published wrist stager that reaches a four-class kappa of 0.5 fits its per-epoch emissions to human-scored PSG. NOOP's V1 and V2 set every coefficient by hand. Tools/SleepTrain fits one on open data only and reports it. - prepare_*.py turn Wearanize+ OA (88 nights, Empatica E4, CC BY 4.0), PhysioNet sleep-accel (31 nights, Apple Watch, ODC-By) and DREAMT (sleep-clinic patients, restricted licence) into one night format: whole-second gravity, integer bpm and whole-second-stamped integer-ms beat intervals, which is what the app hands a stager. - sleeptrain/features.py is the feature definition in plain numpy with every sum taken left to right, so a Swift port can reproduce it. Everything is relative to the night: motion over the night's own jerk floor, posture change over its median change, heart rate as within-night z-scores and ranks, 5-minute HRV scaled to the night's percentiles, and elapsed time as minutes plus three decays so a linear head can learn the sleep-onset descent. - train.py uses every night twice (whole scored span, and cropped to the window SleepStager.bandSleepWindow would allow), fits a base head (motion and heart rate) and an HRV head, counts the stage transitions, reports 10-fold grouped cross-validation and scores DREAMT as an untouched test set, then writes the generated Swift model. - make_oracle.py writes the fixture that pins the Swift port. Datasets are never committed. DREAMT's licence limits it to research, so it only tests the model and never enters a fit. The fit is deterministic: two runs write byte-identical coefficients.
SleepStagerV3 is a drop-in for SleepStagerV2.stageSession: same signature, same [StageSegment] tiling, memoized the same way. It ports Tools/SleepTrain's feature definition line for line and runs the two generated heads. When the night has beat intervals on at least half its epochs both heads run and their log-posteriors are averaged with equal weight; otherwise the base head (motion and heart rate) stages it. A Viterbi pass over the counted stage transitions decodes the result. With the band's sleep window it stages only the epochs inside it, and everything else is wake. Cross-validated agreement with PSG (four-class kappa over every scored epoch, wake outside the window), V2 -> V3: - Wearanize+, band-style window: 0.363 -> 0.547 (second scorer 0.742) - Wearanize+, whole span: 0.301 -> 0.533 - sleep-accel, band-style window: 0.434 -> 0.504 - DREAMT sleep-clinic patients, never trained on: 0.259 -> 0.313 On Wearanize+ every stage share is within 1 pp of PSG (V2: light +13.5, deep -5.4, REM -7.0 pp), and no night starts deep within 5 min of onset. First REM comes 47 min late, as V2's does (48). The equal blend is untuned. The HRV head alone scored 0.554 and 0.320 but called REM high outside its training cohort (+7.9 pp on DREAMT, +5.9 blended). SleepStagerV3OracleTests rebuilds three SplitMix64 synthetic nights and requires every feature and both heads' posteriors within 1e-9 of the Python reference, and every label and segment equal. A night stages in about 0.09 s in a release build (V2: 0.3 s).
`sleeppsg --section v3 --nights DIR ...` replays the shipped V2 and V3 over prepared night-format cohorts. Each night is staged inside a band-style window emulated from its PSG hypnogram, and over its whole scored span. The report gives kappa, per-stage F1, stage-share bias, per-night minute bias with limits of agreement, and first deep / REM latency. On DREAMT, which V3 never trained on, it reproduces train.py's Python figures exactly (0.313 in the window, 0.256 over the span). That is the end-to-end check that the Swift port stages what the reference does. On the training cohorts V3's rows are in-sample, and the report says so.
SleepStagerVersion (.v1 / .v2 / .v3) replaces the useSleepStagerV2 Bool through detectSleep, AnalyticsEngine and the sleep-motion trace line, and dispatches to its recipe. V2 and V3 stage inside the band's sleep window; V1 still relies on the latency trim. The HR-only path keeps V2, as before, because V3 was trained with motion present. PuffinExperiment.sleepStager is the one resolver that the engine, the self-heal restage, the REM-funnel diagnostic line and Settings all read. An explicit choice wins. Otherwise a legacy explicit V2-off stays V1, and everyone else gets V3. The key is not in the backup whitelist, so backups are unchanged. The day-cache config signature carries the choice, so switching restages the scoring window. Settings > Experimental > Sleep staging becomes a V3 / V2 / V1 segmented picker. It reads through that resolver, so an upgraded user whose V2 switch was off sees V1 there, as staged. Five new strings in nine locales; the old toggle's two strings are removed. Tests: SleepStagerSettingTests pins the upgrade rules. detectSleep with .v3 keeps the same detected window and stages it with V3. Every version dispatches to its own recipe. V3 detects no sleep without gravity, like V1 and V2.
Adds the V3 section: features, the two heads and their blend, the decoder, the band window, the training data, and the cross-validated agreement next to V2 and a second human scorer, with its known limits (first REM late, deep over-counted on sleep-clinic nights, and no WHOOP strap in any PSG set). The honest-hedging note no longer says "not PSG-validated". Three lines that named V2 the default are corrected.
…n uses `Repository.restageFromRaw` (edit, add-nap and the post-sync self-heal) called the recipe alone: no band sleep window and no band latency trim. `detectSleep` stages inside the window and trims outside it. Re-staging a detected night over its own bounds therefore staged the lying-awake lead-in as sleep again. On the owner's six banked 5/MG nights, V2's first sleep moved up to 2 h 04 min earlier and TST rose by up to 60 min; V3's first sleep and TST each moved by about 20 min. `SleepStager.stageWindow` is now the one staging funnel. It applies the recipe inside the window (V2/V3), then the trim and the wake-veto. `detectSleep` stages every accepted night through it, and the re-stage reads the band's state and calls it too. The window is resolved once per night and handed to both the recipe and the trim, so the band stream is no longer scanned twice.
…ature records `analyzeRecent` read the staging choice (and the motion-aware wake toggle) once for the day-cache config signature and again inside the day loop. A picker change made while a pass was running could therefore stage the later days of that pass with the new recipe and cache them under a signature that names the old one. The loop now uses the single pass-level read.
…sleeppsg does `train.py`'s summary averaged every night's kappa, so a night with one class on both sides (pe = 1) made the "mean night kappa" column NaN. `sleeppsg --section v3` leaves such a night out, so the two reports that ANALYTICS.md says reproduce each other could print different figures for the same cohort. `kappa` now returns NaN explicitly where it is undefined, as `Confusion.kappa` does, and the summary skips it. No published figure changes: no cohort night has an undefined kappa.
`dayCacheConfigSig` builds 17 values, but the separate `dayCacheConfigFields` list held 16 names: the Healthspan zone set joined the signature without a name. `changedConfigField` refuses to name anything when the counts differ, so every cache drop since has been logged as "unknown", including a switch of the new sleep-stager picker. The pinned test built its signature from the 16-name list, not from the real construction, so it could not notice. The signature is now built from (name, value) pairs, and the names travel to `changedConfigField` with it, so adding or reordering a field cannot leave its name behind. The Kotlin-twin list test goes: this fork ships no Android target.
…the night's recipe "screen staged by V3" printed the current picker value as the recipe that staged the diagnosed night. A stored night keeps no record of its recipe. It may have been staged before the picker moved, and a night the merge takes from an import was never staged here. The line now says the screen shows the stored hypnogram and names the current setting, and it always says the totals can differ, including when the setting is V1.
…and Got up The night editor's default labels became Bedtime / Got up when the Asleep/Woke row moved to the hypnogram's bounds. The nap row opens the same sheet with those defaults, so editing a 14:00 nap showed Bedtime and Got up. A nap edit now uses the nap wording the Add-a-nap sheet and the nap row's pencil already use: "Edit nap times", "Nap started", "Nap ended". All three strings are already translated.
…h it lies in `epochInSleepWindow` judges each 30 s recipe epoch (wall-clock grid) by the one session-grid instant it contains. The session's last recipe epoch is cut short at `end`, and when the session starts δ seconds off the wall clock and ends within δ of a wall-clock boundary, that epoch holds no session-grid instant before `end`. Judged by the instant past `end`, it fell outside a window that runs to the end. V2 and V3 then closed a night the band slept through on 1-29 s of wake, which the trim never samples and so never removed. With the window clamped at `end`, which is what the trim needs, leaving `to` unclamped does not reach that instant either. The epoch now falls back to the session's last grid instant. `end` is threaded through V2's and V3's `stageEpochs` and mirrored in `sleeptrain/model.py`'s `in_window`. The committed V3 oracle regenerates byte-identical: its one windowed case closes 15 min before its end. On the owner's six banked 5/MG nights it applies to two. One gains its last 9 s of sleep. On the other, V3 stages one more epoch. V3 normalises its features over the staged span and decodes it as a whole, so that night's borderline tail re-decodes: its last sleep moves 44 min earlier and TST falls by 6 min. V2's nights move by the sliver alone.
… not 0 A V3 feature missing for the whole staged span was imputed as 0. That is neutral for a z-scored column but an extreme for a percentile rank (typical 0.5) or a ratio. A span with gravity but no heart rate yet, which the re-stage's density gate allows, read as the lowest, flattest heart rate of the night: hr_pct and the three hrsd ranks at z = -1.74, hr_over_low at -1.03. With the heart rate removed, the owner's six banked 5/MG nights staged 32-48 % deep, against 8-18 % with it. A whole-night gap now takes the head's training mean for that column, so it standardises to 0 and moves no logit (those nights come out 84-95 % light). The fit and every PSG figure are unchanged. No record the heads were trained on (Wearanize+ 176, sleep-accel 62) or tested on (DREAMT 20) has such a gap, and `sleeppsg --section v3` scores all three cohorts identically either way. `sleeptrain/features.py`'s `impute` takes the same fallback from `Head.posteriors`. In the regenerated oracle, only the no-beats case's HRV-head posteriors move; that head does not stage the case, and every label is the same.
…say which ran `SleepStager.hrOnlySessions`, the engine's fallback for a day that banked no gravity, always staged with V2. The Rest trace's `stager=` still named the picker's choice (V3 by default), and the picker had no effect on those nights. The choice now reaches them: `hrOnlySessions(stager:)` stages with `stager.hrOnlyRecipe`, which is the choice itself for V2 and V3. V1 builds its epoch grid from gravity and returns one flat "light" block without it, so V1 maps to V2. `analyzeDay` names that recipe in the trace for an HR-only night. This makes V3 reachable with no motion, so it now reads such a span properly. No jerk anywhere in the span (no gravity, or none in two consecutive seconds) leaves the motion features missing, and no measurable block-to-block posture change leaves the posture features missing. With the previous fix, a missing feature takes the head's training mean. Read as present, zero activity and the time-since-move cap made this the stillest night V3 ever saw, and deep came out 19-30 points too high. With motion stripped from the PSG nights, four-class kappa inside the band window: cohort V2 (was) V3 Wearanize+ 0.303 0.477 sleep-accel 0.363 0.446 DREAMT 0.224 0.275 (the only out-of-sample cohort) V3's first REM lands 40-74 min late on these nights. With gravity present, every figure is unchanged: every cohort night carries gravity. `sleeptrain/features.py` mirrors the rule. The oracle gains a fourth, gravity-free night that pins it in both languages; the first three cases are unchanged.
…indow, measured With a band sleep window, V2's REM and deep onset guards run from the band's onset, but its cycle prior (deep early, REM rising toward morning) still reads `c`, the fraction of the whole session. On a long lying-awake lead-in, part of the early deep prior is spent before sleep begins: on the owner's six banked 5/MG nights, `c` at the band's onset was 0.03-0.19, a deep prior of 0.79-1.13 instead of 1.20. Measuring `c` over the window instead scored no better against PSG in the window. Four-class kappa went 0.363 -> 0.360 on Wearanize+, 0.434 -> 0.427 on sleep-accel and 0.259 -> 0.261 on DREAMT, and first REM came later on all three. So the recipe is unchanged, and the claims around it now say exactly what moves with the window: the detection comment said "the recipe starts from the band's onset". `cyclePrior` and ANALYTICS.md record the measurement.
V3 re-implemented V2's `stageSession` veneer: the stable timestamp sort (V2's `stableOrder` closure, V3's `sortedByTs`), the half-open clip (`clipToWindow` / `clip`), the memo key (`V2Key` / `V3Key`, field for field) and the segment tiling. A future fix to the clip bounds or the key would have had to find both copies. `StagerInput` now holds the sort, the clip, one `Key` built from the clipped streams and the sleep window, and `tile`. Both stagers call it and keep their own caches and read windows (V2's pads, V3's epoch span). Output is byte-identical: V2's frozen golden and shuffled-input tests, the V3 oracle and `sleeppsg`'s port validation all pass unchanged. V2's extra "nothing clipped" early return was unreachable once the first bounds check had failed, so it went.
Sleep staging V3: a stager fitted to polysomnography, default, with V2/V1 selectable
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.
Follow-up to #9. That PR fixed the two large problems on this WHOOP 5/MG: REM staged at 36–49 % of sleep, and total sleep 14–65 min above the strap's own band state. Build 11.9.6 re-scored the nights. In a new backup, stored REM is 22–27 % and TST sits within −14 … +25 min of the band's asleep time on all six nights.
A replay harness runs the backup through the real
detectSleep, using the engine's read windows. It reproduces all six stored hypnograms byte for byte, so the figures below are the app's own.Three things were still wrong, and all three come from where sleep onset sits.
SleepStagerV2, so the recipe's pass 1 took onset from its own hypnogram, which slept through the lead-in. The REM-latency guard started up to two hours early and was spent before sleep began.Commits
SleepStager.bandSleepWindowreturns the span the trim keeps.SleepStagerV2.stageSession(sleepWindow:)forces wake outside that span inside the Viterbi lattice.Tools/SleepPSG's port mirrors the guard and gains a guard-off variant.Night.sleepOnsetTs/finalWakeTsare the first and last non-wake interval of the real timeline.Results
PhysioNet sleep-accel, 31 PSG-scored subjects. The "band window" rows use each PSG hypnogram as a stand-in band, run through the production
bandSleepWindow.The pooled deep bias falls from +5.17 to +3.57 pp; REM and wake F1 are unchanged.
On the six 5/MG nights:
These are n = 1 wearer figures. The PSG set carries no R-R and no band, which is why the band rows use the PSG hypnogram as a stand-in.
Verification
swift testinPackages/StrandAnalytics: 2177 pass, with 2 skips that were already there.Tools/SleepPSG: 33 tests pass, including port validation.Tools/SleepBench: 13 tests pass.Tools/Backfillbuilds.SleepModel,SleepView):Strandbuilds and all 1957StrandTestspass, including 5 new ones for the row.NOOPiOSbuilds for the iOS Simulator. The Watch app was left out of a local, uncommitted copy ofproject.yml, because no watchOS runtime is installed on this machine.Tools/doc_comment_lint.pyis clean.Tools/i18n_audit.py --ci mainis clean.Not in this PR