SPPH 604 · Application of Advanced Epidemiological Methods
masld_analytic.rdsEvery step answers the same four questions. That repetition is the point.
You have a published Methods section. You have CDC’s website. How do you get from one to the other, and how do you defend every choice you made on the way?
reproduction/R/00_setup.R → 01_download.R → 02_build_analytic.RThe gap is not a failure. Being able to say why it exists is the skill.
A Methods sentence is not code. Between the sentence and the code there is always a decision — and in a published paper the decision is almost always invisible.
Important
Your job at P1 is not to guess the authors’ decision. It is to make your own, write down why, and count what it cost.
Every step in this deck has the same four lines:
| Script | What it decides | Writes |
|---|---|---|
00_setup.R |
6 cycles, 18 component names, folder paths. No analysis switch. | nothing |
01_download.R |
nothing analytic — fetches raw files, records a manifest | data/raw/, _download_manifest.csv |
02_build_analytic.R |
everything: merge → FLI → MASLD → groups → mortality → funnel | masld_analytic.rds, sample_funnel.csv |
BMX_H.xpt); the variable usually does not (BMXBMI in all six)| Step | Criterion | N, full analytic file | Type |
|---|---|---|---|
| 0 | All NHANES 2007–2018 records | 59,842 | — |
| 1 | Age ≥ 18 | 36,580 | ELIGIBILITY |
| 2 | FLI computable | 14,989 | DESIGN + MISSINGNESS |
| 3 | Hepatic steatosis (FLI ≥ 60) | 6,376 | TARGET POPULATION |
| 4 | MASLD (≥ 1 cardiometabolic criterion) | 6,374 | TARGET POPULATION |
| 5 | Phenotype group assignable | 6,374 | MISSINGNESS |
| 6 | Mortality-eligible and linked | 6,371 | OUTCOME ASCERTAINMENT |
| 7 | Alcohol exclusion applied = FALSE | 6,371 | SWITCH |
Every row carries a type. A funnel shows N falling; it does not show why.
| # | What | Count |
|---|---|---|
| 1 | 18 components × 6 cycles, from wwwn.cdc.gov/.../DataFiles/<COMP>_<suffix>.xpt |
108 files |
| 2 | Multum drug lexicon RXQ_DRUG.xpt — tried at 2015, 2017, 2013 |
0 — all three 404 |
| 3 | Linked mortality, ftp.cdc.gov/.../NHANES_<cycle>_MORT_2019_PUBLIC.dat |
6 files |
About 204 MB. Every URL, byte size and status is in data/raw/_download_manifest.csv.
Note
Why keep a download that 404s? The lexicon was meant to classify medications by therapeutic class. It is gone, so the medication covariates fall back to questionnaire self-report. Deleting the failed download would hide that. Three MISSING rows in the manifest are the honest record that a planned covariate was never built.
| Component | Key variables | Feeds |
|---|---|---|
DEMO |
RIDAGEYR RIAGENDR RIDRETH3 INDFMPIR WTINT2YR SDMVPSU SDMVSTRA |
age, Asian cut, PIR |
BMX |
BMXBMI BMXWAIST BMXHT |
FLI, adiposity, both group axes |
TRIGLY |
LBXTR LBDLDL WTSAF2YR |
FLI, TG criterion, fasting frame |
BIOPRO |
LBXSGTSI (GGT) LBXSCR |
FLI, eGFR |
GLU GHB HDL |
LBXGLU LBXGH LBDHDD |
glycaemia + HDL criteria |
BPX BPQ DIQ |
BPXSY1-4 BPXDI1-4, BPQ050A BPQ100D, DIQ010/050/070 |
BP criterion, “treatment” arms |
ALQ |
ALQ130 ALQ120Q ALQ120U ALQ121 |
alcohol exclusion |
MCQ ALB_CR SMQ PAQ |
MCQ160* URDACT SMQ020/040 PAD680 |
comorbidities, CKD, Model 2 |
RXQ_RX |
— | nothing. Downloaded, never read. |
read_component() stacks a component across all six cycles and pads any variable absent from a cycle with NA. Then 16 left_joins onto DEMO by SEQN.
N after merge: 59,842. Safe here: SEQN is unique across all six cycles (verified, zero duplicates), and no joined component has more than one row per person.
Padding makes “never asked” look identical to “did not answer”
| Variable | What it measures (CDC label) | In | So what |
|---|---|---|---|
RIDRETH3 |
race/ethnicity with a non-Hispanic Asian category | G–J | Asian status unknowable for 2007–2010 → Step 5 |
DIQ070 |
takes diabetic pills to lower blood sugar | F–J | in E, “antidiabetic” collapses to insulin (DIQ050) alone |
MCQ160O |
ever told you had COPD | H–J | its % has a different denominator from every other Table 1 row |
URDACT |
albumin:creatinine ratio, mg/g | F–J | in E, ACR falls back to URXUMA/URXUCR×100 |
ALQ120Q ALQ120U ALQ101 |
how often you drink · the unit that is counted in · “≥12 drinks in a year?” | E–I | retired in the 2017-18 ALQ redesign |
ALQ121 ALQ111 |
the same frequency as one coded item · “ever had a drink?” | J | the replacements — different coding, must be harmonised |
Paper: “The study inclusion criteria were participants aged>18 years with evidence of MASLD.”
| Component → variable | DEMO → RIDAGEYR |
| Code | 02_build_analytic.R:114, filter at :315 |
| Decision | age >= 18 |
| N | 59,842 → 36,580 · ELIGIBILITY · 23,262 dropped |
>, the code says ≥That is 70 people — and it is not a PDF artefact. The published PDF contains 24 correctly encoded ≥ characters (BMI≥30, WHtR≥0.6, FLI≥60, 1≤PIR≤4). Where the authors meant “at least”, they wrote it. At the age criterion they wrote a plain >.
| Rule | N | Deaths | Groups I/II/III/IV | Σ|dev| |
|---|---|---|---|---|
| Paper | 6,300 | 585 | 386 / 4,541 / 769 / 604 | — |
age >= 18 (ours) |
6,371 | 586 | 391 / 4,585 / 787 / 608 | 71 |
age > 18 (paper’s words) |
6,301 | 585 | 379 / 4,534 / 781 / 607 | 29 |
Counter-evidence: the paper’s Table 1 mean age is 51.0 (16.6) — which is what ≥ 18 gives. Strict > 18 gives 51.4 (16.3). This is a strong lead, not a settled answer.
Paper: “A threshold of FLI≥60 was used to determine the presence of hepatic steatosis.”
exp(L)
FLI = -------- × 100, L = 0.953·ln(TG) + 0.139·BMI + 0.718·ln(GGT) + 0.053·WC − 15.745
1 + exp(L)
| Input | Component → variable | Formula wants | NHANES gives |
|---|---|---|---|
| Triglycerides | TRIGLY → LBXTR |
mg/dL | mg/dL ✓ |
| BMI | BMX → BMXBMI |
kg/m² | kg/m² ✓ |
| GGT | BIOPRO → LBXSGTSI |
U/L | U/L ✓ |
| Waist | BMX → BMXWAIST |
cm | cm ✓ |
N: 36,580 → 14,989 (computable) → 6,376 (FLI ≥ 60). Median FLI 85.5 (73.7–94.7).
LBXTR — a lab measurement
77 is a triglyceride of 77 mg/dL.
Stripping 77/99 “to be safe” deletes real data.
ALQ130 — a questionnaire item
777 is refused, 999 is don’t know.
Leaving them in turns 9 non-answers into people drinking 777 or 999 drinks a day.
Important
Same-looking digits, opposite meanings — in the same dataset, in the same pipeline. The codebook decides, variable by variable. Check it before you compare a variable to a number.
Triglycerides come from the fasting subsample, which carries its own weight WTSAF2YR. The paper never says whether it restricted to that subsample.
Warning
Say “outside the frame”, not “never asked to fast” — the weight cannot certify that. 2,031 carry a weight of exactly 0, and 819 have all four FLI inputs with no valid weight at all, so they demonstrably were measured fasting.
Different problems, different remedies: a weight and a population sentence for the first; a missing-data analysis for the second. Never their sum.
Note
The arithmetic does not simply subtract: the full file loses 21,591 here, because the 819 adults with all four FLI inputs but no fasting weight are kept. 21,618 − 819 + 792 = 21,591.
| Row set | FLI step | Final N | What it is |
|---|---|---|---|
| Full analytic file | 14,989 | 6,371 | every adult with all four FLI inputs. Saved as masld_analytic.rds; every table in output/ uses it |
| Locked domain | 14,170 | 6,048 | its subset carrying a valid WTSAF2YR |
They differ by 323 records, and neither is an error.
Warning
Quote which row set you mean, every single time you state an N.
Paper: “MASLD was defined as the presence of hepatic steatosis, accompanied by at least one of the following cardiometabolic criteria…”
| # | Paper | Variables | Our rule |
|---|---|---|---|
| i | BMI≥25 or waist >94 (M) / >80 (F) | BMXBMI BMXWAIST |
bmi>=25 OR waist rule |
| ii | FPG≥5.6 mmol/L or HbA1c≥5.7% or antidiabetic meds | LBXGLU LBXGH DIQ010/050/070 |
LBXGLU>=100 OR LBXGH>=5.7 OR dx OR tx |
| iii | BP≥130/85 or antihypertensive | BPXSY* BPXDI* BPQ050A |
sbp>=130 OR dbp>=85 OR tx |
| iv | TG≥150 mg/dL or lipid-lowering | LBXTR BPQ100D |
LBXTR>=150 OR tx |
| v | HDL ≤40 (M) / <50 (F) or HDL-targeted treatment | LBDHDD |
<=40 M, <50 F — treatment arm not implementable |
N: 6,376 → 6,374. Two people.
The paper sets criterion (ii) in mmol/L. NHANES LBXGLU is in mg/dL. Someone has to convert, and the paper never shows the converted number.
The habit, not the answer
“It’s only 0.9 mg/dL, it can’t matter” is a guess. “I re-ran it and 0 people change status” is a finding. Same decision — but only one of them you can defend to a reviewer. Quantify first, then decide to leave it alone.
A missing criterion counts as not met, not as unknown. n_crit uses rowSums(..., na.rm = TRUE), so an NA contributes 0 — conservative: it can only keep someone out of MASLD, never let someone in. It also barely matters here:
| Meet criterion (i), adiposity | 6,329 of 6,371 |
| Meet exactly one criterion | 354 |
| …of whom, that one criterion is adiposity | 352 |
| Dropped by this whole step | 2 of 6,376 |
Why. FLI is built from BMI and waist, and criterion (i) tests BMI and waist — so selecting on FLI ≥ 60 strongly enriches for criterion (i) without requiring it. (89 of the final 6,371 have FLI ≥ 60 with BMI < 25; 42 fail criterion (i) outright.) It is near-automatic by enrichment, so the other four rarely decide anything.
Report a filter by its effect, not its definition. “MASLD required at least one cardiometabolic criterion” sounds like work; here it excluded 2 people.
Paper: “Individuals with significant alcohol consumption — defined as more than two drinks per day for men and more than one drink per day for women — were excluded.”
The paper says it removed these people, so we built the rule. Getting it right needs two codebook checks that are easy to skip.
1 — “Drinks per day” is frequency × quantity, not ALQ130. ALQ130 is drinks per drinking day. Three drinks every Saturday gives ALQ130 = 3 but an average of 0.4/day. Thresholding ALQ130 on its own removes about 2,000 people, far more than average intake would. So we rebuild it: (ALQ130 × days_per_year) / 365, with ALQ120Q/ALQ120U supplying the frequency (×52 / ×12 / ×1), and ALQ121 replacing that pair in 2017-18.
2 — Strip 777 and 999 before comparing to any number. They mean refused and don’t know. Left in, 9 people here become 777- or 999-drinks-a-day heavy drinkers.
That yields a per-person flag, sig_alcohol. 427 people in the cohort exceed the paper’s threshold. Whether we then drop them is the next slide.
APPLY_ALCOHOL_EXCL <- FALSE, so no rows are dropped: 6,371 → 6,371. The flag is still computed, still saved into masld_analytic.rds, and the funnel still logs the setting — so the choice is recorded, not hidden, and flipping one line turns it on.
Why off: applying the paper’s own stated exclusion moves us further from the paper.
| Cohort | N | Deaths | Σ|group dev| |
|---|---|---|---|
| Paper | 6,300 | 585 | — |
| Ours, exclusion off | 6,371 | 586 | 71 |
| Ours, exclusion on (427 dropped) | 5,944 | 546 | 356 |
reproduction_report.md §6 says the +71 gap is “almost entirely the alcohol-exclusion ambiguity”. The numbers do not support that — Step 1’s age rule fits far betterPaper: “Obesity was defined as BMI≥30kg/m² (≥25 for Asians). High central adiposity was defined as a WHtR≥0.6.”
| WHtR < 0.6 | WHtR ≥ 0.6 | |
|---|---|---|
| Obese | I — 391 | II — 4,585 |
| Non-obese | III — 787 | IV — 608 |
whtr = BMXWAIST / BMXHT — both in cm, so the units cancel. Five people sit exactly on 0.6 and count as high, matching ≥.
The check that proves the construction is right
Our per-group BMI medians: 30.9 / 35.2 / 27.6 / 28.8 — identical to all four published values. That holds independently of the N gap.
asian comes from RIDRETH3 == 6 — and RIDRETH3 does not exist in 2007-08 or 2009-10. The code recodes the resulting NA to 0, i.e. not Asian, for 2,322 of 6,371 rows (36%).
RIDRETH1, whose category 5 is “Other Race — Including Multi-Racial”: it contains Asians but cannot isolate themSo: at most 17 of 6,371 are misgrouped. The paper states one rule for 2007–2018; the data support it only for 2011–2018.
BMI≥25 kg/m² / BMI<25 kg/m²The headers are wrong, and you can prove it without asking the authors: the paper’s own group III and IV BMI medians are 27.6 and 28.8, sitting in columns labelled “BMI < 25”. Impossible.
Coding to 30 reproduces those medians exactly. Coding to 25 does not.
Finding an internal contradiction and resolving it against the data — rather than picking whichever reading you prefer — is exactly what M2 asks for.
Paper: “The definitions of comorbidities in the NHANES study have been previously described.” — that is the entire specification.
| Derived | Variables | Rule |
|---|---|---|
htn |
BPQ020, BPXSY* BPXDI* |
self-report or ≥140/90 |
t2dm |
DIQ010, LBXGH |
self-report or HbA1c ≥ 6.5 |
dyslip |
BPQ080 BPQ100D, LBDLDL |
self-report or meds or LDL ≥ 130 |
ckd |
LBXSCR, URDACT/URXUMA/URXUCR |
eGFR < 60 (CKD-EPI 2021) or ACR ≥ 30 |
pir_cat |
INDFMPIR |
<1 Low · 1–4 Middle · >4 High |
current_smoke |
SMQ020 SMQ040 |
current among ever-smokers |
Warning
The code comment says these are “calibrated to reproduce the paper’s Table 1 prevalences”. That is fitting a definition to a target — circular. Report it as: the paper did not specify, we chose X, here is the residual gap (1–3 points).
Six fixed-width .dat files from NCHS, joined by SEQN.
| Field | Columns | Used for |
|---|---|---|
SEQN |
1–6 | join key |
ELIGSTAT |
15 | 1 = eligible for linkage |
MORTSTAT |
16 | 0 alive / 1 dead |
UCOD_LEADING |
17–19 | 001 heart … 005 cerebrovascular |
PERMTH_EXM |
46–48 | the clock — months from the MEC exam |
Decision: PERMTH_EXM, not PERMTH_INT — the exposure is measured at the MEC exam, so follow-up starts when the exposure was measured. The paper does not say which it used.
Important
Verify the column positions before trusting anything. A one-column offset corrupts everything silently. Check: ELIGSTAT ∈ {1,2,3}, MORTSTAT ∈ {0,1}, UCOD_LEADING ∈ 1–10, PERMTH_EXM ≥ 0, SEQN in the cycle’s range.
| Definition | CV deaths |
|---|---|
cv_death in masld_analytic.rds — ucod_leading ∈ {1, 5} |
185 |
Heart only — ucod_leading == 1 |
158 |
| The paper | 158 |
README.md says “diseases-of-heart”; reproduction_report.md finding #3 says heart-only04_table2_cox.R:42 quietly defines its own cv_heart and uses that, so the published Table 2 is correctIf you read the analytic file and use cv_death, you will disagree with every table in this repository. Use mortstat == 1 & ucod_leading == 1 until it is fixed.
s0 <- dat # 59,842
s1 <- filter(s0, age >= 18) # 36,580 ELIGIBILITY
s2 <- filter(s1, !is.na(fli)) # 14,989 DESIGN + MISSINGNESS
s3 <- filter(s2, steatosis == 1) # 6,376 TARGET POPULATION
s4 <- filter(s3, masld == 1) # 6,374 TARGET POPULATION
s5 <- filter(s4, !is.na(group)) # 6,374 MISSINGNESS
s6 <- filter(s5, eligstat == 1, !is.na(mortstat), !is.na(time_yr)) # 6,371 OUTCOME
s7 <- if (APPLY_ALCOHOL_EXCL) filter(s6, sig_alcohol == 0) else s6 # 6,371 SWITCHTwo files: masld_analytic.rds (6,371 rows) and merged_all.rds (all 59,842, pre-filter, so any step can be re-derived without re-reading the .xpt files).
ELIGIBILITY · DESIGN · MISSINGNESS · TARGET POPULATION · OUTCOME ASCERTAINMENT — if you cannot name a row’s type, you do not yet know what the row means.
| Decision | Why | Cost |
|---|---|---|
⚠ Age >= 18, paper writes > 18 |
matches the reported mean age | 70 rows; the strongest lead on the N gap, not settled |
| ⚠ Alcohol exclusion off | applying it gives 5,944 vs 6,300 | 427 rows |
⚠ Asian cut only where RIDRETH3 exists |
absent in cycles E, F | ≤ 17 of 6,371 misgrouped |
⚠ cv_death = heart + cerebrovascular |
— | 185 vs the paper’s 158 |
| Both row sets kept (6,371 / 6,048) | unweighted paper vs design-aware estimate | 323 rows differ |
PERMTH_EXM as the clock |
exposure measured at the MEC exam | 6.76 y vs the paper’s 6.9 y |
| Unweighted throughout | the paper reports no weights, strata or PSUs | the design-aware estimate is P4 |
| Decision | Why | Cost |
|---|---|---|
| FLI transcribed verbatim, no unit conversion | NHANES already supplies mg/dL, U/L, cm | none |
No code-stripping in LBXTR/LBXSGTSI |
77 there is a measurement |
would delete real values |
Strip 777/999 in ALQ130/ALQ120Q |
there they are non-answers | 9 non-answers saved |
| Alcohol = frequency × quantity | ALQ130 is per drinking-day |
ALQ130 alone removes ~2,000, not 427 |
| Glucose 100 mg/dL for 5.6 mmol/L | conventional equivalent | 212 in the gap, 0 reclassified |
| Missing criterion = absent | conservative | negligible — 6,329/6,371 already meet (i) |
NHANES BP averaging, not rowMeans |
documented CDC rule | 5,523 people’s DBP changed |
| Composite comorbidities | paper cites prior work instead of specifying | 1–3 pt gaps; calibration is circular |
| Reproduced | Published | |
|---|---|---|
| Analytic N | 6,371 | ~6,300 |
| Groups I/II/III/IV | 391 / 4,585 / 787 / 608 | 386 / 4,541 / 769 / 604 |
| Deaths | 586 | 585 |
| Group BMI medians | 30.9 / 35.2 / 27.6 / 28.8 | identical |
| Group IV vs I HR, unadjusted → Model 2 | 15.14 → 2.91 | 15.13 → 2.89 |
Reproduced HRs: unweighted, full 6,371-row file (Model 2 on its complete cases). Published: the paper’s Table 2.
Yes, closely — apart from a residual gap of 71 that is not yet resolved.
Read crudely, Group IV has the poorest survival, and the paper reports that. Under Model 2, Groups III and IV have similar HRs against Group I (ours: 3.00 vs 2.91). The replication succeeds; the disagreement is about which contrast supports the conclusion.
.xpt → your code → cohort consequenceTip
Full detail, with every number and line reference: reproduction/COHORT_BUILD_STEPS.md
Open data/derived/merged_all.rds and answer, with code:
age > 18?Write four lines for each: what the paper says · what you did · why · what it cost.