Skip to content

fix(concepts): complete KDIGO AKI staging with the RRT and urine-output legs - #212

Merged
amrit110 merged 1 commit into
mainfrom
fix/aki-rrt-urine-labels
Aug 25, 2026
Merged

fix(concepts): complete KDIGO AKI staging with the RRT and urine-output legs#212
amrit110 merged 1 commit into
mainfrom
fix/aki-rrt-urine-labels

Conversation

@amrit110

Copy link
Copy Markdown
Member

What changed

The acute_kidney_injury / aki_stage_2 / aki_stage_3 concepts were creatinine-only. KDIGO stages AKI on three legs; two of them were missing entirely, and both were flagged not implemented -- see 'still open' in the concept docstrings. This label is used twice (concept-bottleneck target and alert-event hazard onset), so an incomplete gold standard is a correctness problem wherever it appears.

1. Renal replacement therapy = automatic Stage 3. Added as one more OR-branch of aki_stage_3, not as a new named concept: the clinical fact is "RRT initiation implies Stage 3", not a separate concept the bottleneck needs its own head for. Expressed with the existing CodeOccurrenceRule machinery (its first-occurrence semantics are exactly an onset) over an alternation anchored to the PROCEDURE//START//{itemid} code shape SOFA_SOURCE_CONFIG's ventilation ids already use, with observed_families=("PROCEDURE",) so "procedures were charted and none was dialysis" is a real negative.

2. Urine output stages AKI independently of creatinine. New sofa.urine_output_rate generalizes urine_output_24h to an arbitrary trailing window and, optionally, to KDIGO's own weight-normalized mL/kg/h form. urine_output_24h is now its 24 h absolute-volume special case, so SOFA's renal component and the oliguria concept are behaviourally untouched (asserted by a test comparing the two frames). New DerivedUrineRateRule / CanonicalUrineRate express the criteria declaratively, parallel to DerivedSofaSignalRule / CanonicalSofaSignal, so AKI's urine logic stays inside the same "OR of typed component rules" machinery as every other concept:

concept urine criterion
acute_kidney_injury (Stage 1) < 0.5 mL/kg/h over a trailing 6 h
aki_stage_2 < 0.5 mL/kg/h over a trailing 12 h
aki_stage_3 < 0.3 mL/kg/h over a trailing 24 h, or 0 mL over a trailing 12 h (anuria)

KDIGO's Stage 1 range is 6-12 h; it collapses to "assessable from 6 h onward" for a single-instant rule, and Stage 2 is the >= 12 h escalation of the same rate.

mimic-code itemid verification

The dialysis itemids are mimic-code's own mimic-iv/concepts/treatment/rrt.sql dialysis_active = 1 set: 225441 (IHD), 225802 (CRRT), 225803 (CVVHD), 225805 (peritoneal), 225809 (CVVHDF), 225955 (SCUF). That query's two deliberate exclusions are excluded here too and have their own test: 224270 (Dialysis Catheter, line placement, not therapy) and 225436 (CRRT Filter Change, maintenance on an already-active circuit whose therapy start has its own row). Urine output is not hardcoded: it resolves through prefixes_for_loinc("9187-6"), i.e. all nine already-mapped collection routes (Foley, void, condom cath, suprapubic, straight cath, both ureteral stents, both nephrostomies).

The weight-coverage caveat

