from urllib.request import urlretrieve
urlretrieve(
"https://meps.ahrq.gov/mepsweb/data_files/pufs/h251/h251dta.zip",
"data/h251dta.zip",
)Read Stata .dta files in Python: MEPS 2023
read dta file Python, Stata dta Python, MEPS Python, h251.dta, MEPS 2023 full year consolidated, Stata value labels Python, complex survey analysis Python
A Stata .dta file carries more than the data: a label for each variable and value labels that map codes to text. Many readers drop the labels or force you to pick between codes and text. With svy you get the data and the labels together, ready for survey estimation.
The example is the 2023 full-year consolidated file of the Medical Expenditure Panel Survey (MEPS HC-251, h251.dta): 18,919 people, 1,374 variables.
Get the file
AHRQ publishes it on the HC-251 page as a zipped Stata file:
Read it
pip install svysvy.read_stata returns a polars DataFrame. It reads the .dta inside the zip, so there is nothing to unpack:
import svy
meps = svy.read_stata("data/h251dta.zip")
print(meps.shape)
meps.select("DUPERSID", "AGE23X", "SEX", "INSCOV23", "TOTEXP23", "VARSTR", "VARPSU", "PERWT23F").head(3)(18919, 1374)
| DUPERSID | AGE23X | SEX | INSCOV23 | TOTEXP23 | VARSTR | VARPSU | PERWT23F |
|---|---|---|---|---|---|---|---|
| str | i64 | i64 | i64 | i64 | i64 | i64 | f64 |
| "2790002101" | 58 | 2 | 1 | 646 | 2019 | 1 | 11664.426815 |
| "2790002102" | 27 | 1 | 1 | 1894 | 2019 | 1 | 32212.113596 |
| "2790004101" | 49 | 2 | 1 | 986 | 2084 | 1 | 21944.142826 |
With 1,374 columns, read only what you need:
svy.read_stata("data/h251dta.zip", columns=["DUPERSID", "TOTEXP23"]).shape(18919, 2)
svy.read_dta is the same function under another name.
Keep the labels
svy.create_from_stata reads the data and both kinds of labels into a Sample:
sample = svy.create_from_stata("data/h251dta.zip")
print(sample.meta.var_labels["INSCOV23"])
sample.meta.value_labels["INSCOV23"]HEALTH INSURANCE COVERAGE INDICATOR 2023
{'-1': '-1 INAPPLICABLE',
'1': '1 ANY PRIVATE',
'2': '2 PUBLIC ONLY',
'3': '3 UNINSURED'}
The data keep the codes. The labels go along with them and show up in every estimate, as you will see below.
Clear the reserve codes
MEPS stores “inapplicable”, “refused”, “don’t know” and “cannot be computed” as ordinary numbers: -1, -7, -8 and -15. The value labels name them, but to Stata and to svy they are just numbers. Leave them in and they count as answers:
sample.data["HAVEUS42"].value_counts().sort("HAVEUS42")| HAVEUS42 | count |
|---|---|
| i64 | u32 |
| -8 | 452 |
| -7 | 304 |
| -1 | 248 |
| 1 | 13469 |
| 2 | 4446 |
Turn them into nulls before estimating. recode maps each new value to the old values it replaces:
sample = sample.wrangling.recode(
["HAVEUS42"],
{None: [-1, -7, -8, -15]},
replace=True,
)Set the design
MEPS is a stratified, clustered sample. The variance strata, PSUs and person weight are in the file:
sample = sample.set_design(
svy.Design(stratum="VARSTR", psu="VARPSU", wgt="PERWT23F")
)
print(sample.n_strata, "strata,", sample.n_psus, "PSUs")105 strata, 262 PSUs
Some people have a person weight of zero because they were out of scope for 2023. Keep them: the zero weight already takes them out of the estimates.
Estimates now carry design-based standard errors and the value labels:
print(sample.estimation.prop("INSCOV23"))╭──────────────────────────────── Estimate: PROP (TAYLOR) ────────────────────────────────╮ │ │ │ HEALTH INSURANCE COVERAGE INDICATOR 2023 est se lci uci cv (%) │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ 1 ANY PRIVATE 0.6454 0.0095 0.6264 0.6640 1.48 │ │ 2 PUBLIC ONLY 0.2912 0.0080 0.2757 0.3072 2.74 │ │ 3 UNINSURED 0.0634 0.0042 0.0555 0.0723 6.69 │ │ │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯
print(sample.estimation.prop("HAVEUS42", drop_nulls=True))╭───────────────────────────── Estimate: PROP (TAYLOR) ─────────────────────────────╮ │ │ │ DOES PERSON HAVE USC PROVIDER-R4/2 est se lci uci cv (%) │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ 1 YES 0.7393 0.0059 0.7274 0.7509 0.80 │ │ 2 NO 0.2607 0.0059 0.2491 0.2726 2.28 │ │ │ ╰───────────────────────────────────────────────────────────────────────────────────╯
print(sample.estimation.mean("TOTEXP23"))╭───────────────── Estimate: MEAN (TAYLOR) ──────────────────╮ │ y: TOTAL HEALTH CARE EXP 23 │ │ │ │ est se lci uci cv (%) │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ 7,487.2616 173.3692 7,144.8246 7,829.6986 2.32 │ │ │ ╰────────────────────────────────────────────────────────────╯
Other formats
The same steps work for the other two formats. Only the reader changes, along with what each format keeps:
- SAS: Read NHANES DEMO_L.xpt in Python. Transport files have variable labels but no value labels.
- SPSS: Read SPSS .sav files in Python: GSS 2024. Declared missing values arrive as nulls.
Data: Medical Expenditure Panel Survey, HC-251, Agency for Healthcare Research and Quality.