Chronic-condition prevalence and multimorbidity from NHANES public microdata with svy: reproducing two NCHS data briefs at published precision including their Korn-Graubard intervals, agreeing with R’s survey package to machine precision, and working through the weight, skip-pattern and age-standardization decisions the cycle demands.
NHANES is the reference dataset for American chronic-disease prevalence, and almost all of the difficulty in using it is upstream of the estimator. The design is a plain stratified two-stage sample. What is not plain is everything about how the eight condition indicators get built.
Working through it on the August 2021–August 2023 cycle, svy reproduces the published NCHS estimates at published precision, including the Korn–Graubard confidence intervals:
NCHS Data Brief 508 / 511
svy
Published
Obesity, adults 20+
40.26 (se 1.84)
40.3 (se 1.8)
Obesity, men
39.20 (se 1.93)
39.2 (se 1.9)
Obesity, women
41.28 (se 2.20)
41.3 (se 2.2)
Severe obesity, adults 20+
9.41 (se 0.66)
9.4 (se 0.7)
Hypertension, adults 18+
47.70 [45.4, 50.0]
47.7 [45.4, 49.9]
Hypertension, men
50.87 [48.5, 53.2]
50.8 [48.4, 53.2]
Awareness, among hypertensive
59.17 [56.4, 61.9]
59.2 [56.4, 61.9]
Treatment, among hypertensive
51.19 [48.6, 53.7]
51.2 [48.6, 53.7]
Notethat the svy estimators have been validated separately and in depth against R’s survey package — see svy vs R’s survey.
Background
NHANES combines a household interview with a standardized physical examination in a mobile examination centre (MEC). This cycle interviewed 11,933 people and examined 8,860 of them.
╭────────── Design ───────────╮│ Row index None ││ Stratum SDMVSTRA ││ PSU SDMVPSU ││ SSU None ││ Weight WTMEC2YR ││ With replacement False ││ Prob None ││ Hit None ││ MOS None ││ Population size None ││ Replicate weights None │╰─────────────────────────────╯
Fifteen masked variance strata, two masked PSUs in each, so 30 PSUs and 15 degrees of freedom; regardless of the six thousand people in the analysis.
What the files actually carry
svy.read_sas dispatches .xpt to a native Rust reader, so there is no conversion step. create_from_sas goes one further and returns a Sample with the file’s metadata already imported:
DataFrame (11933, 27)
11933 records, labels imported:
Age in years at screening
Full sample 2-year MEC exam weight
Reading the metadata from all eight modules gives a uniform and consequential result:
Module
Variables
With a label
Value-label sets
User-missing
DEMO_L
27
27
0
0
BMX_L
22
22
0
0
BPXO_L
12
12
0
0
BPQ_L
6
6
0
0
DIQ_L
9
9
0
0
GHB_L
3
3
0
0
KIQ_U_L
9
9
0
0
MCQ_L
35
35
0
0
Variable labels survive. All 123 variables across the eight modules carry one, as the two above show.
Value labels do not, because they were never there. A SAS transport file stores a format name per variable, never the format’s contents; the mapping itself lives in a SAS catalogue that NHANES does not ship. And NHANES sets a format name on exactly two of the 123 variables — WTINT2YR and WTMEC2YR, both 20.6, a numeric width-and-decimals format for printing the weights. Not one variable carries a format that would map 1 to “Yes” or 9 to “Don’t know”. That mapping exists only in the HTML codebooks on CDC’s website.
The user_missing and tagged_missings blocks are empty too, and for a separate reason: NHANES encodes refusals and don’t-knows as ordinary numbers rather than as SAS special missing values. There is nothing in the file to import.
So MCQ160C == 9 — a respondent who does not know whether they have coronary heart disease — arrives as the number 9, and there are 34 of them. Write svy.col("MCQ160C") == 1 and every one is counted as not having the disease.
svy will not rescue you here, and says so. Its test suite asserts the absence of a missing-value model:
svy has no model of missing values. A 99 labelled “Refusal” is the integer 99 with the label “Refusal”. svy reads it, prints it, and forms no opinion about it. Absence is a polars null, which needs no metadata.
Labelling a code changes what it is called and nothing else. There is no set_missing to reach for, and MissingKind in svy.core.enumerations is vestigial. The recode is the analyst’s job, which is what the next section is about.
NoteOne thing the reader does handle for you
The 449 people who provided no blood sample have WTPH2YR set to zero. In the XPT file that zero is encoded as an IBM-format denormal rather than a true zero, and svy normalises it on read:
View code
w = svy.read_sas("data/GHB_L.xpt")["WTPH2YR"]print("min:", w.min(), "| == 0:", (w ==0).sum(), "| > 0:", (w >0).sum())
min: 0.0 | == 0: 449 | > 0: 6750
So the obvious filter works: WTPH2YR > 0 selects exactly the people with a usable phlebotomy weight, and == 0 finds all 449 without one. Worth knowing because that is not guaranteed — a reader that passes the denormal through would make == 0 match nothing at all, and silently admit 449 zero-weight records into the design.
The recode, and what each step is worth
The design goes on first, and everything after it runs through Sample.wrangling, so the design travels with the data rather than being bolted on at the end:
View code
sample = svy.Sample(data=df, design=DESIGN)
Two operations then stand between the raw file and a usable indicator.
Sentinels. Every questionnaire item uses 7 = Refused and 9 = Don’t know. They have to become null before any indicator is built. recode maps new values to the old ones they replace, so this is one call over all sixteen items:
View code
sample = sample.wrangling.recode( ITEMS, # all sixteen questionnaire items {None: [7, 9]}, # Refused and Don't know -> null replace=True, # overwrite in place)
Skip patterns.BPQ150 (“now taking blood pressure medication”) is asked only of people who answered yes to BPQ020 (“ever told you had high blood pressure”). For everyone else it is null — and that null means not on medication, not unknown. This one is conditional on another column, so it is a mutate:
View code
sample = sample.wrangling.mutate({"BPQ150": ( svy.when(svy.col("BPQ150").is_not_null()).then(svy.col("BPQ150")) .when(svy.col("BPQ020") ==2).then(svy.lit(2.0)) # asked, said no -> No .otherwise(None) # gate unknown -> unknown )})
The two together are the whole cleaning step, and both return a new Sample, so the pipeline chains.
Running it three times — correct, then with each step disabled — prices them separately. Adults 20+, MEC weight:
Condition
Correct
Skips not closed
Sentinels not zapped
Hypertension
49.12 (se 1.03, n=5,821)
95.13 (se 0.34, n=3,367)
49.10 (se 1.04, n=5,822)
Diabetes (diagnosed)
11.81 (se 0.70, n=6,023)
11.81 (se 0.70, n=6,023)
11.81 (se 0.70, n=6,023)
Obesity
40.26 (se 1.84, n=5,929)
40.26 (se 1.84, n=5,929)
40.26 (se 1.84, n=5,929)
High cholesterol
38.61 (se 0.91, n=5,985)
38.61 (se 0.91, n=5,985)
38.37 (se 0.90, n=6,023)
Cardiovascular disease
9.73 (se 0.51, n=5,995)
9.73 (se 0.51, n=5,995)
9.70 (se 0.51, n=6,021)
Chronic lung disease
5.74 (se 0.60, n=6,013)
5.74 (se 0.60, n=6,013)
5.73 (se 0.60, n=6,023)
Kidney disease
2.95 (se 0.33, n=6,012)
2.95 (se 0.33, n=6,012)
2.95 (se 0.33, n=6,023)
Cancer
11.70 (se 0.53, n=6,017)
11.70 (se 0.53, n=6,017)
11.69 (se 0.53, n=6,023)
The two failures are nothing alike.
The skip-pattern error is catastrophic and it disguises itself as precision. Leaving the structural nulls as missing, then dropping them, restricts the analysis to the 3,367 people who had already been told they have high blood pressure — among whom the prevalence of high blood pressure is, unsurprisingly, 95%. The estimate nearly doubles, and the standard error falls from 1.03 to 0.34. Judged on its confidence interval alone, the wrong answer looks like the better one.
The sentinel error is small here, and it is a bias rather than noise. Across 6,023 adults there are only 173 refusals and don’t-knows, so the effect is a quarter of a point at most. But it is always in the same direction — a don’t-know becomes a “no”, so prevalence is always understated — and it silently inflates the denominator, since those records are no longer dropped. In a cycle or a subgroup with more nonresponse, the same mechanism scales up. It is worth doing correctly regardless of what it costs today.
WarningDo not drop records to subset
CDC’s own guidance is blunt about this:
When working with complex survey data such as NHANES, you should never delete or drop records from your analysis dataset before executing your analysis procedures.
Filtering to adults treats the number of adults in each cluster as fixed, when it is itself a random quantity, and understates the variance. In svy the subpopulation is a where= argument and the design stays whole:
Each chronic condition is conventionally built as an OR across up to three sources:
condition = self-reported diagnosis OR measured/lab threshold OR on medication
Self-report alone misses undiagnosed disease; a lab threshold alone misses controlled disease. The union is the standard operationalisation.
It is also the part most often ported unexamined from one cycle to the next, and this cycle is where that breaks. Whether a source contributes anything at all depends on its skip pattern, so the recipe has to be tested rather than assumed. Counting the cases each medication source adds beyond self-report alone:
Source
New cases
Why
Insulin (DIQ050)
0
Asked only of DIQ010 == 1. It is nested inside the source it is meant to supplement, so it can never add anyone.
Oral agents (DIQ070)
120
Asked of DIQ010 ∈ {1,3}orDIQ160 == 1. The extra 120 are 60 borderline and 60 prediabetic respondents on metformin — not people with diabetes.
Statins (BPQ101D)
272
Ungated: asked of everyone. These are real cases, 12.8% of all medication users, that self-report misses.
So for diabetes the medication source is either vacuous or actively wrong, and diagnosed diabetes here is DIQ010 == 1 alone. For cholesterol the medication source is essential.
ImportantBPQ101D is the trap for anyone porting a 2017–2018 pipeline
In 2017–2018, cholesterol medication (BPQ100D) sat behind a gate, itself behind BPQ080. In 2021–2023 the variable was renamed to BPQ101Dand the gate was removed — CDC states that “the skip pattern for cholesterol medication use changed.”
Code that reproduces the old logic as BPQ080 == 1 & BPQ101D == 1 still runs, raises nothing, and drops 272 treated people. The same rename happened to blood-pressure medication: BPQ050A became BPQ150, where the gate was kept.
Two more renames matter for anyone trending against earlier cycles. MCQ160P (“ever told you had COPD, emphysema, ChB”) is one question that replaced three separately-asked items — MCQ160G, MCQ160K and MCQ160O. A union of three items and a single item are not the same measurement and should not be plotted as one series.
Once the definitions do line up, svy.combine_samples() stacks cycles into a single stratified design, nesting wave inside stratum inside PSU so that Taylor variance treats the waves as independent. It picks up an existing wave column such as NHANES’s own SDDSRVYR, and adjust="average" divides the weights by the number of cycles — which matters for totals and not for proportions. Nothing here pools cycles.
The eight conditions used here:
Condition
Definition
Hypertension
mean measured SBP ≥ 130 or mean DBP ≥ 80 orBPQ150 == 1
Diabetes (diagnosed)
DIQ010 == 1
Obesity
BMXBMI ≥ 30
High cholesterol
BPQ080 == 1orBPQ101D == 1
Cardiovascular disease
any of MCQ160B–MCQ160F (heart failure, CHD, angina, MI, stroke)
Chronic lung disease
MCQ160P
Kidney disease
KIQ022 == 1
Cancer
MCQ220 == 1
In code, the OR needs to distinguish “no” from “not known”, so a plain any_horizontal will not do — a confirmed Yes has to win over a missing source, while nothing-says-yes-and-something-is-unknown has to stay null:
View code
def any_yes(*sources):"""OR across sources, propagating nulls only when undecided."""return ( svy.when(svy.any_horizontal(*[s.fill_null(False) for s in sources])) .then(True) .when(svy.any_horizontal(*[s.is_null() for s in sources])) .then(None) .otherwise(False) )sample = sample.wrangling.mutate( {"high_chol": any_yes(yes("BPQ080"), yes("BPQ101D")),"cvd": any_yes( yes("MCQ160B"), yes("MCQ160C"), yes("MCQ160D"), yes("MCQ160E"), yes("MCQ160F"), ),"obesity": at_least("BMXBMI", 30),"kidney": yes("KIQ022"), })
Counting conditions per person needs the same care. A respondent with two confirmed conditions and one unknown does not have “exactly two”, so the count is null unless every flag is resolved:
View code
CONDITIONS = ["hypertension", "diabetes", "obesity", "high_chol","cvd", "lung", "kidney", "cancer"]flags = [svy.col(c) for c in CONDITIONS]n = svy.sum_horizontal(*flags) # booleans sum without a castcomplete = svy.all_horizontal(*[f.is_not_null() for f in flags])sample = sample.wrangling.mutate({"n_conditions": svy.when(complete).then(n).otherwise(None),"multimorbid": svy.when(complete).then(n >=2).otherwise(None),})
That costs 5.7% of adults, which is worth reporting rather than hiding. It has to be a second mutate — like with_columns, one call evaluates every expression against the frame as it was on entry, so a column created in the same call is not visible to its siblings.
The demographic groupings are categorize and recode rather than hand-written when chains:
Note RIDRETH3 has no code 5 — it is reserved in RIDRETH1 and unused here. Writing the map out explicitly rather than looping over a range is what makes that visible.
Three judgement calls are worth stating rather than burying.
The blood-pressure threshold is a choice. 130/80 is the 2017 ACC/AHA definition and is what NCHS uses for this cycle; the older 140/90 gives a substantially lower prevalence.
A measured blood pressure is required, not merely used when present. Without one, “not on medication” cannot be turned into “not hypertensive”. This also keeps interview-only records out of the denominator, where the medication item alone would otherwise admit 514 people at zero weight — contributing nothing to the estimate while inflating the reported sample size.
DIQ010 == 3 is “Borderline”, a real third category and not a missing code. It is resolved to “no” here, which is what NCHS does for diagnosed diabetes, but folding it into “yes” instead is a defensible choice that changes the answer.
The weight must match the most restrictive source
CDC’s rule is explicit:
You must use the weight of the smallest subpopulation that includes all the variables you want to include in your analysis.
This cycle introduced a new weight that makes the rule bite. HbA1c (LBXGH) is attempted on all MEC examinees aged 12+, but roughly 5% of examined adults gave no blood, and nonresponse to the blood draw differs sharply by age and race. So NCHS shipped WTPH2YR, a phlebotomy weight, in every lab file:
The phlebotomy weight should be used for analyses that use variables derived from blood analytes.
The arithmetic shows exactly what it is for. Adults 20+:
Population represented
WTMEC2YR over the MEC sample
245,061,169
WTPH2YR over the phlebotomy subset
245,061,169
WTMEC2YR over the phlebotomy subset
233,934,098
The phlebotomy weight re-inflates a smaller set of people to the same national total. The MEC weight, applied to those same people, represents 11.1 million fewer Americans — and reports no error while doing it.
On the point estimate the effect is modest: total diabetes (diagnosed or HbA1c ≥ 6.5%) comes to 14.09% under WTPH2YR and 13.98% under WTMEC2YR on identical records, well inside a standard error of 0.75. The damage is not usually in the first estimate. It is that the analysis silently stops representing the population it claims to.
Switching weight is switching Design, and the frame has to move with it — the phlebotomy subsample is the records with a positive WTPH2YR:
The rule cuts the other way too. Diagnosed diabetes rests on DIQ010 alone, a household-interview item, so the interview weight is the correct one:
Diagnosed diabetes, adults 20+
Estimate
WTINT2YR, all interviewed adults
11.23 (se 0.67)
WTMEC2YR, MEC examinees only
11.81 (se 0.70)
NCHS Data Brief 516, published
11.3 [9.3, 13.5]
Reaching for WTMEC2YR because the rest of the analysis is MEC-based moves the estimate by 0.6 points, away from the published figure. (Brief 516 estimates this on the fasting subsample under yet another weight, so this is corroboration rather than a replication.)
Prevalence
The sample is already built and cleaned, so estimation is one call:
Note the df column: three domains report 14 rather than 15, because one stratum contributes no observations to them. svy adjusts the reference distribution per domain rather than reusing the full-sample degrees of freedom.
Which interval method
ci_method takes logit (the default, matching Stata’s svy: prop), beta (matching R’s svyciprop(method = "beta")), korn-graubard (matching the NCHS SAS macro) and wilson.
beta and korn-graubard are both Korn–Graubard and differ in two places, both of which only bite in small domains and on rare conditions. korn-graubard truncates the effective sample size at the number of people actually observed; beta leaves it uncapped, because R does. In 12 of the 72 condition-by-domain cells here the uncapped n_eff exceeds the number of people observed — for chronic lung disease among Asian adults it reaches 854 from 335 respondents, and the interval comes out 26% narrower than the capped one.
The second difference is sharper. Among Asian adults aged 20–39, no one out of 102 reported kidney disease:
Method
Estimate
95% CI
logit
0.00
[0.00, 0.0000]
beta
0.00
[0.00, 0.0000]
wilson
0.00
[0.00, 0.0000]
korn-graubard
0.00
[0.00, 0.0655]
Three of the four report a zero-width 95% interval — the claim that no Asian adult under 40 in the United States has kidney disease, from 102 observations. Only korn-graubard returns a proper one-sided interval.
For beta this is correct behaviour: it is faithfully reproducing R’s svyciprop(method = "beta"), which degenerates at the boundary. korn-graubard follows the NCHS SAS macro, which adds explicit p = 0 and p = 1 handling. The wilson case is a genuine limitation — the Wilson interval is normally well-behaved at zero, but the design-based effective sample size is p(1-p)/se², which is 0/0 there, and svy short-circuits to a point.
For rare conditions in small domains, which is most subgroup work on this data, use korn-graubard. It is also what NCHS uses, which is the other reason the validation below lands.
Multimorbidity
tabulate gives the whole distribution of the condition count with design-based standard errors. A second positional argument would make it a cross-tab against another variable:
estimation.mean and estimation.prop take y as either a name or a sequence of names, so eight conditions come back in one call. categorical.tabulate takes rowvar as a single string, so tabulating each of the eight means eight calls and stacking the results yourself. Not wrong, just an asymmetry worth knowing before you write the loop.
multimorbid — the two-or-more flag built during the recode — is an ordinary binary outcome, so it goes through prop like any other. The overall figure and each breakdown are separate calls:
Stacking those four results gives the table below — two or more conditions, adults 20+:
Domain
%
SE
95% CI
df
All adults
49.48
1.42
[46.42, 52.55]
15
Men
50.40
1.69
[46.74, 54.06]
15
Women
48.59
1.68
[44.94, 52.24]
15
Age 20-39
22.78
1.74
[19.15, 26.75]
15
Age 40-59
52.70
1.57
[49.29, 56.09]
15
Age 60+
75.70
1.60
[72.09, 79.07]
15
Asian, non-Hispanic
30.87
3.40
[23.72, 38.77]
14
Black, non-Hispanic
56.81
1.65
[52.98, 60.57]
14
Mexican American
40.05
3.88
[31.69, 48.85]
14
Other Hispanic
42.93
3.77
[34.79, 51.37]
15
Other/multiracial
50.04
2.83
[43.87, 56.21]
15
White, non-Hispanic
52.13
1.56
[48.74, 55.50]
15
Half of American adults carry at least two of these eight conditions, and three quarters of those over 60 do.
Note the degrees of freedom. Three domains report 14 rather than 15, because one stratum contributes no observations to them; svy adjusts the reference distribution per domain rather than reusing the full-sample df. The width of the Asian and Mexican American intervals — more than twice the White interval — is the reduced-oversampling caveat from the top of this page, showing up exactly where CDC said it would.
That gives Taylor-linearised standard errors, t-based intervals on the design df, and an adjusted Wald F, all on the log-odds scale. exponentiate=True reports the same fit as odds ratios, with White non-Hispanic and Men as reference:
Note the footer: the standard error, t and p stay on the link scale, and the interval is the exponentiated link-scale bound rather than a delta-method interval on the ratio scale. The t statistic and p-value are unchanged either way, since \(\beta = 0 \iff \mathrm{OR} = 1\).
ImportantThe model has 5,929 observations and 7 residual degrees of freedom
DF Residuals: 7. The design supplies 15 degrees of freedom and the model spends 8 of them on parameters. The reference distribution for every t statistic above is \(t_7\), not the normal — which is why the Asian coefficient, at t = 2.88, gives p = 0.024 rather than the 0.004 a normal approximation would report.
Adding one more categorical predictor with a few levels would exhaust what is left. Six thousand respondents do not buy model complexity on a 30-PSU design; this is the single most common way NHANES regressions overclaim.
For a prevalence paper, predicted probabilities usually communicate better than odds ratios, and margins() gives them with design-correct standard errors:
variables= gives the average marginal effect instead — the average change in predicted probability across the sample, which for a categorical predictor is a contrast against its reference level:
View code
for r in m.margins(variables=["sex", "BMXBMI"]):print(r)
╭─────────────── GLM Margins: sex (ame, 95% CI) ────────────────╮│││ Contrast Margin SE 95% CI││ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ││ Women - Men -0.027832 0.007545 [-0.045673, -0.009990] │││╰───────────────────────────────────────────────────────────────╯
Women are about 2.8 percentage points less likely to have diagnosed diabetes than men, adjusting for age, BMI, and race and ethnicity. That is a sentence a reader can act on; “odds ratio 0.737” is not.
The link matters less than it looks
link= takes probit and cloglog alongside the default logit. Their coefficients are not comparable — the BMI coefficient is 0.076 under logit, 0.042 under probit and 0.065 under cloglog, because each lives on its own scale. The marginal effects, which are on the probability scale, very nearly agree:
link
AME, women vs men
AME, per BMI unit
logit
-0.02783
+0.00721
probit
-0.02803
+0.00733
cloglog
-0.02673
+0.00678
The spread across links is smaller than the standard error on any one of them, which is the practical argument for reporting margins rather than coefficients: the marginal effect is a property of the fitted probabilities, so it barely notices which link produced them.
Validation
Four NCHS data briefs cover this cycle. They are the stronger test: they exercise the whole pipeline — definitions, weight choice and estimator together — not just the variance formula. All exclude pregnant women, and the widely-quoted headline figures are the crude ones reproduced here.
Obesity, adults 20+ (Brief 508)
svy
Published
Total
40.26 (se 1.84)
40.3 (se 1.8)
Men
39.20 (se 1.93)
39.2 (se 1.9)
Women
41.28 (se 2.20)
41.3 (se 2.2)
Age 20-39
35.53
35.5
Age 40-59
46.38
46.4
Age 60+
38.90
38.9
Severe obesity (BMI ≥ 40)
9.41 (se 0.66)
9.4 (se 0.7)
Every published figure at published precision, standard errors included, on an analytic sample of 5,929 — the brief’s stated n exactly.
Hypertension, adults 18+ (Brief 511)
svy
Published
Total
47.70 [45.4, 50.0]
47.7 [45.4, 49.9]
Men
50.87 [48.5, 53.2]
50.8 [48.4, 53.2]
Women
44.61 [41.5, 47.8]
44.6 [41.4, 47.8]
Aware, among hypertensive
59.17 [56.4, 61.9]
59.2 [56.4, 61.9]
Treated, among hypertensive
51.19 [48.6, 53.7]
51.2 [48.6, 53.7]
The confidence intervals match to the last published digit. NCHS computes these with its own Korn–Graubard SAS macro; ci_method="korn-graubard" reproduces it, effective-sample-size truncation included, on a 15-df design.
The estimators are separately validated against R’s survey package — see svy vs R’s survey.
Age standardization
Every NCHS brief leads with age-adjusted rates, so this is not optional for public-health work. svy has it as weighting.standardize.
A directly standardized proportion is a fixed-weight combination of the age-specific ones,
where \(N_i\) is the year-2000 U.S. standard population in age group \(i\). NCHS collapses that standard to three groups for these briefs — 20–39, 40–59 and 60 and over.
The part that is easy to get wrong is the variance. Because the age-specific estimates come from the same sample, they are correlated, so the variance needs the full covariance matrix and not just the diagonal:
If you are coming from survey, the R panel above is the same call; the arguments map one to one, and the two agree to round-off on all nine indicators here.
WarningStandardized weights belong to one analysis
where bakes one variable’s missingness into the weights, and by bakes in the domain structure. Reusing a standardized sample to estimate a different variable, or a different breakdown, is silently wrong rather than an error. Call standardize once per estimate — which is why the code above passes svy.col("obesity").is_not_null() rather than standardizing once and reusing.
Crude against age-standardized, adults 20+:
Condition
Crude
SE
Age-standardized
SE
Shift
Hypertension
49.12
1.03
46.05
1.13
-3.06
Obesity
40.26
1.84
40.34
1.88
+0.08
High cholesterol
38.61
0.91
35.38
0.87
-3.24
Diabetes (diagnosed)
11.81
0.70
10.62
0.58
-1.19
Cancer
11.70
0.53
9.79
0.34
-1.91
Cardiovascular disease
9.73
0.51
8.23
0.42
-1.50
Severe obesity (BMI ≥ 40)
9.41
0.66
9.73
0.70
+0.32
Chronic lung disease
5.74
0.60
5.12
0.51
-0.62
Kidney disease
2.95
0.33
2.59
0.30
-0.36
Almost everything falls, because this cycle’s sample is older than the 2000 standard — the age-based oversampling of 60+ that replaced the race and income oversampling.
Three independent checks confirm the standard population and the method, since Brief 508 publishes both columns:
Crude
Age-adjusted
Obesity, adults 20+
40.26 vs 40.3
40.34 vs 40.3
Severe obesity
9.41 vs 9.4
9.73 vs 9.7
Obesity, men
39.20 vs 39.2
39.29 vs 39.3
Obesity, women
41.28 vs 41.3
41.39 vs 41.4
Reproducing the crude-to-adjusted shift — +0.3 for severe obesity, +0.1 for each sex — is the discriminating test, and it lands in all four cases. Adding Brief 511’s hypertension figure on the 18+ base makes five.
Brief 511 uses an 18+ base instead, which needs the standard population’s 15–19 group split to get 18–19. That number is published — 8,001,203, in the master list behind the collapsed table, and confirmed to the person by the SEER single-year standard populations (3,936,904 + 4,064,299). It is worth chasing down rather than approximating: splitting 15–19 pro rata instead moves the answer by a tenth of a point, enough to change the rounded figure.
With the published value, Brief 511’s age-adjusted hypertension comes out at 44.54 against a published 44.5, with a 95% interval of [42.13, 46.96] against a published [42.1, 46.9]:
Everything above is computed from eight public NHANES modules for the August 2021–August 2023 cycle. They are U.S. government public-domain files, downloadable without registration from https://wwwn.cdc.gov/Nchs/Data/Nhanes/Public/2021/DataFiles/:
The linking key is SEQN throughout, and every module is left-joined onto DEMO_L so that skipping a component does not drop a respondent.
The R cross-check uses survey 4.5 and haven 2.5.5 against the same eight files.
References
Korn, E. L. and Graubard, B. I. (1998). Confidence intervals for proportions with small expected number of positive counts estimated from survey data. Survey Methodology, 24(2), 193–201.
Parker, J. D. et al. (2017). National Center for Health Statistics Data Presentation Standards for Proportions. Vital and Health Statistics 2(175).
Emmerich, S. D., Fryar, C. D., Stierman, B. and Ogden, C. L. (2024). Obesity and severe obesity prevalence in adults: United States, August 2021–August 2023. NCHS Data Brief 508.
Fryar, C. D., Kit, B., Carroll, M. D. and Afful, J. (2024). Hypertension prevalence, awareness, treatment, and control in adults age 18 and older: United States, August 2021–August 2023. NCHS Data Brief 511.
Analyses use public-use NHANES microdata from the National Center for Health Statistics. This does not constitute an endorsement by NCHS or CDC of this product.
TipCommunity signal
Help make svy the standard for survey analysis in Python
If rigorous, design-based survey inference in Python matters to you, starring the repository helps signal demand and prioritize validation and stability work.