Weight coverage is genuinely poor: ~10-17% of subjects in the real MIMIC-IV extraction have any weight reading at all. The rate criteria therefore abstain rather than guess:

  • each window is join_asof-backward to the most recent daily weight (LAB//224639//kg) at or before its end instant, falling back to admission weight (LAB//226512//kg) only where no daily weight has been charted yet -- daily weight is the current weight a mL/kg/h rate actually means, admission weight is the best available estimate early in a stay;
  • a window with neither weight, or with a non-positive charted weight, is dropped from scoring entirely -- not treated as non-oliguric, not defaulted to a population average. It stays unobserved (masked out of supervision) instead of becoming a silent negative, the same observability convention sepsis3 documents;
  • the anuria branch is absolute volume, so it needs no weight and keeps Stage 3's urine criterion assessable for the ~85% of subjects with none.

The two weight itemids live in SOFA_SOURCE_CONFIG rather than the LOINC layer on purpose: they carry the same body-weight LOINC, so a LOINC lookup could not tell daily from admission, and the fallback order needs exactly that distinction.

Conventions chosen where the brief left them open

  • Rule dataclass shape: a dedicated DerivedUrineRateRule(threshold, direction, window_hours, source, weight_normalized=True) plus canonical CanonicalUrineRate, rather than widening DerivedSofaSignalRule's signal literal -- the window and the weight normalization are per-rule parameters, and the existing urine_24h signal keeps its exact meaning for SOFA/oliguria.
  • API shape: urine_output_rate(..., window_hours, weight_normalized) returning (key, time, value), the same three-column shape pf_ratio_readings / urine_output_24h return, so _derived_reading_ids handles both uniformly. window_hours is not returned: the rule that asked already knows it.
  • Boundaries: the rate criteria are strict below (KDIGO says "less than 0.5 mL/kg/h", so exactly 0.5 does not stage), and anuria is at_or_below 0 mL. Both mirror the existing at_or_above on Stage 3's creatinine >= 4.0.
  • Stage relationships: unchanged -- the three stages remain independent binary concepts, so an RRT-only patient is Stage 3 without being Stage 1, exactly as a creatinine >= 4.0 patient already could be.
  • Observed mask: aki_stage_3_observed now also turns on for subjects with procedure data (the RRT leg is assessable for them) or with a weight-backed urine window, following label_concepts' existing "observed if any leg was assessable" OR. This widens supervision slightly on subjects with no creatinine; it is the same trade-off on_vasopressors already makes.
  • Per-source expansion: the urine legs need SOFA_SOURCE_CONFIG, so they are dropped on eICU/GEMINI like sepsis3's rule, and the concepts survive on their creatinine legs. Concept names and counts per task set are unchanged.

Tests and coverage

tests/odyssey/data/test_aki_kdigo.py, 29 new cases in the existing synthetic-event-sequence style: each of the six RRT itemids individually (parametrized, so a broken alternation branch cannot hide), both exclusions, pattern anchoring (PROCEDURE//END//, a longer itemid, a suffixed segment), first-occurrence onsets, OR composition with the creatinine legs in both orders, mL/kg/h arithmetic at all three windows, multi-route summation, partial-window exclusion, the weight fallback order (both / admission only / daily only / none / later-only / non-positive), the unobserved-is-not-a-negative contract, threshold boundaries at exactly 0.5, the weight-free anuria branch, a mixed five-subject cohort exercising creatinine-only / urine-only / RRT-only / combined, and the per-source expansion. test_value_binning.py's rule-type exhaustiveness check was extended to name the two derived-signal rule types (their thresholds are on derived signals, so no CLINICAL_RANGES bin edge corresponds to them).

  • ruff format / ruff check: clean. mypy odyssey: clean (tests are excluded from mypy by .pre-commit-config.yaml). typos: clean.
  • Coverage on the touched modules (pytest tests/odyssey/data --cov=odyssey.data.concepts --cov=odyssey.data.sofa): concepts.py 98%, sofa.py 97%, 423 passed / 1 skipped. Every uncovered line is a pre-existing defensive raise or an unrelated branch; no new code is uncovered.

Known limitation carried over (flagged, not changed)

A trailing window is summed over whatever urine rows it contains, so sparse charting is indistinguishable from low output: a key charted once a day reads as oliguric on one row, and as anuric if that row is 0 mL. This is exactly the property the existing oliguria concept and SOFA's renal component already have, so it is documented in urine_output_rate's docstring rather than fixed here -- a minimum-readings-per-window guard would change the existing concept's behaviour too and is a modelling decision worth making deliberately, not slipping into this PR.

Not included

No docs/experiments.md row: this is a label-definition correctness fix, and the registry only gets a row when something is actually retrained or rescored against the new label. README.md's "Known concept-rule limitations" paragraph is updated, since it claimed urine-output-based AKI staging was not implemented.

🤖 Generated with Claude Code

…ut legs

The acute_kidney_injury / aki_stage_2 / aki_stage_3 concepts were
creatinine-only. KDIGO stages AKI on three legs, and two were missing
entirely -- both flagged "not implemented -- see 'still open'" in the
concept docstrings. An incomplete gold standard is a correctness problem
wherever the label is used, and it is used twice: as the concept
bottleneck's supervised target and as the alert-event hazard head's onset
definition.

1. Renal replacement therapy is an automatic Stage 3, whatever creatinine
   and urine output say. Added as one more OR-branch of aki_stage_3, not a
   separate named concept: the clinical fact is "RRT implies Stage 3", not
   a concept the bottleneck needs its own head for. The item ids are
   mimic-code's own rrt.sql dialysis_active = 1 procedureevents set
   (225441 IHD, 225802 CRRT, 225803 CVVHD, 225805 peritoneal, 225809
   CVVHDF, 225955 SCUF) and its two deliberate exclusions are excluded
   here too (224270 dialysis catheter placement, 225436 CRRT filter
   change). Matched with the existing CodeOccurrenceRule machinery, whose
   first-occurrence semantics are exactly what an onset needs, over an
   alternation anchored to the PROCEDURE//START//{itemid} code shape that
   SOFA_SOURCE_CONFIG's ventilation ids already use.

2. Urine output stages AKI independently of creatinine. New
   sofa.urine_output_rate generalizes urine_output_24h to an arbitrary
   trailing window and to KDIGO's own weight-normalized mL/kg/h form;
   urine_output_24h is now its 24 h absolute-volume special case, so
   SOFA's renal component and the oliguria concept are untouched. New
   DerivedUrineRateRule / CanonicalUrineRate express the three stage
   criteria declaratively, like every other rule in the file: Stage 1
   < 0.5 mL/kg/h over 6 h, Stage 2 < 0.5 over 12 h, Stage 3 < 0.3 over
   24 h OR 0 mL over 12 h (true anuria, absolute volume, so it needs no
   weight at all).

Weight is the real constraint and is handled by abstaining, not guessing.
Only ~10-17% of subjects in the MIMIC-IV extraction have any weight
reading, so each window is asof-joined backward to the most recent daily
weight (224639) at or before it, falling back to admission weight
(226512) before any daily weight exists -- daily weight is the current
weight a mL/kg/h rate means, admission weight is the best available early
in a stay. A window with neither, or a non-positive charted weight, is
dropped rather than scored against an invented default: it stays
unobserved (masked out of supervision) instead of becoming a silent
negative, the same observability convention sepsis3 uses.

Thresholds follow the existing sign conventions: the rate criteria are
strict "below" because KDIGO says "less than 0.5 mL/kg/h" (exactly 0.5
does not stage), and anuria is "at_or_below" 0 mL, mirroring Stage 3's
existing at_or_above 4.0 creatinine trigger.

No experiment-registry row: this changes the label definition, so it only
gets one once something is retrained/rescored against it.

Tests: tests/odyssey/data/test_aki_kdigo.py, 29 cases -- each of the six
RRT item ids individually, both exclusions, pattern anchoring, first-
occurrence onsets, OR composition with the creatinine legs, mL/kg/h
arithmetic at all three windows, partial-window exclusion, the weight
fallback order (both weights / admission only / daily only / none / later
only / non-positive), the unobserved-not-negative contract, threshold
boundaries, the weight-free anuria branch, a mixed five-subject cohort,
the per-source expansion, and the source-config contract of the two
urine-output forms. Coverage on the two touched modules: concepts.py 98%,
sofa.py 97% (every uncovered line pre-existing and defensive).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.03%. Comparing base (cd96842) to head (7008153).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #212      +/-   ##
==========================================
+ Coverage   88.96%   89.03%   +0.07%     
==========================================
  Files          57       57              
  Lines        8446     8491      +45     
==========================================
+ Hits         7514     7560      +46     
+ Misses        932      931       -1     
Files with missing lines Coverage Δ
odyssey/data/concepts.py 97.94% <100.00%> (+0.33%) ⬆️
odyssey/data/sofa.py 97.38% <100.00%> (+0.28%) ⬆️

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@amrit110
amrit110 merged commit 3d7ecbb into main Aug 25, 2026
10 checks passed
@amrit110
amrit110 deleted the fix/aki-rrt-urine-labels branch August 25, 2026 14:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant