psgscoring
Open-source AASM-compliant respiratory scoring for polysomnography.
Paper
Rombaut B, Rombaut B, Rombaut C, et al. Graded evidence in place of thresholds: an open-source, AASM-compliant method for respiratory event detection in polysomnography. Manuscript in preparation, 2026.
Technical details (signal processing chain, classification logic, bias corrections): Technical Reference (Online Supplement)
What this library does
psgscoring detects and classifies respiratory events (apneas, hypopneas, RERAs) in polysomnography recordings following AASM rules. It extends YASA (Vallat & Walker, eLife 2021) from sleep staging into a complete clinical respiratory scoring pipeline.
Four things that distinguish this library:
- Graded evidence instead of a threshold cascade — the AASM Rule 1A conjunction is evaluated as a product of graded terms (flow reduction, duration, desaturation-or-arousal, breath template) rather than a chain of yes/no cuts. One operating point replaces three parameter combinations. On MESA (n=150, held out) this raises event-level agreement over the rule cascade by a median ΔF1 of +0.029 (p = 7·10⁻⁸) without costing anything on the AHI itself.
- AHI confidence interval — every study is scored at three stringency levels (strict/standard/sensitive), yielding a per-study robustness grade (A/B/C) rather than a single AHI number.
- Clinical auditability — every event carries a confidence score, the rule that admitted it, the graded terms behind that decision, and the counters of every correction that touched it. Rejected candidates are kept with their rejection reason rather than discarded, so a reviewing physician can see what was not scored and why.
- Measured, not asserted — every behavioural change ships with the
measurement that motivated it and a decision rule fixed before the sweep.
CHANGELOG.mdcarries the numbers, including the ones that argued against the change. Validation runs on PSG-IPA (5 recordings, 12 scorers each) and MESA/NSRR (held out, nothing tuned on it).
Release policy
Releases are measurement points, not milestones. Several may be cut on the same day: every behavioural change is validated against the golden harness and at least one cohort before it ships, and the version is what makes that validation citable. A high version number reflects how often the work is measured, not instability.
The stability guarantee lives in the profiles, not in the version number:
mesa_shhsandchicago_1999are frozen. They reproduce published results (paper v31/v37 and the 1999 Chicago Criteria respectively) and are pinned against behavioural changes, including repairs that every other profile receives.- All other profiles track the current best understanding of the AASM rules
and may move between releases when a measurement justifies it. Each such
change is behind a profile field whose default preserves the previous
behaviour, is recorded in
CHANGELOG.mdwith the measurement that motivated it, and is pinned by a test.
If you need scored values to stay identical across time — for a study, a
regulatory submission, or a paper — pin the version (psgscoring==0.18.0)
and record which profile you used. Do not rely on a profile name alone.
Installation
pip install psgscoring
Requirements: Python ≥3.9, numpy, scipy, mne. No GPU required.
Quick Start
import mne
from psgscoring import run_pneumo_analysis
# Load EDF and provide a hypnogram (e.g., from YASA)
raw = mne.io.read_raw_edf("recording.edf", preload=True)
hypnogram = ["W", "N1", "N2", "N2", "N3", ...] # per 30-s epoch
# Run the full pipeline
results = run_pneumo_analysis(raw, hypnogram, scoring_profile="aasm_v3_rec")
# Access results
resp = results["respiratory"]["summary"]
print(f"AHI: {resp['ahi_total']}, Severity: {resp['severity']}")
print(f"Events: {resp['n_obstructive']} OA, {resp['n_hypopnea']} Hyp")
# AHI confidence interval
interval = results["ahi_interval"]
print(f"AHI interval: [{interval['strict']['ahi']}–{interval['sensitive']['ahi']}]")
print(f"Robustness: {interval['robustness_grade']}")
Scoring Profiles
| Parameter | Strict | Standard | Sensitive |
|---|---|---|---|
| Hypopnea threshold | ≥30% | ≥30% | ≥25% |
| SpO₂ nadir window | 30 s | 45 s | 45 s |
| Peak-based detection | No | Yes | Yes |
list_profiles() enumerates the full registry (AASM v1/v2/v3, CMS/Medicare,
Chicago 1999, and the NSRR dataset profile).
aasm_v3_breath — breath-graded hypopnea scoring (v0.13.0, opt-in)
run_pneumo_analysis(raw, hypnogram, scoring_profile="aasm_v3_breath")
A second hypopnea detector that takes the breath, not the sample, as the unit. The AASM speaks of peak signal excursions; this scores them directly. Five differences from the default detector:
-
Breath-level events. An event is a run of consecutive breaths below the threshold, so boundaries land on breath transitions and a recovery breath ends the run by itself — no smoothing, and the merge/split problem that depresses event-F1 largely disappears.
-
Two-pass patient calibration. Pass 1 finds the incontestable events; pass 2 measures the baseline from only the breathing that is not an event, and derives that patient's own SpO₂ delay by cross-correlation instead of assuming a fixed 30–45 s window.
-
Graded AASM predicates. The rule structure stays literally the Rule 1A conjunction — ≥30% reduction AND ≥10 s AND (≥3% desaturation OR arousal) — but each threshold gets a tolerance rather than being infinitely sharp.
-
Every event carries
p_scoredplus acriteriadict giving each predicate's contribution, so the audit trail says why, not just what.p_scoredranks events by how well they satisfy the AASM conjunction. It is not the probability that a scorer would mark the event: measured against the 12-scorer fractions on PSG-IPA (163 events) the correlation is only r = 0.194 and the level is +0.33 too high. Use it to order events, not as a likelihood. -
One strictness axis (
hypopnea_strictness, default 0.50) instead of three parameter combinations.
Scope is hypopneas only; apneas keep the existing detector.
Status — opt-in, not the default. On PSG-IPA (5 recordings, 12 scorers)
the median event-F1 rises 0.343 → 0.434, the percentile within the
inter-scorer distribution p6 → p17, and mean |ΔAHI| against the scorer median
falls 1.84 → 0.29. On MESA the paired advantage replicates on two
disjoint held-out samples of 50 (p = 0.0069 and p = 0.0016). But the two
datasets disagree on the absolute level by ~16 AHI points, and until that
is explained the existing clinical output stays the default. See
docs/interim_conclusie_klinisch_gebruik.md.
aasm_v3_dual — apneas on both flow sensors (v0.14.0, opt-in)
run_pneumo_analysis(raw, hypnogram, scoring_profile="aasm_v3_dual")
The AASM assigns apneas to the oronasal thermistor and hypopneas to nasal pressure. Choosing one sensor means inheriting its blind spot: nasal pressure misses mouth breathing, the thermistor is too slow for short events. This profile detects apneas on both and merges them, so neither channel can veto the other.
Each merged apnea carries corroboration — both, thermistor_only or
pressure_only. Nothing is discarded on that basis. Across 1785 apnea
detections on MESA only 19% are seen by both sensors, so requiring
corroboration would remove four events in five. Discarding can be licensed
explicitly (corroborate_apnea_events(..., corroboration_licensed=True)); no
profile does so.
meta["flow_channels"]["thermistor_check"] reports the envelope correlation
between the two channels either way. Under this profile it is informational —
the second sensor is additive and can only add events. It gates only the
substitutive path, where a dead thermistor would delete them.
Why this exists. v0.13.2 made the thermistor the apnea sensor outright. On
a montage where that channel carried no usable breathing signal, apneas went
from 93 to 0 and the conclusion moved from moderate CSAS — human-confirmed —
to mild SAS. That release was rolled back. aasm_v3_rec remains the default
and is byte-identical.
aasm_v3_pressure — nasal-pressure reference (v0.14.1, opt-in)
run_pneumo_analysis(raw, hypnogram, scoring_profile="aasm_v3_pressure")
Apnea detection is not the only consumer of a flow signal. The AHI robustness
sweep, baseline anchoring, the arousal/RERA coupling, Cheyne-Stokes detection
and the ventilatory burden each take one — and each took the apnea channel.
This profile scores apneas exactly as aasm_v3_rec does and points those five
at the nasal pressure instead.
| profile | apneas | derived analyses |
|---|---|---|
aasm_v3_rec |
thermistor¹ | thermistor¹ |
aasm_v3_pressure |
thermistor¹ | nasal pressure |
aasm_v3_dual |
both, merged | nasal pressure |
¹ where it passes the quality check; nasal pressure otherwise.
The AASM assigns quantitative flow assessment to nasal pressure and treats the oronasal thermistor as qualitative — able to show that flow stopped, not to grade how much of it is left. Reading a thermistor for the ventilatory burden therefore measures something the sensor does not claim to report: on one recording it read 20.4% instead of 42.6%, below the ≤25% reference, for a patient spending 94.7% of the night under 90% saturation.
Identical to aasm_v3_rec on any montage without a usable thermistor. Set
flow_reference on any profile to get the same behaviour;
meta["flow_channels"]["reference_sensor"] reports which channel was used.
Validation
PSG-IPA (PhysioNet): 5 recordings, 59 independent scorer sessions.
Against the scorer median, aasm_v3_rec gives bias +1.69/h, MAE 1.76/h,
Pearson r = 0.997, weighted κ = 0.839, severity concordance 4/5. Note the
sample size: five recordings is enough to expose a defect, not enough to
estimate a population.
MESA (NSRR, external cohort, n = 150): nothing is tuned on it. Against a reconstructed AASM-2015 reference, per profile:
| profile | median F1 | precision | recall | bias (/h) | MAE (/h) |
|---|---|---|---|---|---|
aasm_v3_rec |
0.438 | 0.519 | 0.408 | −5.30 | 10.12 |
aasm_v3_breath |
0.510 | 0.628 | 0.472 | −5.18 | 9.51 |
aasm_v3_prob |
0.513 | 0.670 | 0.462 | −6.41 | 9.97 |
aasm_v3_dual |
0.446 | 0.498 | 0.490 | −1.17 | 9.24 |
aasm_v3_breath_dual |
0.504 | 0.577 | 0.511 | −2.34 | 9.37 |
Paired against aasm_v3_rec, breath-graded scoring raises event agreement by
a median ΔF1 of +0.029 (95/150 recordings, p = 6.8·10⁻⁸); the probabilistic
variant by +0.036 (p = 1.4·10⁻⁹).
These numbers moved substantially in 0.17.0, and the reason is worth
stating plainly. The earlier figures on this cohort reported a bias of −11 to
−15/h and were read as under-detection. Most of it was not: a signal-quality
gate was rejecting effort channels by how the EDF declared its unit rather
than by the signal in them, and it failed 52 of 52 MESA recordings. Repairing
it halved the bias at identical F1, precision and recall on three of the
five profiles — the events had been found all along and were being discarded
in the accounting. See CHANGELOG.md 0.16.0 and docs/.
Bias corrections
The twelve below were the original systematic pass: six over-counting and six
under-counting mechanisms, each identified and quantified separately. The list
has grown since and is no longer the whole story — CHANGELOG.md carries the
current state, with the measurement behind every entry. The most consequential
recent one: a sensor-quality gate that judged how the EDF declared its unit
rather than the sensor itself.
| # | Correction | Direction | Clinical impact |
|---|---|---|---|
| 1 | Post-apnea baseline inflation | Over-counting | Prevents false Mild→Moderate |
| 2 | SpO₂ cross-contamination | Over-counting | Flags uncertain coupling |
| 3 | Cheyne-Stokes trough scoring | Over-counting | Prevents HF misdiagnosis as OSA |
| 4 | Low-confidence defaults | Over-counting | Confidence stratification |
| 5 | Artefact-flank exclusion | Over-counting | Prevents post-disconnect events |
| 6 | Local baseline validation | Over-counting | Rejects inflated-baseline FPs |
| 7 | Peak-based amplitude detection | Under-counting | AASM-conformant breath-level |
| 8 | Extended SpO₂ nadir window | Under-counting | Catches delayed desaturations |
| 9 | Flow smoothing removal | Under-counting | Eliminated +54 FPs on PSG-IPA |
| 10 | Position signal auto-mapping | Under-counting | Handles raw ADC encoding |
| 11 | Configurable profiles | Under-counting | Sensitivity adjustment per study |
| 12 | Flattening-based RERA | Under-counting | Flow limitation without amplitude drop |
Since then (see CHANGELOG.md for the measurements):
| Correction | Direction | What it was |
|---|---|---|
| Square-root linearisation applied per profile, not per montage | Over-counting | The AASM Rule 3 linearisation ran only when the hypopnoea and apnoea channels differed, so the same profile pre-processed one-channel and two-channel montages differently |
| Scale-free RIP quality gate | Under-counting | An absolute amplitude threshold rejected effort channels by how the EDF declared its unit; on MESA it failed 52 of 52 recordings, halving the reported AHI bias once repaired |
| Stability filter across all hypopnoea subtypes | Over-counting | The filter compared the event type exactly against "hypopnea", so every subtyped hypopnoea escaped it |
| Desaturation re-use limit | Over-counting | One desaturation could confirm any number of adjacent events (opt-in, off by default) |
| Breath-granular event boundaries | Neither | The rule cascade starts hypopnoeas 2–5 s before human scorers; snapping to breath edges removes that offset (opt-in, off by default) |
| Coherence-based thermistor gate | Neither | The gate deciding which sensor carries apnoeas correlated amplitude envelopes, while the question is whether both sensors see the same breaths — a timing question (opt-in, off by default) |
| Long-event splitting | Neither | Splits events beyond a duration ceiling (opt-in, off by default) |
| Low-baseline desaturation relaxation | Neither | Relaxes the 3% desaturation requirement when the local baseline is already hypoxic (opt-in, off by default) |
Four of these are off, and that is a result, not an omission. Each was
implemented, measured, and left disabled because the measurement did not
support enabling it: the desaturation limit removed 8 events in 4303; breath
boundaries bought recall no precision; long-event splitting duplicated an
existing splitter and found no hypopnoea candidates; the coherence gate is a
real repair of a real defect but costs 2.95/h of AHI bias on MESA when
switched on. They ship as switches so the finding stays reproducible and the
next cohort can overturn it. docs/third_party_comparison.md records one row
per idea evaluated, including the ones rejected.
Architecture
~14,600 lines across 21 submodules, 817 unit tests (CI: Python 3.9–3.12):
constants · utils · signal · breath · breath_scoring · classify · spo2 · plm · ancillary · arousal · respiratory · indices · ventilation · pipeline · ml_classifier · profiles · postprocess · signal_quality · signal_quality_channels · ecg_effort · _types
Behaviour is pinned by a golden-output harness (PSGSCORING_GOLDEN=1) that
scores fixed synthetic recordings and compares a digest. Every release runs it
before publishing; a difference is a stop, not a re-bless.
Related
- YASAFlaskified — web platform integrating psgscoring with YASA staging, multilingual PDF reports, EDF+ export, and FHIR R4
- YASA — AI-based sleep staging (Vallat & Walker, eLife 2021)
- slaapkliniek.be — live instance (no installation required)
Citation
@article{rombaut2026psgscoring,
title = {Graded evidence in place of thresholds: an open-source,
{AASM}-compliant method for respiratory event detection in
polysomnography},
author = {Rombaut, Bart and Rombaut, Briek and Rombaut, Cedric},
year = {2026},
note = {Manuscript in preparation}
}
Disclaimer
psgscoring is research software — not a medical device. It is not CE-marked (MDR 2017/745) or FDA-cleared. All outputs are research-grade estimates that must be reviewed by a qualified clinician before any diagnostic or therapeutic decision. See DISCLAIMER.md for the full text.
License
BSD-3-Clause. See LICENSE.
Contact: bart.rombaut@gmail.com
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file psgscoring-0.18.0.tar.gz.
File metadata
- Download URL: psgscoring-0.18.0.tar.gz
- Upload date:
- Size: 1.4 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7dfef10894d6f651a0926ab8ffaf9f9785eae1bee9a74829637a7ee2b6a924c7
|
|
| MD5 |
d670e41aa9d10154b925909547d74134
|
|
| BLAKE2b-256 |
e7332b17e39ea93737e7c60bf589c8b5430a10d744274f99ce66eabe0c414b32
|
Provenance
The following attestation bundles were made for psgscoring-0.18.0.tar.gz:
Publisher:
publish.yml on bartromb/psgscoring
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
psgscoring-0.18.0.tar.gz -
Subject digest:
7dfef10894d6f651a0926ab8ffaf9f9785eae1bee9a74829637a7ee2b6a924c7 - Sigstore transparency entry: 2479626123
- Sigstore integration time:
-
Permalink:
bartromb/psgscoring@124935b6a4c030f6994d6fa9ad04cf0a7cb7192f -
Branch / Tag:
refs/tags/v0.18.0 - Owner: https://github.com/bartromb
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@124935b6a4c030f6994d6fa9ad04cf0a7cb7192f -
Trigger Event:
release
-
Statement type:
File details
Details for the file psgscoring-0.18.0-py3-none-any.whl.
File metadata
- Download URL: psgscoring-0.18.0-py3-none-any.whl
- Upload date:
- Size: 1.3 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
62696de196ee21cdc2b7f3f48dd1dd6eba353cf08734214f18bc9798b21e3375
|
|
| MD5 |
a10d54f7c5f59b7118a977a3e7894be6
|
|
| BLAKE2b-256 |
84c3ea9e23ea8dedf76e66a59ac79cceaf5a61e7e50c986da9dd35a96ee478ce
|
Provenance
The following attestation bundles were made for psgscoring-0.18.0-py3-none-any.whl:
Publisher:
publish.yml on bartromb/psgscoring
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
psgscoring-0.18.0-py3-none-any.whl -
Subject digest:
62696de196ee21cdc2b7f3f48dd1dd6eba353cf08734214f18bc9798b21e3375 - Sigstore transparency entry: 2479626204
- Sigstore integration time:
-
Permalink:
bartromb/psgscoring@124935b6a4c030f6994d6fa9ad04cf0a7cb7192f -
Branch / Tag:
refs/tags/v0.18.0 - Owner: https://github.com/bartromb
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@124935b6a4c030f6994d6fa9ad04cf0a7cb7192f -
Trigger Event:
release
-
Statement type: