trustmedx-backend / train_model.py
satwikshreshth1's picture
Upload folder using huggingface_hub
b0a4f16 verified
Raw
History Blame Contribute Delete
9.15 kB
"""Reproduce the TrustMedX CatBoost (feature-engineered) model and export
serving artifacts.
Mirrors the training pipeline in TrustMedX.ipynb: UCI Diabetes 130-US
Hospitals data, patient-level 80/10/10 GroupShuffleSplit (seed 42),
feature engineering, OrdinalEncoder + StandardScaler preprocessing,
CatBoost (300 iterations, depth 6, lr 0.05, class-weighted), followed by
temperature scaling and a split-conformal quantile fitted on the
validation set.
Outputs (server/artifacts/):
model.cbm trained CatBoost model
preprocessor.joblib fitted ColumnTransformer
metadata.json feature schema, category levels, T, qhat, metrics
"""
import io
import json
import os
import urllib.request
import zipfile
import joblib
import numpy as np
import pandas as pd
from catboost import CatBoostClassifier
from scipy.optimize import minimize_scalar
from sklearn.compose import ColumnTransformer
from sklearn.metrics import average_precision_score, roc_auc_score
from sklearn.model_selection import GroupShuffleSplit
from sklearn.preprocessing import OrdinalEncoder, StandardScaler
UCI_URL = ("https://archive.ics.uci.edu/static/public/296/"
"diabetes+130-us+hospitals+for+years+1999-2008.zip")
ARTIFACT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "artifacts")
DATA_CACHE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "diabetic_data.csv")
MED_COLS = ['metformin', 'repaglinide', 'nateglinide', 'chlorpropamide', 'glimepiride',
'acetohexamide', 'glipizide', 'glyburide', 'tolbutamide', 'pioglitazone',
'rosiglitazone', 'acarbose', 'miglitol', 'troglitazone', 'tolazamide',
'insulin', 'glyburide-metformin', 'glipizide-metformin',
'glimepiride-pioglitazone', 'metformin-rosiglitazone', 'metformin-pioglitazone']
def load_data():
if not os.path.exists(DATA_CACHE):
print("Downloading dataset from UCI ...")
with urllib.request.urlopen(UCI_URL) as resp:
outer = zipfile.ZipFile(io.BytesIO(resp.read()))
# the outer zip contains dataset_diabetes/diabetic_data.csv (possibly nested)
csv_name = next(n for n in outer.namelist() if n.endswith("diabetic_data.csv"))
with outer.open(csv_name) as f:
raw = f.read()
with open(DATA_CACHE, "wb") as f:
f.write(raw)
df = pd.read_csv(DATA_CACHE, dtype=str, keep_default_na=False)
# numeric columns back to int
int_cols = ['admission_type_id', 'discharge_disposition_id', 'admission_source_id',
'time_in_hospital', 'num_lab_procedures', 'num_procedures',
'num_medications', 'number_outpatient', 'number_emergency',
'number_inpatient', 'number_diagnoses']
for c in int_cols:
df[c] = df[c].astype(int)
return df
def clean(df):
df = df.drop(columns=['weight', 'payer_code', 'encounter_id'])
# lab columns: 'None' means test not ordered
df['max_glu_serum'] = df['max_glu_serum'].replace('None', 'Not_tested')
df['A1Cresult'] = df['A1Cresult'].replace('None', 'Not_tested')
for col in ['race', 'medical_specialty', 'diag_1', 'diag_2', 'diag_3']:
df[col] = df[col].replace('', 'Unknown')
df['target'] = (df['readmitted'] == '<30').astype(int)
df = df.drop(columns=['readmitted'])
return df
def map_diag_category(code):
if pd.isna(code) or code in ('Unknown', '?'):
return 'Unknown'
try:
code_num = float(code)
except ValueError:
if str(code).startswith('V') or str(code).startswith('E'):
return 'Injury_External'
return 'Other'
if 390 <= code_num <= 459 or code_num == 785:
return 'Circulatory'
elif 460 <= code_num <= 519 or code_num == 786:
return 'Respiratory'
elif 520 <= code_num <= 579 or code_num == 787:
return 'Digestive'
elif 250 <= code_num < 251:
return 'Diabetes'
elif 800 <= code_num <= 999:
return 'Injury'
elif 710 <= code_num <= 739:
return 'Musculoskeletal'
elif 580 <= code_num <= 629 or code_num == 788:
return 'Genitourinary'
elif 140 <= code_num <= 239:
return 'Neoplasm'
return 'Other'
def engineer_features(df):
df = df.copy()
df['total_prior_visits'] = (df['number_outpatient'] + df['number_emergency']
+ df['number_inpatient'])
df['diag_1_category'] = df['diag_1'].apply(map_diag_category)
df['diag_2_category'] = df['diag_2'].apply(map_diag_category)
df['diag_3_category'] = df['diag_3'].apply(map_diag_category)
df['num_med_changes'] = df[MED_COLS].apply(
lambda row: sum(1 for v in row if v in ['Up', 'Down']), axis=1)
df['num_meds_prescribed'] = df[MED_COLS].apply(
lambda row: sum(1 for v in row if v != 'No'), axis=1)
df['procedures_per_day'] = df['num_procedures'] / (df['time_in_hospital'] + 1)
df['meds_per_day'] = df['num_medications'] / (df['time_in_hospital'] + 1)
df['is_elderly'] = df['age'].apply(
lambda x: 1 if x in ['[70-80)', '[80-90)', '[90-100)'] else 0)
return df
def temperature_scale(logits, T):
return 1 / (1 + np.exp(-logits / T))
def main():
os.makedirs(ARTIFACT_DIR, exist_ok=True)
df = clean(load_data())
gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
train_val_idx, test_idx = next(gss.split(df, groups=df['patient_nbr']))
train_val_df, test_df = df.iloc[train_val_idx], df.iloc[test_idx]
gss2 = GroupShuffleSplit(n_splits=1, test_size=0.125, random_state=42)
train_idx, val_idx = next(gss2.split(train_val_df, groups=train_val_df['patient_nbr']))
train_df, val_df = train_val_df.iloc[train_idx], train_val_df.iloc[val_idx]
def xy(d):
d = d.drop(columns=['patient_nbr'])
return d.drop(columns=['target']), d['target']
X_train, y_train = xy(train_df)
X_val, y_val = xy(val_df)
X_test, y_test = xy(test_df)
print(f"Train {X_train.shape}, Val {X_val.shape}, Test {X_test.shape}")
X_train_fe = engineer_features(X_train)
X_val_fe = engineer_features(X_val)
X_test_fe = engineer_features(X_test)
categorical_cols = X_train_fe.select_dtypes(include='object').columns.tolist()
numeric_cols = [c for c in X_train_fe.columns if c not in categorical_cols]
preprocessor = ColumnTransformer(transformers=[
('cat', OrdinalEncoder(handle_unknown='use_encoded_value', unknown_value=-1),
categorical_cols),
('num', StandardScaler(), numeric_cols),
])
X_train_p = preprocessor.fit_transform(X_train_fe)
X_val_p = preprocessor.transform(X_val_fe)
model = CatBoostClassifier(
iterations=300, depth=6, learning_rate=0.05,
scale_pos_weight=(y_train == 0).sum() / (y_train == 1).sum(),
random_state=42, verbose=0)
model.fit(X_train_p, y_train)
val_probs = model.predict_proba(X_val_p)[:, 1]
auroc = roc_auc_score(y_val, val_probs)
auprc = average_precision_score(y_val, val_probs)
print(f"Validation AUROC {auroc:.4f} AUPRC {auprc:.4f} "
f"(notebook reference: 0.6632 / 0.2155)")
# temperature scaling on validation set
clipped = np.clip(val_probs, 1e-6, 1 - 1e-6)
logits = np.log(clipped / (1 - clipped))
y_val_arr = y_val.values
def nll(T):
p = np.clip(temperature_scale(logits, T), 1e-6, 1 - 1e-6)
return -np.mean(y_val_arr * np.log(p) + (1 - y_val_arr) * np.log(1 - p))
optimal_T = minimize_scalar(nll, bounds=(0.1, 10), method='bounded').x
calibrated = temperature_scale(logits, optimal_T)
print(f"Optimal temperature T = {optimal_T:.4f} (notebook reference: 0.7494)")
# split conformal quantile on validation set
alpha = 0.1
scores = np.where(y_val_arr == 1, 1 - calibrated, calibrated)
n = len(scores)
q_level = np.ceil((n + 1) * (1 - alpha)) / n
qhat = float(np.quantile(scores, q_level))
print(f"Conformal qhat = {qhat:.4f} (notebook reference: 0.6684)")
model.save_model(os.path.join(ARTIFACT_DIR, "model.cbm"))
joblib.dump(preprocessor, os.path.join(ARTIFACT_DIR, "preprocessor.joblib"))
# category levels for form dropdowns; medians/modes as form defaults
cat_levels = {c: sorted(X_train_fe[c].unique().tolist()) for c in categorical_cols}
defaults = {}
for c in categorical_cols:
defaults[c] = X_train_fe[c].mode().iloc[0]
for c in numeric_cols:
defaults[c] = float(X_train_fe[c].median())
metadata = {
"feature_order": categorical_cols + numeric_cols,
"categorical_cols": categorical_cols,
"numeric_cols": numeric_cols,
"raw_input_cols": X_train.columns.tolist(),
"med_cols": MED_COLS,
"category_levels": cat_levels,
"defaults": defaults,
"temperature": float(optimal_T),
"conformal_qhat": qhat,
"val_auroc": float(auroc),
"val_auprc": float(auprc),
}
with open(os.path.join(ARTIFACT_DIR, "metadata.json"), "w") as f:
json.dump(metadata, f, indent=2)
print(f"Artifacts written to {ARTIFACT_DIR}")
if __name__ == "__main__":
main()