File size: 7,739 Bytes
09d5f59
5b21e2e
 
 
09d5f59
 
5b21e2e
09d5f59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# app.py
import numpy as np
import pandas as pd
import streamlit as st
import joblib
from pathlib import Path

st.set_page_config(page_title="Employee Attrition Predictor (XGBoost)", page_icon="🏢", layout="centered")

BASE_DIR = Path(__file__).resolve().parent
MODEL_PATH = BASE_DIR / "xgb_model.pkl"
FEATURES_PATH = BASE_DIR / "feature_names.pkl"
THRESHOLD_PATH = BASE_DIR / "threshold.pkl"

# --------- Load artifacts ---------
@st.cache_resource
def load_artifacts():
    missing = [p.name for p in [MODEL_PATH, FEATURES_PATH, THRESHOLD_PATH] if not p.exists()]
    if missing:
        raise FileNotFoundError(
            f"Missing files: {missing}. Put them in the repo root (same folder as app.py)."
        )

    model = joblib.load(MODEL_PATH)
    feature_names = joblib.load(FEATURES_PATH)
    threshold = joblib.load(THRESHOLD_PATH)

    # Safety
    if not isinstance(feature_names, (list, tuple)) or len(feature_names) == 0:
        raise ValueError("feature_names.pkl must be a non-empty list of column names.")
    threshold = float(threshold)

    return model, list(feature_names), threshold


model, feature_names, threshold = load_artifacts()

st.title("🏢 Employee Attrition Predictor (XGBoost)")
st.caption("Predicts the probability that an employee will leave (Attrition=1).")

with st.expander("Model info"):
    st.write(f"**Model:** XGBoost (saved as `xgb_model.pkl`)")
    st.write(f"**Number of features:** {len(feature_names)}")
    st.write(f"**Decision threshold:** {threshold:.2f}")
    st.write("Tip: Probability is used for Kaggle submissions (ROC-AUC metric).")


# --------- Helpers ---------
def build_input_from_form(form_values: dict) -> pd.DataFrame:
    """
    Create a single-row dataframe aligned to training feature order.
    Any missing one-hot columns are filled with 0.
    """
    X = pd.DataFrame([form_values])
    X = X.reindex(columns=feature_names, fill_value=0)
    return X


def predict_single(X_one_row: pd.DataFrame):
    proba = float(model.predict_proba(X_one_row)[:, 1][0])
    pred = int(proba >= threshold)
    return pred, proba


# --------- Input mode selection ---------
mode = st.radio("Choose input method", ["Single prediction (form)", "Batch prediction (CSV upload)"], horizontal=True)

