Spaces:
Sleeping
Sleeping
| """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() | |