Sleep staging against an expert hypnogram: the irreversible Rechtschaffen & Kales to AASM mapping stated first, then Cohen's kappa over the whole recording and over the sleep period, with the confusion matrix and four sensitivity runs

nb-7-2-sleep Level 7 · Applied Electives ~8 min Used in L7.2 · Sleep EEG and staging

Downloads from ds-sleep-edf when you run it.

Download the notebook (.ipynb) Outputs below are the ones stored when it was executed — you do not need to run anything to read it.

nb-7-2-sleep · Sleep EEG and staging (L7.2)

Lesson L7.2 · Level 7 · Status draft — for expert review; uncertain points carry TODO(confirm).

One night, scored twice: once by a sleep technologist in 1989 and once by an automatic stager now. The exercise is Cohen's κ between them, and most of this notebook is about what has to be settled before that number exists at all.

  1. The night, and why it is not a representative one.
  2. The mapping. The expert scored to Rechtschaffen & Kales and the stager returns AASM stages. R&K 3 and 4 both become N3 and the merge cannot be undone; movement-time and unscored epochs have no AASM counterpart at all. The mapping and the policy for those epochs are stated before any κ is computed, because a κ quoted without them is not reproducible.
  3. The staging, the hypnograms and the whole-night spectrogram.
  4. κ, over the whole recording and over the sleep period, five-class and three-class, with the confusion matrix that says where the disagreement is and four sensitivity runs that say how much each choice was worth.
  5. Spindles and slow waves, detected and labelled algorithmic, because this recording ships an expert hypnogram and no graphoelement marks at all.

Data. ds-sleep-edf — Sleep-EDF Database Expanded, PhysioNet sleep-edfx v1.0.0, Kemp, Zwinderman, Tuk, Kamphuisen & Oberyé (2000), Analysis of a sleep-dependent neuronal feedback loop, IEEE TBME 47(9), 1185–1194, DOI 10.1109/10.867928; dataset DOI 10.13026/C2X676. Licence ODC-By 1.0, access: open — spec §10.7 class A, so derived assets may ship with attribution, and w-raw-scroller's sleep mode ships from this same night. Verified from primary material; see site/notes/data-p4-licences.md.

One sleep-cassette PSG (about 48 MB) and its hypnogram (a few kB) are fetched out of the 8.1 GB deposit, and deleted in a finally as soon as the signal is in memory.

"Expert" here means the dataset's technologist, not this site's author. The hypnogram was "manually scored by well-trained technicians … according to the 1968 Rechtschaffen and Kales manual, but based on Fpz-Cz/Pz-Oz EEGs instead of C4-A1/C3-A2 EEGs". Nobody on this project has reviewed it.

In [1]:
# Setup: dependencies, the shared helpers, non-interactive plotting, a quiet downloader.
import importlib.util
import subprocess
import sys
import time
import warnings
from collections import Counter
from pathlib import Path

_needed = ("mne", "scipy", "matplotlib", "pandas", "pooch", "sklearn")
_missing = [p for p in _needed if importlib.util.find_spec(p) is None]
if _missing:
    _req = next((d / "requirements.txt" for d in (Path.cwd(), *Path.cwd().parents)
                 if (d / "requirements.txt").exists()), None)
    _cmd = [sys.executable, "-m", "pip", "install", "-q"]
    _cmd += ["-r", str(_req)] if _req else ["mne==1.10.2", "yasa", "scikit-learn"]
    subprocess.check_call(_cmd)

