meds_summary_stats
Fast, privacy-safe summary statistics over MEDS datasets, for validating ETLs and catching regressions in them over time.
You ship a new version of an ETL. The dataset version has not changed, so the subject count should not have moved, the schema should be identical, and the code vocabulary should be the same set of strings. This tool turns that expectation into a file you can commit and a check you can run in CI.
[!WARNING] Under active initial development. The stats surface and comparison semantics are not yet stable; pin a version.
Two commands
# Reduce a dataset to a small, aggregated, non-sensitive fingerprint.
extract_MEDS_summary_stats /data/mimic-iv-meds -o baseline.json
# ... later, after an ETL change ...
extract_MEDS_summary_stats /data/mimic-iv-meds -o current.json
# Diff the two against a tolerance policy. Exit code 1 if it regressed.
compare_MEDS_summary_stats baseline.json current.json
Both are also python -m meds_summary_stats extract / ... compare, and both are importable
(extract_summary_stats, compare_summary_stats).
A worked example
Three complete MEDS datasets are committed under examples/datasets/, so
everything below runs against a fresh clone with no setup. Every output on this page is executed as
a doctest, so it is what the tool actually prints.
>>> print_directory(EXAMPLES / "datasets" / "v1_baseline")
├── data
│ ├── held_out
│ │ └── 0.parquet
│ ├── train
│ │ └── 0.parquet
│ └── tuning
│ └── 0.parquet
└── metadata
├── codes.parquet
├── dataset.json
└── subject_splits.parquet
160 subjects, 2258 measurements, a ten-code vocabulary — half of it mapped into LOINC, ICD10CM, and
SNOMED via parent_codes, half unmapped. examples/build_datasets.py
is the readable source of truth for what is in them.
1. Extract a baseline
extract_MEDS_summary_stats examples/datasets/v1_baseline -o examples/stats/v1_baseline.json
That fingerprint is committed at examples/stats/v1_baseline.json
— 8 KB of JSON, safe to keep in version control next to the ETL that produced it. The headline
block:
>>> from meds_summary_stats.extract import read_summary_stats
>>> baseline = read_summary_stats(EXAMPLES / "stats" / "v1_baseline.json")
>>> print(json.dumps(baseline["counts"], indent=2, sort_keys=True))
{
"n_events": 2258,
"n_measurements": 2258,
"n_static_measurements": 160,
"n_subjects": 160,
"n_subjects_with_birth": 160,
"n_subjects_with_death": 0,
"n_subjects_with_static": 160,
"n_unique_codes": 10,
"numeric": {
"mean": 103.9,
"n_finite": 1037,
"n_infinite": 0,
"n_nan": 0,
"n_negative": 0,
"n_present": 1037,
"n_zero": 0,
"std": 26.3615
},
"quality": {
"n_empty_code": 0,
"n_null_code": 0,
"n_null_subject_id": 0
}
}
Splits survive the default suppression floor of 20 subjects, so they are reported by name:
>>> print(json.dumps(baseline["splits"]["by_split"], indent=2, sort_keys=True))
{
"held_out": {
"n_measurements": 315,
"n_subjects": 24
},
"train": {
"n_measurements": 1593,
"n_subjects": 112
},
"tuning": {
"n_measurements": 350,
"n_subjects": 24
}
}
Re-extracting the same dataset reproduces it exactly, which is the property the whole tool rests on:
>>> from meds_summary_stats.compare import compare_summary_stats
>>> from meds_summary_stats.report import render_text
>>> print(render_text(compare_summary_stats(baseline, baseline)))
PASS - 186 paths checked, 3 ignored, no findings.
2. A broken ETL, same source data
examples/datasets/v1_etl_regression is the same source
data — dataset_version still 1.0 — put through an ETL carrying three independent bugs: TEMP is
emitted in Celsius instead of Fahrenheit, LAB//SODIUM is silently renamed, and every twentieth
subject is dropped.
extract_MEDS_summary_stats examples/datasets/v1_etl_regression -o current.json
compare_MEDS_summary_stats examples/stats/v1_baseline.json current.json # exit 1
>>> regressed = read_summary_stats(EXAMPLES / "stats" / "v1_etl_regression.json")
>>> report = compare_summary_stats(baseline, regressed)
>>> report.status, report.failed, report.counts
('fail', True, {'error': 44, 'info': 0, 'warning': 12})
All three bugs are caught, each on a path that names the problem:
>>> findings = {f.path: f for f in report.findings}
>>> print(findings["counts.n_subjects"].message) # dropped subjects
160 -> 152 (-5.00%), over relative threshold 0.005
>>> findings["codes.digests.alphabetical"].kind # renamed code
'exact'
>>> loinc = findings["code_metadata.ontology.by_vocabulary.LOINC.numeric.mean"]
>>> loinc.baseline, loinc.current # Fahrenheit -> Celsius
(103.9, 88.1595)
Note what the second one does not say. The vocabulary change is detected by a digest, so neither the old code nor the new one appears anywhere in the report:
>>> blob = json.dumps(report.to_dict())
>>> "SODIUM" in blob or "LAB//NA" in blob
False
And note what the third one does. The per-code moments digest says only "some code's distribution moved"; the per-vocabulary moments narrow it to the LOINC-mapped measurements. That is as specific as a report can get without naming a code.
3. A genuine data refresh
examples/datasets/v2_data_refresh bumps dataset_version to
2.0 and has 220 subjects instead of 160 — same schema, same vocabulary, more data. Strictly, that
fails:
>>> refreshed = read_summary_stats(EXAMPLES / "stats" / "v2_data_refresh.json")
>>> compare_summary_stats(baseline, refreshed).failed
True
Which is the wrong answer, and why relax exists. It tells the comparison that a version bump
excuses volume drift but nothing else:
compare_MEDS_summary_stats examples/stats/v1_baseline.json current.json \
--on-dataset-version-change relax # exit 0
>>> from meds_summary_stats.policy import ComparePolicy
>>> policy = ComparePolicy.from_dict({"on_dataset_version_change": "relax"})
>>> relaxed = compare_summary_stats(baseline, refreshed, policy)
>>> relaxed.status, relaxed.failed, relaxed.counts
('warn', False, {'error': 0, 'info': 60, 'warning': 3})
The 60 volume findings drop to informational. What survives as a warning is only the ordering of the vocabulary by frequency, which genuinely did shift as the cohort grew:
>>> sorted(f.path for f in relaxed.findings if str(f.severity) == "warning")
['codes.digests.by_measurement_frequency', 'codes.digests.by_subject_frequency',
'codes.digests.numeric_moments.digest']
Crucially, relax does not blind the check. The set of codes is unchanged here, and if the refresh
had also broken the vocabulary or the schema, that would still be a hard error:
>>> baseline["codes"]["digests"]["alphabetical"] == refreshed["codes"]["digests"]["alphabetical"]
True
>>> from copy import deepcopy
>>> also_broken = deepcopy(refreshed)
>>> also_broken["layout"]["data_columns"]["subject_id"] = "Int32"
>>> broken_report = compare_summary_stats(baseline, also_broken, policy)
>>> broken_report.failed
True
>>> [f.path for f in broken_report.findings if str(f.severity) == "error"]
['layout.data_columns.subject_id']
The two failure modes are distinguishable, which is the whole point: a data refresh moves volume, a broken ETL moves volume and the vocabulary and a distribution.
What it emits
A single canonical JSON document. Roughly:
| Block | Contents |
|---|---|
counts |
subjects, measurements, events, unique codes, static rows, births, deaths, global numeric moments, data-quality invariants |
codes |
vocabulary digests, occurrence distributions, concentration curves |
code_metadata |
metadata coverage, plus per-external-vocabulary statistics and mapping coverage from parent_codes |
per_subject |
distributions of measurements, events, distinct codes, and record span per subject |
time |
calendar-year histogram, optional time quantiles |
splits |
per-split subject and measurement counts, plus data/split membership mismatches |
layout |
shard count, column names and dtypes, per-shard presence, dtype conflicts |
dataset |
verbatim metadata/dataset.json |
docs/stats-surface.md documents every field, with live output.
The privacy contract
The output is designed to be committable to a public repository as a baseline. Three rules, enforced structurally rather than by convention:
- No code string is ever emitted — at any threshold, under any flag. The vocabulary appears
only as digests (
sha256over the sorted code list, over the frequency-ordered list, and over frequency-restricted subsets) and as aggregate distributions. That is enough to detect that the vocabulary changed, and never enough to reconstruct it. Nor is the vocabulary ever partitioned by the structure of the code strings: splitting on the common//convention is not done, both because MEDS does not require it and because for a flat vocabulary the prefix is the whole code, so the list of prefixes would be the list of codes. - No small cells. Any split, calendar-year, or ontology-vocabulary entry backed by fewer than
min_cell_sizedistinct subjects (default 20) is dropped entirely — the key is never written, only a<suppressed>rollup of how many were dropped and their pooled volume. - No extremes. No
min, nomax, anywhere. Not on values, not on timestamps. The subject with the longest record is exactly where re-identification starts; quantiles replace extremes, and are themselves suppressed belowmin_numeric_cell_sizeobservations.
The one exception is deliberate, and it is where the deeper vocabulary inspection lives:
parent_codes entries name concepts in published external terminologies (LOINC/8867-4), which are
properties of the terminology rather than of any patient — and, decisively, the grouping is
declared by the dataset rather than guessed from string shape. So that block reports vocabularies
by name, with per-vocabulary volume, subject counts, numeric moments, and key-set digests (still
subject to suppression), plus dataset-level mapping coverage. It is the only axis that says where
the vocabulary moved, and it catches a regression nothing else can: when a concept-mapping join
silently stops resolving, every count over the data is unchanged and only
ontology.coverage.frac_measurements_mapped falls.
How comparison works
Both documents flatten to path -> value. Each path resolves to exactly one rule — the most
specific glob that matches it — and the rule says how to compare:
| kind | behavior |
|---|---|
exact |
any inequality is a finding (schema, dtypes, digests) |
relative |
flag when abs(new - old) / max(abs(old), 1) exceeds a threshold |
absolute |
flag when the raw delta exceeds a threshold |
ignore |
never compared (timestamps, tool version) |
Each rule has a warn and an error tier. The built-in policy is already a reasonable ETL
regression policy; a config file only states your overrides:
on_dataset_version_change: relax
fail_on: error
rules:
- {path: counts.n_subjects, kind: relative, warn: 0.0, error: 0.002}
- {path: codes.digests.alphabetical, kind: exact, on_change: error}
- {path: time.by_year.*.n_measurements, kind: relative, error: 0.05}
See examples/policy.yaml for a fully commented one.
on_dataset_version_change: relax
The setting that makes the motivating workflow practical. The premise of the check is "same data,
new ETL". When the data itself moved — dataset.dataset_version differs — volume drift is expected
and flagging it is noise, but a schema or vocabulary regression is still a bug. relax draws that
line: everything numeric drops to informational, exact rules stay hard.
>>> from copy import deepcopy
>>> from meds_summary_stats.policy import ComparePolicy
>>> baseline = {"stats_schema_version": 1, "config": {},
... "dataset": {"dataset_version": "1.0"},
... "layout": {"data_columns": {"code": "String"}},
... "counts": {"n_subjects": 100_000}}
>>> refreshed = deepcopy(baseline)
>>> refreshed["dataset"]["dataset_version"] = "2.0"
>>> refreshed["counts"]["n_subjects"] = 140_000
Strictly, a 40% jump in subjects is a failure:
>>> compare_summary_stats(baseline, refreshed).status
'fail'
Under `relax`, it is expected -- the data changed:
>>> policy = ComparePolicy.from_dict({"on_dataset_version_change": "relax"})
>>> compare_summary_stats(baseline, refreshed, policy).status
'pass'
But a schema change is never excused:
>>> broken = deepcopy(refreshed)
>>> broken["layout"]["data_columns"]["code"] = "Int64"
>>> report = compare_summary_stats(baseline, broken, policy)
>>> report.status, [f.path for f in report.findings if str(f.severity) == "error"]
('fail', ['layout.data_columns.code'])
Reading a report
>>> from meds_summary_stats.report import render_text
>>> regressed = deepcopy(baseline)
>>> regressed["counts"]["n_subjects"] = 98_000
>>> print(render_text(compare_summary_stats(baseline, regressed)))
FAIL - 3 paths checked, 0 ignored; 1 error.
<BLANKLINE>
ERROR (1)
counts.n_subjects
100000 -> 98000 (-2.00%), over relative threshold 0.005
compare_MEDS_summary_stats also writes a machine-readable report (--output) and a
GitHub-flavored markdown one (--markdown), and appends the markdown to $GITHUB_STEP_SUMMARY
when it is running inside Actions.
In CI
A composite GitHub Action, usable from any repository:
- name: Check for summary-stat regressions
uses: Medical-Event-Data-Standard/meds_summary_stats@v0
with:
meds-dir: build/meds
baseline: baselines/mimic-iv-3.1.json
policy: .github/meds-stats-policy.yaml
package-spec: meds_summary_stats==0.1.0
docs/github-action.md covers the reusable-workflow form, advisory
rollout with fail-on: never, reacting to the outcome, label-gated baseline refresh, and the full
input/output/exit-code tables.
Performance
One scan_parquet per shard, normalized and concatenated, then a handful of aggregates collected
together on the polars streaming engine so the common scan is shared. The dataset is read a small
fixed number of times regardless of its size, and the only frames materialized are per-subject and
per-code — both orders of magnitude smaller than the data.
The default numeric-detail: moments needs no sorting. quantiles adds full-column quantiles over
numeric_value and time, which does require materializing those columns; it is opt-in for that
reason. Per-subject and per-code quantiles are always computed, since those frames are small.
Installation
uv add meds_summary_stats # or: uvx --from meds_summary_stats extract_MEDS_summary_stats --help
Requires Python 3.12+.
Contributing
See CONTRIBUTORS.md. Briefly: uv sync --group dev, then uv run pytest -v.
Tests are predominantly doctests — the API-level behavior of essentially every function is pinned by
examples in its docstring, with tests/ reserved for end-to-end runs and
hypothesis properties (notably, that no code string ever
escapes into the output for any dataset).
License
MIT
Metadata
Release files for meds-summary-stats 0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| meds_summary_stats-0.tar.gz | 219.1 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| meds_summary_stats-0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 277.7 kB
Release files / meds_summary_stats-0.tar.gz
| Download URL | meds_summary_stats-0.tar.gz |
|---|---|
| Size | 219.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6b70b1d81ef613adf4ab63dda3498315a09d29369da54eef67fa63a24001c259
|
|
BLAKE2b-256 checksum How to use checksums |
4656c75e9e985fe2ae7027e01279dad27d0c7521b0b4f2b0d5675dc51429fbc3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.13
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 4, 2026.
Transparency logRelease files / meds_summary_stats-0-py3-none-any.whl
| Download URL | meds_summary_stats-0-py3-none-any.whl |
|---|---|
| Size | 58.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
212020507cf444da620dc0a687e47dd35e5105da9a8999598edf080e2fd1c3a9
|
|
BLAKE2b-256 checksum How to use checksums |
1ff0463d8194c1c6ee025d17d9d67122adb029667188f377c1a61c23c350e2aa
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.13
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 4, 2026.
Transparency logSecurity for meds-summary-stats 0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for meds_summary_stats-0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 4, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.