# --------- Single prediction (manual form) ---------
if mode == "Single prediction (form)":
    st.subheader("Single prediction")

    # Minimal & robust form: user enters main numeric features + selects a few one-hot options.
    # Because your training features are one-hot, we provide a simple way to set them.
    # Any feature not set will default to 0.

    # Detect numeric-ish columns (not perfect, but good for UI)
    numeric_cols = [c for c in feature_names if not any(c.startswith(prefix) for prefix in [
        "BusinessTravel_", "Department_", "EducationField_", "Gender_", "JobRole_", "MaritalStatus_"
    ])]

    # Split numeric cols into two columns for nicer UI
    col_left, col_right = st.columns(2)
    form_values = {}

    with col_left:
        st.markdown("**Numeric / ordinal inputs**")
        # Provide a curated list of common HR numeric columns if present
        preferred_numeric = [
            "Age", "DistanceFromHome", "Education", "EnvironmentSatisfaction", "HourlyRate",
            "JobInvolvement", "JobLevel", "JobSatisfaction", "MonthlyIncome", "MonthlyRate",
            "NumCompaniesWorked", "PercentSalaryHike", "PerformanceRating", "RelationshipSatisfaction",
            "StockOptionLevel", "TotalWorkingYears", "TrainingTimesLastYear", "WorkLifeBalance",
            "YearsAtCompany", "YearsInCurrentRole", "YearsSinceLastPromotion", "YearsWithCurrManager",
            "OverTime",  # might be 0/1
            # engineered features (if you used these names)
            "tenure_ratio", "promotion_gap", "manager_stability", "income_per_level",
            "time_experience", "no_promotion", "income_experience",
        ]

        # Use preferred list if exists, else fallback to numeric_cols
        shown_numeric = [c for c in preferred_numeric if c in feature_names] or numeric_cols[:18]

        for c in shown_numeric:
            if c == "OverTime":
                form_values[c] = st.selectbox("OverTime (0=No, 1=Yes)", [0, 1], index=0)
            else:
                # default 0; user can adjust
                form_values[c] = st.number_input(c, value=0.0, step=1.0)

    with col_right:
        st.markdown("**Categorical one-hot selections**")
        st.caption("Select one option per group. Unselected groups remain 0 (baseline category).")

        # Map groups to their one-hot columns
        groups = {
            "BusinessTravel": [c for c in feature_names if c.startswith("BusinessTravel_")],
            "Department": [c for c in feature_names if c.startswith("Department_")],
            "EducationField": [c for c in feature_names if c.startswith("EducationField_")],
            "Gender": [c for c in feature_names if c.startswith("Gender_")],
            "JobRole": [c for c in feature_names if c.startswith("JobRole_")],
            "MaritalStatus": [c for c in feature_names if c.startswith("MaritalStatus_")],
        }

        # Initialize all one-hot group columns to 0
        for gcols in groups.values():
            for c in gcols:
                form_values[c] = 0

        for gname, gcols in groups.items():
            if not gcols:
                continue
            # Convert one-hot col name to label
            labels = ["(baseline / dropped category)"] + [c.split(f"{gname}_", 1)[1] for c in gcols]
            choice = st.selectbox(gname, labels, index=0)
            if choice != "(baseline / dropped category)":
                # Find matching one-hot column and set to 1
                target_col = f"{gname}_{choice}"
                if target_col in form_values:
                    form_values[target_col] = 1

    # Ensure all missing features exist
    for c in feature_names:
        form_values.setdefault(c, 0)

    X_one = build_input_from_form(form_values)

    if st.button("Predict", type="primary"):
        pred, proba = predict_single(X_one)

        st.metric("Attrition probability (P=1)", f"{proba:.3f}")
        if pred == 1:
            st.error(f"Prediction: Attrition = 1 (Leave)  | threshold={threshold:.2f}")
        else:
            st.success(f"Prediction: Attrition = 0 (Stay)  | threshold={threshold:.2f}")

        with st.expander("Show input vector (aligned features)"):
            st.dataframe(X_one)

# --------- Batch prediction (CSV upload) ---------
else:
    st.subheader("Batch prediction (CSV upload)")
    st.write("Upload a CSV that already matches the training feature format (after preprocessing/one-hot).")
    st.caption("If your CSV is raw, preprocess it the same way as in your notebook before uploading.")

    uploaded = st.file_uploader("Upload CSV", type=["csv"])
    if uploaded is not None:
        df_in = pd.read_csv(uploaded)

        # Drop target if user included it
        df_in = df_in.drop(columns=["Attrition"], errors="ignore")

        # Align columns to training feature set
        Xb = df_in.reindex(columns=feature_names, fill_value=0)

        probs = model.predict_proba(Xb)[:, 1]
        preds = (probs >= threshold).astype(int)

        out = df_in.copy()
        out["Attrition_proba"] = probs
        out["Attrition_pred"] = preds

        st.success(f"Predicted {len(out)} rows.")
        st.dataframe(out.head(20))

        csv_bytes = out.to_csv(index=False).encode("utf-8")
        st.download_button("Download predictions CSV", data=csv_bytes, file_name="predictions.csv", mime="text/csv")

st.divider()
st.caption("Built with Streamlit • Model: XGBoost • Metric focus: ROC-AUC")