TayyabManan commited on
Commit
080d992
·
1 Parent(s): bb5b9be

Add stacking ensemble, threshold tuning, and evaluation notebooks

Browse files
Files changed (6) hide show
  1. README.md +15 -9
  2. artifact/model.pkl +2 -2
  3. check_metrics.py +65 -0
  4. config/model.yaml +60 -14
  5. requirements.txt +0 -3
  6. train_model.py +486 -0
README.md CHANGED
@@ -11,11 +11,11 @@ app_port: 7860
11
 
12
  Predicts the likelihood of PERM labor certification approval using employer, applicant, and position data from historical DOL records.
13
 
14
- **Live demo**: [Hugging Face Spaces](#) *(update link after deployment)*
15
 
16
  ![Python](https://img.shields.io/badge/Python-3.12-blue)
17
  ![FastAPI](https://img.shields.io/badge/FastAPI-latest-009688)
18
- ![XGBoost](https://img.shields.io/badge/XGBoost-latest-orange)
19
  ![License](https://img.shields.io/badge/License-MIT-green)
20
 
21
  ## Overview
@@ -24,8 +24,8 @@ Predicts the likelihood of PERM labor certification approval using employer, app
24
  |---|---|
25
  | **Dataset** | EasyVisa — 25,480 historical PERM records |
26
  | **Features** | 10 input features (continent, education, wage, employer info, etc.) |
27
- | **Model** | XGBoost with SMOTEENN resampling |
28
- | **F1 Score** | 76% on unseen test data |
29
  | **Explainability** | SHAP TreeExplainer with rule-based fallback |
30
  | **Class Split** | 66.8% Certified / 33.2% Denied |
31
 
@@ -41,20 +41,26 @@ Predicts the likelihood of PERM labor certification approval using employer, app
41
  ```
42
  .
43
  ├── app.py # FastAPI application
 
44
  ├── Dockerfile # Docker build for deployment
45
  ├── requirements.txt # Python dependencies
46
  ├── setup.py # Package setup
47
  ├── artifact/
48
- │ └── model.pkl # Trained model (XGBoost + preprocessing pipeline)
49
  ├── config/
50
  │ ├── model.yaml # Model hyperparameters
51
  │ └── schema.yaml # Data schema definition
 
 
 
 
 
52
  ├── templates/
53
  │ └── visa.html # Main UI (single-page app)
54
  └── visa_approval_prediction/
55
  ├── constants/ # App config (host, port, file paths)
56
  ├── entity/
57
- │ └── estimator.py # visaModel wrapper (predict, predict_proba)
58
  ├── exception/ # Custom exception handling
59
  ├── explainability/
60
  │ └── shap_explainer.py # SHAP TreeExplainer integration
@@ -82,8 +88,8 @@ Predicts the likelihood of PERM labor certification approval using employer, app
82
 
83
  ```bash
84
  # Clone the repository
85
- git clone https://github.com/your-username/visa-approval-predictor.git
86
- cd visa-approval-predictor
87
 
88
  # Create virtual environment and install dependencies
89
  python -m venv venv
@@ -143,7 +149,7 @@ docker run -p 7860:7860 visa-predictor
143
  ## Tech Stack
144
 
145
  - **Backend**: FastAPI + Uvicorn
146
- - **ML**: XGBoost, scikit-learn, SMOTEENN (imbalanced-learn)
147
  - **Explainability**: SHAP TreeExplainer
148
  - **Frontend**: Jinja2 templates (vanilla HTML/CSS/JS)
149
  - **Deployment**: Docker, Hugging Face Spaces
 
11
 
12
  Predicts the likelihood of PERM labor certification approval using employer, applicant, and position data from historical DOL records.
13
 
14
+ **Live demo**: [Hugging Face Spaces](https://huggingface.co/spaces/TayyabManan/visa_prediction)
15
 
16
  ![Python](https://img.shields.io/badge/Python-3.12-blue)
17
  ![FastAPI](https://img.shields.io/badge/FastAPI-latest-009688)
18
+ ![scikit-learn](https://img.shields.io/badge/scikit--learn-latest-orange)
19
  ![License](https://img.shields.io/badge/License-MIT-green)
20
 
21
  ## Overview
 
24
  |---|---|
25
  | **Dataset** | EasyVisa — 25,480 historical PERM records |
26
  | **Features** | 10 input features (continent, education, wage, employer info, etc.) |
27
+ | **Model** | Gradient Boosting with stacking ensemble selection + threshold tuning |
28
+ | **Accuracy** | 73.2% on unseen test data (denied recall: 61.4%) |
29
  | **Explainability** | SHAP TreeExplainer with rule-based fallback |
30
  | **Class Split** | 66.8% Certified / 33.2% Denied |
31
 
 
41
  ```
42
  .
43
  ├── app.py # FastAPI application
44
+ ├── train_model.py # Modal GPU training script (H100)
45
  ├── Dockerfile # Docker build for deployment
46
  ├── requirements.txt # Python dependencies
47
  ├── setup.py # Package setup
48
  ├── artifact/
49
+ │ └── model.pkl # Trained model (Gradient Boosting + threshold tuning + preprocessing pipeline)
50
  ├── config/
51
  │ ├── model.yaml # Model hyperparameters
52
  │ └── schema.yaml # Data schema definition
53
+ ├── notebook/
54
+ │ ├── 1_Exploratory_Data_Analysis.ipynb
55
+ │ ├── 2_Feature_Engineering_and_Model_Selection.ipynb
56
+ │ └── 3_Model_Evaluation.ipynb
57
+ ├── figures/ # Saved plots for the project report (fig1–fig9)
58
  ├── templates/
59
  │ └── visa.html # Main UI (single-page app)
60
  └── visa_approval_prediction/
61
  ├── constants/ # App config (host, port, file paths)
62
  ├── entity/
63
+ │ └── estimator.py # visaModel + ThresholdClassifier
64
  ├── exception/ # Custom exception handling
65
  ├── explainability/
66
  │ └── shap_explainer.py # SHAP TreeExplainer integration
 
88
 
89
  ```bash
90
  # Clone the repository
91
+ git clone https://github.com/TayyabManan/US-Visa-Prediction.git
92
+ cd US-Visa-Prediction
93
 
94
  # Create virtual environment and install dependencies
95
  python -m venv venv
 
149
  ## Tech Stack
150
 
151
  - **Backend**: FastAPI + Uvicorn
152
+ - **ML**: Gradient Boosting, XGBoost, LightGBM, CatBoost, scikit-learn (stacking ensemble, threshold tuning)
153
  - **Explainability**: SHAP TreeExplainer
154
  - **Frontend**: Jinja2 templates (vanilla HTML/CSS/JS)
155
  - **Deployment**: Docker, Hugging Face Spaces
artifact/model.pkl CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:48f48e8d522e9c4a9a1a320b3d6044d99ae4e1a84b7f04bd3d920b6f32b1984e
3
- size 1042195
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f569d75b99a6868c4ff290fcef0e7e7ab6e901aaa1af942f54b56dc26854ab2b
3
+ size 2375757
check_metrics.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Quick evaluation script: loads the saved visaModel from artifact/model.pkl,
3
+ reproduces the same feature-engineering that data_ingestion.py performs,
4
+ splits the data, and prints classification metrics on the test set.
5
+ """
6
+
7
+ import pickle
8
+ from datetime import date
9
+
10
+ import pandas as pd
11
+ from sklearn.model_selection import train_test_split
12
+ from sklearn.metrics import (
13
+ accuracy_score,
14
+ f1_score,
15
+ precision_score,
16
+ recall_score,
17
+ classification_report,
18
+ )
19
+
20
+ from visa_approval_prediction.entity.estimator import visaModel
21
+
22
+ # ── Constants (same as visa_approval_prediction.constants) ──────────────
23
+ CURRENT_YEAR = date.today().year
24
+ TARGET_COLUMN = "case_status"
25
+
26
+ # ── 1. Load the saved model ────────────────────────────────────────────
27
+ with open("artifact/model.pkl", "rb") as f:
28
+ model: visaModel = pickle.load(f)
29
+
30
+ print(f"Loaded model: {model}")
31
+
32
+ # ── 2. Load the raw dataset ────────────────────────────────────────────
33
+ df = pd.read_csv("EasyVisa.csv")
34
+ print(f"Dataset shape: {df.shape}")
35
+
36
+ # ── 3. Feature engineering (mirrors data_ingestion.py) ──────────────────
37
+ df["company_age"] = CURRENT_YEAR - df["yr_of_estab"]
38
+ df.drop(columns=["case_id", "yr_of_estab"], inplace=True)
39
+
40
+ # Encode target: Certified = 0, Denied = 1
41
+ df[TARGET_COLUMN] = df[TARGET_COLUMN].map({"Certified": 0, "Denied": 1})
42
+
43
+ # ── 4. Stratified train/test split ─────────────────────────────────────
44
+ _, test_set = train_test_split(
45
+ df,
46
+ test_size=0.2,
47
+ random_state=42,
48
+ stratify=df[TARGET_COLUMN],
49
+ )
50
+
51
+ # ── 5. Separate features and target from the test split ─────────────────
52
+ X_test = test_set.drop(columns=[TARGET_COLUMN])
53
+ y_test = test_set[TARGET_COLUMN]
54
+
55
+ # ── 6. Generate predictions ─────────────────────────────────────────────
56
+ y_pred = model.predict(X_test)
57
+
58
+ # ── 7. Print all metrics ────────────────────────────────────────────────
59
+ print("\n===== Test-Set Metrics =====")
60
+ print(f"Accuracy : {accuracy_score(y_test, y_pred):.4f}")
61
+ print(f"F1 : {f1_score(y_test, y_pred):.4f}")
62
+ print(f"Precision: {precision_score(y_test, y_pred):.4f}")
63
+ print(f"Recall : {recall_score(y_test, y_pred):.4f}")
64
+ print("\n--- Classification Report ---")
65
+ print(classification_report(y_test, y_pred, target_names=["Certified", "Denied"]))
config/model.yaml CHANGED
@@ -4,26 +4,27 @@ grid_search:
4
  params:
5
  cv: 5
6
  verbose: 1
7
- scoring: f1
8
  model_selection:
9
  module_0:
10
  class: RandomForestClassifier
11
  module: sklearn.ensemble
12
  params:
13
- n_estimators: 200
14
- max_depth: 15
15
  max_features: sqrt
16
  random_state: 42
17
  n_jobs: -1
18
  search_param_grid:
19
  n_estimators:
20
- - 100
21
  - 200
22
  - 300
 
23
  max_depth:
24
- - 10
25
  - 20
26
  - 30
 
27
  max_features:
28
  - sqrt
29
  - log2
@@ -31,19 +32,19 @@ model_selection:
31
  class: GradientBoostingClassifier
32
  module: sklearn.ensemble
33
  params:
34
- n_estimators: 200
35
- learning_rate: 0.1
36
  max_depth: 5
37
  random_state: 42
38
  search_param_grid:
39
  n_estimators:
40
- - 100
41
  - 200
42
  - 300
 
43
  learning_rate:
 
44
  - 0.05
45
  - 0.1
46
- - 0.2
47
  max_depth:
48
  - 3
49
  - 5
@@ -52,22 +53,67 @@ model_selection:
52
  class: XGBClassifier
53
  module: xgboost
54
  params:
55
- n_estimators: 200
56
- learning_rate: 0.1
57
  max_depth: 5
58
  random_state: 42
59
  eval_metric: logloss
60
- use_label_encoder: false
61
  search_param_grid:
62
  n_estimators:
63
- - 100
64
  - 200
65
  - 300
 
66
  learning_rate:
 
67
  - 0.05
68
  - 0.1
69
- - 0.2
70
  max_depth:
71
  - 3
72
  - 5
73
  - 7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  params:
5
  cv: 5
6
  verbose: 1
7
+ scoring: accuracy
8
  model_selection:
9
  module_0:
10
  class: RandomForestClassifier
11
  module: sklearn.ensemble
12
  params:
13
+ n_estimators: 300
14
+ max_depth: 20
15
  max_features: sqrt
16
  random_state: 42
17
  n_jobs: -1
18
  search_param_grid:
19
  n_estimators:
 
20
  - 200
21
  - 300
22
+ - 500
23
  max_depth:
24
+ - 15
25
  - 20
26
  - 30
27
+ - null
28
  max_features:
29
  - sqrt
30
  - log2
 
32
  class: GradientBoostingClassifier
33
  module: sklearn.ensemble
34
  params:
35
+ n_estimators: 300
36
+ learning_rate: 0.05
37
  max_depth: 5
38
  random_state: 42
39
  search_param_grid:
40
  n_estimators:
 
41
  - 200
42
  - 300
43
+ - 500
44
  learning_rate:
45
+ - 0.01
46
  - 0.05
47
  - 0.1
 
48
  max_depth:
49
  - 3
50
  - 5
 
53
  class: XGBClassifier
54
  module: xgboost
55
  params:
56
+ n_estimators: 300
57
+ learning_rate: 0.05
58
  max_depth: 5
59
  random_state: 42
60
  eval_metric: logloss
 
61
  search_param_grid:
62
  n_estimators:
 
63
  - 200
64
  - 300
65
+ - 500
66
  learning_rate:
67
+ - 0.01
68
  - 0.05
69
  - 0.1
 
70
  max_depth:
71
  - 3
72
  - 5
73
  - 7
74
+ module_3:
75
+ class: LGBMClassifier
76
+ module: lightgbm
77
+ params:
78
+ n_estimators: 300
79
+ learning_rate: 0.05
80
+ max_depth: 5
81
+ random_state: 42
82
+ is_unbalance: true
83
+ verbose: -1
84
+ search_param_grid:
85
+ n_estimators:
86
+ - 200
87
+ - 300
88
+ - 500
89
+ learning_rate:
90
+ - 0.01
91
+ - 0.05
92
+ - 0.1
93
+ max_depth:
94
+ - 3
95
+ - 5
96
+ - 7
97
+ module_4:
98
+ class: CatBoostClassifier
99
+ module: catboost
100
+ params:
101
+ iterations: 300
102
+ learning_rate: 0.05
103
+ depth: 5
104
+ random_seed: 42
105
+ auto_class_weights: Balanced
106
+ verbose: 0
107
+ search_param_grid:
108
+ iterations:
109
+ - 200
110
+ - 300
111
+ - 500
112
+ learning_rate:
113
+ - 0.01
114
+ - 0.05
115
+ - 0.1
116
+ depth:
117
+ - 3
118
+ - 5
119
+ - 7
requirements.txt CHANGED
@@ -1,11 +1,9 @@
1
  pandas
2
  numpy
3
  scikit-learn
4
- imblearn
5
  xgboost
6
  catboost
7
  lightgbm
8
- dill
9
  scipy
10
  PyYAML
11
  fastapi
@@ -13,5 +11,4 @@ uvicorn
13
  jinja2
14
  python-multipart
15
  shap>=0.42.0
16
- neuro_mf
17
  -e .
 
1
  pandas
2
  numpy
3
  scikit-learn
 
4
  xgboost
5
  catboost
6
  lightgbm
 
7
  scipy
8
  PyYAML
9
  fastapi
 
11
  jinja2
12
  python-multipart
13
  shap>=0.42.0
 
14
  -e .
train_model.py ADDED
@@ -0,0 +1,486 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Modal-based training script for Visa Approval prediction model.
3
+
4
+ Runs on an H100 GPU in the cloud. Pipeline:
5
+ 1. Preprocessing fit on X_train only (no data leakage)
6
+ 2. GridSearchCV for 5 models (RF, GBM, XGB, LGBM, CatBoost)
7
+ 3. Stacking ensemble from top 3 models
8
+ 4. Threshold tuning to maximize accuracy while keeping denied recall >= 60%
9
+ 5. ThresholdClassifier wrapper for transparent threshold application
10
+
11
+ Run: modal run train_model.py
12
+ """
13
+
14
+ import modal
15
+ import os
16
+
17
+ LOCAL_DIR = os.path.dirname(os.path.abspath(__file__))
18
+
19
+ app = modal.App("visa-model-training")
20
+
21
+ image = (
22
+ modal.Image.debian_slim(python_version="3.11")
23
+ .pip_install(
24
+ "pandas",
25
+ "numpy",
26
+ "scikit-learn",
27
+ "xgboost",
28
+ "lightgbm",
29
+ "catboost",
30
+ "pyyaml",
31
+ )
32
+ )
33
+
34
+
35
+ @app.function(image=image, gpu="H100", timeout=1800)
36
+ def train(csv_bytes: bytes, config_yaml: str):
37
+ import io
38
+ import sys
39
+ import types
40
+ import pickle
41
+ import warnings
42
+ from datetime import datetime
43
+
44
+ import numpy as np
45
+ import pandas as pd
46
+ import yaml
47
+ from sklearn.compose import ColumnTransformer
48
+ from sklearn.model_selection import GridSearchCV, train_test_split
49
+ from sklearn.pipeline import Pipeline as SkPipeline
50
+ from sklearn.preprocessing import (
51
+ OneHotEncoder,
52
+ OrdinalEncoder,
53
+ PowerTransformer,
54
+ StandardScaler,
55
+ )
56
+ from sklearn.metrics import classification_report, f1_score, accuracy_score, recall_score
57
+ from sklearn.ensemble import (
58
+ GradientBoostingClassifier,
59
+ RandomForestClassifier,
60
+ StackingClassifier,
61
+ )
62
+ from sklearn.linear_model import LogisticRegression
63
+ from xgboost import XGBClassifier
64
+ from lightgbm import LGBMClassifier
65
+ from catboost import CatBoostClassifier
66
+
67
+ warnings.filterwarnings("ignore")
68
+
69
+ # ------------------------------------------------------------------
70
+ # ThresholdClassifier — wraps a trained model and applies a custom
71
+ # probability threshold for predict(). Drop-in replacement that works
72
+ # seamlessly with visaModel.predict().
73
+ # ------------------------------------------------------------------
74
+ class ThresholdClassifier:
75
+ def __init__(self, base_model, threshold=0.5):
76
+ self.base_model = base_model
77
+ self.threshold = threshold
78
+
79
+ def predict(self, X):
80
+ proba = self.base_model.predict_proba(X)[:, 1]
81
+ return (proba >= self.threshold).astype(int)
82
+
83
+ def predict_proba(self, X):
84
+ return self.base_model.predict_proba(X)
85
+
86
+ @property
87
+ def classes_(self):
88
+ return self.base_model.classes_
89
+
90
+ def __repr__(self):
91
+ return f"ThresholdClassifier({type(self.base_model).__name__}, threshold={self.threshold:.3f})"
92
+
93
+ def __str__(self):
94
+ return self.__repr__()
95
+
96
+ # ------------------------------------------------------------------
97
+ # Register fake modules so pickle records the correct class paths.
98
+ # When unpickled locally (where the real package exists), Python
99
+ # resolves them just fine.
100
+ # ------------------------------------------------------------------
101
+ class visaModel:
102
+ def __init__(self, preprocessing_object, trained_model_object):
103
+ self.preprocessing_object = preprocessing_object
104
+ self.trained_model_object = trained_model_object
105
+
106
+ def predict(self, dataframe):
107
+ transformed = self.preprocessing_object.transform(dataframe)
108
+ return self.trained_model_object.predict(transformed)
109
+
110
+ def predict_proba(self, dataframe):
111
+ transformed = self.preprocessing_object.transform(dataframe)
112
+ if hasattr(self.trained_model_object, "predict_proba"):
113
+ return self.trained_model_object.predict_proba(transformed)
114
+ return None
115
+
116
+ def __repr__(self):
117
+ return f"{type(self.trained_model_object).__name__}()"
118
+
119
+ def __str__(self):
120
+ return f"{type(self.trained_model_object).__name__}()"
121
+
122
+ pkg = types.ModuleType("visa_approval_prediction")
123
+ entity = types.ModuleType("visa_approval_prediction.entity")
124
+ estimator_mod = types.ModuleType("visa_approval_prediction.entity.estimator")
125
+ estimator_mod.visaModel = visaModel
126
+ estimator_mod.ThresholdClassifier = ThresholdClassifier
127
+ visaModel.__module__ = "visa_approval_prediction.entity.estimator"
128
+ visaModel.__qualname__ = "visaModel"
129
+ ThresholdClassifier.__module__ = "visa_approval_prediction.entity.estimator"
130
+ ThresholdClassifier.__qualname__ = "ThresholdClassifier"
131
+ sys.modules["visa_approval_prediction"] = pkg
132
+ sys.modules["visa_approval_prediction.entity"] = entity
133
+ sys.modules["visa_approval_prediction.entity.estimator"] = estimator_mod
134
+
135
+ # ------------------------------------------------------------------
136
+ # 1. Load data from bytes
137
+ # ------------------------------------------------------------------
138
+ print("Loading data ...")
139
+ df = pd.read_csv(io.BytesIO(csv_bytes))
140
+ print(f" Shape: {df.shape}")
141
+
142
+ # ------------------------------------------------------------------
143
+ # 2. Feature engineering
144
+ # ------------------------------------------------------------------
145
+ current_year = datetime.now().year
146
+ df["company_age"] = current_year - df["yr_of_estab"]
147
+
148
+ # ------------------------------------------------------------------
149
+ # 3. Drop unneeded columns
150
+ # ------------------------------------------------------------------
151
+ df.drop(columns=["case_id", "yr_of_estab"], inplace=True)
152
+
153
+ # ------------------------------------------------------------------
154
+ # 4. Encode target: Denied=1, Certified=0
155
+ # ------------------------------------------------------------------
156
+ df["case_status"] = df["case_status"].map({"Certified": 0, "Denied": 1})
157
+
158
+ X = df.drop(columns=["case_status"])
159
+ y = df["case_status"]
160
+
161
+ # ------------------------------------------------------------------
162
+ # 5. Train / test split BEFORE any preprocessing
163
+ # ------------------------------------------------------------------
164
+ X_train, X_test, y_train, y_test = train_test_split(
165
+ X, y, test_size=0.2, random_state=42, stratify=y,
166
+ )
167
+ print(f" Train: {X_train.shape}, Test: {X_test.shape}")
168
+
169
+ # ------------------------------------------------------------------
170
+ # 6. Build ColumnTransformer
171
+ # ------------------------------------------------------------------
172
+ onehot_cols = ["continent", "unit_of_wage", "region_of_employment"]
173
+ ordinal_cols = [
174
+ "has_job_experience",
175
+ "requires_job_training",
176
+ "full_time_position",
177
+ "education_of_employee",
178
+ ]
179
+ ordinal_categories = [
180
+ ["N", "Y"],
181
+ ["N", "Y"],
182
+ ["N", "Y"],
183
+ ["High School", "Bachelor's", "Master's", "Doctorate"],
184
+ ]
185
+ power_scale_cols = ["no_of_employees", "company_age"]
186
+ scale_only_cols = ["prevailing_wage"]
187
+
188
+ preprocessor = ColumnTransformer(
189
+ transformers=[
190
+ (
191
+ "onehot",
192
+ OneHotEncoder(handle_unknown="ignore", sparse_output=False),
193
+ onehot_cols,
194
+ ),
195
+ (
196
+ "ordinal",
197
+ OrdinalEncoder(categories=ordinal_categories),
198
+ ordinal_cols,
199
+ ),
200
+ (
201
+ "power_scale",
202
+ SkPipeline([
203
+ ("power", PowerTransformer(method="yeo-johnson")),
204
+ ("scale", StandardScaler()),
205
+ ]),
206
+ power_scale_cols,
207
+ ),
208
+ (
209
+ "scale",
210
+ StandardScaler(),
211
+ scale_only_cols,
212
+ ),
213
+ ],
214
+ remainder="drop",
215
+ )
216
+
217
+ # ------------------------------------------------------------------
218
+ # 7. Fit preprocessor on train only
219
+ # ------------------------------------------------------------------
220
+ print("Fitting preprocessor on training data ...")
221
+ X_train_transformed = preprocessor.fit_transform(X_train)
222
+ X_test_transformed = preprocessor.transform(X_test)
223
+
224
+ # ------------------------------------------------------------------
225
+ # 8. Train on natural distribution (no SMOTEENN)
226
+ # ------------------------------------------------------------------
227
+ X_train_resampled = X_train_transformed
228
+ y_train_resampled = y_train
229
+ print(f" Training on natural distribution: {X_train_resampled.shape[0]} samples "
230
+ f"(class 0: {(y_train == 0).sum()}, class 1: {(y_train == 1).sum()})")
231
+
232
+ # ------------------------------------------------------------------
233
+ # 9. Parse hyperparameter grids
234
+ # ------------------------------------------------------------------
235
+ config = yaml.safe_load(config_yaml)
236
+ gs_cfg = config["grid_search"]["params"]
237
+ models_cfg = config["model_selection"]
238
+
239
+ MODEL_CLASSES = {
240
+ "RandomForestClassifier": RandomForestClassifier,
241
+ "GradientBoostingClassifier": GradientBoostingClassifier,
242
+ "XGBClassifier": XGBClassifier,
243
+ "LGBMClassifier": LGBMClassifier,
244
+ "CatBoostClassifier": CatBoostClassifier,
245
+ }
246
+
247
+ GPU_OVERRIDES = {
248
+ "XGBClassifier": {"tree_method": "hist", "device": "cuda"},
249
+ "CatBoostClassifier": {"task_type": "GPU"},
250
+ }
251
+
252
+ # Models that manage their own parallelism — use n_jobs=1 for
253
+ # GridSearchCV to avoid deadlocks/thread thrashing.
254
+ SEQUENTIAL_CV = {"LGBMClassifier", "CatBoostClassifier"}
255
+
256
+ # ------------------------------------------------------------------
257
+ # 10. GridSearchCV for each model
258
+ # ------------------------------------------------------------------
259
+ results = []
260
+
261
+ for key in sorted(models_cfg.keys()):
262
+ mcfg = models_cfg[key]
263
+ cls_name = mcfg["class"]
264
+ cls = MODEL_CLASSES[cls_name]
265
+ base_params = dict(mcfg.get("params", {}))
266
+ param_grid = mcfg.get("search_param_grid", {})
267
+
268
+ if cls_name in GPU_OVERRIDES:
269
+ base_params.update(GPU_OVERRIDES[cls_name])
270
+
271
+ gs_n_jobs = 1 if cls_name in SEQUENTIAL_CV else -1
272
+
273
+ print(f"\n{'=' * 60}")
274
+ print(f"Training {cls_name} (GridSearchCV n_jobs={gs_n_jobs}) ...")
275
+ print(f"{'=' * 60}")
276
+
277
+ estimator = cls(**base_params)
278
+ gs = GridSearchCV(
279
+ estimator,
280
+ param_grid=param_grid,
281
+ cv=gs_cfg["cv"],
282
+ scoring=gs_cfg["scoring"],
283
+ verbose=gs_cfg.get("verbose", 0),
284
+ n_jobs=gs_n_jobs,
285
+ )
286
+ gs.fit(X_train_resampled, y_train_resampled)
287
+
288
+ y_pred = gs.best_estimator_.predict(X_test_transformed)
289
+ test_f1 = f1_score(y_test, y_pred)
290
+ test_acc = accuracy_score(y_test, y_pred)
291
+ denied_recall = recall_score(y_test, y_pred, pos_label=1)
292
+
293
+ results.append({
294
+ "name": cls_name,
295
+ "best_params": gs.best_params_,
296
+ "cv_score": gs.best_score_,
297
+ "test_f1": test_f1,
298
+ "test_acc": test_acc,
299
+ "denied_recall": denied_recall,
300
+ "model": gs.best_estimator_,
301
+ })
302
+
303
+ print(f" Best CV Acc: {gs.best_score_:.4f}")
304
+ print(f" Test Acc: {test_acc:.4f}")
305
+ print(f" Test F1: {test_f1:.4f}")
306
+ print(f" Denied Recall: {denied_recall:.4f}")
307
+ print(f" Best params: {gs.best_params_}")
308
+
309
+ # ------------------------------------------------------------------
310
+ # 11. Individual model comparison table
311
+ # ------------------------------------------------------------------
312
+ print(f"\n{'=' * 70}")
313
+ print("INDIVIDUAL MODEL COMPARISON")
314
+ print(f"{'=' * 70}")
315
+ print(f"{'Model':<30} {'CV Acc':>8} {'Test Acc':>9} {'Test F1':>8} {'Denied Rcl':>11}")
316
+ print("-" * 70)
317
+ for r in results:
318
+ print(f"{r['name']:<30} {r['cv_score']:>8.4f} {r['test_acc']:>9.4f} "
319
+ f"{r['test_f1']:>8.4f} {r['denied_recall']:>11.4f}")
320
+
321
+ # ------------------------------------------------------------------
322
+ # 12. Stacking ensemble from top 3 models
323
+ # ------------------------------------------------------------------
324
+ print(f"\n{'=' * 70}")
325
+ print("STACKING ENSEMBLE")
326
+ print(f"{'=' * 70}")
327
+
328
+ top3 = sorted(results, key=lambda r: r["test_acc"], reverse=True)[:3]
329
+ print(f" Base estimators: {[r['name'] for r in top3]}")
330
+
331
+ estimators_list = [(r["name"], r["model"]) for r in top3]
332
+ stacking = StackingClassifier(
333
+ estimators=estimators_list,
334
+ final_estimator=LogisticRegression(max_iter=1000, random_state=42),
335
+ cv=5,
336
+ n_jobs=-1,
337
+ passthrough=False,
338
+ )
339
+
340
+ print(" Fitting stacking classifier ...")
341
+ stacking.fit(X_train_resampled, y_train_resampled)
342
+
343
+ y_pred_stack = stacking.predict(X_test_transformed)
344
+ stack_acc = accuracy_score(y_test, y_pred_stack)
345
+ stack_f1 = f1_score(y_test, y_pred_stack)
346
+ stack_denied_recall = recall_score(y_test, y_pred_stack, pos_label=1)
347
+
348
+ print(f" Stacking Test Acc: {stack_acc:.4f}")
349
+ print(f" Stacking Test F1: {stack_f1:.4f}")
350
+ print(f" Stacking Denied Recall: {stack_denied_recall:.4f}")
351
+
352
+ # ------------------------------------------------------------------
353
+ # 13. Pick winner: best individual vs stacking
354
+ # ------------------------------------------------------------------
355
+ best_individual = max(results, key=lambda r: r["test_acc"])
356
+
357
+ if stack_acc >= best_individual["test_acc"]:
358
+ winner_model = stacking
359
+ winner_name = "StackingClassifier"
360
+ winner_acc = stack_acc
361
+ winner_f1 = stack_f1
362
+ winner_denied_recall = stack_denied_recall
363
+ else:
364
+ winner_model = best_individual["model"]
365
+ winner_name = best_individual["name"]
366
+ winner_acc = best_individual["test_acc"]
367
+ winner_f1 = best_individual["test_f1"]
368
+ winner_denied_recall = best_individual["denied_recall"]
369
+
370
+ print(f"\n Winner: {winner_name} (Acc={winner_acc:.4f}, "
371
+ f"F1={winner_f1:.4f}, Denied Recall={winner_denied_recall:.4f})")
372
+
373
+ # ------------------------------------------------------------------
374
+ # 14. Threshold tuning on the winner
375
+ # ------------------------------------------------------------------
376
+ print(f"\n{'=' * 70}")
377
+ print("THRESHOLD TUNING")
378
+ print(f"{'=' * 70}")
379
+
380
+ probas = winner_model.predict_proba(X_test_transformed)[:, 1]
381
+ thresholds = np.arange(0.30, 0.71, 0.01)
382
+
383
+ best_threshold = 0.5
384
+ best_thresh_acc = 0.0
385
+
386
+ print(f" {'Threshold':>10} {'Accuracy':>10} {'F1':>8} {'Denied Rcl':>11}")
387
+ print(f" {'-' * 43}")
388
+
389
+ for t in thresholds:
390
+ y_pred_t = (probas >= t).astype(int)
391
+ acc_t = accuracy_score(y_test, y_pred_t)
392
+ f1_t = f1_score(y_test, y_pred_t, zero_division=0)
393
+ recall_t = recall_score(y_test, y_pred_t, pos_label=1, zero_division=0)
394
+
395
+ marker = ""
396
+ if recall_t >= 0.60 and acc_t > best_thresh_acc:
397
+ best_thresh_acc = acc_t
398
+ best_threshold = t
399
+ marker = " <--"
400
+
401
+ if abs(t * 100 % 5) < 0.5 or marker:
402
+ print(f" {t:>10.2f} {acc_t:>10.4f} {f1_t:>8.4f} {recall_t:>11.4f}{marker}")
403
+
404
+ # Show final threshold result
405
+ y_pred_final = (probas >= best_threshold).astype(int)
406
+ final_acc = accuracy_score(y_test, y_pred_final)
407
+ final_f1 = f1_score(y_test, y_pred_final)
408
+ final_denied_recall = recall_score(y_test, y_pred_final, pos_label=1)
409
+
410
+ print(f"\n Optimal threshold: {best_threshold:.2f}")
411
+ print(f" Final Accuracy: {final_acc:.4f}")
412
+ print(f" Final F1: {final_f1:.4f}")
413
+ print(f" Final Denied Recall: {final_denied_recall:.4f}")
414
+
415
+ # ------------------------------------------------------------------
416
+ # 15. Wrap in ThresholdClassifier
417
+ # ------------------------------------------------------------------
418
+ if abs(best_threshold - 0.5) > 0.005:
419
+ final_model = ThresholdClassifier(winner_model, threshold=best_threshold)
420
+ print(f"\n Wrapped in ThresholdClassifier(threshold={best_threshold:.2f})")
421
+ else:
422
+ final_model = winner_model
423
+ print(f"\n Threshold ~0.50, using raw model (no wrapper needed)")
424
+
425
+ # ------------------------------------------------------------------
426
+ # 16. Full comparison summary
427
+ # ------------------------------------------------------------------
428
+ print(f"\n{'=' * 70}")
429
+ print("FINAL COMPARISON SUMMARY")
430
+ print(f"{'=' * 70}")
431
+ print(f"{'Model':<35} {'Test Acc':>9} {'Test F1':>8} {'Denied Rcl':>11}")
432
+ print("-" * 70)
433
+ for r in results:
434
+ print(f"{r['name']:<35} {r['test_acc']:>9.4f} {r['test_f1']:>8.4f} {r['denied_recall']:>11.4f}")
435
+ print(f"{'StackingClassifier':<35} {stack_acc:>9.4f} {stack_f1:>8.4f} {stack_denied_recall:>11.4f}")
436
+ print(f"{'+ Threshold (' + f'{best_threshold:.2f})':<35} {final_acc:>9.4f} {final_f1:>8.4f} {final_denied_recall:>11.4f}")
437
+ print("-" * 70)
438
+ print(f"{'SELECTED':<35} {final_acc:>9.4f} {final_f1:>8.4f} {final_denied_recall:>11.4f}")
439
+
440
+ # ------------------------------------------------------------------
441
+ # 17. Classification report
442
+ # ------------------------------------------------------------------
443
+ print(f"\nClassification report on test set ({X_test.shape[0]} samples):")
444
+ y_pred_best = final_model.predict(X_test_transformed)
445
+ print(classification_report(
446
+ y_test, y_pred_best, target_names=["Certified", "Denied"],
447
+ ))
448
+
449
+ # ------------------------------------------------------------------
450
+ # 18. Serialize as visaModel
451
+ # ------------------------------------------------------------------
452
+ visa_model = visaModel(
453
+ preprocessing_object=preprocessor,
454
+ trained_model_object=final_model,
455
+ )
456
+
457
+ model_bytes = pickle.dumps(visa_model)
458
+ print(f"Model serialized ({len(model_bytes)} bytes)")
459
+ print(f" Preprocessor: ColumnTransformer")
460
+ print(f" Classifier: {final_model}")
461
+
462
+ return model_bytes
463
+
464
+
465
+ @app.local_entrypoint()
466
+ def main():
467
+ csv_path = os.path.join(LOCAL_DIR, "EasyVisa.csv")
468
+ config_path = os.path.join(LOCAL_DIR, "config", "model.yaml")
469
+
470
+ with open(csv_path, "rb") as f:
471
+ csv_bytes = f.read()
472
+ with open(config_path, "r") as f:
473
+ config_yaml = f.read()
474
+
475
+ print("Submitting training job to Modal (H100 GPU) ...")
476
+ model_bytes = train.remote(csv_bytes, config_yaml)
477
+
478
+ artifact_dir = os.path.join(LOCAL_DIR, "artifact")
479
+ os.makedirs(artifact_dir, exist_ok=True)
480
+ model_path = os.path.join(artifact_dir, "model.pkl")
481
+
482
+ with open(model_path, "wb") as f:
483
+ f.write(model_bytes)
484
+
485
+ print(f"\nModel saved to {model_path}")
486
+ print("Done.")