From the Paper’s Methods to an Analytic Dataset

SPPH 604 · Application of Advanced Epidemiological Methods

Companion to reproduction/COHORT_BUILD_STEPS.md · Kueh et al. (2026), BMJ Open 16:e113719

This deck

Eight steps, from a CDC download to masld_analytic.rds

Every step answers the same four questions. That repetition is the point.

The question this deck answers

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?

  • The paper: Kueh MTW et al., BMJ Open 2026;16:e113719 — body weight phenotypes and mortality in MASLD, NHANES 2007–2018
  • The code: reproduction/R/00_setup.R → 01_download.R → 02_build_analytic.R
  • The destination: 6,371 rows, against a published 6,300

The gap is not a failure. Being able to say why it exists is the skill.

The one habit

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:

  1. What the paper says — quoted, verbatim
  2. Component → variables — what that sentence forces you to download
  3. The decision — and why, because the sentence did not fully determine it
  4. N afterwards — and the type of the loss

Three scripts, one of which matters

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
  • Cycle letters used throughout: E 07-08 · F 09-10 · G 11-12 · H 13-14 · I 15-16 · J 17-18
  • The file carries the suffix (BMX_H.xpt); the variable usually does not (BMXBMI in all six)

Where we are going: the funnel

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.

Step 0 — What you download

Three downloads

# 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.

Which component supplies what

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.

The merge, and the trap inside it

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

Step 1 — Age eligibility

Step 1 — the four lines

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 paper says >, 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.

Step 2 — Steatosis via the FLI

Step 2 — the Fatty Liver Index

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).

Reserved codes: there is no global rule

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.

⚠ DESIGN is not missing data

Triglycerides come from the fasting subsample, which carries its own weight WTSAF2YR. The paper never says whether it restricted to that subsample.

  • 21,618 of the 36,580 adults fall outside the fasting-weight frame — most were never sampled for it. That is DESIGN.
  • 792 are inside the frame and still lack an input. That is MISSINGNESS.

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.

Two row sets, kept apart

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.

  • The paper does not report using survey weights → 6,371 is what reproduces it
  • A design-aware estimate is only possible on 6,048 → that is P4’s job
  • 6,048 is a different denominator, not a correction to 6,371

Warning

Quote which row set you mean, every single time you state an N.

Step 3 — MASLD criteria

Step 3 — five criteria, at least one required

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.

Judgment call 1 — is the glucose cut 100 or 100.9?

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.

  1. Paper: fasting glucose ≥ 5.6 mmol/L
  2. Converted: 5.6 × 18.016 = 100.9 mg/dL
  3. We coded ≥ 100 — the conventional rounding
  4. 212 people have a glucose between 100 and 100.9. Our rule counts them as meeting criterion (ii); a strict reading would not
  5. So we re-ran MASLD with ≥ 100.9 and counted who falls out: 0 people

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.

Judgment call 2 — and what Step 3 really did

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.

Step 4 — Alcohol

Step 4 — building the alcohol exclusion

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.

⚠ We built it — and then did not apply it

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 better
  • So the paper’s Methods and its own reported N disagree with each other. We cannot resolve that; we can document it, and default to the cohort that reproduces the tables

Step 5 — The four phenotypes

Step 5 — obesity × central adiposity

Paper: “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.

⚠ A cut-point you cannot apply to a third of the cohort

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%).

  • Where the variable does exist, the Asian rule moves 121 of 250 Asian participants into the obese arm. This rule has real bite.
  • In 2007–2010 the only ethnicity variable is RIDRETH1, whose category 5 is “Other Race — Including Multi-Racial”: it contains Asians but cannot isolate them
  • 71 cohort members fall in it; only 17 have BMI in [25, 30) — the window where the rule changes anything

So: at most 17 of 6,371 are misgrouped. The paper states one rule for 2007–2018; the data support it only for 2011–2018.

The paper contradicts itself, and the data settle it

  • Methods text: obesity = BMI ≥ 30
  • Table 1 and Table 2 column headers: 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.

Steps 6–8

Step 6 — covariates, where the paper says almost nothing

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).

Step 7 — the outcome is not in NHANES

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.

⚠ A trap inside our own saved dataset

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-only
  • 04_table2_cox.R:42 quietly defines its own cv_heart and uses that, so the published Table 2 is correct

If 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.

Step 8 — save, and count what you lost

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  SWITCH

Two 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.

What it all cost

The decisions ledger — where we may differ from the paper

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

The ledger — decisions the paper left to us

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

Did it reproduce?

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.

For P1

  1. Work the loop, in order: paper sentence → operational concept → NHANES variable → component → that cycle’s codebook → raw .xpt → your code → cohort consequence
  2. Write four lines per step: the quoted sentence, the component and variables, the decision and its reason, the N afterwards
  3. Type every funnel row. DESIGN, MISSINGNESS and TARGET POPULATION are not interchangeable and do not add up
  4. Two decisions here are still open (age, alcohol). Reproducing a number is not the goal — being able to say why yours differs is

Tip

Full detail, with every number and line reference: reproduction/COHORT_BUILD_STEPS.md

One exercise

Open data/derived/merged_all.rds and answer, with code:

  1. How many participants are exactly 18? What happens to N, deaths, and the four group sizes if you use age > 18?
  2. How many people would the alcohol exclusion remove? Does applying it move you toward or away from the paper’s 6,300?
  3. Pick one variable from Step 0’s availability table. Which cycles is it missing from, and what does the NA-padding do to the statistic it feeds?

Write four lines for each: what the paper says · what you did · why · what it cost.