# Import Libraries
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score, f1_score
import matplotlib.pyplot as plt
import seaborn as sns
# Import NSUDH dataset
df = pd.read_csv("../data/NSDUH_2023_Tab.txt",
sep="\t",
low_memory=False)
# Checking Data
df.head
<bound method NDFrame.head of QUESTID2 FILEDATE ANALWT2_C VESTR_C VEREP PDEN10 COUTYP4 \
0 10000053 03/25/2025 3276.469874 40031 2 2 2
1 10000679 03/25/2025 15630.082955 40021 2 2 3
2 10001208 03/25/2025 4018.172390 40043 1 2 2
3 10001260 03/25/2025 10711.709540 40030 2 2 2
4 10001588 03/25/2025 8195.104779 40023 2 2 2
... ... ... ... ... ... ... ...
56700 50556120 03/25/2025 417.630119 40018 2 2 2
56701 50557151 03/25/2025 7625.717934 40017 1 2 2
56702 50558694 03/25/2025 23556.083908 40042 1 1 1
56703 50558696 03/25/2025 5193.882625 40040 2 1 1
56704 50558785 03/25/2025 2676.234296 40027 1 2 2
MAIIN102 AIIND102 AGE3 ... COSUTELE2 COSUAPTDL2 COSURXDL2 \
0 2 2 10 ... 3.0 3.0 3.0
1 2 2 9 ... 3.0 3.0 3.0
2 2 2 9 ... 3.0 3.0 3.0
3 2 2 1 ... 2.0 2.0 2.0
4 2 2 10 ... 3.0 3.0 3.0
... ... ... ... ... ... ... ...
56700 2 2 9 ... NaN NaN NaN
56701 2 2 11 ... 3.0 3.0 3.0
56702 2 2 10 ... 2.0 2.0 2.0
56703 2 2 11 ... 3.0 3.0 3.0
56704 2 2 3 ... 2.0 2.0 2.0
COSUSVHLT2 COHCTELE2 COHCAPTDL2 COHCRXDL2 COHCSVHLT2 LANGVER \
0 3.0 3.0 3.0 3.0 3.0 1
1 3.0 3.0 3.0 3.0 3.0 1
2 3.0 3.0 3.0 3.0 3.0 1
3 2.0 2.0 2.0 2.0 2.0 1
4 3.0 1.0 3.0 3.0 3.0 1
... ... ... ... ... ... ...
56700 NaN NaN NaN NaN NaN 2
56701 3.0 2.0 2.0 2.0 2.0 1
56702 2.0 2.0 2.0 2.0 2.0 1
56703 3.0 2.0 2.0 2.0 2.0 1
56704 2.0 2.0 2.0 2.0 2.0 1
GQTYPE2
0 NaN
1 NaN
2 NaN
3 NaN
4 NaN
... ...
56700 NaN
56701 NaN
56702 NaN
56703 NaN
56704 NaN
[56705 rows x 2636 columns]>
missing_summary = df.isna().mean() * 100
missing_summary.sort_values(ascending=False)
SRCCLFRSED 99.871264
SRCFRSEDNM 99.871264
GQTYPE2 99.850101
SRCSEDNM2 99.728419
SRCFRTRQNM 99.370426
...
SEXIDENT22 0.000000
SPEAKENGL 0.000000
LVLDIFSEE2 0.000000
LVLDIFHEAR2 0.000000
LVLDIFWALK2 0.000000
Length: 2636, dtype: float64
# Checking for duplicate rows
df.duplicated().sum()
np.int64(0)
# Filtering dataset for relevant age group (18 and above)
'''I used AGE3 instead of CATAG7 because AGE3 is the final edited age variable in NSDUH. It’s the most accurate age measure because it incorporates multiple consistency checks—birthdate, roster age, screener age, and internal corrections. CATAG7 is just a categorical recode derived from AGE3. For filtering out respondents under 18, it’s better to filter at the source (AGE3) and let the model work with the true final age before any recoding.
AGE3 Len : 2 RECODE - FINAL EDITED AGE
Freq Pct
1 = Respondent is 12 or 13 years old
2 = Respondent is 14 or 15 years old
3 = Respondent is 16 or 17 years old
4 = Respondent is between 18 and 20 years old
5 = Respondent is between 21 and 23 years old
6 = Respondent is 24 or 25 years old
7 = Respondent is between 26 and 29 years old
8 = Respondent is between 30 and 34 years old
9 = Respondent is between 35 and 49 years old
10 = Respondent is between 50 and 64 years old
11 = Respondent is 65 years old or older'''
df_no_age = df[df['AGE3'] >= 4]
df_no_age.shape
(45133, 2636)
# Checking how many error codes are present in the dataset
'''Code Meaning Should Convert to NaN?
94 / 994 / 9994 Don't know
97 / 997 / 9997 Refused
98 / 998 / 9998 Blank / Not answered
85 / 985 / 9985 Bad / inconsistent data
99 / 999 / 9999 Legitimate skip
89 / 989 / 9989 Legitimate skip - logically assigned'''
nan_codes = [
94, 97, 98, 85,
994, 997, 998, 985,
9994, 9997, 9998, 9985,
99, 999, 9999,
89, 989, 9989
]
for code in nan_codes:
count = (df_no_age == code).sum().sum()
print(code, count)
94 72183
97 44145
98 1898217
85 9487
994 7015
997 3857
998 33083
985 5817
9994 4675
9997 1899
9998 20242
9985 1774
99 17222835
999 1267661
9999 1453080
89 4185
989 154
9989 1332
# Converting the following error codes to NaN
nan_codes = [
94, 97, 98, 85,
994, 997, 998, 985,
9994, 9997, 9998, 9985,
99, 999, 9999,
89, 989, 9989
]
df_ErrorCode_NaN = df_no_age.replace(nan_codes, np.nan)
nan_codes = [
94, 97, 98, 85,
994, 997, 998, 985,
9994, 9997, 9998, 9985,
99, 999, 9999,
89, 989, 9989
]
for code in nan_codes:
count = (df_ErrorCode_NaN == code).sum().sum()
print(code, count)
94 0
97 0
98 0
85 0
994 0
997 0
998 0
985 0
9994 0
9997 0
9998 0
9985 0
99 0
999 0
9999 0
89 0
989 0
9989 0
# Checking for any NaNs in the Target Variable (IRAMDEYR)
df_ErrorCode_NaN['IRAMDEYR'].isna().sum()
np.int64(0)
# 2. DROP rows where the target is NaN
df_dropped_targetNaN = df_ErrorCode_NaN.dropna(subset=['IRAMDEYR'])
before = df.shape[0]
after = df_dropped_targetNaN.shape[0]
print("Rows before:", before)
print("Rows after :", after)
print("Rows removed:", before - after)
Rows before: 56705
Rows after : 45133
Rows removed: 11572
df_dropped_targetNaN.describe()
| QUESTID2 | ANALWT2_C | VESTR_C | VEREP | PDEN10 | COUTYP4 | MAIIN102 | AIIND102 | AGE3 | SERVICE | ... | COMHSVHLT2 | COSUTELE2 | COSUAPTDL2 | COSURXDL2 | COSUSVHLT2 | COHCTELE2 | COHCAPTDL2 | COHCRXDL2 | COHCSVHLT2 | LANGVER | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| count | 4.513300e+04 | 45133.000000 | 45133.000000 | 45133.000000 | 45133.000000 | 45133.000000 | 45133.000000 | 45133.000000 | 45133.000000 | 45108.000000 | ... | 43333.000000 | 43354.000000 | 43364.000000 | 43362.000000 | 43347.000000 | 43378.000000 | 43375.000000 | 43380.000000 | 43347.00000 | 45133.000000 |
| mean | 3.027962e+07 | 5706.297779 | 40025.494849 | 1.429774 | 1.628631 | 1.713602 | 1.984579 | 1.984357 | 7.816697 | 1.948967 | ... | 2.410772 | 2.533884 | 2.537450 | 2.563004 | 2.578518 | 1.968809 | 2.036749 | 2.196358 | 2.26424 | 1.042519 |
| std | 1.172008e+07 | 8511.336263 | 14.499402 | 0.495049 | 0.586271 | 0.730958 | 0.123222 | 0.124090 | 2.194038 | 0.220068 | ... | 0.592895 | 0.618880 | 0.603669 | 0.563047 | 0.542894 | 0.761610 | 0.720678 | 0.611436 | 0.56382 | 0.201772 |
| min | 1.000005e+07 | 1.516955 | 40001.000000 | 1.000000 | 1.000000 | 1.000000 | 1.000000 | 1.000000 | 4.000000 | 1.000000 | ... | 1.000000 | 1.000000 | 1.000000 | 1.000000 | 1.000000 | 1.000000 | 1.000000 | 1.000000 | 1.00000 | 1.000000 |
| 25% | 2.013831e+07 | 973.740425 | 40013.000000 | 1.000000 | 1.000000 | 1.000000 | 2.000000 | 2.000000 | 6.000000 | 2.000000 | ... | 2.000000 | 2.000000 | 2.000000 | 2.000000 | 2.000000 | 1.000000 | 2.000000 | 2.000000 | 2.00000 | 1.000000 |
| 50% | 3.027958e+07 | 2697.651023 | 40025.000000 | 1.000000 | 2.000000 | 2.000000 | 2.000000 | 2.000000 | 8.000000 | 2.000000 | ... | 2.000000 | 3.000000 | 3.000000 | 3.000000 | 3.000000 | 2.000000 | 2.000000 | 2.000000 | 2.00000 | 1.000000 |
| 75% | 4.039094e+07 | 6788.874141 | 40038.000000 | 2.000000 | 2.000000 | 2.000000 | 2.000000 | 2.000000 | 9.000000 | 2.000000 | ... | 3.000000 | 3.000000 | 3.000000 | 3.000000 | 3.000000 | 3.000000 | 3.000000 | 3.000000 | 3.00000 | 1.000000 |
| max | 5.055870e+07 | 118941.430150 | 40050.000000 | 2.000000 | 3.000000 | 3.000000 | 2.000000 | 2.000000 | 11.000000 | 2.000000 | ... | 3.000000 | 3.000000 | 3.000000 | 3.000000 | 3.000000 | 3.000000 | 3.000000 | 3.000000 | 3.00000 | 2.000000 |
8 rows × 2634 columns
df_dropped_targetNaN.isna().sum().sort_values(ascending=False)
YOLOSEV 45133
YODSCEV 45133
YODPREV 45133
YOWRCHR 45133
YOWRDST 45133
...
FLVVAPMON 0
FLVVAPYR 0
IREDUHIGHST2 0
IIMARIT 0
MAIIN102 0
Length: 2636, dtype: int64
# Drop ID, weight, design, and metadata variables
cols_to_drop = []
# Unique identifiers
cols_to_drop += [c for c in df_dropped_targetNaN.columns if 'CASEID' in c or 'QUESTID' in c]
# Weight variables
cols_to_drop += [c for c in df_dropped_targetNaN.columns if 'WT' in c or 'WGT' in c or 'ANALWT' in c]
# Sampling design variables
cols_to_drop += [c for c in df_dropped_targetNaN.columns if 'VESTR' in c or 'VEREP' in c or 'ESTRAT' in c]
# Interview month/day/time variables
cols_to_drop += [c for c in df_dropped_targetNaN.columns if c.startswith('INTV_')]
# Meta / administration variables
cols_to_drop += [c for c in df_dropped_targetNaN.columns if c.endswith('_A') or c.endswith('_E')]
# Year and file metadata
cols_to_drop += [c for c in ['YEAR', 'FILEDATE', 'QUARTER'] if c in df_dropped_targetNaN.columns]
# Remove duplicates in the list
cols_to_drop = list(set(cols_to_drop))
len(cols_to_drop), cols_to_drop[:20]
(7,
['WTPOUND2',
'ANALWT2_C',
'FILEDATE',
'VESTR_C',
'QUESTID2',
'WTANSWER',
'VEREP'])
drop_patterns = [
r'^CASEID', r'^QUESTID', r'^PANEL', r'^VERSION',
r'^INTV', r'^FILE', r'^YEAR',
r'^WT', r'WGT', r'WEIGHT', r'ANALWT',
r'^VESTR', r'^VEREP',
r'_A$', r'_E$', r'_ORIG$', r'_R$', r'_RC$'
]
import re
cols_to_drop = [
c for c in df_dropped_targetNaN.columns
if any(re.search(pat, c) for pat in drop_patterns)
]
len(cols_to_drop), cols_to_drop[:30]
(7,
['QUESTID2',
'FILEDATE',
'ANALWT2_C',
'VESTR_C',
'VEREP',
'WTANSWER',
'WTPOUND2'])
df_Removed_Columns = df_dropped_targetNaN.drop(columns=cols_to_drop, errors='ignore')
drop_patterns = [
r'^CASEID', r'^QUESTID', r'^PANEL', r'^VERSION',
r'^INTV', r'^FILE', r'^YEAR',
r'^WT', r'WGT', r'WEIGHT', r'ANALWT',
r'^VESTR', r'^VEREP',
r'_A$', r'_E$', r'_ORIG$', r'_R$', r'_RC$'
]
import re
cols_to_drop2 = [
c for c in df_Removed_Columns.columns
if any(re.search(pat, c) for pat in drop_patterns)
]
len(cols_to_drop2), cols_to_drop2[:30]
(0, [])
# Checking to see which variables have >99% missingess
na_percent = df_Removed_Columns.isna().mean() * 100
# Filter columns with > 99% missing
cols_over_99 = na_percent[na_percent > 99]
# Print count
print("Number of variables with >99% missingness:", len(cols_over_99))
# Print the variable names
print("\nVariables with >99% missingness:")
print(cols_over_99.index.tolist())
# Optional: show top 20 instead of the whole list
print("\nPreview (first 20):")
print(cols_over_99.head(20))
Number of variables with >99% missingness: 282
Variables with >99% missingness:
['EDUSCKEST', 'EDUSKPEST', 'HLCALLFG', 'HLCALL99', 'BKPOSTOB', 'BKOTHOF2', 'ALTOTFG', 'ALFQFLG', 'ALDYSFG', 'MJFQFLG', 'BLRECFL2', 'BLNT30C1', 'BLNT30C2', 'RSNOMRJ', 'RSNMRJMO', 'CCTOTFG', 'CCFQFLG', 'CRTOTFG', 'CRFQFLG', 'HRTOTFG', 'HRFQFLG', 'HALTOTFG', 'HALFQFLG', 'INHTOTFG', 'INHFQFLG', 'METOTFG', 'MEFQFLG', 'PNRNORXFG', 'TRQNORXFG', 'STMNORXFG', 'SEDNORXFG', 'SRCSEDNM2', 'SRCFRPNRNM', 'SRCFRTRQNM', 'SRCFRSEDNM', 'SRCCLFRPNR', 'SRCCLFRTRQ', 'SRCCLFRSED', 'OTCFLAG', 'OTDGNDLA', 'OTDGNDLB', 'OTDGNDLC', 'OTDGNDLD', 'OTDGNDLE', 'SUNTINSCV', 'SUNTENCV', 'SUNTNOOPN', 'YEATNDYR', 'YEHMSLYR', 'YESCHFLT', 'YESCHWRK', 'YESCHIMP', 'YESCHINT', 'YETCGJOB', 'YELSTGRD', 'YECIGFRNDOF2', 'YECIGNEXTYR2', 'YESTSCIG', 'YESTSMJ', 'YESTSALC', 'YESTSDNK', 'YEPCHKHW', 'YEPHLPHW', 'YEPCHORE', 'YEPLMTTV', 'YEPLMTSN', 'YEPGDJOB', 'YEPPROUD', 'YEYARGUP', 'YEYFGTSW', 'YEYFGTGP', 'YEYHGUN', 'YEYSELL', 'YEYSTOLE', 'YEYATTAK', 'YEPPKCIG', 'YEPMJEVR', 'YEPMJMO', 'YEPALDLY', 'YEGPKCIG', 'YEGMJEVR', 'YEGMJMO', 'YEGALDLY', 'YEFPKCIG', 'YEFMJEVR', 'YEFMJMO', 'YEFALDLY', 'YETLKNON', 'YETLKPAR', 'YETLKBGF', 'YETLKOTA', 'YETLKSOP', 'YEPRTDNG', 'YEPRBSLV', 'YEVIOPRV', 'YEDGPRGP', 'YESLFHLP', 'YEPRGSTD', 'YESCHACT', 'YECOMACT', 'YEFAIACT', 'YEOTHACT', 'YEDECLAS', 'YEDERGLR', 'YEDESPCL', 'YEPVNTYR', 'YERLGSVC', 'YERLGIMP', 'YERLDCSN', 'YERLFRND', 'YUSUITHK', 'YUCOSUITHK', 'YUSUIPLN', 'YUCOSUIPLN', 'SCHFELT', 'TCHGJOB', 'AVGGRADE', 'STNDSCIG', 'STNDSMJ', 'STNDALC', 'STNDDNK', 'PARCHKHW', 'PARHLPHW', 'PRCHORE2', 'PRLMTTV2', 'PARLMTSN', 'PRGDJOB2', 'PRPROUD2', 'ARGUPAR', 'YOFIGHT2', 'YOGRPFT2', 'YOHGUN2', 'YOSELL2', 'YOSTOLE2', 'YOATTAK2', 'PRPKCIG2', 'PRMJEVR2', 'PRMJMO', 'PRALDLY2', 'YFLPKCG2', 'YFLTMRJ2', 'YFLMJMO', 'YFLADLY2', 'FRDPCIG2', 'FRDMEVR2', 'FRDMJMON', 'FRDADLY2', 'TALKPROB', 'PRTALK3', 'PRBSOLV2', 'PREVIOL2', 'PRVDRGO2', 'GRPCNSL2', 'PREGPGM2', 'YTHACT2', 'DRPRVME3', 'ANYEDUC3', 'RLGATTD', 'RLGIMPT', 'RLGDCSN', 'RLGFRND', 'YUSUITHKYR', 'YUCOSUITHK2', 'YUSUIPLNYR', 'YUCOSUIPLN2', 'YODPREV', 'YODSCEV', 'YOLOSEV', 'YODPDISC', 'YODPLSIN', 'YODSLSIN', 'YOLSI2WK', 'YODPR2WK', 'YOWRHRS', 'YOWRDST', 'YOWRCHR', 'YOWRIMP', 'YODPPROB', 'YOWRPROB', 'YOWRAGE', 'YOWRDEPR', 'YOWRDISC', 'YOWRLSIN', 'YOWRPLSR', 'YOWRELES', 'YOWREMOR', 'YOWRGAIN', 'YOWRGROW', 'YOWRPREG', 'YOWRGNL2', 'YOWRLOSE', 'YOWRDIET', 'YOWRLSL2', 'YOWRSLEP', 'YOWRSMOR', 'YOWRENRG', 'YOWRSLOW', 'YOWRSLNO', 'YOWRJITT', 'YOWRJINO', 'YOWRTHOT', 'YOWRCONC', 'YOWRDCSN', 'YOWRNOGD', 'YOWRWRTH', 'YO_MDEA1', 'YO_MDEA2', 'YO_MDEA3', 'YO_MDEA4', 'YO_MDEA5', 'YO_MDEA6', 'YO_MDEA7', 'YO_MDEA8', 'YODSMMDE', 'YOPBINTF', 'YOPBDLYA', 'YOPBRMBR', 'YOPBAGE', 'YOPBNUM', 'YOPB2WK', 'YOPSHMGT', 'YOPSWORK', 'YOPSRELS', 'YOPSSOC', 'YOPSDAYS', 'YOSEEDOC', 'YOFAMDOC', 'YOOTHDOC', 'YOPSYCH', 'YOPSYMD', 'YOSOCWRK', 'YOCOUNS', 'YOOTHMHP', 'YONURSE', 'YORELIG', 'YOHERBAL', 'YOOTHHLP', 'YOTMTNOW', 'YORX12MO', 'YORXNOW', 'YORXHLP', 'YOTMTHLP', 'YMDELT', 'YMDEYR', 'YMDEAUD5YR', 'YMIUD5YANY', 'YMSUD5YANY', 'YMDERSUD5ANY', 'YMDESUD5ANYO', 'YTXMDEYR', 'YRXMDEYR', 'YMDETXRX', 'YDOCMDE', 'YOMDMDE', 'YPSY1MDE', 'YPSY2MDE', 'YSOCMDE', 'YCOUNMDE', 'YOMHMDE', 'YNURSMDE', 'YRELMDE', 'YHBCHMDE', 'YHLTMDE', 'YALTMDE', 'YMDEHPRX', 'YMDEHPO', 'YMDERXO2', 'YMDEHARX', 'YSDSHOME', 'YSDSWRK', 'YSDSREL', 'YSDSSOC', 'YSDSOVRL', 'MDEIMPY', 'YMDEIMAD5YR', 'YMIMI5YANY', 'YMIMR5YANY', 'YMIMS5YANY', 'CIRROSAGE', 'HEPBCAGE', 'HIVAIDSAG', 'GQTYPE2']
Preview (first 20):
EDUSCKEST 99.915804
EDUSKPEST 99.960118
HLCALLFG 99.953471
HLCALL99 99.953471
BKPOSTOB 100.000000
BKOTHOF2 99.519199
ALTOTFG 99.339729
ALFQFLG 99.295416
ALDYSFG 99.960118
MJFQFLG 99.550218
BLRECFL2 99.915804
BLNT30C1 99.884785
BLNT30C2 99.802805
RSNOMRJ 99.922451
RSNMRJMO 99.898079
CCTOTFG 99.995569
CCFQFLG 99.953471
CRTOTFG 99.995569
CRFQFLG 99.991137
HRTOTFG 99.986706
dtype: float64
# Look at how often these variables are non-missing among people with IRAMDEYR=1
df_99 = df_Removed_Columns.drop(columns=cols_over_99.index)
df_99.shape
(45133, 2347)
Split X and Y
'''Missing Values (NaNs):
-Many variables had missing values due to survey skip patterns or special response codes.
-For numeric variables, missing values were replaced with the median of the column. The median is robust to outliers and preserves the typical value without disproportionately influencing the results.
-For categorical variables, missing values were replaced with the mode (most frequent category), which preserves the dominant response and minimizes distortion.
-This ensures that all columns are usable for tree-based algorithms without introducing extreme bias, especially important for feature importance ranking.
Encoding Categorical Variables:
-Tree-based models like RandomForest cannot directly process string categories.
-We converted categorical variables to numeric codes using LabelEncoder, which assigns each category a unique integer. Importantly, RandomForest treats these codes as discrete categories, not as ordered values, so this transformation does not bias splits or feature importance.
Rationale:
-These steps allow us to use the full set of variables in a first-pass feature importance analysis with RandomForest.
-Alternative models like LightGBM or CatBoost can handle missing values natively, but median/mode imputation provides a simple, consistent approach for exploratory feature ranking.
-No other cleaning (e.g., dropping additional columns) is required for this stage, as we have already removed identifiers, survey weights, timestamps, and variables with extremely high missingness.'''
from sklearn.preprocessing import LabelEncoder
# Separate target
TARGET = "IRAMDEYR"
y = df_99[TARGET]
X = df_99.drop(columns=[TARGET])
# Identify numeric vs categorical
numeric_cols = X.select_dtypes(include=[np.number]).columns.tolist()
categorical_cols = X.select_dtypes(exclude=[np.number]).columns.tolist()
print(f"Numeric columns: {len(numeric_cols)}")
print(f"Categorical columns: {len(categorical_cols)}")
Numeric columns: 2346
Categorical columns: 0
# Impute missing values
# Numeric -> median
for col in numeric_cols:
median_val = X[col].median()
X[col] = X[col].fillna(median_val)
# Categorical -> mode
for col in categorical_cols:
mode_val = X[col].mode()[0]
X[col] = X[col].fillna(mode_val)
# Check if any NaNs remain
print("Any NaNs left in the dataset?", X.isna().any().any())
# Count of NaNs per column (should all be 0)
nan_counts = X.isna().sum()
print(nan_counts[nan_counts > 0]) # this will print nothing if there are no NaNs
total_nans = X.isna().sum().sum()
print("Total NaNs in the dataset:", total_nans)
Any NaNs left in the dataset? False
Series([], dtype: int64)
Total NaNs in the dataset: 0
# Encode categorical variables
le_dict = {} # save encoders in case needed later
for col in categorical_cols:
le = LabelEncoder()
X[col] = le.fit_transform(X[col])
le_dict[col] = le
# Combine X and y for later use
df_prepared = pd.concat([X, y], axis=1)
# Check if any NaNs remain
print("Any NaNs left in the dataset?", y.isna().any().any())
# Count of NaNs per column (should all be 0)
nan_counts = y.isna().sum()
print(nan_counts[nan_counts > 0]) # this will print nothing if there are no NaNs
total_nans = y.isna().sum().sum()
print("Total NaNs in the dataset:", total_nans)
Any NaNs left in the dataset? False
[]
Total NaNs in the dataset: 0
Correlation only works on linear continuous relationships. Our dataset is mostly categorical and ordinal, so Pearson correlation wouldn’t capture any useful signal. That’s why the papers and frameworks use model-based feature selection, like LASSO, ElasticNet, RFE, and Random Forest. Those methods work regardless of whether the relationship is linear or categorical.
# Removing target leakage features that was used to impute / recode the target IRAMDEYR
# IRAMDEYR → AMDEYR → AMDELT → ADSMMDEA → MDE symptom items + fallback items + timing
leakage_vars = [
# Target + imputation
"IRAMDEYR", "IIAMDEYR",
# Pre-imputation past-year MDE
"AMDEYR",
# Lifetime MDE + imputation
"AMDELT", "IRAMDELT", "IIAMDELT",
# MDE with impairment (logically dependent on AMDEYR)
"AMDEIMP", "IRAMDEIMP", "IIAMDEIMP",
# DSM summary
"ADSMMDEA",
# Raw DSM symptom items (if present)
"D_MDEA1", "D_MDEA2", "D_MDEA3", "D_MDEA4", "D_MDEA5",
"D_MDEA6", "D_MDEA7", "D_MDEA8", "D_MDEA9",
# Symptom recodes (one-digit suffixes, if present)
"AD_MDEA1", "AD_MDEA2", "AD_MDEA3", "AD_MDEA4",
"AD_MDEA5", "AD_MDEA6", "AD_MDEA7", "AD_MDEA8",
# Symptom recodes (true DSM flags: two-digit suffix)
"AD_MDEA11", "AD_MDEA21", "AD_MDEA31", "AD_MDEA41",
"AD_MDEA51", "AD_MDEA61", "AD_MDEA71", "AD_MDEA81", "AD_MDEA91",
# Fallback/gating items
"ADPB2WK",
"ADDPREV", "ADDSCEV", "ADLOSEV", "ADLSI2WK", "ADDPR2WK",
"ADWRHRS", "ADWRDST", "ADWRCHR", "ADWRIMP", "ADDPPROB",
# Soft-leakage variables
"ARXMDEYR", # received prescription meds
"ATXMDEYR", # received counseling/therapy
"AHLTMDE", # told by provider they have depression
]
print(leakage_vars)
len(leakage_vars)
['IRAMDEYR', 'IIAMDEYR', 'AMDEYR', 'AMDELT', 'IRAMDELT', 'IIAMDELT', 'AMDEIMP', 'IRAMDEIMP', 'IIAMDEIMP', 'ADSMMDEA', 'D_MDEA1', 'D_MDEA2', 'D_MDEA3', 'D_MDEA4', 'D_MDEA5', 'D_MDEA6', 'D_MDEA7', 'D_MDEA8', 'D_MDEA9', 'AD_MDEA1', 'AD_MDEA2', 'AD_MDEA3', 'AD_MDEA4', 'AD_MDEA5', 'AD_MDEA6', 'AD_MDEA7', 'AD_MDEA8', 'AD_MDEA11', 'AD_MDEA21', 'AD_MDEA31', 'AD_MDEA41', 'AD_MDEA51', 'AD_MDEA61', 'AD_MDEA71', 'AD_MDEA81', 'AD_MDEA91', 'ADPB2WK', 'ADDPREV', 'ADDSCEV', 'ADLOSEV', 'ADLSI2WK', 'ADDPR2WK', 'ADWRHRS', 'ADWRDST', 'ADWRCHR', 'ADWRIMP', 'ADDPPROB', 'ARXMDEYR', 'ATXMDEYR', 'AHLTMDE']
50
X_no_leakage = X.drop(columns=[col for col in leakage_vars if col != 'IRAMDEYR'], errors='ignore')
X_no_leakage.shape
(45133, 2315)
# Initialize the model
from sklearn.ensemble import RandomForestClassifier
rf_model_no_leakage = RandomForestClassifier(
n_estimators=500,
max_depth=None,
random_state=42,
n_jobs=-1
)
rf_model_no_leakage.fit(X_no_leakage, y)
RandomForestClassifier(n_estimators=500, n_jobs=-1, random_state=42)In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
<div class="estimator-table">
<details>
<summary>Parameters</summary>
<table class="parameters-table">
<tbody>
<tr class="user-set">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('n_estimators',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">n_estimators </td>
<td class="value">500</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('criterion',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">criterion </td>
<td class="value">'gini'</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('max_depth',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">max_depth </td>
<td class="value">None</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('min_samples_split',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">min_samples_split </td>
<td class="value">2</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('min_samples_leaf',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">min_samples_leaf </td>
<td class="value">1</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('min_weight_fraction_leaf',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">min_weight_fraction_leaf </td>
<td class="value">0.0</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('max_features',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">max_features </td>
<td class="value">'sqrt'</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('max_leaf_nodes',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">max_leaf_nodes </td>
<td class="value">None</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('min_impurity_decrease',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">min_impurity_decrease </td>
<td class="value">0.0</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('bootstrap',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">bootstrap </td>
<td class="value">True</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('oob_score',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">oob_score </td>
<td class="value">False</td>
</tr>
<tr class="user-set">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('n_jobs',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">n_jobs </td>
<td class="value">-1</td>
</tr>
<tr class="user-set">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('random_state',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">random_state </td>
<td class="value">42</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('verbose',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">verbose </td>
<td class="value">0</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('warm_start',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">warm_start </td>
<td class="value">False</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('class_weight',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">class_weight </td>
<td class="value">None</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('ccp_alpha',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">ccp_alpha </td>
<td class="value">0.0</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('max_samples',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">max_samples </td>
<td class="value">None</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('monotonic_cst',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">monotonic_cst </td>
<td class="value">None</td>
</tr>
</tbody>
</table>
</details>
</div>
</div></div></div></div></div><script>function copyToClipboard(text, element) {
// Get the parameter prefix from the closest toggleable content
const toggleableContent = element.closest('.sk-toggleable__content');
const paramPrefix = toggleableContent ? toggleableContent.dataset.paramPrefix : '';
const fullParamName = paramPrefix ? `${paramPrefix}${text}` : text;
const originalStyle = element.style;
const computedStyle = window.getComputedStyle(element);
const originalWidth = computedStyle.width;
const originalHTML = element.innerHTML.replace('Copied!', '');
navigator.clipboard.writeText(fullParamName)
.then(() => {
element.style.width = originalWidth;
element.style.color = 'green';
element.innerHTML = "Copied!";
setTimeout(() => {
element.innerHTML = originalHTML;
element.style = originalStyle;
}, 2000);
})
.catch(err => {
console.error('Failed to copy:', err);
element.style.color = 'red';
element.innerHTML = "Failed!";
setTimeout(() => {
element.innerHTML = originalHTML;
element.style = originalStyle;
}, 2000);
});
return false;
}
document.querySelectorAll('.fa-regular.fa-copy').forEach(function(element) {
const toggleableContent = element.closest('.sk-toggleable__content');
const paramPrefix = toggleableContent ? toggleableContent.dataset.paramPrefix : '';
const paramName = element.parentElement.nextElementSibling.textContent.trim();
const fullParamName = paramPrefix ? ${paramPrefix}${paramName} : paramName;
element.setAttribute('title', fullParamName);
});
Tiering with Tier 0 + Tiers 1–4
'''Tier 0 serves a different role from Tiers 1-4.
Tier 0 is a master candidate list for Phase 2 (clinical review), so a simple “top 100 ranked features” is the right method.
Tiers 1-4 are performance-driven tiers that reflect different cumulative contributions of importance. They should remain based on percentage thresholds, because that structure tests how predictive performance scales with increasingly informative subsets.
So Tier 0 = fixed-K;
Tiers 1-4 = cumulative importance thresholds.'''
# Get feature importances from your Random Forest model (already leakage-clean)
importances = pd.Series(
rf_model_no_leakage.feature_importances_,
index=X_no_leakage.columns
).sort_values(ascending=False)
# Tier 0: Top-100 features
K = 100
tier0 = importances.head(K).index.tolist()
# Tiers 1–4 (cumulative thresholds)
cumulative_importance = importances.cumsum()
tier1 = importances[cumulative_importance <= 0.25].index.tolist()
tier2 = importances[cumulative_importance <= 0.45].index.tolist()
tier3 = importances[cumulative_importance <= 0.60].index.tolist()
tier4 = importances[cumulative_importance <= 0.70].index.tolist()
# Combine into dictionary
tiers = {
'Tier 0 (Top 100)': tier0,
'Tier 1': tier1,
'Tier 2': tier2,
'Tier 3': tier3,
'Tier 4': tier4
}
# Print tier sizes
print("Feature counts per tier:")
for tier_name, features in tiers.items():
print(f"{tier_name}: {len(features)} features")
# Plot feature importances for each tier
for tier_name, features in tiers.items():
if len(features) == 0:
continue
plt.figure(figsize=(10, 12))
sns.barplot(
x=importances[features].values,
y=importances[features].index,
palette='viridis'
)
plt.title(f"{tier_name} Feature Importances ({len(features)} features)")
plt.xlabel("Importance")
plt.ylabel("Feature")
plt.tight_layout()
plt.show()
Feature counts per tier:
Tier 0 (Top 100): 100 features
Tier 1: 5 features
Tier 2: 12 features
Tier 3: 29 features
Tier 4: 52 features
C:\Users\agila\AppData\Local\Temp\ipykernel_16380\2494543076.py:47: FutureWarning:
Passing `palette` without assigning `hue` is deprecated and will be removed in v0.14.0. Assign the `y` variable to `hue` and set `legend=False` for the same effect.
sns.barplot(

C:\Users\agila\AppData\Local\Temp\ipykernel_16380\2494543076.py:47: FutureWarning:
Passing `palette` without assigning `hue` is deprecated and will be removed in v0.14.0. Assign the `y` variable to `hue` and set `legend=False` for the same effect.
sns.barplot(

C:\Users\agila\AppData\Local\Temp\ipykernel_16380\2494543076.py:47: FutureWarning:
Passing `palette` without assigning `hue` is deprecated and will be removed in v0.14.0. Assign the `y` variable to `hue` and set `legend=False` for the same effect.
sns.barplot(

C:\Users\agila\AppData\Local\Temp\ipykernel_16380\2494543076.py:47: FutureWarning:
Passing `palette` without assigning `hue` is deprecated and will be removed in v0.14.0. Assign the `y` variable to `hue` and set `legend=False` for the same effect.
sns.barplot(

C:\Users\agila\AppData\Local\Temp\ipykernel_16380\2494543076.py:47: FutureWarning:
Passing `palette` without assigning `hue` is deprecated and will be removed in v0.14.0. Assign the `y` variable to `hue` and set `legend=False` for the same effect.
sns.barplot(

Re-Cleaning Dataset for Logistics Regression
df_dropped_targetNaN.shape
(45133, 2636)
drop_patterns_with_weight = [
r'^CASEID', r'^QUESTID', r'^PANEL', r'^VERSION',
r'^INTV', r'^FILE', r'^YEAR',
r'^WT(?!ANSWER$)(?!POUND.*$)', # Drop WT* except WTANSWER/WTPOUND (we drop separately)
r'WGT', r'WEIGHT',
r'^VESTR', r'^VEREP', r'^ESTRAT',
r'_A$', r'_E$', r'_ORIG$', r'_R$', r'_RC$'
]
# Identify columns to drop without weight variable
cols_to_drop_with_weight = [
c for c in df_dropped_targetNaN.columns
if any(re.search(pat, c) for pat in drop_patterns_with_weight)
]
# Never drop ANALWT2_C
cols_to_drop_with_weight = [c for c in cols_to_drop if c != "ANALWT2_C"]
# Also manually drop these (we know they must go)
manual_dropping = [
"WTANSWER", "WTPOUND2", # useless weight components
"QUESTID2", "FILEDATE",
"VESTR_C", "VEREP" # replicate/strata → DROP
]
cols_to_drop_with_weight += manual_dropping
# Remove duplicates
cols_to_drop_with_weight = list(set(cols_to_drop_with_weight))
len(cols_to_drop_with_weight), cols_to_drop_with_weight[:30]
(6, ['WTPOUND2', 'FILEDATE', 'VESTR_C', 'QUESTID2', 'WTANSWER', 'VEREP'])
df_removed_columns_LR = df_dropped_targetNaN.drop(columns=cols_to_drop_with_weight, errors='ignore')
# Checking to see which variables have >99% missingess
na_percent = df_removed_columns_LR.isna().mean() * 100
# Filter columns with > 99% missing
cols_over_99_LR = na_percent[na_percent > 99]
# Print count
print("Number of variables with >99% missingness:", len(cols_over_99_LR))
# Print the variable names
print("\nVariables with >99% missingness:")
print(cols_over_99_LR.index.tolist())
# Optional: show top 20 instead of the whole list
print("\nPreview (first 20):")
print(cols_over_99_LR.head(300))
Number of variables with >99% missingness: 282
Variables with >99% missingness:
['EDUSCKEST', 'EDUSKPEST', 'HLCALLFG', 'HLCALL99', 'BKPOSTOB', 'BKOTHOF2', 'ALTOTFG', 'ALFQFLG', 'ALDYSFG', 'MJFQFLG', 'BLRECFL2', 'BLNT30C1', 'BLNT30C2', 'RSNOMRJ', 'RSNMRJMO', 'CCTOTFG', 'CCFQFLG', 'CRTOTFG', 'CRFQFLG', 'HRTOTFG', 'HRFQFLG', 'HALTOTFG', 'HALFQFLG', 'INHTOTFG', 'INHFQFLG', 'METOTFG', 'MEFQFLG', 'PNRNORXFG', 'TRQNORXFG', 'STMNORXFG', 'SEDNORXFG', 'SRCSEDNM2', 'SRCFRPNRNM', 'SRCFRTRQNM', 'SRCFRSEDNM', 'SRCCLFRPNR', 'SRCCLFRTRQ', 'SRCCLFRSED', 'OTCFLAG', 'OTDGNDLA', 'OTDGNDLB', 'OTDGNDLC', 'OTDGNDLD', 'OTDGNDLE', 'SUNTINSCV', 'SUNTENCV', 'SUNTNOOPN', 'YEATNDYR', 'YEHMSLYR', 'YESCHFLT', 'YESCHWRK', 'YESCHIMP', 'YESCHINT', 'YETCGJOB', 'YELSTGRD', 'YECIGFRNDOF2', 'YECIGNEXTYR2', 'YESTSCIG', 'YESTSMJ', 'YESTSALC', 'YESTSDNK', 'YEPCHKHW', 'YEPHLPHW', 'YEPCHORE', 'YEPLMTTV', 'YEPLMTSN', 'YEPGDJOB', 'YEPPROUD', 'YEYARGUP', 'YEYFGTSW', 'YEYFGTGP', 'YEYHGUN', 'YEYSELL', 'YEYSTOLE', 'YEYATTAK', 'YEPPKCIG', 'YEPMJEVR', 'YEPMJMO', 'YEPALDLY', 'YEGPKCIG', 'YEGMJEVR', 'YEGMJMO', 'YEGALDLY', 'YEFPKCIG', 'YEFMJEVR', 'YEFMJMO', 'YEFALDLY', 'YETLKNON', 'YETLKPAR', 'YETLKBGF', 'YETLKOTA', 'YETLKSOP', 'YEPRTDNG', 'YEPRBSLV', 'YEVIOPRV', 'YEDGPRGP', 'YESLFHLP', 'YEPRGSTD', 'YESCHACT', 'YECOMACT', 'YEFAIACT', 'YEOTHACT', 'YEDECLAS', 'YEDERGLR', 'YEDESPCL', 'YEPVNTYR', 'YERLGSVC', 'YERLGIMP', 'YERLDCSN', 'YERLFRND', 'YUSUITHK', 'YUCOSUITHK', 'YUSUIPLN', 'YUCOSUIPLN', 'SCHFELT', 'TCHGJOB', 'AVGGRADE', 'STNDSCIG', 'STNDSMJ', 'STNDALC', 'STNDDNK', 'PARCHKHW', 'PARHLPHW', 'PRCHORE2', 'PRLMTTV2', 'PARLMTSN', 'PRGDJOB2', 'PRPROUD2', 'ARGUPAR', 'YOFIGHT2', 'YOGRPFT2', 'YOHGUN2', 'YOSELL2', 'YOSTOLE2', 'YOATTAK2', 'PRPKCIG2', 'PRMJEVR2', 'PRMJMO', 'PRALDLY2', 'YFLPKCG2', 'YFLTMRJ2', 'YFLMJMO', 'YFLADLY2', 'FRDPCIG2', 'FRDMEVR2', 'FRDMJMON', 'FRDADLY2', 'TALKPROB', 'PRTALK3', 'PRBSOLV2', 'PREVIOL2', 'PRVDRGO2', 'GRPCNSL2', 'PREGPGM2', 'YTHACT2', 'DRPRVME3', 'ANYEDUC3', 'RLGATTD', 'RLGIMPT', 'RLGDCSN', 'RLGFRND', 'YUSUITHKYR', 'YUCOSUITHK2', 'YUSUIPLNYR', 'YUCOSUIPLN2', 'YODPREV', 'YODSCEV', 'YOLOSEV', 'YODPDISC', 'YODPLSIN', 'YODSLSIN', 'YOLSI2WK', 'YODPR2WK', 'YOWRHRS', 'YOWRDST', 'YOWRCHR', 'YOWRIMP', 'YODPPROB', 'YOWRPROB', 'YOWRAGE', 'YOWRDEPR', 'YOWRDISC', 'YOWRLSIN', 'YOWRPLSR', 'YOWRELES', 'YOWREMOR', 'YOWRGAIN', 'YOWRGROW', 'YOWRPREG', 'YOWRGNL2', 'YOWRLOSE', 'YOWRDIET', 'YOWRLSL2', 'YOWRSLEP', 'YOWRSMOR', 'YOWRENRG', 'YOWRSLOW', 'YOWRSLNO', 'YOWRJITT', 'YOWRJINO', 'YOWRTHOT', 'YOWRCONC', 'YOWRDCSN', 'YOWRNOGD', 'YOWRWRTH', 'YO_MDEA1', 'YO_MDEA2', 'YO_MDEA3', 'YO_MDEA4', 'YO_MDEA5', 'YO_MDEA6', 'YO_MDEA7', 'YO_MDEA8', 'YODSMMDE', 'YOPBINTF', 'YOPBDLYA', 'YOPBRMBR', 'YOPBAGE', 'YOPBNUM', 'YOPB2WK', 'YOPSHMGT', 'YOPSWORK', 'YOPSRELS', 'YOPSSOC', 'YOPSDAYS', 'YOSEEDOC', 'YOFAMDOC', 'YOOTHDOC', 'YOPSYCH', 'YOPSYMD', 'YOSOCWRK', 'YOCOUNS', 'YOOTHMHP', 'YONURSE', 'YORELIG', 'YOHERBAL', 'YOOTHHLP', 'YOTMTNOW', 'YORX12MO', 'YORXNOW', 'YORXHLP', 'YOTMTHLP', 'YMDELT', 'YMDEYR', 'YMDEAUD5YR', 'YMIUD5YANY', 'YMSUD5YANY', 'YMDERSUD5ANY', 'YMDESUD5ANYO', 'YTXMDEYR', 'YRXMDEYR', 'YMDETXRX', 'YDOCMDE', 'YOMDMDE', 'YPSY1MDE', 'YPSY2MDE', 'YSOCMDE', 'YCOUNMDE', 'YOMHMDE', 'YNURSMDE', 'YRELMDE', 'YHBCHMDE', 'YHLTMDE', 'YALTMDE', 'YMDEHPRX', 'YMDEHPO', 'YMDERXO2', 'YMDEHARX', 'YSDSHOME', 'YSDSWRK', 'YSDSREL', 'YSDSSOC', 'YSDSOVRL', 'MDEIMPY', 'YMDEIMAD5YR', 'YMIMI5YANY', 'YMIMR5YANY', 'YMIMS5YANY', 'CIRROSAGE', 'HEPBCAGE', 'HIVAIDSAG', 'GQTYPE2']
Preview (first 20):
EDUSCKEST 99.915804
EDUSKPEST 99.960118
HLCALLFG 99.953471
HLCALL99 99.953471
BKPOSTOB 100.000000
...
YMIMS5YANY 100.000000
CIRROSAGE 99.725256
HEPBCAGE 99.058339
HIVAIDSAG 99.736335
GQTYPE2 99.811668
Length: 282, dtype: float64
df_99_LR = df_removed_columns_LR.drop(columns=cols_over_99_LR.index)
df_99_LR.shape
(45133, 2348)
# Remove target leakage features that was used to impute / recode the target IRAMDEYR
leakage_vars_LR = [
# Target + imputation
#"IRAMDEYR", "IIAMDEYR",
"IIAMDEYR",
# Pre-imputation past-year MDE
"AMDEYR",
# Lifetime MDE + imputation
"AMDELT", "IRAMDELT", "IIAMDELT",
# MDE with impairment (logically dependent on AMDEYR)
"AMDEIMP", "IRAMDEIMP", "IIAMDEIMP",
# DSM summary
"ADSMMDEA",
# Raw DSM symptom items (if present)
"D_MDEA1", "D_MDEA2", "D_MDEA3", "D_MDEA4", "D_MDEA5",
"D_MDEA6", "D_MDEA7", "D_MDEA8", "D_MDEA9",
# Symptom recodes (one-digit suffixes, if present)
"AD_MDEA1", "AD_MDEA2", "AD_MDEA3", "AD_MDEA4",
"AD_MDEA5", "AD_MDEA6", "AD_MDEA7", "AD_MDEA8",
# Symptom recodes (true DSM flags: two-digit suffix)
"AD_MDEA11", "AD_MDEA21", "AD_MDEA31", "AD_MDEA41",
"AD_MDEA51", "AD_MDEA61", "AD_MDEA71", "AD_MDEA81", "AD_MDEA91",
# Fallback/gating items
"ADPB2WK",
"ADDPREV", "ADDSCEV", "ADLOSEV", "ADLSI2WK", "ADDPR2WK",
"ADWRHRS", "ADWRDST", "ADWRCHR", "ADWRIMP", "ADDPPROB",
# Soft-leakage variables
"ARXMDEYR", # received prescription meds
"ATXMDEYR", # received counseling/therapy
"AHLTMDE", # told by provider they have depression
]
# Drop only those leakage columns that actually exist in this PUF
cols_to_drop_leakaging = [c for c in leakage_vars_LR if c in df_99_LR.columns]
df_no_leakage_LR = df_99_LR.drop(columns=cols_to_drop_leakaging)
print("Dropped leakage columns:", cols_to_drop_leakaging)
Dropped leakage columns: ['IIAMDEYR', 'AMDEYR', 'AMDELT', 'IRAMDELT', 'IIAMDELT', 'AMDEIMP', 'IRAMDEIMP', 'IIAMDEIMP', 'ADSMMDEA', 'AD_MDEA1', 'AD_MDEA2', 'AD_MDEA3', 'AD_MDEA4', 'AD_MDEA5', 'AD_MDEA6', 'AD_MDEA7', 'AD_MDEA8', 'ADPB2WK', 'ADDPREV', 'ADDSCEV', 'ADLOSEV', 'ADLSI2WK', 'ADDPR2WK', 'ADWRHRS', 'ADWRDST', 'ADWRCHR', 'ADWRIMP', 'ADDPPROB', 'ARXMDEYR', 'ATXMDEYR', 'AHLTMDE']
df_no_leakage_LR.shape
(45133, 2317)
Define Target, Weight, and Base Feature Matrix
# Columns
TARGET_COL = "IRAMDEYR"
WEIGHT_COL = "ANALWT2_C"
# Sanity checks
assert TARGET_COL in df_no_leakage_LR.columns, "IRAMDEYR not found in df_no_leakage_LR"
assert WEIGHT_COL in df_no_leakage_LR.columns, "ANALWT2_C not found in df_no_leakage_LR"
# Raw target and weights
y_raw = df_no_leakage_LR[TARGET_COL]
w = df_no_leakage_LR[WEIGHT_COL]
# Binary-encode target: 1 = past-year MDE, 0 = no MDE
y = (y_raw == 1).astype(int)
# Features = all columns except target + weight
X = df_no_leakage_LR.drop(columns=[TARGET_COL, WEIGHT_COL], errors='ignore')
print("Base X shape:", X.shape)
print("y positive rate:", y.mean().round(3))
Base X shape: (45133, 2315)
y positive rate: 0.115
Tier 1 and Subset Features
# Tier to evaluate
tier_name = "Tier 1"
tier_features = tiers[tier_name]
# Keep only those columns in X
X_tier = X[tier_features].copy()
print(f"{tier_name}: {len(tier_features)} features")
print("X_tier shape:", X_tier.shape)
Tier 1: 5 features
X_tier shape: (45133, 5)
Train / Test Split (with Weights Kept Aligned)
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test, w_train, w_test = train_test_split(
X_tier, y, w,
test_size=0.2,
random_state=42,
stratify=y
)
print("Train shape:", X_train.shape, y_train.shape)
print("Test shape:", X_test.shape, y_test.shape)
Train shape: (36106, 5) (36106,)
Test shape: (9027, 5) (9027,)
Define Preprocessing (Median Impute + Scaling for Numeric)
'''Here we define numeric vs categorical and build a ColumnTransformer that:
-Imputes numeric with median, scales with StandardScaler
-Imputes categorical with mode, encodes with OneHotEncoder'''
'''Why median is better for NSDUH over mean imputation as NSDUH variables are mostly:
-Ordinal categories (0,1,2,3)
-Non-normal/long-tailed distributions
-Heavily skewed (e.g., hours, days, counts)
-Integer-coded survey recodes
-Mean imputation pushes values toward the wrong part of the distribution
-Median imputation better reflects the actual underlying "typical" respondent
-Median preserves the ordinal structure
-This prevents distortion of the predictors and reduces bias in logistic regression.'''
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
# All numeric
numeric_cols = X_train.columns.tolist()
categorical_cols = []
numeric_transformer = Pipeline(steps=[
('imputer', SimpleImputer(strategy='median')),
('scaler', StandardScaler())
])
preprocessor = ColumnTransformer(
transformers=[
('num', numeric_transformer, numeric_cols)
],
remainder='drop'
)
Building Logistic Regression Pipeline + CV Hyperparameter Tuning
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, GridSearchCV
log_reg_pipe = Pipeline(steps=[
('preprocess', preprocessor),
('model', LogisticRegression(
max_iter=2000,
solver='liblinear'
))
])
param_grid = {
'model__C': [0.01, 0.1, 1.0, 10.0],
'model__class_weight': [None, 'balanced']
}
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
grid = GridSearchCV(
estimator=log_reg_pipe,
param_grid=param_grid,
cv=cv,
scoring='roc_auc',
n_jobs=1,
verbose=1
)
grid.fit(X_train, y_train)
print("Best params:", grid.best_params_)
print("Best CV ROC AUC:", grid.best_score_)
Fitting 5 folds for each of 8 candidates, totalling 40 fits
Best params: {'model__C': 1.0, 'model__class_weight': None}
Best CV ROC AUC: 0.8406564536348655
Refit best model on full training set with survey weights
# Refit best estimator WITH survey weights (correct way)
best_log_reg = grid.best_estimator_
best_log_reg.fit(
X_train,
y_train,
model__sample_weight=w_train
)
Pipeline(steps=[('preprocess',
ColumnTransformer(transformers=[('num',
Pipeline(steps=[('imputer',
SimpleImputer(strategy='median')),
('scaler',
StandardScaler())]),
['ADPSDAYS', 'ASDSSOC2',
'ADPSHMGT', 'ADPSRELS',
'ADPSWORK'])])),
('model',
LogisticRegression(max_iter=2000, solver='liblinear'))])</pre><b>In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. <br />On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.</b></div><div class="sk-container" hidden><div class="sk-item sk-dashed-wrapped"><div class="sk-label-container"><div class="sk-label fitted sk-toggleable"><input class="sk-toggleable__control sk-hidden--visually" id="sk-estimator-id-4" type="checkbox" ><label for="sk-estimator-id-4" class="sk-toggleable__label fitted sk-toggleable__label-arrow"><div><div>Pipeline</div></div><div><a class="sk-estimator-doc-link fitted" rel="noreferrer" target="_blank" href="https://scikit-learn.org/1.7/modules/generated/sklearn.pipeline.Pipeline.html">?<span>Documentation for Pipeline</span></a><span class="sk-estimator-doc-link fitted">i<span>Fitted</span></span></div></label><div class="sk-toggleable__content fitted" data-param-prefix="">
<div class="estimator-table">
<details>
<summary>Parameters</summary>
<table class="parameters-table">
<tbody>
<tr class="user-set">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('steps',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">steps </td>
<td class="value">[('preprocess', ...), ('model', ...)]</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('transform_input',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">transform_input </td>
<td class="value">None</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('memory',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">memory </td>
<td class="value">None</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('verbose',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">verbose </td>
<td class="value">False</td>
</tr>
</tbody>
</table>
</details>
</div>
</div></div></div><div class="sk-serial"><div class="sk-item sk-dashed-wrapped"><div class="sk-label-container"><div class="sk-label fitted sk-toggleable"><input class="sk-toggleable__control sk-hidden--visually" id="sk-estimator-id-5" type="checkbox" ><label for="sk-estimator-id-5" class="sk-toggleable__label fitted sk-toggleable__label-arrow"><div><div>preprocess: ColumnTransformer</div></div><div><a class="sk-estimator-doc-link fitted" rel="noreferrer" target="_blank" href="https://scikit-learn.org/1.7/modules/generated/sklearn.compose.ColumnTransformer.html">?<span>Documentation for preprocess: ColumnTransformer</span></a></div></label><div class="sk-toggleable__content fitted" data-param-prefix="preprocess__">
<div class="estimator-table">
<details>
<summary>Parameters</summary>
<table class="parameters-table">
<tbody>
<tr class="user-set">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('transformers',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">transformers </td>
<td class="value">[('num', ...)]</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('remainder',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">remainder </td>
<td class="value">'drop'</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('sparse_threshold',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">sparse_threshold </td>
<td class="value">0.3</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('n_jobs',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">n_jobs </td>
<td class="value">None</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('transformer_weights',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">transformer_weights </td>
<td class="value">None</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('verbose',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">verbose </td>
<td class="value">False</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('verbose_feature_names_out',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">verbose_feature_names_out </td>
<td class="value">True</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('force_int_remainder_cols',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">force_int_remainder_cols </td>
<td class="value">'deprecated'</td>
</tr>
</tbody>
</table>
</details>
</div>
</div></div></div><div class="sk-parallel"><div class="sk-parallel-item"><div class="sk-item"><div class="sk-label-container"><div class="sk-label fitted sk-toggleable"><input class="sk-toggleable__control sk-hidden--visually" id="sk-estimator-id-6" type="checkbox" ><label for="sk-estimator-id-6" class="sk-toggleable__label fitted sk-toggleable__label-arrow"><div><div>num</div></div></label><div class="sk-toggleable__content fitted" data-param-prefix="preprocess__num__"><pre>['ADPSDAYS', 'ASDSSOC2', 'ADPSHMGT', 'ADPSRELS', 'ADPSWORK']</pre></div></div></div><div class="sk-serial"><div class="sk-item"><div class="sk-serial"><div class="sk-item"><div class="sk-estimator fitted sk-toggleable"><input class="sk-toggleable__control sk-hidden--visually" id="sk-estimator-id-7" type="checkbox" ><label for="sk-estimator-id-7" class="sk-toggleable__label fitted sk-toggleable__label-arrow"><div><div>SimpleImputer</div></div><div><a class="sk-estimator-doc-link fitted" rel="noreferrer" target="_blank" href="https://scikit-learn.org/1.7/modules/generated/sklearn.impute.SimpleImputer.html">?<span>Documentation for SimpleImputer</span></a></div></label><div class="sk-toggleable__content fitted" data-param-prefix="preprocess__num__imputer__">
<div class="estimator-table">
<details>
<summary>Parameters</summary>
<table class="parameters-table">
<tbody>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('missing_values',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">missing_values </td>
<td class="value">nan</td>
</tr>
<tr class="user-set">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('strategy',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">strategy </td>
<td class="value">'median'</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('fill_value',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">fill_value </td>
<td class="value">None</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('copy',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">copy </td>
<td class="value">True</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('add_indicator',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">add_indicator </td>
<td class="value">False</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('keep_empty_features',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">keep_empty_features </td>
<td class="value">False</td>
</tr>
</tbody>
</table>
</details>
</div>
</div></div></div><div class="sk-item"><div class="sk-estimator fitted sk-toggleable"><input class="sk-toggleable__control sk-hidden--visually" id="sk-estimator-id-8" type="checkbox" ><label for="sk-estimator-id-8" class="sk-toggleable__label fitted sk-toggleable__label-arrow"><div><div>StandardScaler</div></div><div><a class="sk-estimator-doc-link fitted" rel="noreferrer" target="_blank" href="https://scikit-learn.org/1.7/modules/generated/sklearn.preprocessing.StandardScaler.html">?<span>Documentation for StandardScaler</span></a></div></label><div class="sk-toggleable__content fitted" data-param-prefix="preprocess__num__scaler__">
<div class="estimator-table">
<details>
<summary>Parameters</summary>
<table class="parameters-table">
<tbody>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('copy',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">copy </td>
<td class="value">True</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('with_mean',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">with_mean </td>
<td class="value">True</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('with_std',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">with_std </td>
<td class="value">True</td>
</tr>
</tbody>
</table>
</details>
</div>
</div></div></div></div></div></div></div></div></div></div><div class="sk-item"><div class="sk-estimator fitted sk-toggleable"><input class="sk-toggleable__control sk-hidden--visually" id="sk-estimator-id-9" type="checkbox" ><label for="sk-estimator-id-9" class="sk-toggleable__label fitted sk-toggleable__label-arrow"><div><div>LogisticRegression</div></div><div><a class="sk-estimator-doc-link fitted" rel="noreferrer" target="_blank" href="https://scikit-learn.org/1.7/modules/generated/sklearn.linear_model.LogisticRegression.html">?<span>Documentation for LogisticRegression</span></a></div></label><div class="sk-toggleable__content fitted" data-param-prefix="model__">
<div class="estimator-table">
<details>
<summary>Parameters</summary>
<table class="parameters-table">
<tbody>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('penalty',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">penalty </td>
<td class="value">'l2'</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('dual',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">dual </td>
<td class="value">False</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('tol',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">tol </td>
<td class="value">0.0001</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('C',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">C </td>
<td class="value">1.0</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('fit_intercept',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">fit_intercept </td>
<td class="value">True</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('intercept_scaling',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">intercept_scaling </td>
<td class="value">1</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('class_weight',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">class_weight </td>
<td class="value">None</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('random_state',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">random_state </td>
<td class="value">None</td>
</tr>
<tr class="user-set">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('solver',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">solver </td>
<td class="value">'liblinear'</td>
</tr>
<tr class="user-set">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('max_iter',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">max_iter </td>
<td class="value">2000</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('multi_class',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">multi_class </td>
<td class="value">'deprecated'</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('verbose',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">verbose </td>
<td class="value">0</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('warm_start',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">warm_start </td>
<td class="value">False</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('n_jobs',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">n_jobs </td>
<td class="value">None</td>
</tr>
<tr class="default">
<td><i class="copy-paste-icon"
onclick="copyToClipboard('l1_ratio',
this.parentElement.nextElementSibling)"
></i></td>
<td class="param">l1_ratio </td>
<td class="value">None</td>
</tr>
</tbody>
</table>
</details>
</div>
</div></div></div></div></div></div></div><script>function copyToClipboard(text, element) {
// Get the parameter prefix from the closest toggleable content
const toggleableContent = element.closest('.sk-toggleable__content');
const paramPrefix = toggleableContent ? toggleableContent.dataset.paramPrefix : '';
const fullParamName = paramPrefix ? `${paramPrefix}${text}` : text;
const originalStyle = element.style;
const computedStyle = window.getComputedStyle(element);
const originalWidth = computedStyle.width;
const originalHTML = element.innerHTML.replace('Copied!', '');
navigator.clipboard.writeText(fullParamName)
.then(() => {
element.style.width = originalWidth;
element.style.color = 'green';
element.innerHTML = "Copied!";
setTimeout(() => {
element.innerHTML = originalHTML;
element.style = originalStyle;
}, 2000);
})
.catch(err => {
console.error('Failed to copy:', err);
element.style.color = 'red';
element.innerHTML = "Failed!";
setTimeout(() => {
element.innerHTML = originalHTML;
element.style = originalStyle;
}, 2000);
});
return false;
}
document.querySelectorAll('.fa-regular.fa-copy').forEach(function(element) {
const toggleableContent = element.closest('.sk-toggleable__content');
const paramPrefix = toggleableContent ? toggleableContent.dataset.paramPrefix : '';
const paramName = element.parentElement.nextElementSibling.textContent.trim();
const fullParamName = paramPrefix ? ${paramPrefix}${paramName} : paramName;
element.setAttribute('title', fullParamName);
});
Predicting on test set (Probabilities + Class Labels)
# Probability for class 1 (MDE)
y_proba = best_log_reg.predict_proba(X_test)[:, 1]
# Hard labels using 0.5 threshold
y_pred = (y_proba >= 0.5).astype(int)
Compute Weighted Metrics: Accuracy, Sensitivity, Specificity, PPV, NPV, F1, F2, ROC AUC, PR AUC
from sklearn.metrics import (
accuracy_score,
recall_score,
precision_score,
f1_score,
fbeta_score,
roc_auc_score,
precision_recall_curve,
auc,
confusion_matrix
)
# Confusion matrix with weights
tn, fp, fn, tp = confusion_matrix(
y_test, y_pred,
sample_weight=w_test
).ravel()
# Basic metrics
accuracy = accuracy_score(y_test, y_pred, sample_weight=w_test)
sensitivity = recall_score(y_test, y_pred, sample_weight=w_test) # TPR / recall
specificity = tn / (tn + fp) if (tn + fp) > 0 else np.nan
ppv = precision_score(y_test, y_pred, sample_weight=w_test) # Positive Predictive Value
npv = tn / (tn + fn) if (tn + fn) > 0 else np.nan # Negative Predictive Value
f1 = f1_score(y_test, y_pred, sample_weight=w_test)
f2 = fbeta_score(y_test, y_pred, beta=2, sample_weight=w_test)
# ROC AUC (weighted)
roc_auc = roc_auc_score(y_test, y_proba, sample_weight=w_test)
# PR AUC (via precision-recall curve)
precisions, recalls, _ = precision_recall_curve(
y_test, y_proba,
sample_weight=w_test
)
pr_auc = auc(recalls, precisions)
print(f"=== {tier_name} — Weighted Test Metrics ===")
print(f"Accuracy: {accuracy:.3f}")
print(f"Sensitivity (Recall): {sensitivity:.3f}")
print(f"Specificity: {specificity:.3f}")
print(f"PPV (Precision): {ppv:.3f}")
print(f"NPV: {npv:.3f}")
print(f"F1: {f1:.3f}")
print(f"F2: {f2:.3f}")
print(f"ROC AUC: {roc_auc:.3f}")
print(f"PR AUC: {pr_auc:.3f}")
print("Confusion matrix (weighted):")
print(f" TN: {tn:.1f}, FP: {fp:.1f}")
print(f" FN: {fn:.1f}, TP: {tp:.1f}")
=== Tier 1 — Weighted Test Metrics ===
Accuracy: 0.972
Sensitivity (Recall): 0.635
Specificity: 1.000
PPV (Precision): 0.991
NPV: 0.971
F1: 0.774
F2: 0.684
ROC AUC: 0.829
PR AUC: 0.836
Confusion matrix (weighted):
TN: 47082423.8, FP: 21888.1
FN: 1426902.7, TP: 2477163.9
Bootstrapped 95% CI for ROC AUC and F1
n_bootstrap = 500 # can bump to 1000 if laptop handles it
rng = np.random.RandomState(42)
roc_values = []
f1_values = []
y_test_arr = y_test.to_numpy()
w_test_arr = w_test.to_numpy()
for i in range(n_bootstrap):
# resample indices with replacement
idx = rng.randint(0, len(y_test_arr), len(y_test_arr))
y_b = y_test_arr[idx]
proba_b = y_proba[idx]
pred_b = y_pred[idx]
w_b = w_test_arr[idx]
# ROC AUC
try:
roc_values.append(
roc_auc_score(y_b, proba_b, sample_weight=w_b)
)
except ValueError:
# can happen if bootstrap sample has only one class
continue
# F1
f1_values.append(
f1_score(y_b, pred_b, sample_weight=w_b)
)
roc_ci = (np.percentile(roc_values, 2.5), np.percentile(roc_values, 97.5))
f1_ci = (np.percentile(f1_values, 2.5), np.percentile(f1_values, 97.5))
print(f"ROC AUC 95% CI: [{roc_ci[0]:.3f}, {roc_ci[1]:.3f}]")
print(f"F1 95% CI: [{f1_ci[0]:.3f}, {f1_ci[1]:.3f}]")
ROC AUC 95% CI: [0.795, 0.861]
F1 95% CI: [0.737, 0.807]
import numpy as np
import pandas as pd
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, GridSearchCV, train_test_split
from sklearn.metrics import (
accuracy_score, recall_score, precision_score, f1_score, fbeta_score,
roc_auc_score, precision_recall_curve, auc, confusion_matrix
)
def evaluate_tier(tier_name, tier_features, X, y, w):
print(f"\n=== Running Logistic Regression for {tier_name} ({len(tier_features)} features) ===")
# Subset X to this tier
X_tier = X[tier_features].copy()
# Train-test split
X_train, X_test, y_train, y_test, w_train, w_test = train_test_split(
X_tier, y, w,
test_size=0.2,
random_state=42,
stratify=y
)
# Preprocessing (median impute + scaling)
numeric_cols = X_train.columns.tolist()
numeric_transformer = Pipeline(steps=[
('imputer', SimpleImputer(strategy='median')),
('scaler', StandardScaler())
])
preprocessor = ColumnTransformer(
transformers=[('num', numeric_transformer, numeric_cols)],
remainder='drop'
)
# Logistic Regression Pipeline + CV
log_reg_pipe = Pipeline(steps=[
('preprocess', preprocessor),
('model', LogisticRegression(
max_iter=2000,
solver='liblinear'
))
])
param_grid = {
'model__C': [0.01, 0.1, 1.0, 10.0],
'model__class_weight': [None, 'balanced']
}
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
grid = GridSearchCV(
estimator=log_reg_pipe,
param_grid=param_grid,
cv=cv,
scoring='roc_auc',
n_jobs=1,
verbose=0
)
grid.fit(X_train, y_train)
best_model = grid.best_estimator_
# Refit using survey weights
best_model.fit(X_train, y_train, model__sample_weight=w_train)
# Predict
y_proba = best_model.predict_proba(X_test)[:, 1]
y_pred = (y_proba >= 0.5).astype(int)
# Weighted metrics
tn, fp, fn, tp = confusion_matrix(y_test, y_pred, sample_weight=w_test).ravel()
metrics = {
"Tier": tier_name,
"Feature_Count": len(tier_features),
"Accuracy": accuracy_score(y_test, y_pred, sample_weight=w_test),
"Sensitivity": recall_score(y_test, y_pred, sample_weight=w_test),
"Specificity": tn / (tn + fp),
"PPV": precision_score(y_test, y_pred, sample_weight=w_test),
"NPV": tn / (tn + fn),
"F1": f1_score(y_test, y_pred, sample_weight=w_test),
"F2": fbeta_score(y_test, y_pred, beta=2, sample_weight=w_test),
"ROC_AUC": roc_auc_score(y_test, y_proba, sample_weight=w_test),
"PR_AUC": auc(*precision_recall_curve(
y_test, y_proba, sample_weight=w_test
)[1::-1]),
"TN": tn, "FP": fp, "FN": fn, "TP": tp
}
print(f"Finished {tier_name} ✓")
return metrics
results = []
for tier_name, tier_features in tiers.items():
metrics = evaluate_tier(tier_name, tier_features, X, y, w)
results.append(metrics)
# Convert to DataFrame for comparison
results_df = pd.DataFrame(results)
results_df
=== Running Logistic Regression for Tier 0 (Top 100) (100 features) ===
Finished Tier 0 (Top 100) ✓
=== Running Logistic Regression for Tier 1 (5 features) ===
Finished Tier 1 ✓
=== Running Logistic Regression for Tier 2 (12 features) ===
Finished Tier 2 ✓
=== Running Logistic Regression for Tier 3 (29 features) ===
Finished Tier 3 ✓
=== Running Logistic Regression for Tier 4 (52 features) ===
Finished Tier 4 ✓
Tier
Feature_Count
Accuracy
Sensitivity
Specificity
PPV
NPV
F1
F2
ROC_AUC
PR_AUC
TN
FP
FN
TP
0
Tier 0 (Top 100)
100
0.995128
0.991536
0.995425
0.947268
0.999296
0.968897
0.982354
0.999676
0.997569
4.688882e+07
215490.696164
3.304401e+04
3.871023e+06
1
Tier 1
5
0.971597
0.634509
0.999535
0.991241
0.970585
0.773737
0.683721
0.829097
0.835851
4.708242e+07
21888.125341
1.426903e+06
2.477164e+06
2
Tier 2
12
0.985758
0.970803
0.986997
0.860880
0.997554
0.912543
0.946628
0.997614
0.982910
4.649183e+07
612482.848786
1.139883e+05
3.790078e+06
3
Tier 3
29
0.985917
0.971832
0.987085
0.861812
0.997640
0.913522
0.947637
0.998514
0.987752
4.649595e+07
608366.414519
1.099692e+05
3.794097e+06
4
Tier 4
52
0.986358
0.969399
0.987763
0.867826
0.997439
0.915805
0.947226
0.998456
0.987261
4.652790e+07
576409.973102
1.194673e+05
3.784599e+06
Results Summary
What you found:
Tier 1 (5 features) works but misses a lot of true MDE cases:
Tier 2–4 (12–52 features) perform extremely well:
Tier 0 (100 features) is the maximum-possible-performance tier:
A model using about 29–52 variables (Tier 3–4) can detect past-year major depression with extremely high accuracy: around 97% sensitivity and 99% specificity. These tiers offer nearly the same predictive value as using 100 features, but with far fewer inputs—meaning they can be adapted into clinical workflows.
Final verdict: Using only 12–30 variables, the model can detect depression cases with about 97% sensitivity and 99% specificity. This is comparable to or better than many psychiatric screening tools. The smallest tier (5 variables) is much more conservative and misses cases, so it isn’t suitable for screening. The larger tiers show that adding more contextual variables dramatically improves ability to identify patients with depression.Using only 12–30 variables, the model can detect depression cases with about 97% sensitivity and 99% specificity. This is comparable to or better than many psychiatric screening tools. The smallest tier (5 variables) is much more conservative and misses cases, so it isn’t suitable for screening. The larger tiers show that adding more contextual variables dramatically improves ability to identify patients with depression.
Best possible / upper bound:
Interpretation:
Interpretation:
Interpretation:
Interpretation:
Interpretation: