Run it in the browser
The notebook works through the whole analysis: pooling the survey years, the weighted against unweighted comparison, PCA and clustering with the stability check, the classifier, and the threshold trade-off.
The notebook cannot ship the data. The three public-use files total about 1.1 GB, far past what a repository or a notebook can carry. It therefore runs on a synthetic dataset built from this study's published aggregates, so every cell executes with no setup, and a flag at the top switches it onto the real files once you have downloaded them from SAMHSA.
Numbers produced from the synthetic data are simulated. The real figures are on the analysis page.
The pipeline
Three tab-separated files, one per year, about 2,650 columns each. Only twelve are read: nine analysis variables and the three that describe the survey design. Pooling requires dividing each weight by the number of years.
Binary indicators for past-month use and past-year depression. Dropping missing outcomes removes all adolescents, because they are asked a different instrument, so the population is adults 18 and over.
Every prevalence computed with and without the weights, so the difference is visible rather than assumed away.
Standardise, principal components, k-means on the leading three, then refit on subsamples to see whether the groups are reproducible.
Penalised logistic regression and a classification tree, cross-validated, then the threshold sweep that shows what the default cut-off costs.
Refit with the weights and the cluster structure, and compare the standard errors against the naive ones.
The R pipeline
nsduh_mde_analysis.R, 134 lines.
Source on GitHub The results it produces
Show the full script (134 lines)
nsduh_mde_analysis.RR# Major Depressive Episode and Substance Use in the United States # National Survey on Drug Use and Health (NSDUH), 2022-2024 # # The analysis pipeline in R. The original was written as an R Markdown document; # this is the same code path, cleaned for publication and with the survey design # added in Part 6. # # The NSDUH public-use files are about 1.1 GB and are not distributed with this # repository. Download them from SAMHSA and set DATA_DIR: # https://www.samhsa.gov/data/data-we-collect/nsduh-national-survey-drug-use-and-health DATA_DIR <- "path/to/nsduh" library(tidyverse) library(VIM) # missingness visualisation library(factoextra) # PCA and clustering plots library(caret) # cross-validation and tuning library(rpart) # classification tree library(rpart.plot) library(survey) # complex survey design, Part 6 # --------------------------------------------------------------------------- # Part 1. Read and pool the survey years # --------------------------------------------------------------------------- # Each file is one year, tab separated, roughly 2,650 columns. Only a handful are # needed, and the three design variables matter as much as the analysis ones: # ANALWT2_C the analysis weight # VESTR_C the variance estimation stratum # VEREP the variance estimation replicate vars <- c("IRSEX", "CATAG6", "INCOME", "HEALTH2", "IRALCRC", "IRMJRC", "CIGMON", "AMDEYR", "AMIPY", "ANALWT2_C", "VESTR_C", "VEREP") read_year <- function(year) { path <- file.path(DATA_DIR, sprintf("NSDUH_%d_Tab.txt", year)) d <- read.delim(path)[, vars] d$YEAR <- year d } df <- bind_rows(lapply(c(2022, 2023, 2024), read_year)) # Pooling years: the weight must be divided by the number of years, or the pooled # estimates describe a population three times too large. df$WT <- df$ANALWT2_C / 3 # --------------------------------------------------------------------------- # Part 2. Recode # --------------------------------------------------------------------------- df <- df %>% mutate( ALC_PASTMO = as.integer(IRALCRC == 1), MJ_PASTMO = as.integer(IRMJRC == 1), CIG_PASTMO = as.integer(CIGMON == 1), MDE = ifelse(AMDEYR %in% c(1, 2), as.integer(AMDEYR == 1), NA), AMI = ifelse(AMIPY %in% c(1, 2), as.integer(AMIPY == 1), NA), HEALTH = ifelse(HEALTH2 %in% 1:4, HEALTH2, NA) ) # The adult depression question is not asked of 12-17 year olds (CATAG6 == 1), so # dropping missing MDE removes them entirely. That is a restriction on the # population, not missing data: the analysis is of adults 18 and over. cat("adolescents in the pooled file:", sum(df$CATAG6 == 1), "\n") nsduh <- df %>% filter(!is.na(MDE), !is.na(HEALTH)) cat("analysis sample:", nrow(nsduh), "\n") # --------------------------------------------------------------------------- # Part 3. Missingness and descriptives # --------------------------------------------------------------------------- VIM::aggr(df[, c("IRSEX","CATAG6","INCOME","HEALTH","ALC_PASTMO", "MJ_PASTMO","CIG_PASTMO","MDE","AMI")], numbers = TRUE, prop = FALSE, sortVars = TRUE) # Unweighted prevalence: a property of the sample, not of the country. nsduh %>% group_by(YEAR) %>% summarise(across(c(ALC_PASTMO, MJ_PASTMO, CIG_PASTMO, MDE), ~ mean(.) * 100)) # --------------------------------------------------------------------------- # Part 4. PCA and k-means # --------------------------------------------------------------------------- pca_vars <- c("ALC_PASTMO","MJ_PASTMO","CIG_PASTMO","IRSEX","CATAG6","INCOME","HEALTH") X <- scale(nsduh[, pca_vars]) # standardise: the variables are on different scales pc <- prcomp(X) fviz_eig(pc) # scree plot scores <- pc$x[, 1:3] set.seed(1) fviz_nbclust(scores[sample(nrow(scores), 25000), ], kmeans, method = "wss", k.max = 8) km <- kmeans(scores, centers = 4, nstart = 10) nsduh$cluster <- km$cluster nsduh %>% group_by(cluster) %>% summarise(n = n(), across(c(ALC_PASTMO, MJ_PASTMO, CIG_PASTMO, MDE), ~ mean(.) * 100)) # --------------------------------------------------------------------------- # Part 5. Predicting MDE # --------------------------------------------------------------------------- model_vars <- pca_vars glm_data <- nsduh[, c(model_vars, "MDE")] glm_data$MDE <- factor(ifelse(glm_data$MDE == 1, "Yes", "No"), levels = c("No", "Yes")) set.seed(1) ctrl <- trainControl(method = "cv", number = 10, classProbs = TRUE, summaryFunction = twoClassSummary) lasso_fit <- train(x = glm_data[, model_vars], y = glm_data$MDE, method = "glmnet", metric = "ROC", trControl = ctrl, tuneGrid = expand.grid(alpha = 1, lambda = 10^seq(-4, 0, length = 20))) # The outcome is imbalanced at roughly 7.9 to 1. At the default 0.50 cut-off the # model almost never predicts the minority class, so accuracy looks high and kappa # is near zero. Inspect the trade-off rather than reporting accuracy alone. thresholder(lasso_fit, threshold = seq(0.05, 0.5, by = 0.05), final = TRUE, statistics = "all") tree <- rpart(MDE ~ ., data = glm_data, method = "class", cp = 0.001, control = rpart.control(maxdepth = 4)) rpart.plot(tree, type = 2, extra = 104) # --------------------------------------------------------------------------- # Part 6. The survey design # --------------------------------------------------------------------------- # Everything above treats the data as a simple random sample. NSDUH is a stratified # multistage sample, so unweighted percentages describe the respondents rather than # the population, and standard errors computed without the design are too small. options(survey.lonely.psu = "adjust") des <- svydesign(ids = ~VEREP, strata = ~VESTR_C, weights = ~WT, data = nsduh, nest = TRUE) # Population estimates, with design-correct standard errors. svymean(~ALC_PASTMO + MJ_PASTMO + CIG_PASTMO + MDE, des) svyby(~MDE, ~CATAG6, des, svymean) # The same logistic regression, design aware. Compare these standard errors with # the naive ones: they are substantially larger. svyglm(MDE ~ ALC_PASTMO + MJ_PASTMO + CIG_PASTMO + IRSEX + CATAG6 + INCOME + HEALTH, design = des, family = quasibinomial())
R to Python
| R | Python | Note |
|---|---|---|
read.delim(path)[, vars] | pd.read_csv(path, sep="\t", usecols=...) | Read only the needed columns; the files are very wide |
scale(x) | StandardScaler().fit_transform(x) | PCA needs standardised inputs |
prcomp(x) | sklearn.decomposition.PCA | |
kmeans(x, centers = 4) | KMeans(n_clusters=4) | Set the seed in both; labels are arbitrary |
caret::train(method = "glmnet") | LogisticRegression(l1_ratio=1) | caret tunes lambda by cross-validation; set C explicitly in Python |
rpart(...) | DecisionTreeClassifier | Different pruning defaults; set depth and leaf size explicitly |
caret::thresholder() | sweep the cut-off manually | No direct equivalent |
survey::svydesign / svyglm | no full equivalent | The notebook uses weighted fits with cluster-robust errors as an approximation. For production survey work, R's survey package is the better tool. |
Rebuilding this site
Stages needing the survey files write aggregate results into tools/derived/,
which is committed, so the pages rebuild on any checkout without the 1.1 GB of data.
bash tools/build.sh # rebuild the pages from committed data bash tools/build.sh --all # also recompute from the NSDUH files