Repository navigation
sleep: halve the RSA respiration weight, measured against PSG with R-R (DREAMT) - #2613
Open
UtkuDenizAltiok wants to merge 2 commits into
Open
UtkuDenizAltiok wants to merge 2 commits into
UtkuDenizAltiok wants to merge 2 commits into
Conversation
sleep-accel carries no beat-to-beat intervals, so the recipe's RSA respiration term has never run against PSG truth. DREAMT (PhysioNet v2.2.0, 100 adults, an Empatica E4 beside PSG) has the E4's inter-beat intervals. --dataset now also takes a DREAMT folder. The reader collapses the 64 Hz file per second (gravity mean, HR), turns the forward-filled IBI column back into beats by arithmetic (a repeated interval hides a change; the gap between changes says how many), and aligns the 30 s grid on the first scored epoch, which sits 24 s past a multiple of 30. PSGSubject gains rr and ahi; for sleep-accel rr stays empty and the full report is byte-identical. Section 8 (rsa, DREAMT only) runs the shipped stager with and without R-R by AHI and by beats kept, checks whether the feature separates the PSG stages, and compares shapes and weights of the term subject by subject. The port gains a respShape knob for that; shipped stays .symmetric (port validation 12377/12377). DREAMT is restricted-access: never committed, and the tool prints cohort aggregates only. It is a sleep-clinic cohort (ryanbr#348, ryanbr#437), so nothing is fitted to its base rates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SleepStagerV2 adds respWeight·z to the deep emission and subtracts it from REM, where z is the night-normalised regularity of breathing read from the R-R series. Until now it had never met PSG truth with R-R present. On DREAMT (PhysioNet v2.2.0, n = 100, Tools/SleepPSG section 8) the feature separates the stages the way the term assumes (AUC deep vs REM 0.658, light vs REM 0.597), but light epochs spread over z like every other stage, so at 0.6 the term moved them into deep or REM. Per subject, against 0.6: 0.3 raises kappa for 61 and lowers it for 28 (mean 0.204 -> 0.216), |deep bias| 10.60 -> 9.76 pp, |REM bias| 11.16 -> 9.74 pp, REM 21.4 -> 18.4 % of sleep (truth 14.0). AHI < 5: 16 / 7; E4 kept >= 80 % of beats: 31 / 10. The separation itself implies 0.29-0.35 (d' from the AUCs). sleep-accel has no R-R and is unchanged. Swift and Kotlin move together and pin the weight. The frozen golden moves one boundary by three epochs (16290 -> 16200 s). The V2-flag test asserted that a flat synthetic night shows deep; that deep came from one truncated edge epoch whose score the z-score turns into +8, so the test now asserts what it meant: V2's hypnogram, not V1's. RecipeConfig.shipped follows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UtkuDenizAltiok
added a commit
to UtkuDenizAltiok/noop
that referenced
this pull request
Sep 30, 2026
…MT harness, a8d25c1 shipped
UtkuDenizAltiok
added a commit
to UtkuDenizAltiok/noop
that referenced
this pull request
Oct 1, 2026
…, DREAMT harness, a8d25c1 shipped; REM and memory findings; cleanup PRs in progress
Author
|
Hi @ryanbr, could you review this sleep change when you have time? Sleep stages are a big part of why I use NOOP with my WHOOP, so I wanted this checked against sleep-lab recordings. Giving the breathing feature less weight improved agreement for more people than it worsened in the DREAMT comparison, while keeping the feature. The limitations are in the description and all checks are passing. |
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.
What this PR does
NOOP's sleep stager reads how regular your breathing is from the heartbeat (R-R) and uses it to tell deep sleep (regular breathing) from REM (irregular). That signal had never been checked against a sleep lab with heartbeats present:
Tools/SleepPSG's dataset has none. This PR adds a second dataset that has them, DREAMT, and on it the signal turns out to be real but counted about twice too strongly: it pushes light sleep into both deep and REM. So its weight goes from 0.6 to 0.3.Two commits:
sleeppsg: score the shipped stager against DREAMT, with R-R live. A reader for DREAMT's 64 Hz E4 files, and a section 8 that runs the shippedSleepStagerV2with and without R-R. It is stratified by apnoea severity and by how many beats the E4 kept. It also checks whether the feature separates the PSG stages, and compares shapes and weights of the term subject by subject. The sleep-accel report is byte-identical before and after (same SHA-256 on the full output).sleep: halve the RSA respiration weight.respWeight0.6 → 0.3 on Swift and Kotlin, pinned on both.Why. The recipe adds
respWeight · zto deep and subtracts it from REM, where z is the night-normalised breathing regularity; light is left alone. On DREAMT (PhysioNet v2.2.0, 100 adults, 80 190 PSG-scored epochs):Per subject, against 0.6:
The same ordering holds with AHI under 5 (n = 26: 0.3 better than 0.6 for 16, worse for 7) and where the E4 kept at least 80 % of the beats (n = 41: 31 / 10).
Why 0.3 and not less. Against 0.3, the 0.2 and "off" rows are within 0.001 in mean κ (per subject 51 / 31 and 50 / 40). DREAMT's deep truth is 3.4 % of the night, so any push toward deep scores badly on this cohort; below 0.3 it mostly rewards that. The weight the separation itself implies does not depend on base rates. For two equal-variance classes a unit of z is worth d′ = √2·Φ⁻¹(AUC) log-odds: 0.58 for deep vs REM, where the term's slope is 2 × respWeight, and 0.35 for light vs REM, where the slope is respWeight. That gives 0.29–0.35. So 0.3 halves the term rather than deleting a feature that does carry information. "Regular breathing only" fixes the REM share but floods deep, and "REM side only" keeps the REM over-call; neither is taken.
What a user sees. On nights with R-R, a little less REM and deep and a little more light. On one WHOOP 5.0 wearer's six nights: REM 31.9 → 29.3 % of sleep, deep 21.6 → 21.2 %, light 46.5 → 49.5 %. Detection is unchanged and wake barely moves (7.8 → 7.9 % of the window). Nights without R-R are unchanged.
Type of change
How it was tested
DreamtTests, 6 tests, synthetic files, runs in CI): beats recovered from the forward-filled IBI column (consecutive, one or two hidden repeats, a gap), the grid aligned on the first scored epoch, the per-second collapse, the participant table. Each test was seen to fail with its logic broken, then restored byte-identical.respShapeknob defaults to the shipped shape.testRespWeightIsPinned/respWeightIsPinned. The frozen golden moves one boundary by three epochs (10740–16290 → 10740–16200 REM), regenerated on both platforms. With 0.6 restored, the pin and the golden fail.testDetectSleepThreadsV2FlagIntoNormalNightasserted that its flat synthetic night shows deep under V2. Nothing in that night calls for deep: flat HR, identical breathing in every epoch. 354 of its 360 epochs score 0.812972. The three at each edge, whose beat windows reach past the stream, differ only in the fourth decimal (up to 0.8148). With a night spread of 0.00016, the per-night z-score turns that into z of +2.7 to +11.6, which the term at 0.6 painted deep. The test now asserts what it states it proves: the flag gives V2's hypnogram, which is not V1's, and it still shows REM. Same change in the Kotlin twin.29f43bb2(on7f396e98; the finalca008abachanges only two test comments, re-checked withSleepStagerV2Tests18/18 and the doc lint): WhoopStore 631 · StrandAnalytics 2103 · StrandImport 327 · doc lint · i18n · parity ledger · parity ratchet · parity governance 127 · macOSStrandTests2,254 (only the two locale-dependentTodayCarryOverTests) · iOS build.Tools/SleepPSG:swift test39/39, and the full sleep-accel report is byte-identical tomain's before and after both commits.assembleFullDebug+testFullDebugUnitTest): green onca008aba(run 36712653602).DREAMT is restricted-access (PhysioNet Restricted Health Data License 1.5.0): each user signs the agreement and downloads it; it is never committed and the tool prints aggregates only. It is a sleep-clinic cohort (#348, #437). Nothing here fits a prior or base rate to it: the change is the strength of one feature, and the table is read per subject and per stratum.
Checklist
docs/CONTRIBUTING.mdStrand.xcodeproj/) or any secrets/keystoresRelated issues
Refs #348, #437, #987, #2576