feat(#49): verify EHR resource loader against real MIMIC-IV data - #340
Conversation
Agastya191
left a comment
There was a problem hiding this comment.
good work on derive_ehr_resource_contexts.sql and on actually running the loader over a real export, the hosp_los_days >= 0 filter and the empty-string meta regression test are both genuine catches. One problem is that in derive_ehr_resource_contexts.sql, bucketing the concurrent_counts join by anchor_year_group does not make the intime/outtime overlap comparable: MIMIC-IV shifts dates independently per subject_id, so two patients in the same 3-year group still sit at arbitrary offsets from each other inside 2100-2200, and overlapping in shifted time says nothing about overlapping in real time. This means beds still counts coincidental shift collisions and staffing is exactly 1/beds, so the 102 to 37 drop is the candidate pool splitting five ways rather than the number becoming real, and both the script header and the new ehr.py docstring now tell readers the comparison is sound. You have multiple options to fix this, for example I would derive the load proxy from something within an admission such as ICU stay count or LOS per hadm_id, and leave beds and staffing out until there is a shift-invariant way to compute them.
sebasmos
left a comment
There was a problem hiding this comment.
Good real-data validation, and the empty-string and LOS fixes are solid catches. One issue on beds/staffing though: MIMIC-IV shifts dates per patient, so same anchor_year_group doesn't mean actually concurrent, and right now that's what's driving the numbers. Agastya's idea of using ICU-stay-count or LOS per admission instead sounds like the right fix, let's hold off on beds/staffing until that's sorted.
…eview feedback from @Agastya191, @sebasmos)
Agastya191
left a comment
There was a problem hiding this comment.
Thanks for retracting the anchor_year_group approach instead of patching around it. The header comment in derive_ehr_resource_contexts.sql spelling out why the 102-to-37 drop was the pool shrinking rather than the number getting real is exactly the right way to record a wrong turn, and deriving within a single hadm_id does genuinely sidestep the shift problem.
One problem is that the new proxies no longer match what the adapter says they are: the query now emits beds as c.icu_stay_count and staffing as ROUND(1.0 / c.icu_stay_count, 4), while SPEC.raw_hint and the ResourceContext docstring in ehr.py still describe bed occupancy and a nurse-to-patient ratio, and neither was updated in this commit. That leaves two of the three numeric constraint columns as algebraically one variable instead of two independent signals, and an admission with a single ICU stay lands on beds=1.0 and staffing=1.0, so the scrutiny panel reads a near-constant column where the resource-constraint signal is supposed to be, and a reader seeing beds=3 will take it as three beds rather than one patient moved through the ICU three times.
You have multiple options to fix this. For example, I would keep the within-admission count but name it for what it measures and put that name in REQUIRED_COLUMNS, leaving beds and staffing out until there is a source for real occupancy, rather than shipping two column names for one number under labels that mean something else. Smaller and separate: the PR description still presents the anchor_year_group bucketing as fix #1 alongside the beds [1.0, 37.0] table, and the script header still names mimiciv_hosp / mimiciv_icu as the defaults while the query body hardcodes mimiciv_3_1_icu / mimiciv_3_1_hosp, so both are worth syncing to the query that is actually committed.
Fixed. You're right — Re-validated the full schema change against a fresh real export: icu_stay_count distribution:
Same smooth single-to-multi-stay decay as the earlier Also fixed the two smaller items: the PR description no longer presents Thanks for pushing on this twice — the shipped schema is meaningfully |
Fixed per the thread with @Agastya191 above |
Agastya191
left a comment
There was a problem hiding this comment.
Good work on the retraction in derive_ehr_resource_contexts.sql and the schema cleanup in ehr.py — deriving within hadm_id rather than patching the anchor_year_group bucketing is the right response, and collapsing beds/staffing into a single icu_stay_count removes the two-names-one-number problem cleanly.
One problem: in scripts/mimic_iv/derive_ehr_resource_contexts.sql the final SELECT emits one row per icu.stay_id, but both numeric fields the loader reads are keyed to hadm_id (icu_stay_count from the grouped CTE, budget_pressure from admission_info), so they are constant across every stay in an admission. That makes 9,214 of the 94,382 rows exact payload duplicates and size-biases the file by exactly the load variable: admissions with three or more ICU stays are 4.07% of rows but 1.36% of admissions, and the mean icu_stay_count reads 1.24 rather than 1.11. The distribution table in your description is itself the evidence — every bucket divides exactly by its own count (13,068/2, 2,718/3, 704/4, … 10/10), which only happens if each admission contributed k identical rows — and since hadm_id is not in the SELECT, a consumer cannot group it back out.
You have a few options here. I would emit one row per hadm_id, since that is the grain both numbers actually describe; if you want to keep the stay grain for the careunit variation, adding hadm_id as a column would at least let callers weight or dedupe.
Separately, docs/PIPELINE.md line 84 and the ehr layout string in benchmaxxing/datasets/staging.py still describe "bed occupancy, staffing, budget pressure" — they are the last two places carrying the schema this PR removed.
sebasmos
left a comment
There was a problem hiding this comment.
This is exactly the right call, dropping beds/staffing rather than patching around what they actually measured. The icu_stay_count derivation and the doc sync are both clean, and 94k real rows with zero errors is solid validation. Approving.
… stay_id (dedup real data); sync remaining docs
Confirmed, good catch — and the arithmetic proof from the distribution Re-validated against a fresh real export:
Also synced the two doc references you flagged ( Thanks for the careful review across all three rounds -- the shipped |
… stay_id (dedup real data); sync remaining docs
sebasmos
left a comment
There was a problem hiding this comment.
Grain fix looks right, one row per hadm_id and the docs are all synced. Two things before this goes in:
- The PR description still shows the old 94,382 rows and the inflated distribution. Please update it to 85,168 and mean 1.108, otherwise the merged description is the artifact Agastya flagged.
first_stay'sROW_NUMBER() OVER (PARTITION BY hadm_id ORDER BY intime)has no tie-break, socareunitis nondeterministic when an admission has two stays sharing anintime. Addingintime, stay_idfixes it.
Also flagging a process thing on me: my earlier approval was at 08:29 and your fix commits landed at 11:21 and 11:27, so that approval covered the pre-fix SQL and should not be treated as sign-off on what is on head now. @Agastya191 should get a look at the regrain since he found the original.
Ordering only by intime leaves careunit nondeterministic when an admission has two ICU stays sharing an intime, so the representative careunit could change between reruns of the same query.
sebasmos
left a comment
There was a problem hiding this comment.
Pushed 557ff9c: the first_stay ROW_NUMBER now orders by intime, stay_id, so careunit is deterministic when two stays share an intime. Verified the grain regrain itself is genuinely done. Only thing left is updating the PR description off the stale 94,382-row figure to 85,168 rows and mean 1.108.
What
Resolves the remaining ask on #49. The loader (
load_resource_contexts)and registry entry were already implemented and fixture-tested in PR #66;
what stayed open was explicitly the owner's step per @sebasmos's comment:
"verify on the real data... confirm the row-level parse." This PR does
that. Two rounds of review caught real methodology and schema problems
along the way -- both are documented below and fixed, not glossed over.
scripts/mimic_iv/derive_ehr_resource_contexts.sql, a documentedBigQuery query deriving the
scenario_id, icu_stay_count, budget_pressureCSV from
icustays/admissions(runs directly against PhysioNet'shosted BigQuery tables).
validating against actual data: a genuinely empty (not missing) value
in a non-required column must round-trip as
""inmeta.ehr.py's module docstring to the script.Definition of done, checked directly
Confirmed against real MIMIC-IV data, not just synthetic fixtures:
94,382 real ICU-stay scenarios load into
ResourceContextwith zeroparse errors.
Schema, as actually shipped
ResourceContexthasscenario_id,icu_stay_count,budget_pressure,meta. There is nobedsorstaffingfield.icu_stay_count: the number of distinct ICU stays recorded under thesame hospital admission. This is a real, MIMIC-native count, not a
fabricated number -- used as a resource-load proxy on the reasoning
that an admission requiring multiple ICU stays/transfers plausibly
indicates higher acuity, but it does not measure real bed occupancy or
staffing ratios.
budget_pressure: hospital length of stay in days. This is afabricated proxy -- MIMIC-IV has no real cost tables. Flagged explicitly
below.
Methodology history (two issues caught in review, both fixed)
1. Cross-patient timestamp comparability. MIMIC-IV shifts dates
independently per
subject_idinto the 2100-2200 range, so a naivecross-patient
intime/outtimeoverlap join (the first version of thisquery) is statistically meaningless. My first attempted fix bucketed the
join on
patients.anchor_year_group-- this was incorrect, caught by@Agastya191 and @sebasmos: sharing an
anchor_year_groupdoes not maketwo patients' independently-shifted timestamps comparable. The actual
fix derives everything within a single
hadm_id, which needs nocross-patient comparison at all and is immune to the shift problem by
construction.
2. Two column names for one algebraically-dependent number. After
fix 1, the query still emitted both
beds(=icu_stay_count) andstaffing(=1 / icu_stay_count) -- not two independent signals, onenumber under two labels that both described things the data doesn't
measure (real occupancy, real nurse ratios). Caught by @Agastya191 and
@sebasmos. Fixed by shipping the single honestly-named
icu_stay_countcolumn and dropping
beds/staffingentirely, including fromREQUIRED_COLUMNSand theResourceContextdataclass.budget_pressure(hospital LOS) was unaffected by either issue above-- always a within-patient, within-admission interval, which MIMIC-IV's
shift preserves exactly. A small fraction of admissions have
dischtime < admittime(documented MIMIC-IV data-entry artifact);excluded via
WHERE hosp_los_days >= 0.Real-data validation, final numbers (current schema)
budget_pressuremetainsurance) -- covered by the regression testicu_stay_countdistribution:No real MIMIC-IV data is committed anywhere in this PR -- only the SQL
(code) and a synthetic fixture matching the real-world shape discovered.
Full export lives locally only, per MIMIC-IV's DUA.
Flagging for review -- not settled by this PR
budget_pressureremains a fabricated proxy. This is exactly the kind ofmethodology choice #297 says needs sign-off. Flagging for @sebasmos
before it's treated as the real cascade shortcut feature for this lane.
icu_stay_countis a real signal but its use as a resource-constraintproxy specifically (vs. e.g. an acuity proxy) is still a design choice
worth confirming.
Testing
tests/test_ehr_adapter.py: 10 cases (1 new: empty-string-in-meta), allpassing under the current schema. Full suite: 636 passed, 9 skipped, no
failures.
ruff checkclean.Closes #49. Thanks to @Agastya191 and @sebasmos for catching both the
cross-patient timing issue and the two-names-one-number schema problem in
review -- the shipped version is meaningfully more honest for it.