_shared = next((d / "_shared" for d in (Path.cwd(), *Path.cwd().parents)
                if (d / "_shared" / "helpers_l7.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/L7/ (or notebooks/) so that _shared/helpers_l7.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l7 as L7

import matplotlib.pyplot as plt
import numpy as np
import mne
import pooch

mne.set_log_level("WARNING")
# Silence the downloader at the source rather than scrubbing cache paths out of stored outputs
# afterwards: a re-execution would put them straight back (Phase 3 note in notebooks/README.md).
pooch.get_logger().setLevel("WARNING")
plt.rcParams["figure.dpi"] = 72

HAS_YASA = importlib.util.find_spec("yasa") is not None
EPOCH_S = float(L7.SLEEP_SPEC["epoch_s"])
PAD_EPOCHS = 60                       # 30 minutes either side of the sleep period, for section 4.2

print(f"MNE {mne.__version__}; helpers_l7 imported from notebooks/_shared")
print(f"yasa installed: {HAS_YASA}"
      + ("" if HAS_YASA else "  -> the documented fallback stager will run instead"))
print(L7.dataset_line("ds-sleep-edf"))
MNE 1.10.2; helpers_l7 imported from notebooks/_shared
yasa installed: True
ds-sleep-edf — Sleep-EDF Database Expanded (PhysioNet sleep-edfx v1.0.0)
    licence ODC-By-1.0, access open, snippets allowed; DOI 10.13026/C2X676
    ODC-By 1.0, access open: spec section 10.7 class A.  Derived assets may ship with attribution (citation, licence and DOI in the sidecar and on the lab page).  The expert hypnograms are the recording's own scoring and carry `source`, not `label_source: algorithmic`.

1 · The night

Everything about this recording that changes what a number computed on it means.

In [2]:
for k, v in L7.SLEEP_SPEC.items():
    print(f"  {k:16s} {v}")
  dataset          ds-sleep-edf
  study            sleep cassette (SC)
  subject          0
  recording        1
  record           SC4001E0-PSG.edf with SC4001EC-Hypnogram.edf
  record_note      the same night w-raw-scroller's sleep mode ships, so the widget and the notebook describe one recording rather than two.
  epoch_s          30.0
  eeg              EEG Fpz-Cz
  eog              EOG horizontal
  emg              None
  emg_note         The submental EMG channel is NOT used.  It is a rectified, low-pass filtered envelope sampled at 1 Hz, not a raw EMG signal, and MNE upsamples it to 100 Hz on read, so a stager that expects EMG would be fed 100 copies of each sample.  Respiration, temperature and the event marker are also at 1 Hz; only EEG and EOG are at 100 Hz.
  montage_note     Two bipolar derivations, Fpz-Cz and Pz-Oz.  The hypnogram was scored on these rather than on the clinical C4-A1 / C3-A2, and there is no central derivation at all.  Spindle and K-complex detectors tuned to central derivations are not being asked the same question here.
  scoring          Rechtschaffen & Kales (1968), by a technician identified by the eighth letter of the hypnogram filename.  Stages W, R, 1, 2, 3, 4, M (movement time) and ? (not scored).
  label_source     expert — and 'expert' here means the DATASET's sleep technologist, whose scoring ships with the recording, not a review by this site's author.
  recording_note   A sleep-cassette recording made at home over a whole day and night, about 22 hours long, of which about three quarters of the scored epochs are wake.  That is the recording, not a defect, and it changes what an agreement statistic computed over the whole record means.
  seed             20260918
In [3]:
L7.disk_report("before any download", folders={"Sleep-EDF cache": L7.sleep_edf_root()})
already_on_disk = [p for p in L7.sleep_edf_root().rglob("*") if p.is_file()] \
    if L7.sleep_edf_root().exists() else []
print(f"{len(already_on_disk)} Sleep-EDF files were cached before this run; they are kept and "
      f"anything this run fetches is deleted")

t0 = time.time()
paths = L7.fetch_sleep_night()
try:
    signal_rates = L7.edf_signal_rates(paths[0])  # from the header, before the file is deleted
    raw, ann = L7.read_sleep_night(*paths)        # preload=True: the signal is now in memory
    print(f"read in {time.time() - t0:.0f} s")
finally:
    freed = L7.drop_sleep_edf(paths, keep=already_on_disk, verbose=True)
L7.disk_report("after the download was deleted", folders={"Sleep-EDF cache": L7.sleep_edf_root()})

print(f"\n{raw.times[-1] / 3600:.2f} hours, {raw.n_times:,} samples at "
      f"{raw.info['sfreq']:.0f} Hz nominal")
print(f"channels: {raw.ch_names}")
demographics = L7.subject_demographics(raw)
print(f"subject (from the EDF header): {demographics}")
n_epochs = int(raw.n_times // (EPOCH_S * raw.info["sfreq"]))
print(f"{n_epochs} scoring epochs of {EPOCH_S:.0f} s")
free disk before any download: 1.98 GB  (Sleep-EDF cache 0.0 MB)
0 Sleep-EDF files were cached before this run; they are kept and anything this run fetches is deleted
  SC4001E0-PSG.edf (48.3 MB) + SC4001EC-Hypnogram.edf (4.6 kB)
read in 305 s
    deleted 48.3 MB of Sleep-EDF downloads
free disk after the download was deleted: 1.93 GB  (Sleep-EDF cache 0.0 MB)

22.08 hours, 7,950,000 samples at 100 Hz nominal
channels: ['EEG Fpz-Cz', 'EEG Pz-Oz', 'EOG horizontal', 'Resp oro-nasal', 'EMG submental', 'Temp rectal', 'Event marker']
subject (from the EDF header): {'age': 33, 'male': False, 'raw_field': 'X 2 Female 33yr'}
2650 scoring epochs of 30 s

Three things about this montage that a normal EEG recipe gets wrong

  • Two bipolar derivations, Fpz-Cz and Pz-Oz. No common reference, no central derivation, no montage to set.
  • The submental EMG is not EMG. It is a rectified, low-pass filtered envelope sampled at 1 Hz, and MNE upsamples it to 100 Hz on read, so a stager that expects EMG would be handed a hundred copies of each sample. Respiration, temperature and the event marker are at 1 Hz too. Only the two EEG derivations and the EOG are at 100 Hz. Nothing here uses the 1 Hz channels.
  • The expert scored on these derivations, not on the clinical C4-A1 / C3-A2, so a detector tuned to a central derivation is not being asked the same question.
In [4]:
print(f"MNE reports one sampling rate for the whole file: {raw.info['sfreq']:.0f} Hz, and every "
      f"channel comes back with {raw.n_times:,} samples.  The EDF HEADER says otherwise:")
print(f"\n{'channel':20s} {'header rate':>12s}   what it really is")
for ch, r in signal_rates.items():
    print(f"{ch:20s} {r:9.2f} Hz   "
          + ("a signal" if r >= 10 else "a 1 Hz channel filled in to 100 Hz on read"))
print(f"\nThis is not visible after the read — MNE brings every signal up to the file's highest rate, "
      f"so counting distinct values per second finds 100 in every channel.  The header is the only "
      f"place the difference survives, which is why helpers_l7.edf_signal_rates reads it directly.")
print(f"\n{L7.SLEEP_SPEC['emg_note']}")
MNE reports one sampling rate for the whole file: 100 Hz, and every channel comes back with 7,950,000 samples.  The EDF HEADER says otherwise:

channel               header rate   what it really is
EEG Fpz-Cz              100.00 Hz   a signal
EEG Pz-Oz               100.00 Hz   a signal
EOG horizontal          100.00 Hz   a signal
Resp oro-nasal            1.00 Hz   a 1 Hz channel filled in to 100 Hz on read
EMG submental             1.00 Hz   a 1 Hz channel filled in to 100 Hz on read
Temp rectal               1.00 Hz   a 1 Hz channel filled in to 100 Hz on read
Event marker              1.00 Hz   a 1 Hz channel filled in to 100 Hz on read

This is not visible after the read — MNE brings every signal up to the file's highest rate, so counting distinct values per second finds 100 in every channel.  The header is the only place the difference survives, which is why helpers_l7.edf_signal_rates reads it directly.

The submental EMG channel is NOT used.  It is a rectified, low-pass filtered envelope sampled at 1 Hz, not a raw EMG signal, and MNE upsamples it to 100 Hz on read, so a stager that expects EMG would be fed 100 copies of each sample.  Respiration, temperature and the event marker are also at 1 Hz; only EEG and EOG are at 100 Hz.

2 · The mapping, before any κ

The expert scoring is Rechtschaffen & Kales (1968) and the automatic stager returns AASM stages. The two vocabularies are not the same, and reconciling them is a choice the lesson makes, not a detail.

In [5]:
rk = L7.expert_hypnogram(raw, ann)
counts = Counter(rk.tolist())
print(f"R&K stage per 30-s epoch, {len(rk)} epochs:")
for s in L7.RK_STAGES:
    if counts.get(s):
        print(f"  {s:2s}  {counts[s]:5d} epochs  {counts[s] * EPOCH_S / 60:7.1f} min  "
              f"{100 * counts[s] / len(rk):5.1f} %")
print(f"\nwake is {100 * counts.get('W', 0) / len(rk):.1f} % of the scored epochs. "
      f"{L7.SLEEP_SPEC['recording_note']}")

print(f"\nThe hypnogram FILE covers {sum(ann.duration) / 3600:.1f} h against the PSG's "
      f"{raw.times[-1] / 3600:.2f} h.")
tail = [(o, d, x) for o, d, x in zip(ann.onset, ann.duration, ann.description)
        if o + d > raw.times[-1]]
for o, d, x in tail:
    print(f"  the annotation starting at {o / 3600:.2f} h for {d / 3600:.2f} h is '{x}' — "
          f"{int(d / EPOCH_S)} epochs of it, all of them after the last sample")
R&K stage per 30-s epoch, 2650 epochs:
  W    1997 epochs    998.5 min   75.4 %
  1      58 epochs     29.0 min    2.2 %
  2     250 epochs    125.0 min    9.4 %
  3     101 epochs     50.5 min    3.8 %
  4     119 epochs     59.5 min    4.5 %
  R     125 epochs     62.5 min    4.7 %

wake is 75.4 % of the scored epochs. A sleep-cassette recording made at home over a whole day and night, about 22 hours long, of which about three quarters of the scored epochs are wake.  That is the recording, not a defect, and it changes what an agreement statistic computed over the whole record means.

The hypnogram FILE covers 24.0 h against the PSG's 22.08 h.
  the annotation starting at 14.52 h for 7.57 h is 'Sleep stage W' — 908 epochs of it, all of them after the last sample
  the annotation starting at 22.08 h for 1.92 h is 'Sleep stage ?' — 230 epochs of it, all of them after the last sample
In [6]:
print(L7.RK_TO_AASM_NOTE)
print("\nThe mapping this notebook applies:")
for k in L7.RK_STAGES:
    v = L7.RK_TO_AASM[k]
    print(f"  R&K {k:2s} -> {v if v else 'excluded by policy (see below)'}")
print("\nThe policies available for M and ?, and what each one does:")
for k, v in L7.STAGE_POLICIES.items():
    print(f"  [{k}] {v}")
The expert scoring follows the 1968 Rechtschaffen & Kales manual and the automatic stager returns AASM stages, so the two vocabularies must be reconciled before Cohen's kappa means anything.
  * R&K 3 and R&K 4 BOTH become AASM N3.  **This merge is irreversible**: nothing in the mapped labels says which epochs were 3 and which were 4, so a kappa computed after it cannot be undone or recomputed at the finer resolution.
  * R&K 'M' (movement time) has no AASM counterpart.  It is not a shallower stage; it is an epoch the scorer could not score because of movement.
  * R&K '?' means not scored at all.
  * W, 1, 2 and R map one-to-one onto WAKE, N1, N2 and REM.
A kappa quoted without this mapping and without the policy for M and ? is not reproducible, because excluding those epochs and including them give different numbers.

The mapping this notebook applies:
  R&K W  -> WAKE
  R&K 1  -> N1
  R&K 2  -> N2
  R&K 3  -> N3
  R&K 4  -> N3
  R&K R  -> REM
  R&K M  -> excluded by policy (see below)
  R&K ?  -> excluded by policy (see below)

The policies available for M and ?, and what each one does:
  [exclude] drop the epoch from the comparison entirely.  The default, and the honest one: the expert did not assign it a sleep stage, so there is no reference to agree with.  It shrinks the denominator.
  [wake] score them as WAKE.  Defensible for movement time, which usually sits beside wake, but it invents a reference the expert did not give and it inflates the largest class.
  [keep] carry them through as their own category.  Then the automatic stager can never match them, because no AASM stager outputs 'M' or '?', so every such epoch counts as a disagreement.
In [7]:
aasm, keep, policy = L7.map_rk_to_aasm(rk, movement="exclude", unscored="exclude")
print(f"policy: movement={policy['movement']}, unscored={policy['unscored']}")
print(f"  movement-time epochs in this recording: {policy['n_movement']}")
print(f"  unscored epochs in this recording:      {policy['n_unscored']}")
print(f"  epochs excluded:                        {policy['n_excluded']}")
print(f"  epochs kept:                            {policy['n_kept']}")
print(f"\n  R&K 3 ({policy['n_rk3']} epochs) + R&K 4 ({policy['n_rk4']} epochs) -> "
      f"{policy['merged_3_and_4']} N3 epochs.  IRREVERSIBLE.")

print("\nOn this night the policy for M and ? changes nothing, because the recording contains none of "
      "either — every epoch inside the PSG carries a sleep stage.  It is still stated, because the "
      "same code on a different night of this dataset would change the denominator and therefore the "
      "kappa, and because a reader cannot tell which case they are in without being told:")
for mov in L7.STAGE_POLICIES:
    _, k2, p2 = L7.map_rk_to_aasm(rk, movement=mov, unscored=mov)
    print(f"  movement={mov:8s} unscored={mov:8s} -> {p2['n_kept']} epochs compared, "
          f"{p2['n_excluded']} excluded")
policy: movement=exclude, unscored=exclude
  movement-time epochs in this recording: 0
  unscored epochs in this recording:      0
  epochs excluded:                        0
  epochs kept:                            2650

  R&K 3 (101 epochs) + R&K 4 (119 epochs) -> 220 N3 epochs.  IRREVERSIBLE.

On this night the policy for M and ? changes nothing, because the recording contains none of either — every epoch inside the PSG carries a sleep stage.  It is still stated, because the same code on a different night of this dataset would change the denominator and therefore the kappa, and because a reader cannot tell which case they are in without being told:
  movement=exclude  unscored=exclude  -> 2650 epochs compared, 0 excluded
  movement=wake     unscored=wake     -> 2650 epochs compared, 0 excluded
  movement=keep     unscored=keep     -> 2650 epochs compared, 0 excluded

3 · Automatic staging

yasa's SleepStaging is pinned in notebooks/requirements.txt. When it is absent the notebook falls back to a shrinkage LDA over band powers trained on a different subject's night, and says so; the fallback's own limitations are printed either way, because a reader on that path needs them and a reader on this path should know what the alternative would have been.

In [8]:
t0 = time.time()
auto, stager = L7.stage_night(raw, metadata={"age": demographics["age"],
                                             "male": demographics["male"]})
print(f"  {time.time() - t0:.1f} s, path = {stager['path']}")
print(f"  channels: {stager['channels']}")
if stager["path"] == "yasa":
    print(f"  classes: {stager['classes']}")
    print(f"  mean posterior of the chosen class: {np.mean(stager['confidence']):.3f}")
print(f"\nThe fallback this notebook would have used instead:")
for k in ("what_it_is", "what_it_is_not", "why_it_exists", "why_not_a_threshold_rule"):
    print(f"  [{k}] {L7.FALLBACK_STAGER[k]}")

print(f"\nautomatic stage counts: "
      + ", ".join(f"{k} {v}" for k, v in sorted(Counter(np.asarray(auto).tolist()).items())))
  automatic staging: yasa 0.7.0 SleepStaging on EEG Fpz-Cz, EOG horizontal
  4.4 s, path = yasa
  channels: ['EEG Fpz-Cz', 'EOG horizontal']
  classes: ['N1', 'N2', 'N3', 'REM', 'WAKE']
  mean posterior of the chosen class: 0.855

The fallback this notebook would have used instead:
  [what_it_is] A five-class linear discriminant over per-epoch log band powers of one EEG derivation and one EOG channel, TRAINED ON A DIFFERENT NIGHT FROM A DIFFERENT SUBJECT and applied unchanged to the scored night.  Nothing from the night being scored enters the fit, so its kappa is an honest out-of-sample number.
  [what_it_is_not] It is NOT a competitive sleep stager.  One training night, no temporal context (a real scorer uses the epochs either side), no spindle or K-complex detector, no EMG, no demographics.  Its kappa says what a linear model on band powers buys, not what the state of the art does, and it is not the answer to the L7.2 exercise.
  [why_it_exists] So the notebook runs top to bottom with none of the optional packages installed, and says which path it took.  It costs one extra 48 MB download, which is why it only happens when yasa is absent.
  [why_not_a_threshold_rule] The obvious fallback — thresholds on relative band power — was written first and discarded.  Relative delta over a 0.5-30 Hz total exceeds 0.55 in almost every epoch of real EEG, wake included, because the spectrum is 1/f; the rule labelled 2602 of 2650 epochs N3 and scored kappa = 0.000.  Thresholds that did work would have had to be read off the night being scored, which is circular.

automatic stage counts: N1 17, N2 280, N3 262, REM 104, WAKE 1987
In [9]:
fig, _ = L7.plot_hypnograms(aasm, auto, mask=keep,
                            title="One night scored twice: expert (R&K mapped to AASM) above, "
                                  "automatic below, disagreement below that "
                                  "(stage per 30-s epoch)")
plt.show()
Figure 1 of notebook nb-7-2-sleep, an output plot. The text around it states what it shows and the units of every axis.
In [10]:
fig, _ = L7.plot_night_spectrogram(raw, fmax=25.0)
plt.show()
Figure 2 of notebook nb-7-2-sleep, an output plot. The text around it states what it shows and the units of every axis.

4 · Cohen's κ

4.1 · The whole recording

Every epoch the expert scored, which on this night is every epoch in the PSG.

In [11]:
whole = L7.staging_agreement(aasm, auto, mask=keep)
print(f"kappa      {whole['kappa']:.4f}")
print(f"accuracy   {whole['accuracy']:.4f}")
print(f"epochs     {whole['n_epochs']}")
print(f"\n{'stage':6s} {'expert':>8s} {'automatic':>10s} {'recall':>8s} {'precision':>10s}")
for s in whole["labels"]:
    print(f"{s:6s} {whole['expert_counts'][s]:8d} {whole['auto_counts'][s]:10d} "
          f"{whole['recall'][s]:8.3f} {whole['precision'][s]:10.3f}")
kappa      0.7971
accuracy   0.9158
epochs     2650

stage    expert  automatic   recall  precision
WAKE       1997       1987    0.966      0.971
N1           58         17    0.121      0.412
N2          250        280    0.804      0.718
N3          220        262    0.950      0.798
REM         125        104    0.640      0.769
In [12]:
fig, _ = L7.plot_confusion(whole,
                           title=f"Whole recording, {whole['n_epochs']} epochs "
                                 f"(count per cell; kappa = {whole['kappa']:.3f})")
plt.show()
Figure 3 of notebook nb-7-2-sleep, an output plot. The text around it states what it shows and the units of every axis.

4.2 · The sleep period

Three quarters of this recording is wake, because it is a cassette recording made at home across a whole day and night. An agreement statistic computed over all of it is dominated by a stage both scorers find easy. Restricting it to the sleep period is the fairer comparison and gives a different number — so the window has to be quoted with the κ, exactly as the mapping does.

The window is a convention, not a measurement: lights-off and lights-on are not in the record, so sleep efficiency, sleep-onset latency and WASO cannot be computed and are not reported here. What is used instead is 30 minutes either side of the first and last sleep epoch.

In [13]:
bed, bed_info = L7.in_bed_mask(aasm, pad_epochs=PAD_EPOCHS, keep=keep)
print(f"window: epochs {bed_info['lo']}-{bed_info['hi']} = {bed_info['hours']:.2f} h "
      f"({bed_info['n_epochs']} epochs); {bed_info['definition']}")
in_bed = L7.staging_agreement(aasm, auto, mask=bed)
print(f"\n{'':22s} {'kappa':>8s} {'accuracy':>9s} {'epochs':>8s}")
print(f"{'whole recording':22s} {whole['kappa']:8.4f} {whole['accuracy']:9.4f} {whole['n_epochs']:8d}")
print(f"{'sleep period +- 30 min':22s} {in_bed['kappa']:8.4f} {in_bed['accuracy']:9.4f} "
      f"{in_bed['n_epochs']:8d}")

coarse_whole = L7.staging_agreement(L7.coarse_stages(aasm), L7.coarse_stages(auto), mask=keep,
                                    labels=["WAKE", "NREM", "REM"])
coarse_bed = L7.staging_agreement(L7.coarse_stages(aasm), L7.coarse_stages(auto), mask=bed,
                                  labels=["WAKE", "NREM", "REM"])
print(f"\nthree classes instead of five (WAKE / NREM / REM):")
print(f"{'whole recording':22s} {coarse_whole['kappa']:8.4f} {coarse_whole['accuracy']:9.4f} "
      f"{coarse_whole['n_epochs']:8d}")
print(f"{'sleep period +- 30 min':22s} {coarse_bed['kappa']:8.4f} {coarse_bed['accuracy']:9.4f} "
      f"{coarse_bed['n_epochs']:8d}")
print("\nFour numbers, one night, one stager.  Each is correct for the question it answers, and none "
      "of them can be quoted without saying which question that was.")
window: epochs 961-1802 = 7.01 h (841 epochs); first sleep epoch minus pad to last sleep epoch plus pad; a convention, because lights-off and lights-on are not recorded.

                          kappa  accuracy   epochs
whole recording          0.7971    0.9158     2650
sleep period +- 30 min   0.7226    0.7907      841

three classes instead of five (WAKE / NREM / REM):
whole recording          0.8610    0.9457     2650
sleep period +- 30 min   0.7835    0.8847      841

Four numbers, one night, one stager.  Each is correct for the question it answers, and none of them can be quoted without saying which question that was.

4.3 · How much was each choice worth?

Four things were fixed before the κ above: the derivation, whether the EOG channel is supplied, whether the subject's age and sex are supplied, and the mapping. Re-running with each one changed says what it bought. These are sensitivity checks on a pre-specified analysis, not a search for the best number — the headline κ is the one from the pre-specified settings, whatever these show.

In [14]:
variants = []
if stager["path"] == "yasa":
    meta = {"age": demographics["age"], "male": demographics["male"]}
    for label, kw in (
            (f"{L7.SLEEP_SPEC['eeg']} + EOG + demographics (pre-specified)", dict(metadata=meta)),
            (f"{L7.SLEEP_SPEC['eeg']} + EOG, no demographics", dict(metadata=None)),
            (f"{L7.SLEEP_SPEC['eeg']} only, no EOG", dict(eog=None, metadata=meta)),
            ("EEG Pz-Oz + EOG + demographics", dict(eeg="EEG Pz-Oz", metadata=meta))):
        lab, _ = L7.yasa_stage(raw, **kw)
        a = L7.staging_agreement(aasm, lab, mask=keep)
        b = L7.staging_agreement(aasm, lab, mask=bed)
        variants.append((label, a["kappa"], b["kappa"], a["accuracy"]))
    print(f"{'variant':46s} {'kappa (all)':>12s} {'kappa (sleep)':>14s} {'accuracy':>9s}")
    for label, ka, kb, acc in variants:
        print(f"{label:46s} {ka:12.4f} {kb:14.4f} {acc:9.4f}")
    print("\nThe EOG channel is worth more than the demographics here, and the demographics are worth "
          "a NEGATIVE amount on this subject — dropping them raises kappa.  One night is one draw, so "
          "that is a fact about this recording and not a recommendation; it is reported because it is "
          "what happened.")
else:
    print("sensitivity runs need yasa; on the fallback path only the headline number is computed")
variant                                         kappa (all)  kappa (sleep)  accuracy
EEG Fpz-Cz + EOG + demographics (pre-specified)       0.7971         0.7226    0.9158
EEG Fpz-Cz + EOG, no demographics                    0.8126         0.7160    0.9238
EEG Fpz-Cz only, no EOG                              0.5590         0.6379    0.7706
EEG Pz-Oz + EOG + demographics                       0.6760         0.6139    0.8517

The EOG channel is worth more than the demographics here, and the demographics are worth a NEGATIVE amount on this subject — dropping them raises kappa.  One night is one draw, so that is a fact about this recording and not a recommendation; it is reported because it is what happened.

5 · Spindles and slow waves

§6 asks the lesson to teach the graphoelements. This recording ships an expert hypnogram and no graphoelement annotations at all, so there is nothing to check a detection against: every label below is label_source: algorithmic. The lesson may say what a spindle looks like and where in the night they fall; it may not point at a marked one. The detectors were also designed for central derivations, and this montage has none.

In [15]:
grapho = L7.detect_graphoelements(raw, aasm)
if grapho:
    print(f"\n  detector           {grapho['detector']}")
    print(f"  derivation         {grapho['channel']}")
    print(f"  label_source       {grapho['label_source']}")
    print(f"  N2+N3 time         {grapho['n2n3_minutes']:.1f} min")
    print(f"  spindles           {grapho['n_spindles']} "
          f"({grapho['spindle_density_per_min']:.3f} per minute of N2+N3)")
    print(f"  slow waves         {grapho['n_slow_waves']} "
          f"({grapho['slow_wave_density_per_min']:.3f} per minute of N2+N3)")
    print(f"\n  {grapho['caveat']}")
    print("  TODO(confirm): published spindle densities are quoted per minute of N2 on a CENTRAL "
          "derivation.  No published value is quoted here from memory, and this number is not "
          "comparable with one until somebody checks what the comparison would have to hold fixed.")
  106 spindles and 1374 slow waves over 235 min of N2+N3 on EEG Fpz-Cz (algorithmic labels)

  detector           yasa 0.7.0
  derivation         EEG Fpz-Cz
  label_source       algorithmic
  N2+N3 time         235.0 min
  spindles           106 (0.451 per minute of N2+N3)
  slow waves         1374 (5.847 per minute of N2+N3)

  Detected on EEG Fpz-Cz, a frontal bipolar derivation.  Spindle and slow-wave detectors are tuned to central derivations (C4-A1 / C3-A2) and this recording has none, so these densities are not comparable with published values.  label_source: algorithmic — no expert marked them.
  TODO(confirm): published spindle densities are quoted per minute of N2 on a CENTRAL derivation.  No published value is quoted here from memory, and this number is not comparable with one until somebody checks what the comparison would have to hold fixed.
In [16]:
if grapho and grapho.get("spindles") is not None and len(grapho["spindles"]):
    sp = grapho["spindles"]
    t_h = sp["Start"].to_numpy() / 3600.0
    fig, axes = plt.subplots(2, 1, figsize=(11, 4.4), sharex=True)
    axes[0].hist(t_h, bins=44, color="C0")
    axes[0].set_ylabel("Spindles\n(count per 0.5 h)")
    sw_t = grapho["slow_waves"]["Start"].to_numpy() / 3600.0
    axes[1].hist(sw_t, bins=44, color="C1")
    axes[1].set_ylabel("Slow waves\n(count per 0.5 h)")
    axes[1].set_xlabel("Time from the start of the recording (hours)")
    for a in axes:
        a.grid(alpha=0.3)
    fig.suptitle(f"Algorithmic detections across the night on {grapho['channel']} "
                 f"(counts per half hour; no expert marks exist to check them against)", fontsize=10)
    fig.tight_layout()
    plt.show()
Figure 4 of notebook nb-7-2-sleep, an output plot. The text around it states what it shows and the units of every axis.

6 · What the lesson's exercise asks for

"Agreement (Cohen's κ) between automatic and expert staging for one night." The number, and everything that has to be quoted with it for it to be reproducible.

In [17]:
print("=" * 96)
print("nb-7-2-sleep — the numbers the L7.2 exercise asks for")
print("=" * 96)
print(f"\n[1] COHEN'S KAPPA = {whole['kappa']:.4f}  (accuracy {whole['accuracy']:.4f}, "
      f"{whole['n_epochs']} epochs)")
print("    and it does not exist without all of this:")
print(f"      record            {L7.SLEEP_SPEC['record']} ({raw.times[-1] / 3600:.2f} h, "
      f"{len(rk)} epochs of {EPOCH_S:.0f} s)")
print(f"      expert scoring    {L7.SLEEP_SPEC['scoring']}")
print(f"      automatic stager  {stager['stager']} on {', '.join(stager['channels'])}"
      + (f", metadata age {demographics['age']}, male {demographics['male']}"
         if stager["path"] == "yasa" else ""))
print(f"      mapping           W->WAKE, 1->N1, 2->N2, 3->N3, 4->N3, R->REM "
      f"(3 and 4 merged: {policy['n_rk3']} + {policy['n_rk4']} = {policy['merged_3_and_4']} epochs, "
      f"IRREVERSIBLE)")
print(f"      M and ? policy    movement={policy['movement']}, unscored={policy['unscored']}; "
      f"this night has {policy['n_movement']} movement and {policy['n_unscored']} unscored epochs, "
      f"so {policy['n_excluded']} epochs were excluded")
print(f"      window            the whole recording, {whole['n_epochs']} epochs")

print(f"\n[2] The same night on three other windows or vocabularies:")
print(f"      sleep period +- 30 min, five classes   kappa {in_bed['kappa']:.4f} "
      f"({in_bed['n_epochs']} epochs, {bed_info['hours']:.2f} h)")
print(f"      whole recording, three classes         kappa {coarse_whole['kappa']:.4f}")
print(f"      sleep period, three classes            kappa {coarse_bed['kappa']:.4f}")

print(f"\n[3] Where the disagreement is (whole recording, recall per expert stage):")
for s in whole["labels"]:
    print(f"      {s:5s} {whole['recall'][s]:6.3f}   "
          f"expert {whole['expert_counts'][s]:5d} epochs, automatic {whole['auto_counts'][s]:5d}")
print(f"      N1 is where staging always disagrees and it is {100 * whole['expert_counts']['N1'] / whole['n_epochs']:.1f} % "
      f"of this night, so it costs kappa little and would cost a clinical read a great deal.")

if variants:
    print(f"\n[4] What each pre-specified choice was worth (kappa over the whole recording):")
    for label, ka, kb, acc in variants:
        print(f"      {label:46s} {ka:.4f}")

if grapho:
    print(f"\n[5] Graphoelements, label_source ALGORITHMIC, no expert marks exist: "
          f"{grapho['n_spindles']} spindles ({grapho['spindle_density_per_min']:.3f}/min) and "
          f"{grapho['n_slow_waves']} slow waves ({grapho['slow_wave_density_per_min']:.3f}/min) "
          f"over {grapho['n2n3_minutes']:.0f} min of N2+N3 on {grapho['channel']}")

print(f"\n[6] The night: {100 * counts.get('W', 0) / len(rk):.1f} % wake, "
      f"{len(rk) * EPOCH_S / 3600:.1f} h. " + str(L7.SLEEP_SPEC["recording_note"]))
print("=" * 96)
================================================================================================
nb-7-2-sleep — the numbers the L7.2 exercise asks for
================================================================================================

[1] COHEN'S KAPPA = 0.7971  (accuracy 0.9158, 2650 epochs)
    and it does not exist without all of this:
      record            SC4001E0-PSG.edf with SC4001EC-Hypnogram.edf (22.08 h, 2650 epochs of 30 s)
      expert scoring    Rechtschaffen & Kales (1968), by a technician identified by the eighth letter of the hypnogram filename.  Stages W, R, 1, 2, 3, 4, M (movement time) and ? (not scored).
      automatic stager  yasa 0.7.0 SleepStaging on EEG Fpz-Cz, EOG horizontal, metadata age 33, male False
      mapping           W->WAKE, 1->N1, 2->N2, 3->N3, 4->N3, R->REM (3 and 4 merged: 101 + 119 = 220 epochs, IRREVERSIBLE)
      M and ? policy    movement=exclude, unscored=exclude; this night has 0 movement and 0 unscored epochs, so 0 epochs were excluded
      window            the whole recording, 2650 epochs

[2] The same night on three other windows or vocabularies:
      sleep period +- 30 min, five classes   kappa 0.7226 (841 epochs, 7.01 h)
      whole recording, three classes         kappa 0.8610
      sleep period, three classes            kappa 0.7835

[3] Where the disagreement is (whole recording, recall per expert stage):
      WAKE   0.966   expert  1997 epochs, automatic  1987
      N1     0.121   expert    58 epochs, automatic    17
      N2     0.804   expert   250 epochs, automatic   280
      N3     0.950   expert   220 epochs, automatic   262
      REM    0.640   expert   125 epochs, automatic   104
      N1 is where staging always disagrees and it is 2.2 % of this night, so it costs kappa little and would cost a clinical read a great deal.

[4] What each pre-specified choice was worth (kappa over the whole recording):
      EEG Fpz-Cz + EOG + demographics (pre-specified) 0.7971
      EEG Fpz-Cz + EOG, no demographics              0.8126
      EEG Fpz-Cz only, no EOG                        0.5590
      EEG Pz-Oz + EOG + demographics                 0.6760

[5] Graphoelements, label_source ALGORITHMIC, no expert marks exist: 106 spindles (0.451/min) and 1374 slow waves (5.847/min) over 235 min of N2+N3 on EEG Fpz-Cz

[6] The night: 75.4 % wake, 22.1 h. A sleep-cassette recording made at home over a whole day and night, about 22 hours long, of which about three quarters of the scored epochs are wake.  That is the recording, not a defect, and it changes what an agreement statistic computed over the whole record means.
================================================================================================
In [18]:
# Nothing this notebook downloaded is left on disk; the fetch cell deleted it in a finally.
L7.disk_report("at the end", folders={"Sleep-EDF cache": L7.sleep_edf_root()})
left = [p.name for p in L7.sleep_edf_root().rglob("*") if p.is_file()] \
    if L7.sleep_edf_root().exists() else []
print(f"Sleep-EDF files still on disk: {sorted(left) or 'none'} "
      f"(pre-existing before this run: {sorted(p.name for p in already_on_disk) or 'none'})")
assert set(left) <= {p.name for p in already_on_disk}, "this run left a download behind"
print("no download made by this run was left behind")
free disk at the end: 1.93 GB  (Sleep-EDF cache 0.0 MB)
Sleep-EDF files still on disk: none (pre-existing before this run: none)
no download made by this run was left behind