From 7b63841356cdfc3023d36d211fb946949e168289 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:44:57 +0200 Subject: [PATCH 1/2] sleeppsg: score the shipped stager against DREAMT, with R-R live 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 (#348, #437), so nothing is fitted to its base rates. Co-Authored-By: Claude Opus 5.5 --- Tools/SleepPSG/README.md | 65 +++- Tools/SleepPSG/Sources/sleeppsg/Dreamt.swift | 302 ++++++++++++++++ .../Sources/sleeppsg/RecipePort.swift | 14 +- .../Sources/sleeppsg/SleepAccel.swift | 4 + Tools/SleepPSG/Sources/sleeppsg/main.swift | 327 +++++++++++++++--- .../Tests/sleeppsgTests/DreamtTests.swift | 123 +++++++ 6 files changed, 782 insertions(+), 53 deletions(-) create mode 100644 Tools/SleepPSG/Sources/sleeppsg/Dreamt.swift create mode 100644 Tools/SleepPSG/Tests/sleeppsgTests/DreamtTests.swift diff --git a/Tools/SleepPSG/README.md b/Tools/SleepPSG/README.md index 8f8196baf1..483e43bac6 100644 --- a/Tools/SleepPSG/README.md +++ b/Tools/SleepPSG/README.md @@ -28,6 +28,26 @@ unzip -q sleep-accel.zip The archive expands to a long-named directory containing `labels/`, `heart_rate/`, `motion/` and `steps/`. Pass either that directory or its parent — `--dataset` finds the level with `labels/` in it. +## DREAMT — the second dataset, with beat-to-beat intervals + +`sleep-accel` has no R-R, so the recipe's RSA respiration term is silent on it. DREAMT (PhysioNet, v2.2.0: +100 adults, an Empatica E4 on the wrist beside PSG, human-scored 30 s stages) has the E4's inter-beat +intervals, so on it every input of the recipe runs. It is **restricted-access**: each user signs the +PhysioNet Restricted Health Data Use Agreement 1.5.0 and downloads it themselves. It is never committed, +and this tool prints only cohort aggregates and per-stratum counts from it. Only `data_64Hz/` (~14 GB) and +`participant_info.csv` are read: + +```bash +swift run -c release sleeppsg --dataset ~/datasets/dreamt # every section, plus section 8 +swift run -c release sleeppsg --dataset ~/datasets/dreamt --section rsa +``` + +It is a **sleep-clinic cohort** (median AHI 13.5 events/hour; 26 subjects under 5): more wake and far less +N3 than a healthy night (truth: wake 25 %, deep 3.4 % of the night). #348 fitted priors to it and #437 +reverted that within 48 h, because the cohort's base rates had become a prior applied to everyone. So the +DREAMT report stratifies by AHI and by how many beats the E4 kept, and it is used here for one question the +priors do not touch: what the RSA term does when it is live. + ## Run it ```bash @@ -37,8 +57,8 @@ swift run -c release sleeppsg --dataset ~/datasets/motion-and-heart-rate-*-1.0.0 | flag | meaning | |---|---| -| `--dataset PATH` | the extracted `sleep-accel` root. Required for every section except `port`. | -| `--section S` | `all` (default), or one of `port`, `baseline`, `strata`, `rem`, `variants`, `priors`. | +| `--dataset PATH` | the extracted `sleep-accel` root, or the DREAMT folder holding `data_64Hz/`. Required for every section except `port`. | +| `--section S` | `all` (default), or one of `port`, `baseline`, `strata`, `rem`, `variants`, `priors`, `rsa` (DREAMT only). | | `--subjects N` | score only the first N subject ids — a fast smoke run. | | `--csv PATH` | write the per-variant table as CSV. | | `--seed N` | the port-validation corpus seed. | @@ -179,6 +199,41 @@ pooled wake share to truth (4.15 % → 9.39 % against 9.07 %) but worsens the me (5.50 → 5.55 pp) by over-calling wake for the subjects it does not fix, which is the #437 failure. The R-R stream is empty here, so the RSA term is silent in all of this; on a WHOOP night it is live. +### 8. The RSA term (DREAMT only) + +The recipe turns the R-R series into a breathing-regularity score per epoch (the spectral peakedness of the +0.15–0.40 Hz band), z-scores it over the night, and adds `respWeight · z` to the deep emission and +subtracts it from REM — regular breathing → deep, irregular → REM — leaving light alone. Section 8 runs the +shipped `SleepStagerV2.stageSession` with the R-R stream and without it, then asks two separate questions. + +**Is the feature informative?** Yes, and in the direction the term assumes. Grouped by the PSG stage of the +epoch, the mean z is +0.29 in deep, +0.06 in light, −0.09 in wake and −0.25 in REM; the AUC for "a deep +epoch scores higher than a REM epoch" is 0.658, light against REM 0.597, and per subject NREM against REM +has a median AUC of 0.559, above 0.5 for 50 of 71 subjects. + +**Is it weighted right?** Not at 0.6. Light epochs sit near z = 0 with the same spread as every other stage, +so a symmetric ±0.6·z moves light epochs into deep on one side and into REM on the other. With R-R live +against withheld: pooled κ 0.217 → 0.208, per-subject κ lower for 55 subjects and higher for 38, REM +16.2 → 19.9 % of the night (truth 10.5 %), deep 10.7 → 12.8 % (truth 3.4 %). Per subject, against the +shipped recipe: + +| RSA term | mean κ | κ better / worse | mean \|deep bias\| | mean \|REM bias\| | REM % of sleep (truth 14.0) | +|---|---|---|---|---|---| +| symmetric 0.6 (shipped) | 0.204 | — | 10.60 | 11.16 | 21.4 | +| symmetric 0.45 | 0.207 | 58 / 30 | 10.27 | 10.47 | 20.0 | +| symmetric 0.3 | 0.216 | 61 / 28 | 9.76 | 9.74 | 18.4 | +| symmetric 0.2 | 0.217 | 58 / 35 | 9.51 | 9.57 | 17.6 | +| off | 0.216 | 55 / 38 | 9.18 | 9.77 | 17.4 | +| regular breathing only (max(z, 0)), 0.6 | 0.190 | 38 / 54 | 13.23 | 7.78 | 14.2 | +| REM side only, 0.6 | 0.218 | 59 / 30 | 9.16 | 11.13 | 21.3 | + +The same ordering holds with AHI under 5 (n = 26: 0.3 better for 16, worse for 7) and where the E4 kept at +least 80 % of the beats (n = 41: 31 / 10). The measured separation says the same thing independently: for +two equal-variance classes a unit of z is worth d′ log-odds, and d′ = √2·Φ⁻¹(AUC) is 0.58 for deep against +REM (the term's slope there is 2 × respWeight) and 0.35 for light against REM (slope respWeight), i.e. a +weight of about 0.29–0.35, not 0.6. "Regular breathing only" fixes the REM share but floods deep; "REM side +only" keeps the REM over-call. + --- ## The self-check, and what it found @@ -238,3 +293,9 @@ F1 0.515" is exactly this harness's measured REM **precision** (0.515), against detection is not exercised, and nothing here says anything about it. - **n = 31 subjects**, in a sleep-lab setting, wearing a device on the wrist alongside PSG leads. That is more independent subjects than any other reference NOOP has, and it is still 31 people. +- **DREAMT is not a WHOOP either, and not a healthy cohort.** Its R-R comes from the E4's PPG, which drops + beats it is unsure of (median 73 % kept over sleep) and quantises intervals to 1/64 s. On one wearer's six + WHOOP 5.0 nights, rounding the WHOOP R-R to 1/64 s moved the six-night mean deep and REM by under 1 pp of + sleep (single nights by up to 4 pp), where switching the term off moved each by about 4.5 pp, so the + quantisation is not what section 8 measures. Its stage fractions are a clinical population's: section 8 + reads them per stratum and per subject, and nothing in the recipe is fitted to DREAMT's base rates. diff --git a/Tools/SleepPSG/Sources/sleeppsg/Dreamt.swift b/Tools/SleepPSG/Sources/sleeppsg/Dreamt.swift new file mode 100644 index 0000000000..9d12b9d5da --- /dev/null +++ b/Tools/SleepPSG/Sources/sleeppsg/Dreamt.swift @@ -0,0 +1,302 @@ +import Foundation +import WhoopProtocol + +// Dreamt — read PhysioNet DREAMT (the 64 Hz Empatica E4 files) and present each subject as the streams a +// NOOP stager takes, plus the PSG hypnogram to score against. +// +// Wang K, Yang J, Shetty A, Dunn J. DREAMT: Dataset for Real-time sleep stage EstimAtion using +// Multisensor wearable Technology (version 2.2.0). PhysioNet (2026). https://doi.org/10.13026/3f7y-2d80 +// Wang WK, Yang J, Hershkovich L, et al. Proceedings of the fifth Conference on Health, Inference, and +// Learning, PMLR 248:380–396 (2024). +// +// DREAMT is restricted-access (PhysioNet Restricted Health Data License 1.5.0; each user signs the data use +// agreement and downloads it themselves). The files are NEVER committed here, are always supplied by +// `--dataset`, and nothing this tool prints is per-epoch data — only cohort aggregates and per-stratum counts. +// +// ── Why this dataset, and what it changes ─────────────────────────────────────────────────────────────── +// +// `sleep-accel` carries no beat-to-beat intervals, so on it the recipe's RSA respiration term is silent on +// every epoch. DREAMT's E4 reports inter-beat intervals from its PPG, so here the term is LIVE — the only +// PSG-labelled reference NOOP has where all six of the recipe's inputs run. +// +// It is also a sleep-clinic cohort: most subjects were referred for suspected sleep apnoea, and their +// sleep is more fragmented, with more wake and less N3/REM, than a healthy night. PR #348 tuned the recipe's +// priors on it and PR #437 reverted that 48 h later, because the cohort's wake base rate had been absorbed +// into a prior applied to everybody. So this reader reports each subject's AHI beside them, the report +// stratifies by it, and nothing here fits a constant to the cohort's base rates. +// +// ── The mapping ──────────────────────────────────────────────────────────────────────────────────────── +// +// 1. GRAVITY ← per-second mean of ACC_X/Y/Z (the E4's 32 Hz accelerometer in 1/64 g, forward-filled to +// 64 Hz in the file). Same collapse as `SleepAccel`, for the same reason: the recipe's motion thresholds +// are multiples of the night's own median jerk, never an absolute g. +// +// 2. HR ← the E4's HR column at each whole second (the device's 1 Hz PPG heart rate, forward-filled). +// +// 3. R-R ← the IBI column, turned back into beats. The values are SECONDS in the files (0.796875 = 51/64 s), +// although the dataset page labels the column ms. The file forward-fills each inter-beat interval over the +// 64 Hz rows until the next one, so a beat shows as the value CHANGING, and two consecutive beats with the +// same (1/64 s-quantised) interval show as one change. Each change at `t2` with value `v2`, after the +// previous change at `t1` with value `v1`, is therefore resolved by arithmetic, not by guess: +// t2 − t1 = v2 the two changes are consecutive beats (the common case, ~83 %); +// t2 − t1 = v1 + v2 one beat at t1 + v1 repeated the interval v1, hidden by the forward fill; +// t2 − t1 = 2·v1 + v2 two such hidden repeats; +// anything else a detection gap: the E4 dropped beats, and `t2` starts a new run. +// Each beat becomes `RRInterval(ts: whole second of the beat, rrMs)` — the per-second bucketing a WHOOP +// delivers, and the one `SleepStagerV2.features` reads. +// +// 4. TIME ← seconds from the start of the file, shifted so the FIRST PSG-SCORED EPOCH starts on a multiple +// of 30. DREAMT's scoring grid does not start at t = 0 (on the files checked it starts at 24 s past a +// multiple of 30), so aligning on it keeps the recipe's wall-clock 30 s grid exactly on the PSG grid. +// +// 5. LABELS ← `Sleep_Stage` at each epoch: W → wake, N1/N2 → light, N3 → deep, R → rem. `P` (the +// preparation period before lights-out) and `Missing` are unscored and leave the denominator. + +/// A DREAMT subject's metadata, from `participant_info.csv`. Only what the report stratifies on. +struct DreamtInfo { + let age: Double? + let sex: String + let bmi: Double? + /// Apnoea–hypopnoea index, events per hour. The cohort's defining variable. + let ahi: Double? +} + +enum Dreamt { + + /// A multiple of 30, as in `SleepAccel.timeBase`. + static let timeBase = 1_699_999_980 + + static func isDreamtRoot(_ path: String) -> Bool { + FileManager.default.fileExists(atPath: (path as NSString).appendingPathComponent("data_64Hz")) + && FileManager.default.fileExists(atPath: (path as NSString).appendingPathComponent("participant_info.csv")) + } + + /// Accept the directory holding `data_64Hz/` or its parent. + static func resolveRoot(_ path: String) -> String? { + if isDreamtRoot(path) { return path } + let subs = (try? FileManager.default.contentsOfDirectory(atPath: path)) ?? [] + for s in subs.sorted() { + let c = (path as NSString).appendingPathComponent(s) + if isDreamtRoot(c) { return c } + } + return nil + } + + /// `participant_info.csv`: SID, AGE, GENDER, BMI, OAHI, AHI, … — quoted fields may contain commas. + static func participants(root: String) -> [String: DreamtInfo] { + guard let text = try? String(contentsOfFile: (root as NSString).appendingPathComponent("participant_info.csv"), + encoding: .utf8) else { return [:] } + var out: [String: DreamtInfo] = [:] + for line in text.split(whereSeparator: \.isNewline).dropFirst() { + let f = splitCSV(String(line)) + guard f.count >= 6 else { continue } + out[f[0]] = DreamtInfo(age: Double(f[1]), sex: f[2], bmi: Double(f[3]), ahi: Double(f[5])) + } + return out + } + + static func subjectIDs(root: String) -> [String] { + let dir = (root as NSString).appendingPathComponent("data_64Hz") + let files = (try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? [] + return files.compactMap { name -> String? in + guard name.hasSuffix("_whole_df.csv") else { return nil } + return String(name.dropLast("_whole_df.csv".count)) + }.sorted() + } + + /// Stage codes as parsed from the file. `prep` and `missing` are unscored. + enum Code: Int8 { case none = -3, prep = -2, missing = -1, wake = 0, n1 = 1, n2 = 2, n3 = 3, rem = 5 } + + static func label(_ c: Code) -> String? { + switch c { + case .wake: return "wake" + case .n1, .n2: return "light" + case .n3: return "deep" + case .rem: return "rem" + default: return nil + } + } + + /// Load one subject: nil when the file is missing or carries no scored epoch. + static func load(root: String, id: String, info: DreamtInfo?) -> PSGSubject? { + let path = (root as NSString).appendingPathComponent("data_64Hz/\(id)_whole_df.csv") + guard let data = FileManager.default.contents(atPath: path) else { return nil } + + // One pass over the 64 Hz rows. Everything is kept per whole second of FILE time; the shift onto + // the scoring grid is applied at the end, once the first scored epoch is known. + var gx = [Int: Double](), gy = [Int: Double](), gz = [Int: Double](), gn = [Int: Int]() + var hrAt = [Int: Double]() + var stageAt: [(t: Double, code: Code)] = [] + var ibiChanges: [(t: Double, v: Double)] = [] + var lastIbiBytes: [UInt8] = [] + var lastCode = Code.none + + forEachLine(data) { line in + // TIMESTAMP,BVP,ACC_X,ACC_Y,ACC_Z,TEMP,EDA,HR,IBI,Sleep_Stage,… + guard let t = line.double(0) else { return } + let sec = Int(t.rounded(.down)) + if let x = line.double(2), let y = line.double(3), let z = line.double(4) { + gx[sec, default: 0] += x; gy[sec, default: 0] += y; gz[sec, default: 0] += z; gn[sec, default: 0] += 1 + } + if hrAt[sec] == nil, let h = line.double(7), h > 0 { hrAt[sec] = h } + let ibi = line.bytes(8) + if !ibi.isEmpty && ibi != lastIbiBytes { + lastIbiBytes = ibi + if let v = line.double(8), v > 0 { ibiChanges.append((t, v)) } + } + let code = stageCode(line.bytes(9)) + if code != lastCode { stageAt.append((t, code)); lastCode = code } + } + + // The scoring grid starts at the first scored stage change. + guard let firstScored = stageAt.first(where: { label($0.code) != nil }) else { return nil } + let offset = Int(firstScored.t.rounded()) + // Epoch k covers file time [offset + 30k, offset + 30k + 30); its label is the stage in force at the + // epoch's midpoint (the stage column only ever changes on the grid, so any point inside agrees). + let lastT = gn.keys.max() ?? offset + let nEpochs = max(0, (lastT + 1 - offset) / 30) // whole epochs only; a trailing partial is dropped + var truth = [String?](repeating: nil, count: nEpochs) + var si = 0 + for k in 0.. Int { timeBase + fileSec - offset } + + var grav: [GravitySample] = [] + grav.reserveCapacity(gn.count) + for (s, c) in gn where s >= loSec && s < hiSec { + let d = Double(c) + grav.append(GravitySample(ts: wall(s), x: gx[s]! / d, y: gy[s]! / d, z: gz[s]! / d)) + } + grav.sort { $0.ts < $1.ts } + + var hr: [HRSample] = [] + for (s, h) in hrAt where s >= loSec && s < hiSec { + let bpm = Int(h.rounded()) + if bpm > 0 && bpm < 300 { hr.append(HRSample(ts: wall(s), bpm: bpm)) } + } + hr.sort { $0.ts < $1.ts } + + let beats = reconstructBeats(ibiChanges) + var rr: [RRInterval] = [] + rr.reserveCapacity(beats.count) + for b in beats { + let s = Int(b.t.rounded(.down)) + guard s >= loSec && s < hiSec else { continue } + rr.append(RRInterval(ts: wall(s), rrMs: Int((b.v * 1000).rounded()), srcChannel: nil)) + } + + return PSGSubject(id: id, start: start, end: end, grav: grav, hr: hr, truth: truth, rr: rr, + ahi: info?.ahi) + } + + // MARK: - Beats from the forward-filled IBI column + + /// See the header, point 3. Tolerance is one and a half 64 Hz samples: the E4 quantises intervals to + /// 1/64 s and the file stamps each change on a 64 Hz row. + static func reconstructBeats(_ changes: [(t: Double, v: Double)]) -> [(t: Double, v: Double)] { + let tol = 1.5 / 64 + var out: [(t: Double, v: Double)] = [] + out.reserveCapacity(changes.count + changes.count / 8) + var prev: (t: Double, v: Double)? + for c in changes { + if let p = prev { + let d = c.t - p.t + if abs(d - (p.v + c.v)) <= tol { + out.append((p.t + p.v, p.v)) + } else if abs(d - (2 * p.v + c.v)) <= tol { + out.append((p.t + p.v, p.v)); out.append((p.t + 2 * p.v, p.v)) + } + } + out.append(c) + prev = c + } + return out + } + + // MARK: - Parsing + + static func stageCode(_ b: [UInt8]) -> Code { + switch b { + case [0x50]: return .prep // P + case [0x57]: return .wake // W + case [0x4E, 0x31]: return .n1 // N1 + case [0x4E, 0x32]: return .n2 // N2 + case [0x4E, 0x33]: return .n3 // N3 + case [0x52]: return .rem // R + default: return b.isEmpty ? .none : .missing // "Missing", or anything this reader does not claim + } + } + + /// One CSV line as field ranges over the file's bytes. No per-line `String`: the files are ~2 M lines. + struct Line { + let base: UnsafePointer + let buf: UnsafeBufferPointer + var starts: [Int] + var ends: [Int] + func bytes(_ i: Int) -> [UInt8] { + guard i < starts.count else { return [] } + return Array(buf[starts[i].. Double? { + guard i < starts.count, ends[i] > starts[i] else { return nil } + var endPtr: UnsafeMutablePointer? + let v = strtod(base + starts[i], &endPtr) + guard let e = endPtr, UnsafePointer(e) - (base + starts[i]) > 0 else { return nil } + return v.isFinite ? v : nil + } + } + + /// Walk the file line by line (the header is skipped) and hand each line's first ten fields to `body`. + static func forEachLine(_ data: Data, _ body: (Line) -> Void) { + var bytes = [UInt8](data) + bytes.append(0) + bytes.withUnsafeBufferPointer { buf in + guard let raw = buf.baseAddress else { return } + let base = UnsafeRawPointer(raw).assumingMemoryBound(to: CChar.self) + let count = buf.count - 1 + var i = 0 + var first = true + var line = Line(base: base, buf: buf, starts: [], ends: []) + line.starts.reserveCapacity(16); line.ends.reserveCapacity(16) + while i < count { + var lineEnd = i + while lineEnd < count && buf[lineEnd] != 0x0A { lineEnd += 1 } + var e = lineEnd + if e > i && buf[e - 1] == 0x0D { e -= 1 } + if first { first = false; i = lineEnd + 1; continue } + line.starts.removeAll(keepingCapacity: true); line.ends.removeAll(keepingCapacity: true) + var fs = i + var p = i + while p <= e && line.starts.count < 10 { + if p == e || buf[p] == 0x2C { + line.starts.append(fs); line.ends.append(p); fs = p + 1 + } + p += 1 + } + body(line) + i = lineEnd + 1 + } + } + } + + /// Minimal CSV split honouring double quotes (participant_info's history column holds commas). + static func splitCSV(_ s: String) -> [String] { + var out: [String] = [], cur = "", q = false + for ch in s { + if ch == "\"" { q.toggle() } else if ch == "," && !q { out.append(cur); cur = "" } else { cur.append(ch) } + } + out.append(cur) + return out + } +} diff --git a/Tools/SleepPSG/Sources/sleeppsg/RecipePort.swift b/Tools/SleepPSG/Sources/sleeppsg/RecipePort.swift index 8e15d5b671..26075c37c8 100644 --- a/Tools/SleepPSG/Sources/sleeppsg/RecipePort.swift +++ b/Tools/SleepPSG/Sources/sleeppsg/RecipePort.swift @@ -62,6 +62,11 @@ struct RecipeConfig: Equatable { // RSA respiration-regularity weight. var respWeight: Double + /// How the RSA z enters the emissions. `.symmetric` is the shipped `deep += w·z, rem −= w·z`; + /// `.regularOnly` uses max(z, 0), so only REGULAR breathing is evidence (for deep, against REM); + /// `.remOnly` moves REM alone (`rem −= w·z`), leaving deep and light level. + enum RespShape { case symmetric, regularOnly, remOnly } + var respShape: RespShape = .symmetric /// Dead-zone (± this z) on the cardiac terms of the AWAKE emission. 0 disables it, which is the /// shipped state — `dz` is then the identity, so the shipped emission is reproduced exactly. @@ -466,7 +471,14 @@ enum V2Recipe { } for s in stageNames { em[s]! += pr[s]! } if f.jerkMax > f.jerkScale * cfg.jerkFloorGateMult { em["awake"]! += cfg.motionGateBoost } - if let rg = f.respReg { let z = zrg(rg); em["deep"]! += cfg.respWeight * z; em["rem"]! -= cfg.respWeight * z } + if let rg = f.respReg { + let z = zrg(rg) + switch cfg.respShape { + case .symmetric: em["deep"]! += cfg.respWeight * z; em["rem"]! -= cfg.respWeight * z + case .regularOnly: em["deep"]! += cfg.respWeight * max(z, 0); em["rem"]! -= cfg.respWeight * max(z, 0) + case .remOnly: em["rem"]! -= cfg.respWeight * z + } + } seq.append(em) } diff --git a/Tools/SleepPSG/Sources/sleeppsg/SleepAccel.swift b/Tools/SleepPSG/Sources/sleeppsg/SleepAccel.swift index 12e2f8e244..d485309f8b 100644 --- a/Tools/SleepPSG/Sources/sleeppsg/SleepAccel.swift +++ b/Tools/SleepPSG/Sources/sleeppsg/SleepAccel.swift @@ -47,6 +47,10 @@ struct PSGSubject { /// One entry per 30 s epoch of `[start, end)`. `nil` = the epoch carries no PSG score and is excluded /// from every metric rather than silently counted as wake. let truth: [String?] + /// Beat-to-beat intervals. Empty for `sleep-accel`, which carries none; DREAMT's E4 supplies them. + var rr: [RRInterval] = [] + /// Apnoea–hypopnoea index (events per hour), where the dataset reports one (DREAMT). + var ahi: Double? = nil var scoredEpochs: Int { truth.reduce(0) { $0 + ($1 == nil ? 0 : 1) } } /// Duration of the scored night in minutes — the stratification variable. diff --git a/Tools/SleepPSG/Sources/sleeppsg/main.swift b/Tools/SleepPSG/Sources/sleeppsg/main.swift index fb2d0f729c..149fabfc51 100644 --- a/Tools/SleepPSG/Sources/sleeppsg/main.swift +++ b/Tools/SleepPSG/Sources/sleeppsg/main.swift @@ -5,6 +5,7 @@ import WhoopProtocol // sleeppsg — score NOOP's shipped sleep stager against polysomnography. // // USAGE: sleeppsg --dataset /path/to/sleep-accel-1.0.0 [--section all] [--csv out.csv] [--subjects N] +// sleeppsg --dataset /path/to/dreamt (DREAMT: the same sections, plus `rsa`) // sleeppsg --section port (no dataset needed — the port check alone) // // The dataset lives outside this repository and is always an argument. Nothing is written to it. @@ -27,11 +28,12 @@ while let k = it.next() { case "--seed": a.seed = UInt64(it.next() ?? "") ?? a.seed case "-h", "--help": print(""" - usage: sleeppsg --dataset [--section all|port|baseline|strata|rem|variants|priors] + usage: sleeppsg --dataset [--section all|port|baseline|strata|rem|variants|priors|rsa] [--subjects N] [--csv ] [--seed ] - --dataset PhysioNet sleep-accel v1.0.0, extracted. See README.md for the download step and the - attribution its licence requires. Never committed; read-only here. + --dataset PhysioNet sleep-accel v1.0.0 (a folder with labels/) or DREAMT (a folder with data_64Hz/ + and participant_info.csv). See README.md for access and the attribution each requires. + Never committed; read-only here. `rsa` needs R-R, so it runs on DREAMT only. --section which report to print. `port` needs no dataset. --subjects score only the first N subject ids (a fast smoke run; full cohort is the default). """) @@ -88,20 +90,32 @@ guard let datasetArg = a.dataset else { """.utf8)) exit(2) } -guard let root = SleepAccel.resolveRoot(datasetArg) else { - FileHandle.standardError.write(Data("no `labels/` directory under \(datasetArg) — is that the sleep-accel root?\n".utf8)) +// Two datasets: DREAMT (a directory with `data_64Hz/` and `participant_info.csv`) or sleep-accel (one with +// `labels/`). Every section runs on either; the dataset decides only how subjects are read. +let dreamtRoot = Dreamt.resolveRoot(datasetArg) +let isDreamt = dreamtRoot != nil +guard let root = dreamtRoot ?? SleepAccel.resolveRoot(datasetArg) else { + FileHandle.standardError.write(Data("no `labels/` (sleep-accel) or `data_64Hz/` (DREAMT) under \(datasetArg)\n".utf8)) exit(2) } -var ids = SleepAccel.subjectIDs(root: root) +var ids = isDreamt ? Dreamt.subjectIDs(root: root) : SleepAccel.subjectIDs(root: root) if let n = a.subjects { ids = Array(ids.prefix(n)) } +let dreamtInfo = isDreamt ? Dreamt.participants(root: root) : [:] // Loaded concurrently — the motion files run to tens of megabytes of text each and the read dominates the // whole run. Order is restored from `ids` afterwards so the report is identical however the threads finish. +// DREAMT's files are ~140 MB each and are held whole while parsed, so they are read in batches of four. var loaded = [PSGSubject?](repeating: nil, count: ids.count) -loaded.withUnsafeMutableBufferPointer { buf in - let out = buf - DispatchQueue.concurrentPerform(iterations: ids.count) { i in - out[i] = SleepAccel.load(root: root, id: ids[i]) +let batch = isDreamt ? 4 : ids.count +for lo in stride(from: 0, to: ids.count, by: max(1, batch)) { + let hi = min(ids.count, lo + max(1, batch)) + loaded.withUnsafeMutableBufferPointer { buf in + let out = buf + DispatchQueue.concurrentPerform(iterations: hi - lo) { j in + let i = lo + j + out[i] = isDreamt ? Dreamt.load(root: root, id: ids[i], info: dreamtInfo[ids[i]]) + : SleepAccel.load(root: root, id: ids[i]) + } } } let subjects: [PSGSubject] = loaded.compactMap { $0 } @@ -110,23 +124,83 @@ guard !subjects.isEmpty else { } let totalScored = subjects.reduce(0) { $0 + $1.scoredEpochs } -print(""" +if isDreamt { + let ahis = subjects.compactMap { $0.ahi } + print(""" + + ================================================================================ + 2. DATASET + ================================================================================ + PhysioNet DREAMT — Wang et al., PMLR 248 (CHIL 2024). Empatica E4 at 64 Hz beside PSG. + Used under PhysioNet's data use agreement: never committed, and this report prints aggregates only. + Root: \(root) + + subjects \(subjects.count) + PSG-scored epochs \(totalScored) + scored night length median \(f(median(subjects.map { $0.scoredMinutes }), 6, 1)) min \ + range \(f(subjects.map { $0.scoredMinutes }.min() ?? .nan, 5, 1))–\(f(subjects.map { $0.scoredMinutes }.max() ?? .nan, 5, 1)) min + AHI (events/hour) median \(f(median(ahis), 5, 1)) <5: \(ahis.filter { $0 < 5 }.count) 5–15: \ + \(ahis.filter { $0 >= 5 && $0 < 15 }.count) 15–30: \(ahis.filter { $0 >= 15 && $0 < 30 }.count) ≥30: \(ahis.filter { $0 >= 30 }.count) + beat coverage median \(f(median(subjects.map { beatCoverage($0) }), 5, 2)) of the beats the HR implies, over scored sleep + + A SLEEP-CLINIC cohort: most subjects were referred for suspected apnoea, so wake is higher and deep and + REM lower than on a healthy night. PR #348 fitted priors to it and PR #437 reverted that, because the + cohort's base rates had moved into a prior applied to everyone. Read stage fractions by AHI stratum. + The R-R stream is LIVE here: the recipe's RSA respiration term runs on the E4's inter-beat intervals. + """) +} else { + print(""" -================================================================================ -2. DATASET -================================================================================ -PhysioNet sleep-accel v1.0.0 — Walch, Huang, Forger & Goldstein, SLEEP 42(12) zsz180 (2019). -Licence: Open Data Commons Attribution v1.0. Root: \(root) + ================================================================================ + 2. DATASET + ================================================================================ + PhysioNet sleep-accel v1.0.0 — Walch, Huang, Forger & Goldstein, SLEEP 42(12) zsz180 (2019). + Licence: Open Data Commons Attribution v1.0. Root: \(root) -subjects \(subjects.count) -PSG-scored epochs \(totalScored) -scored night length median \(f(median(subjects.map { $0.scoredMinutes }), 6, 1)) min \ -range \(f(subjects.map { $0.scoredMinutes }.min() ?? .nan, 5, 1))–\(f(subjects.map { $0.scoredMinutes }.max() ?? .nan, 5, 1)) min + subjects \(subjects.count) + PSG-scored epochs \(totalScored) + scored night length median \(f(median(subjects.map { $0.scoredMinutes }), 6, 1)) min \ + range \(f(subjects.map { $0.scoredMinutes }.min() ?? .nan, 5, 1))–\(f(subjects.map { $0.scoredMinutes }.max() ?? .nan, 5, 1)) min + + The R-R stream is EMPTY for every subject — this dataset carries no beat-to-beat intervals — so the + recipe's RSA respiration term returns nil on every epoch and contributes exactly 0.0 to every emission. + Five of the recipe's six inputs are live here; the sixth is silent, not wrong. + """) +} + +/// Beats present over the subject's PSG-scored SLEEP epochs, as a fraction of the beats its heart rate +/// implies there (Σ bpm/60 over those seconds). 1.0 is every beat; the E4 drops beats it is unsure of. +/// P(a > b) for a random a from `a` and b from `b`, ties counting half (the Mann–Whitney AUC). +func aucGreater(_ a: [Double], _ b: [Double]) -> Double { + guard !a.isEmpty, !b.isEmpty else { return .nan } + let all = (a.map { ($0, 0) } + b.map { ($0, 1) }).sorted { $0.0 < $1.0 } + var rankSumA = 0.0, i = 0 + while i < all.count { + var j = i + while j + 1 < all.count && all[j + 1].0 == all[i].0 { j += 1 } + let r = Double(i + j) / 2 + 1 + for k in i...j where all[k].1 == 0 { rankSumA += r } + i = j + 1 + } + let na = Double(a.count), nb = Double(b.count) + return (rankSumA - na * (na + 1) / 2) / (na * nb) +} -The R-R stream is EMPTY for every subject — this dataset carries no beat-to-beat intervals — so the -recipe's RSA respiration term returns nil on every epoch and contributes exactly 0.0 to every emission. -Five of the recipe's six inputs are live here; the sixth is silent, not wrong. -""") +func beatCoverage(_ s: PSGSubject) -> Double { + var hrBy = [Int: Int]() + for h in s.hr { hrBy[h.ts] = h.bpm } + var beatsBy = [Int: Int]() + for r in s.rr { beatsBy[r.ts, default: 0] += 1 } + var have = 0.0, expect = 0.0 + for (k, st) in s.truth.enumerated() where st != nil && st != "wake" { + let e0 = s.start + 30 * k + for t in e0..<(e0 + 30) { + if let b = hrBy[t] { expect += Double(b) / 60 } + have += Double(beatsBy[t] ?? 0) + } + } + return expect > 0 ? have / expect : 0 +} // Truth / prediction on the shared 30 s grid, restricted to PSG-scored epochs. struct Scored { @@ -152,11 +226,11 @@ func score(_ s: PSGSubject, using stage: (PSGSubject) -> [StageSegment]) -> Scor /// The SHIPPED stager — `StrandAnalytics.SleepStagerV2`, not the port. func stageShipped(_ s: PSGSubject) -> [StageSegment] { - SleepStagerV2.stageSession(start: s.start, end: s.end, grav: s.grav, hr: s.hr, rr: [], resp: []) + SleepStagerV2.stageSession(start: s.start, end: s.end, grav: s.grav, hr: s.hr, rr: s.rr, resp: []) } func stageVariant(_ cfg: RecipeConfig) -> (PSGSubject) -> [StageSegment] { { s in V2Recipe.stageSession(start: s.start, end: s.end, grav: s.grav, hr: s.hr, - rr: [], resp: [], cfg: cfg) } + rr: s.rr, resp: [], cfg: cfg) } } let shipped = subjects.map { score($0, using: stageShipped) } @@ -266,31 +340,33 @@ if want("baseline") { MAE (paired) \(f(mae(zip(sh.latencyPairsPred, sh.latencyPairsTruth).map { $0 - $1 }), 10, 1)) min (n = \(sh.latencyPairsPred.count)) """) - // A harness that replaces a deleted one has to say whether it is the same instrument. These are the - // figures the previous harness reported on this dataset. They are printed as a self-check, NOT as a - // target: nothing in this tool is tuned to hit them, and where they disagree the disagreement is the - // finding. See `Variants.preNine30Guard` for what explains the prediction-side gap. - let ref: [(String, Double, Double, Int)] = [ - ("subjects", Double(subjects.count), 31, 0), - ("PSG-scored epochs", Double(sh.conf.total), 26773, 0), - ("kappa (4-class)", sh.kappa, 0.349, 3), - ("REM F1", sh.conf.prf("rem").f1, 0.515, 3), - ("REM % of night", sh.predPct["rem"]!, 20.8, 2), - ("deep % predicted", sh.predPct["deep"]!, 19.25, 2), - ("deep % truth", sh.truthPct["deep"]!, 14.76, 2), - ("first-REM predicted, min", median(sh.latencyPred), 142.0, 1), - ("first-REM truth, min", median(sh.latencyTruth), 88.5, 1), - ] - print(""" + if !isDreamt { + // A harness that replaces a deleted one has to say whether it is the same instrument. These are the + // figures the previous harness reported on this dataset. They are printed as a self-check, NOT as a + // target: nothing in this tool is tuned to hit them, and where they disagree the disagreement is the + // finding. See `Variants.preNine30Guard` for what explains the prediction-side gap. + let ref: [(String, Double, Double, Int)] = [ + ("subjects", Double(subjects.count), 31, 0), + ("PSG-scored epochs", Double(sh.conf.total), 26773, 0), + ("kappa (4-class)", sh.kappa, 0.349, 3), + ("REM F1", sh.conf.prf("rem").f1, 0.515, 3), + ("REM % of night", sh.predPct["rem"]!, 20.8, 2), + ("deep % predicted", sh.predPct["deep"]!, 19.25, 2), + ("deep % truth", sh.truthPct["deep"]!, 14.76, 2), + ("first-REM predicted, min", median(sh.latencyPred), 142.0, 1), + ("first-REM truth, min", median(sh.latencyTruth), 88.5, 1), + ] + print(""" - SELF-CHECK against the previous harness's reported figures - Printed to expose disagreement, not to be matched. Nothing here is tuned to these numbers. Stage - fractions use the per-subject-mean convention, which is the one those figures were reported on. + SELF-CHECK against the previous harness's reported figures + Printed to expose disagreement, not to be matched. Nothing here is tuned to these numbers. Stage + fractions use the per-subject-mean convention, which is the one those figures were reported on. - quantity measured previously delta - """) - for (name, got, want, dp) in ref { - print(" \(name.padding(toLength: 28, withPad: " ", startingAt: 0))\(f(got, 9, dp))\(f(want, 13, dp))\(f(got - want, 11, dp))") + quantity measured previously delta + """) + for (name, got, want, dp) in ref { + print(" \(name.padding(toLength: 28, withPad: " ", startingAt: 0))\(f(got, 9, dp))\(f(want, 13, dp))\(f(got - want, 11, dp))") + } } } @@ -366,7 +442,7 @@ if want("rem") { var rows: [AblationRow] = [] for s in subjects { let (feats, _) = V2Recipe.stageEpochsDetailed(start: s.start, end: s.end, grav: s.grav, - hr: s.hr, rr: [], resp: []) + hr: s.hr, rr: s.rr, resp: []) // Per-night normalisation, matching `stageEpochs`. func zfun(_ vals: [Double?]) -> (Double?) -> Double { let present = vals.compactMap { $0 } @@ -511,4 +587,155 @@ if want("priors") { } } +// ───────────────────────────────────────────────────────────────────────────────────────────────────── +// The RSA term, live against silenced (DREAMT: the only dataset here with beat-to-beat intervals) +// ───────────────────────────────────────────────────────────────────────────────────────────────────── + +if want("rsa") && subjects.contains(where: { !$0.rr.isEmpty }) { + print(""" + + ================================================================================ + 8. THE RSA TERM — the shipped recipe with R-R, against the same recipe with R-R withheld + ================================================================================ + The only change between the columns is the R-R stream: `on` is the shipped recipe as the app runs it, + `off` passes no beats, which silences the RSA respiration term exactly as sleep-accel does. Both are + `SleepStagerV2.stageSession` itself. REM is the stage the term exists for. Strata: apnoea severity (AHI, + events/hour) and how many of the beats the E4 kept (coverage over scored sleep). Stage % are pooled over + all scored epochs of the stratum; REM/sleep is REM as a share of scored SLEEP epochs. + """) + let off = subjects.map { s in score(s) { SleepStagerV2.stageSession(start: $0.start, end: $0.end, grav: $0.grav, + hr: $0.hr, rr: [], resp: []) } } + func remOfSleep(_ labels: [String]) -> Double { + let sleep = labels.filter { $0 != "wake" }.count + return sleep == 0 ? .nan : 100 * Double(labels.filter { $0 == "rem" }.count) / Double(sleep) + } + func line(_ name: String, _ idx: [Int]) { + guard !idx.isEmpty else { return } + let on = idx.map { shipped[$0] }, of = idx.map { off[$0] } + func pooled(_ rows: [Scored]) -> Confusion { + var c = Confusion(); for r in rows { for i in 0.. $0.1 }.count, kDown = zip(kOn, kOf).filter { $0.0 < $0.1 }.count + let remOn = cOn.prf("rem"), remOf = cOf.prf("rem") + print(" \(name.padding(toLength: 14, withPad: " ", startingAt: 0))\(String(idx.count).padding(toLength: 4, withPad: " ", startingAt: 0))" + + "κ \(f(cOf.kappa, 5, 3))→\(f(cOn.kappa, 5, 3)) up/down \(kUp)/\(kDown) " + + "REM P \(f(remOf.precision, 4, 2))→\(f(remOn.precision, 4, 2)) R \(f(remOf.recall, 4, 2))→\(f(remOn.recall, 4, 2)) " + + "F1 \(f(remOf.f1, 4, 2))→\(f(remOn.f1, 4, 2)) " + + "REM% \(f(pOf["rem"]!, 5, 1))→\(f(pOn["rem"]!, 5, 1)) (\(f(pT["rem"]!, 5, 1))) " + + "REM/sleep \(f(rsOf, 5, 1))→\(f(rsOn, 5, 1)) (\(f(rsT, 5, 1))) " + + "wake% \(f(pOf["wake"]!, 5, 1))→\(f(pOn["wake"]!, 5, 1)) (\(f(pT["wake"]!, 5, 1))) " + + "deep% \(f(pOf["deep"]!, 5, 1))→\(f(pOn["deep"]!, 5, 1)) (\(f(pT["deep"]!, 5, 1)))") + } + print(" stratum n κ off→on per-subject κ REM precision / recall / F1, off→on " + + "REM % of night (truth) REM % of sleep (truth) wake %, deep % (truth)") + let all = Array(subjects.indices) + line("all", all) + let ahi: (Int) -> Double? = { subjects[$0].ahi } + line("AHI <5", all.filter { (ahi($0) ?? -1) >= 0 && ahi($0)! < 5 }) + line("AHI 5–15", all.filter { (ahi($0) ?? -1) >= 5 && ahi($0)! < 15 }) + line("AHI 15–30", all.filter { (ahi($0) ?? -1) >= 15 && ahi($0)! < 30 }) + line("AHI ≥30", all.filter { (ahi($0) ?? -1) >= 30 }) + let cov = subjects.map { beatCoverage($0) } + line("beats <50%", all.filter { cov[$0] < 0.5 }) + line("beats 50–80%", all.filter { cov[$0] >= 0.5 && cov[$0] < 0.8 }) + line("beats ≥80%", all.filter { cov[$0] >= 0.8 }) + line("≥80%, AHI <15", all.filter { cov[$0] >= 0.8 && (ahi($0) ?? 99) < 15 }) + + // Does the feature carry the physiology the term assumes? The recipe adds respWeight·z to DEEP and + // subtracts it from REM (regular breathing → deep, irregular → REM) and leaves light alone. z is the + // per-night z-score of the RSA peakedness, exactly as `stageEpochs` computes it. If z does not separate + // the truth stages, a symmetric ±z on deep and REM only moves epochs OUT of light, into both. + print(""" + + Is the feature informative? Per-night z of the RSA peakedness, grouped by the PSG stage of the epoch. + AUC = P(z of a random epoch of the first stage > z of a random REM epoch); 0.5 = no information, and the + recipe's sign assumes AUC > 0.5 for deep vs REM. `present` = epochs where the feature is not nil. + """) + var zBy = [String: [Double]](), presentBy = [String: (Int, Int)]() + var subjAUC: [Double] = [] + for s in subjects { + let (feats, _) = V2Recipe.stageEpochsDetailed(start: s.start, end: s.end, grav: s.grav, hr: s.hr, rr: s.rr, resp: []) + let vals = feats.map { $0.respReg } + let present = vals.compactMap { $0 } + guard present.count >= 2 else { continue } + let m = present.reduce(0, +) / Double(present.count) + let sd0 = (present.reduce(0.0) { $0 + ($1 - m) * ($1 - m) } / Double(present.count)).squareRoot() + let sdv = sd0 == 0 ? 1.0 : sd0 + var nrem: [Double] = [], rem: [Double] = [] + for (i, st) in s.truth.enumerated() where i < feats.count { + guard let st = st else { continue } + var pc = presentBy[st] ?? (0, 0); pc.1 += 1 + if let v = vals[i] { + pc.0 += 1 + let z = (v - m) / sdv + zBy[st, default: []].append(z) + if st == "rem" { rem.append(z) } else if st != "wake" { nrem.append(z) } + } + presentBy[st] = pc + } + if nrem.count >= 20 && rem.count >= 20 { subjAUC.append(aucGreater(nrem, rem)) } + } + for st in stageOrder { + let z = zBy[st] ?? [], pc = presentBy[st] ?? (0, 0) + print(" \(st.padding(toLength: 7, withPad: " ", startingAt: 0)) mean z \(f(mean(z), 6, 3)) median z \(f(median(z), 6, 3)) present \(f(pc.1 == 0 ? .nan : 100 * Double(pc.0) / Double(pc.1), 5, 1)) %") + } + let zr = zBy["rem"] ?? [] + print(""" + AUC deep vs REM \(f(aucGreater(zBy["deep"] ?? [], zr), 6, 3)) light vs REM \(f(aucGreater(zBy["light"] ?? [], zr), 6, 3)) \ + wake vs REM \(f(aucGreater(zBy["wake"] ?? [], zr), 6, 3)) (pooled epochs) + per-subject AUC NREM vs REM: median \(f(median(subjAUC), 6, 3)), > 0.5 in \(subjAUC.filter { $0 > 0.5 }.count)/\(subjAUC.count) subjects + """) + + // Shapes of the term, subject by subject (every subject counts once, as in section 7). + print(""" + + Shapes of the term, subject by subject. `better κ` counts subjects whose own kappa rises against the + shipped recipe; |bias| is each subject's |predicted − truth| share of their night, averaged. + """) + func shapeRows(_ name: String, _ idx: [Int]) { + let base = idx.map { shipped[$0] } + let baseK = base.map { confusion(ref: $0.truth, pred: $0.pred).kappa } + print(" \(name) (n = \(idx.count))") + print(" shape w mean κ better/worse κ mean|bias| wake deep REM light REM/sleep (truth)") + let w0 = RecipeConfig.shipped.respWeight + let shapes: [(String, RecipeConfig.RespShape, Double)] = [0.6, 0.45, 0.3, 0.2].map { w in + (w == w0 ? "symmetric (shipped)" : "symmetric", RecipeConfig.RespShape.symmetric, w) + } + [ + ("off", .symmetric, 0.0), + ("regularOnly", .regularOnly, 0.6), ("regularOnly", .regularOnly, 1.2), ("remOnly", .remOnly, 0.6), + ] + for (label, shape, w) in shapes { + var cfg = RecipeConfig.shipped; cfg.respShape = shape; cfg.respWeight = w + let rows = idx.map { score(subjects[$0], using: stageVariant(cfg)) } + let k = rows.map { confusion(ref: $0.truth, pred: $0.pred).kappa } + let up = zip(k, baseK).filter { $0.0 > $0.1 + 1e-12 }.count, dn = zip(k, baseK).filter { $0.0 < $0.1 - 1e-12 }.count + let biases = rows.map { stageBias(ref: $0.truth, pred: $0.pred) } + func mb(_ st: String) -> Double { mean(biases.map { abs($0[st] ?? 0) }) } + print(" \(label.padding(toLength: 20, withPad: " ", startingAt: 0))\(f(w, 4, 2)) \(f(mean(k), 7, 3)) \(String(up).padding(toLength: 3, withPad: " ", startingAt: 0))/ \(String(dn).padding(toLength: 3, withPad: " ", startingAt: 0)) " + + "\(f(mb("wake"), 5, 2)) \(f(mb("deep"), 5, 2)) \(f(mb("rem"), 5, 2)) \(f(mb("light"), 5, 2)) " + + "\(f(remOfSleep(rows.flatMap { $0.pred }), 5, 1)) (\(f(remOfSleep(rows.flatMap { $0.truth }), 4, 1)))") + } + } + shapeRows("all", all) + shapeRows("AHI <5", all.filter { (ahi($0) ?? -1) >= 0 && ahi($0)! < 5 }) + shapeRows("beats ≥80%", all.filter { cov[$0] >= 0.8 }) + + // The dose: the shipped weight, half of it, and none (= R-R withheld, since a nil feature scores z = 0). + print("\n respWeight dose (port; shipped: \(f(RecipeConfig.shipped.respWeight, 4, 2)))") + for w in [0.6, 0.3, 0.0] { + var cfg = RecipeConfig.shipped; cfg.respWeight = w + let rows = subjects.map { score($0, using: stageVariant(cfg)) } + var c = Confusion(); for r in rows { for i in 0.. String { + let root = NSTemporaryDirectory() + "sleeppsg-dreamt-" + UUID().uuidString + let fm = FileManager.default + try fm.createDirectory(atPath: root + "/data_64Hz", withIntermediateDirectories: true) + try (header + rows).write(toFile: root + "/data_64Hz/S901_whole_df.csv", atomically: true, encoding: .utf8) + try ("SID,AGE,GENDER,BMI,OAHI,AHI,Mean_SaO2,Arousal Index,MEDICAL_HISTORY,Sleep_Disorders\n" + info) + .write(toFile: root + "/participant_info.csv", atomically: true, encoding: .utf8) + addTeardownBlock { try? fm.removeItem(atPath: root) } + return root + } + + /// One row per second from `from` to `to` (exclusive): the stage, a constant HR, no IBI. + private func seconds(_ from: Int, _ to: Int, stage: String, hr: String = "60.0", acc: String = "0.0,0.0,64.0") -> String { + (from..