Setup

Code
import pandas as pd
from copy import deepcopy
import warnings
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
from matplotlib import MatplotlibDeprecationWarning
import matplotlib.patches as patches
import random
from sklearn.metrics import confusion_matrix 
from sklearn.model_selection import KFold
from sklearn.pipeline import Pipeline
from pygam import LogisticGAM, f, s
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier
from sklearn.linear_model import LogisticRegression 
from sklearn.linear_model import LogisticRegressionCV  
from sklearn.model_selection import GridSearchCV
from sklearn.neighbors import KNeighborsClassifier
from sklearn.svm import SVC
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.ensemble import RandomForestClassifier
from statsmodels.stats.outliers_influence import variance_inflation_factor

random.seed(42)
sns.set_theme(style="whitegrid", palette="pastel",
    rc={
        "figure.dpi": 150,
        "axes.spines.right": False, 
        "axes.spines.top": False,
    })
def save_fig(fig, name):
    fig.savefig(f"figures/{name}.png", dpi=150)

# The boxplot for seaborn likes to spits out PendingDeprecationWarning and I could not figure it out. So I just disable the warning.
warnings.filterwarnings("ignore", category=PendingDeprecationWarning)
warnings.filterwarnings("ignore", category=MatplotlibDeprecationWarning)

Dataset

This data is gathered from a private hospital in Indonesia. It contains blood test result and the patient treatment given after the blood test. The dataset can be downloaded from here

First, take a glimpse at the dataset to check for irregularities and learn about the features.

Code
df = pd.read_csv("data/data-ori.csv")
df
HAEMATOCRIT HAEMOGLOBINS ERYTHROCYTE LEUCOCYTE THROMBOCYTE MCH MCHC MCV AGE SEX SOURCE
0 35.1 11.8 4.65 6.3 310 25.4 33.6 75.5 1 F out
1 43.5 14.8 5.39 12.7 334 27.5 34.0 80.7 1 F out
2 33.5 11.3 4.74 13.2 305 23.8 33.7 70.7 1 F out
3 39.1 13.7 4.98 10.5 366 27.5 35.0 78.5 1 F out
4 30.9 9.9 4.23 22.1 333 23.4 32.0 73.0 1 M out
... ... ... ... ... ... ... ... ... ... ... ...
4407 32.8 10.4 3.49 8.1 72 29.8 31.7 94.0 92 F in
4408 33.7 10.8 3.67 6.7 70 29.4 32.0 91.8 92 F in
4409 33.2 11.2 3.47 7.2 235 32.3 33.7 95.7 93 F out
4410 31.5 10.4 3.15 9.1 187 33.0 33.0 100.0 98 F in
4411 33.5 10.9 3.44 5.8 275 31.7 32.5 97.4 99 F out

4412 rows × 11 columns

Code
df.describe()
HAEMATOCRIT HAEMOGLOBINS ERYTHROCYTE LEUCOCYTE THROMBOCYTE MCH MCHC MCV AGE
count 4412.000000 4412.000000 4412.000000 4412.000000 4412.000000 4412.000000 4412.000000 4412.000000 4412.000000
mean 38.197688 12.741727 4.541260 8.718608 257.524479 28.234701 33.343042 84.612942 46.626473
std 5.974784 2.079903 0.784091 5.049041 113.972365 2.672639 1.228664 6.859101 21.731218
min 13.700000 3.800000 1.480000 1.100000 8.000000 14.900000 26.000000 54.000000 1.000000
25% 34.375000 11.400000 4.040000 5.675000 188.000000 27.200000 32.700000 81.500000 29.000000
50% 38.600000 12.900000 4.570000 7.600000 256.000000 28.700000 33.400000 85.400000 47.000000
75% 42.500000 14.200000 5.050000 10.300000 321.000000 29.800000 34.100000 88.700000 64.000000
max 69.000000 18.900000 7.860000 76.600000 1183.000000 40.800000 39.000000 115.600000 99.000000

Interesting that the minimum age is 1 and the maximum is 99, seems convenient. It is also interesting that some of the maximum values are insanely far from the mean.

Let us do some one factor encoding for the output (SOURCE) and (SEX). - 1 For Male - 1 For in-care

Code
df['SEX'] = [1 if i == 'M' else 0 for i in df['SEX'] ]
df['SOURCE'] = [1 if i == 'in' else 0 for i in df['SOURCE']]

EDA

Missing values

Code
df.isnull().sum()
HAEMATOCRIT     0
HAEMOGLOBINS    0
ERYTHROCYTE     0
LEUCOCYTE       0
THROMBOCYTE     0
MCH             0
MCHC            0
MCV             0
AGE             0
SEX             0
SOURCE          0
dtype: int64

No missing data

Class balance

Code
df['SOURCE'].value_counts()
SOURCE
0    2628
1    1784
Name: count, dtype: int64
Code
round(df['SOURCE'].value_counts()[0]/df['SOURCE'].value_counts().sum(), 3)
np.float64(0.596)

It seems that there is a slight class imbalance, where patients who are given out of care treatments are \(\approx 60\%\) of the data

Outliers

Code
numeric_feat = [i for i in df.columns if i not in ["SEX", "SOURCE"]]
fig, axes = plt.subplots(3, 3, figsize=(12, 10))
for ax, col in zip(axes.flatten(), numeric_feat):
    sns.boxplot(df[col], ax=ax)
    ax.set_title(col)
plt.tight_layout()

Consistent with our finding in df.describe() earlier. There are no negatives so the outliers will not be removed. Especially since I do not know whether the outliers usually happens with really sick people or not. (Simply put, no domain knowledge)

Let us check the predictors

Code
fig, axes = plt.subplots(5, 2, figsize=(12, 12))

for ax, col in zip(axes.flatten(), numeric_feat):
    df[df['SOURCE'] == 0][col].plot(kind='density', ax=ax, label='Out-care')
    df[df['SOURCE'] == 1][col].plot(kind='density', ax=ax, label='In-care')
    ax.set_title(col)
    ax.legend()

fig.delaxes(axes.flatten()[9])
fig.tight_layout()

save_fig(fig, "Dist")
plt.show()

Seems normal, no extreme skewness or anything to worry about. None of the data actually went into the negative, so it must be from the plot. Regarding the densities difference, both classes seem to overlap heavily and there are barely any distinction between the two. Therefore, the expectation of the models that we are about to do should be low.

Code
df['SEX'].value_counts()
SEX
1    2290
0    2122
Name: count, dtype: int64

Looks really balanced for the only categorical predictor we have.

Correlation

Code
fig, ax = plt.subplots(figsize=(8,8))
sns.heatmap(df.corr(), annot=True, ax=ax, cbar=False)

plt.tight_layout()
save_fig(fig, "Corr")
plt.show()

Code
# For highlighting
fig, ax = plt.subplots(figsize=(8,8))
sns.heatmap(df.corr(), annot=True, ax=ax, cbar=False)


n = len(df.columns)
veil = dict(facecolor="white", alpha=0.6, edgecolor="none", zorder=3)

ax.add_patch(patches.Rectangle((3, 0), n-3, n-6, **veil))   # right of 1st cluster
ax.add_patch(patches.Rectangle((8, 5), n-8, n-5, **veil))   # right of 2nd cluster


ax.add_patch(patches.Rectangle((3, 5), 2, 3, **veil))   # left of 2nd cluster

ax.add_patch(patches.Rectangle((0, 3), 3, n-3, **veil))   # below 1st cluster
ax.add_patch(patches.Rectangle((3, 8), 5, n-3, **veil))   # below 2nd cluster


# ax.add_patch(patches.Rectangle((5, 0), 3, 5, **veil))   # above block

# Border
ax.add_patch(patches.Rectangle((0, 0), 3, 3, fill=False,
             edgecolor="black", lw=3, zorder=4)) # First cluster

ax.add_patch(patches.Rectangle((5, 5), 3, 3, fill=False,
             edgecolor="black", lw=3, zorder=4)) # Second cluster

plt.tight_layout()
save_fig(fig, "Corr12")
plt.show()

There are no strong correlation between any of the predictors and the target variable (SOURCE). But, we can see that there are some multicolinearity going on with the predictors. For example, HAEMOGLOBINS have a very strong correlation with HAEMATOCRIT. Which makes sense, because by definition, HAEMOGLOBINS are a protein that is contained in red blood cells while HAEMATOCRIT are volume of red blood cells relative to total blood cells.

Variance Inflation Factor (VIF)

Because we have multicollinearity, and I do not have medical background to justify direct feature elimination, I will perform iterative feature selection using VIF by eliminating features that have VIF > 10.

Code
to_remove = ["SOURCE"]
max_vif = 11

while max_vif > 10:
    vif_data = pd.DataFrame()
    feature_col = [features for features in df.columns if features not in to_remove]
    vif_data["feature"] = feature_col
    vif_data["VIF"] = [variance_inflation_factor(df[feature_col].values, i)
                            for i in range(len(feature_col))]
    print(vif_data)
    max_vif = vif_data['VIF'].max()
    highest_vif = vif_data.iloc[vif_data['VIF'].idxmax()]['feature']

    if max_vif <= 10:
        break
    print("Next Remove:", highest_vif, "VIF:",max_vif, "\n")
    to_remove.append(highest_vif)
        feature           VIF
0   HAEMATOCRIT   4826.714632
1  HAEMOGLOBINS   5135.341274
2   ERYTHROCYTE   2163.380078
3     LEUCOCYTE      4.777937
4   THROMBOCYTE      7.092200
5           MCH  11726.362828
6          MCHC   2683.858106
7           MCV   5936.487995
8           AGE      7.793866
9           SEX      2.429804
Next Remove: MCH VIF: 11726.362827923707 

        feature          VIF
0   HAEMATOCRIT  4208.054143
1  HAEMOGLOBINS  1609.286833
2   ERYTHROCYTE   964.276224
3     LEUCOCYTE     4.777057
4   THROMBOCYTE     6.969074
5          MCHC  1172.766483
6           MCV  1143.541289
7           AGE     7.631677
8           SEX     2.427135
Next Remove: HAEMATOCRIT VIF: 4208.054143371129 

        feature         VIF
0  HAEMOGLOBINS  224.385784
1   ERYTHROCYTE  189.046889
2     LEUCOCYTE    4.776140
3   THROMBOCYTE    6.920672
4          MCHC  345.902727
5           MCV  321.615027
6           AGE    7.627768
7           SEX    2.424601
Next Remove: MCHC VIF: 345.9027274329704 

        feature         VIF
0  HAEMOGLOBINS  190.151616
1   ERYTHROCYTE  116.583441
2     LEUCOCYTE    4.640159
3   THROMBOCYTE    6.864122
4           MCV   66.240006
5           AGE    7.589208
6           SEX    2.408508
Next Remove: HAEMOGLOBINS VIF: 190.1516157055886 

       feature        VIF
0  ERYTHROCYTE  27.877315
1    LEUCOCYTE   4.494059
2  THROMBOCYTE   6.750448
3          MCV  41.536179
4          AGE   7.542791
5          SEX   2.284626
Next Remove: MCV VIF: 41.53617932307115 

       feature       VIF
0  ERYTHROCYTE  8.480231
1    LEUCOCYTE  4.399395
2  THROMBOCYTE  6.670476
3          AGE  4.649109
4          SEX  2.268295

Summary

  • The data contains no missing values.
  • The predictors distributions looks pretty normal and there are no imbalance within the categorical predictor.
  • There is a slight class imbalance on the target variable (60/40), but should not pose a problem and there will be no action taken.
  • There are a lot of outliers but none of them are in the negatives (which I know is impossible), but since I do not know whether the outliers are considered normal for sick people, there will be no action taken.
  • There are some strong multicollinearity going on with (HAEMATOCRIT - HAEMOGLOBINS - ERYTHROCYTE), and (MCH - MCV).
  • There are some moderate multicollinearity going on with (MCH- MCHC).
  • The multicollinearity predictors will be reduced for some models but, the full model will be shown as well.

After feature selection, we are left with 5 features, which are - ERYTHROCYTE - LEUCOCYTE - THROMBOCYTE - AGE - SEX

Modelling Functions

Added as extended analysis after the course. Mostly refactoring to keep things clean. Also, I’ve added scaling cost that punishes false negatives and lower the threshold for every model to predict for In-Care treatment.

Code
def kfold_data(df, n_splits):
    '''
    Split data using K-fold cross validation.
    '''
    kf = KFold(n_splits= n_splits)
    train_indexes = []
    test_indexes = []

    for train_index, test_index in kf.split(df):
        train_indexes.append(train_index)
        test_indexes.append(test_index)
    
    return train_indexes, test_indexes

def get_metrics(costs, pred_proba, y_test):
    '''
    Compute multiple costs for misclassification rate.
    '''
    result = []
    for i in costs:
        weighted_pred = []
        for j in pred_proba:
            weighted_pred.append(np.argmax(j * [1, i]))
        
        cf = confusion_matrix(y_test, np.array(weighted_pred))
        TN, FP = cf[0][0], cf[0][1]
        FN, TP = cf[1][0], cf[1][1]

        expected_cost = (i * FN + FP) / np.sum(cf)
        sensitivity =  TP / (TP + FN)
        specificity =  TN / (TN + FP)
        result.append([i, expected_cost, sensitivity, specificity])
    
    # Returns a list
    return result

def get_gam_metrics(costs, y_pred, y_test):
    result = []
    for i in costs:
        threshold = 1 / (1+i)
        weighted_pred = (y_pred > threshold).astype(int)

        cf = confusion_matrix(y_test, np.array(weighted_pred))
        TN, FP = cf[0][0], cf[0][1]
        FN, TP = cf[1][0], cf[1][1]

        expected_cost = (i * FN + FP) / np.sum(cf)
        sensitivity =  TP / (TP + FN)
        specificity =  TN / (TN + FP)
        result.append([i, expected_cost, sensitivity, specificity])

    return result

def cv_predict(df, model, model_name, train_indexes, test_indexes, to_remove, grid_search):
    '''
    Use K-fold cross validation to predict and log results.
    '''
    models = []
    results = []

    for fold in range(len(train_indexes)):
        # Dataset split
        X_train = df[[i for i in df.columns if i not in to_remove]].iloc[train_indexes[fold]]
        Y_train = df['SOURCE'].iloc[train_indexes[fold]]
        x_test = df[[i for i in df.columns if i not in to_remove]].iloc[test_indexes[fold]]
        y_test = df['SOURCE'].iloc[test_indexes[fold]]

        if grid_search:
            # Train
            model.fit(X_train, Y_train)
            models.append(deepcopy(model))
            # models.append(search_model.cv_results_['mean_test_score'])
            # Test
            pred_proba = model.best_estimator_.predict_proba(x_test)
            
        else:
            # Train
            model.fit(X_train, Y_train)
            models.append(deepcopy(model))
            # Test
            pred_proba = model.predict_proba(x_test)

         # Get metrics
        metrics = get_metrics([1, 3], pred_proba, y_test)

        for c in range(len(metrics)):
            results.append( [model_name, fold] +  metrics[c])
    
    # Returns list of confusion matrices?, list of model objects, and df
    return models, pd.DataFrame(results, columns=['model_name', 'fold', 'cost', 'expected_cost', 'sensitivity', 'specificity'])

def gam_predict(df, model_name, train_indexes, test_indexes, is_reduced):
    models = []
    results = []

    for fold in range(len(train_indexes)):
        X_train = df[[i for i in df.columns if i != "SOURCE"]].iloc[train_indexes[fold]]
        Y_train = df['SOURCE'].iloc[train_indexes[fold]]

        x_test = df[[i for i in df.columns if i != "SOURCE"]].iloc[test_indexes[fold]]
        y_test = df['SOURCE'].iloc[test_indexes[fold]]

        if is_reduced:
            x_test = x_test[[i for i in df.columns if i in ['ERYTHROCYTE', 'LEUCOCYTE', 'THROMBOCYTE', 'AGE', 'SEX']]]
            X_train_gam = np.column_stack([X_train['ERYTHROCYTE'], X_train['LEUCOCYTE'],
            X_train['THROMBOCYTE'], X_train['AGE'], pd.Categorical(X_train['SEX']).codes])
            gam = LogisticGAM(s(0) + s(1) + s(2)  + s(3) + f(4), verbose=False).gridsearch(
            X_train_gam, 
            pd.Categorical(Y_train).codes,
            lam= [np.logspace(-2, 2, 3)]*5)
            
        else:
            X_train_gam = np.column_stack([X_train['HAEMATOCRIT'], X_train['HAEMOGLOBINS'], X_train['ERYTHROCYTE'], X_train['LEUCOCYTE'],
            X_train['THROMBOCYTE'], X_train['MCH'], X_train['MCHC'], X_train['MCV'], X_train['AGE'], pd.Categorical(X_train['SEX']).codes])
    
            gam = LogisticGAM(s(0) + s(1) + s(2) + s(3) + s(4) + s(5) + s(6) + s(7) + s(8) + f(9), verbose=False).gridsearch(
            X_train_gam, 
            pd.Categorical(Y_train).codes,
            lam=np.logspace(-3, 3, 11))

        models.append(deepcopy(gam))

        pred_proba = gam.predict_proba(x_test)
        metrics = get_gam_metrics([1, 3], pred_proba, y_test)

        for c in range(len(metrics)):
            results.append( [model_name, fold] +  metrics[c])
            
    return models, pd.DataFrame(results, columns=['model_name', 'fold', 'cost', 'expected_cost', 'sensitivity', 'specificity'])


def log_result(models, res, temps):
    models.append(temps[0])
    res = pd.concat([res, temps[1]]).reset_index(drop=True)
    return models, res

models, res = [], pd.DataFrame(columns=['model_name', 'fold', 'cost', 'expected_cost', 'sensitivity', 'specificity'])
train_indexes, test_indexes = kfold_data(df, 5)

Logistic regression

First, I will try the simplest classification model. Additionally, I will use Ridge and Lasso penalties to help with the multicollinearity and give weight to features.

For cleanliness, I will ignore FutureWarning that basically states a new way to set a penalty for this model.

Code
warnings.filterwarnings("ignore", category=FutureWarning)
Code
# No Penalty
lr_none = LogisticRegression(penalty=None, max_iter=10000, random_state=42) 
models, res = log_result(models, res, cv_predict(df, lr_none, 'LR(0) - F', train_indexes, test_indexes, ["SOURCE"], False))

# # L1
lr_l1 = LogisticRegressionCV(penalty= 'l1', solver='saga', max_iter=10000, random_state=42) # l1
models, res = log_result(models, res, cv_predict(df, lr_l1, 'LR(1) - F', train_indexes, test_indexes, ["SOURCE"], False))

# # L2
lr_l2 = LogisticRegressionCV(penalty= 'l2', solver='saga', max_iter=10000, random_state=42) # l2
models, res = log_result(models, res, cv_predict(df, lr_l2, 'LR(2) - F', train_indexes, test_indexes, ["SOURCE"], False))

# No Penalty, reduced using VIF
lr_none = LogisticRegression(penalty=None, max_iter=10000, random_state=42) 
models, res = log_result(models, res, cv_predict(df, lr_none, 'LR(0) - R', train_indexes, test_indexes, to_remove, False))

# # L2, reduced using VIF
lr_l2 = LogisticRegressionCV(penalty= 'l2', solver='saga', max_iter=10000, random_state=42) # l2
models, res = log_result(models, res, cv_predict(df, lr_l2, 'LR(2) - R', train_indexes, test_indexes, to_remove, False))

K-nearest neighbors

Next, is a non-parametric model. Though, from the distribution plot earlier, I doubt the model will perform well since there are barely any distinction between the treatment groups.

Code
knn = KNeighborsClassifier()
knn_cv = GridSearchCV(estimator=knn, 
    param_grid={'n_neighbors': range(1, 21)})  # uses 5-fold cross validation by default

temp_models, temp_res = cv_predict(df, knn_cv, 'KNN', train_indexes, test_indexes, ["SOURCE"], True)
models, res = log_result(models, res, [temp_models, temp_res])
Code
k_cv = pd.DataFrame(columns=["fold", "n_neighbors", "mean_test_scores"])

for i in range(len(temp_models)):
    temp_k_cv = pd.DataFrame({
        "fold": np.full(20, i),
        "n_neighbors": list(range(1,21)),
        "mean_test_scores": temp_models[i].cv_results_['mean_test_score']
    })
    k_cv = pd.concat([k_cv, temp_k_cv]).reset_index(drop=True)
Code
sns.lineplot(data=k_cv, x='n_neighbors', y='mean_test_scores', hue='fold')
plt.ylabel("Mean Test Scores")
plt.xlabel("N-Neighbors")
plt.show()

It seems that as n_neighbors increases, the scores also increases. There might be not a meaningful clusters so this model might not help since if k increases towards the entire data, then the ‘nearest neighbors’ concept is discarded.

Generalized additive models

Due to multicollinearity, the model will spits out a bunch of warning during fitting. This also happens to reduced model where I removed the variables with high correlation with each other. But, it only happens during some section of the gridsearch for the penalty term, so it might be caused by that for the reduced model.

Code
warnings.filterwarnings('ignore')

Full Model

Code
gam_models, gam_res = gam_predict(df, 'GAM-F', train_indexes, test_indexes, False)
models, res = log_result(models, res, [gam_models, gam_res])
Code
gam_models[0].summary()
LogisticGAM                                                                                               
=============================================== ==========================================================
Distribution:                      BinomialDist Effective DoF:                                     40.2517
Link Function:                        LogitLink Log Likelihood:                                 -1879.4705
Number of Samples:                         3529 AIC:                                             3839.4443
                                                AICc:                                            3840.4441
                                                UBRE:                                               3.0971
                                                Scale:                                                 1.0
                                                Pseudo R-Squared:                                   0.2164
==========================================================================================================
Feature Function                  Lambda               Rank         EDoF         P > x        Sig. Code   
================================= ==================== ============ ============ ============ ============
s(0)                              [15.8489]            20           6.3          1.64e-02     *           
s(1)                              [15.8489]            20           3.9          3.09e-04     ***         
s(2)                              [15.8489]            20           4.6          3.27e-01                 
s(3)                              [15.8489]            20           4.0          0.00e+00     ***         
s(4)                              [15.8489]            20           4.8          0.00e+00     ***         
s(5)                              [15.8489]            20           4.0          3.91e-03     **          
s(6)                              [15.8489]            20           3.6          4.83e-04     ***         
s(7)                              [15.8489]            20           2.3          1.11e-02     *           
s(8)                              [15.8489]            20           5.9          9.89e-04     ***         
f(9)                              [15.8489]            2            0.9          4.95e-12     ***         
intercept                                              1            0.0          2.99e-02     *           
==========================================================================================================
Significance codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

WARNING: Fitting splines and a linear function to a feature introduces a model identifiability problem
         which can cause p-values to appear significant when they are not.

WARNING: p-values calculated in this manner behave correctly for un-penalized models or models with
         known smoothing parameters, but when smoothing parameters have been estimated, the p-values
         are typically lower than they should be, meaning that the tests reject the null too readily.

There are a lot of errors, since it uses Iterative Reweighted Least Squares, it might be because of multicollinearity. Additionally, the lambda are all the same which means, every features are penalized the same amount. This is not an optimal result but tuning the penalties will take a long time.

Reduced Model

I used the result of feature selection that was performed earlier using VIF.

Code
gamr_models, gamr_res = gam_predict(df, 'GAM-R', train_indexes, test_indexes, True)
models, res = log_result(models, res, [gamr_models, gamr_res])
Code
gamr_models[0].summary()
LogisticGAM                                                                                               
=============================================== ==========================================================
Distribution:                      BinomialDist Effective DoF:                                     29.6974
Link Function:                        LogitLink Log Likelihood:                                   -1952.92
Number of Samples:                         3529 AIC:                                             3965.2349
                                                AICc:                                            3965.7914
                                                UBRE:                                               3.1303
                                                Scale:                                                 1.0
                                                Pseudo R-Squared:                                   0.1857
==========================================================================================================
Feature Function                  Lambda               Rank         EDoF         P > x        Sig. Code   
================================= ==================== ============ ============ ============ ============
s(0)                              [1.]                 20           10.0         0.00e+00     ***         
s(1)                              [1.]                 20           6.3          0.00e+00     ***         
s(2)                              [1.]                 20           8.4          0.00e+00     ***         
s(3)                              [100.]               20           4.1          1.48e-03     **          
f(4)                              [1.]                 2            1.0          1.21e-06     ***         
intercept                                              1            0.0          6.10e-01                 
==========================================================================================================
Significance codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

WARNING: Fitting splines and a linear function to a feature introduces a model identifiability problem
         which can cause p-values to appear significant when they are not.

WARNING: p-values calculated in this manner behave correctly for un-penalized models or models with
         known smoothing parameters, but when smoothing parameters have been estimated, the p-values
         are typically lower than they should be, meaning that the tests reject the null too readily.

We can see how the multicollinearity affect each features. It is expected that the full model will have large confidence bands for features that are affected by multicollinearity.

Code
fig, axs = plt.subplots(5, 2, figsize=(12, 9))
titles = df[[i for i in df.columns if i != "SOURCE"]].columns

for i, ax in enumerate(axs.flat):
    XX = gam_models[0].generate_X_grid(term=i)
    pdep, confi = gam_models[0].partial_dependence(term=i, width=0.95)

    sns.lineplot(x=XX[:, i], y=pdep, ax=ax)
    sns.lineplot(x=XX[:, i], y=confi[:, 1], c="r", ls="--", ax=ax)
    sns.lineplot(x=XX[:, i], y=confi[:, 0], c="r", ls="--", ax=ax)
    
    ax.set_title(titles[i])
    
fig.tight_layout()
save_fig(fig, "PD_F")

plt.show()

It seems that these features that are affected by multicollinearity are also deemed important since most of them have very high importance relative to other features.

By reducing the model based on feature selection, I expect tighter confidence bands. While I do not have medical background, I assume that these metrics are there for a reason despite the strong correlation. Therefore, the model might lose some predictive ability from this reduction but I can get a much clearer interpretation of each features that are not correlated.

Code
fig, axs = plt.subplots(3, 2, figsize=(9, 6))
titles = df[[i for i in df.columns if i not in to_remove]].columns

for i, ax in enumerate(axs.flat):
    if i >= len(titles):
        fig.delaxes(ax)
        break
    XX = gamr_models[0].generate_X_grid(term=i)
    pdep, confi = gamr_models[0].partial_dependence(term=i, width=0.95)

    sns.lineplot(x=XX[:, i], y=pdep, ax=ax)
    sns.lineplot(x=XX[:, i], y=confi[:, 1], c="r", ls="--", ax=ax)
    sns.lineplot(x=XX[:, i], y=confi[:, 0], c="r", ls="--", ax=ax)
    ax.set_title(titles[i])
    ax.set_ylabel('Partial Dependence')

fig.tight_layout()
save_fig(fig, "PD_R")
plt.show()

There are widening of confidence bands at the edge, presumably due to the lack of data at that points.

These partial dependence does not seem to have linear relationships with the treatment choice, which is to be expected. - It seems that the patient that have ERYTHROCYTE between 2 until 6 are more likely to be admitted to In-care treatment. - Patients that have LEUCOCYTE more than ~40, are more likely to be given In-care treatment. - Patients that have THROMBOCYTE between 0 and 200, are also more likely to be given In-care treatment. The same applies for range between ~800 and ~1100 - Interestingly, patients that are aged between ~40 and ~60 are more likely to be given In-care treatment. I would expect the reverse of this. But again, the partial dependence looks relatively weak. - Male patients are more likely to be given In-care treatment though the partial dependence have the smallest scale among the other features.

Decision trees and ensemble methods

Then, we use decision trees which is probably going to do bad since it is a very simple model and quickly move on to ensemble methods, particularly random forest and gradient boost. - Because the random forest uses deep trees, it will have high variance and tries to reduce it by averaging a lot of trees - The gradient boost start with a shallow tree and sequentially fits the residuals, meaning it will initially start with high bias and reduce it as more trees fitted.

Decision Tree

Code
dtree = DecisionTreeClassifier(random_state= 42)
parameters_dtrees = {'max_depth': [i for i in range(1, 10, 1)]}
gs_dtrees = GridSearchCV(dtree, parameters_dtrees)
models, res = log_result(models, res, cv_predict(df, gs_dtrees, 'DT', train_indexes, test_indexes, ["SOURCE"], True))

Random Forest

Code
rforest = RandomForestClassifier(random_state= 42)
parameters_rforests = {"n_estimators":[i for i in range(20, 100, 20)], "max_features": ['sqrt', 'log2', 5, 7, 9]}
gs_rforest = GridSearchCV(rforest, parameters_rforests)
models, res = log_result(models, res, cv_predict(df, gs_rforest, 'RF', train_indexes, test_indexes, ["SOURCE"], True))

The ensemble methods provide feature importance and from it, I can see which feature are deemed important by the model.

Code
rf_features = {f : [] for f in df.columns if f != "SOURCE"}
for i in models[-1]:
    for importance, names in zip(i.best_estimator_.feature_importances_, i.best_estimator_.feature_names_in_):
        rf_features[names].append(importance)
    
Code
ax = sns.boxplot(data=pd.melt(pd.DataFrame(rf_features)).sort_values(by='value', ascending=False), y="variable", x="value", hue="variable")
plt.ylabel("Features")
plt.xlabel("Feature Importance")
plt.show(ax)

Most of the features have the same importance, but THROMBOCYTE is relatively strong while SEX is relatively weak.

Gradient Boost

Code
parameters_gboost = {
    'n_estimators': [200, 500],
    'learning_rate': [0.05, 0.1],
    'max_depth': [3, 5]
}
gboost = GradientBoostingClassifier(random_state= 42)
gs_gboost = GridSearchCV(gboost, parameters_gboost)
models, res = log_result(models, res, cv_predict(df, gs_gboost, 'GB', train_indexes, test_indexes, ["SOURCE"], True))
Code
gb_features = {f : [] for f in df.columns if f != "SOURCE"}

for i in models[-1]:
    for importance, names in zip(i.best_estimator_.feature_importances_, i.best_estimator_.feature_names_in_):
        gb_features[names].append(importance)
    
Code
ax = sns.boxplot(data=pd.melt(pd.DataFrame(gb_features)).sort_values(by='value', ascending=False), y="variable", x="value", hue="variable")
plt.ylabel("Features")
plt.xlabel("Feature Importance")

plt.show(ax)

The same idea here, but the feature importance is more aggresive in boosting method, so most features are relatively weak except for THROMBOCYTE.

Code
fig, ax = plt.subplots(figsize=(6, 5))
feature_importance = pd.concat([
    pd.melt(pd.DataFrame(gb_features)).assign(model_name="GB"), 
    pd.melt(pd.DataFrame(rf_features)).assign(model_name="RF")])

ax = sns.boxplot(data=feature_importance.sort_values(by='value', ascending=False), y="variable", x="value", hue="model_name")
plt.ylabel("Features")
plt.xlabel("Feature Importance")
sns.move_legend(ax, "lower right")

plt.tight_layout()
save_fig(fig, "feature_compare")
plt.show(fig)

Here, the comparison of importance can be seen between Gradient boost model which is more aggressive, and Random forest.

Support vector machine

Finally, I will try SVM, but unfortunately, this model requires a lot of hyperparameter tuning and my machine can not support it. Hence, the result might be sub optimal.

Code
parameters_svc = [
    {'svc__kernel': ['rbf'], 'svc__C': np.logspace(-2, 2, 5), 'svc__gamma': np.logspace(-3, 1, 5)}
]
svc = SVC(random_state= 42, probability= True)
pipe = Pipeline([('scaler', StandardScaler()), ('svc', svc)])
gs_svc = GridSearchCV(pipe, parameters_svc)

models, res = log_result(models, res, cv_predict(df, gs_svc, 'SVM', train_indexes, test_indexes, ["SOURCE"], True))

Results

Code
fig, ax = plt.subplots(figsize=(7, 5))
ax = sns.boxplot(data = res.sort_values(by='expected_cost', ascending= True)[res['cost'] == 1], y='model_name', x='expected_cost', hue='cost', palette=[sns.color_palette('pastel').as_hex()[0]])
ax.set_ylabel("Model Name")
ax.set_xlabel("Expected Cost")
ax.set_xlim((0.2, 0.8))
plt.tight_layout()
save_fig(fig, "EC1")
plt.show()

If we set the cost to 1, the metric will be equal to misclassification rate. While the KNN model have the lowest misclassification rate in one of the fold, the result varies too much as indicated by the wide box. To me, the best results are SVM, Gradient Boost, and GAM-F.

Code
res[res['cost'] == 1].groupby(by='model_name')[['expected_cost', 'sensitivity', 'specificity']].agg(
    mean_expected_cost = ('expected_cost', 'mean'),
    std_expected_cost = ('expected_cost', 'std'),
    mean_sensitivity = ('sensitivity', 'mean'),
    std_sensitivity = ('sensitivity', 'std'),
    mean_specificity = ('specificity', 'mean'),
    std_specificity = ('specificity', 'std')
).sort_values(by='mean_expected_cost', ascending=True)
mean_expected_cost std_expected_cost mean_sensitivity std_sensitivity mean_specificity std_specificity
model_name
SVM 0.265882 0.040164 0.563652 0.076877 0.837675 0.103816
GB 0.27381 0.035790 0.566912 0.058274 0.832633 0.057580
GAM-F 0.276759 0.046687 0.497475 0.071868 0.875842 0.028997
RF 0.28537 0.030622 0.555648 0.051986 0.820286 0.053320
KNN 0.287652 0.058187 0.455793 0.060892 0.884581 0.049251
LR(0) - F 0.299871 0.031139 0.490775 0.158574 0.820457 0.148648
LR(2) - F 0.300096 0.028792 0.493035 0.163740 0.818371 0.147457
LR(0) - R 0.300551 0.023131 0.460801 0.179267 0.838353 0.133235
LR(1) - F 0.300776 0.028294 0.487984 0.165167 0.820615 0.147829
LR(2) - R 0.314389 0.049182 0.39625 0.195015 0.853302 0.188567
GAM-R 0.317573 0.084907 0.500308 0.222664 0.797594 0.252151
DT 0.320959 0.053415 0.575991 0.225013 0.745779 0.157096

But, the mislcassification rate alone does not tell the whole story. Here, it is explained that most of the model have low sensitivity, which means it misses a lot of patients that were supposed to be given In-Care treatment. This is really bad in this context, because missing an In-Care treatment can be dangerous for patients.

Code
fig, ax = plt.subplots(figsize=(7, 5))
ax = sns.boxplot(data = res.sort_values(by='expected_cost', ascending= True)[res['cost'] == 3], y='model_name', x='expected_cost', hue='cost', palette=[sns.color_palette('pastel').as_hex()[1]], order=res[res['cost'] == 1].sort_values(by='expected_cost', ascending= True)['model_name'].unique())
ax.set_ylabel("Model Name")
ax.set_xlabel("Expected Cost")
ax.set_xlim((0.2, 0.8))
plt.tight_layout()
save_fig(fig, "EC3")
plt.show(ax)

This is the result where the cost of getting a False Negative (Missed an In-care treatment) is tripled and the models are more likely to give In-Care treatment. The three best models perform similarly, which are SVM, Gradient Boost, and RF

Code
res[res['cost'] == 3].groupby(by='model_name')[['expected_cost', 'sensitivity', 'specificity']].agg(
    mean_expected_cost = ('expected_cost', 'mean'),
    std_expected_cost = ('expected_cost', 'std'),
    mean_sensitivity = ('sensitivity', 'mean'),
    std_sensitivity = ('sensitivity', 'std'),
    mean_specificity = ('specificity', 'mean'),
    std_specificity = ('specificity', 'std')
).sort_values(by='mean_expected_cost', ascending=True)
mean_expected_cost std_expected_cost mean_sensitivity std_sensitivity mean_specificity std_specificity
model_name
SVM 0.475753 0.036083 0.837901 0.058034 0.519768 0.095211
GB 0.482099 0.017803 0.834645 0.030494 0.516148 0.074274
RF 0.500006 0.013243 0.845373 0.061724 0.455518 0.149082
GAM-F 0.520648 0.075539 0.806976 0.074577 0.53078 0.096977
LR(0) - F 0.529013 0.015728 0.889955 0.043790 0.328295 0.126786
LR(1) - F 0.533091 0.013636 0.891819 0.048059 0.318935 0.122567
LR(2) - F 0.534905 0.014628 0.890584 0.048896 0.317936 0.120024
KNN 0.54058 0.031971 0.795436 0.091765 0.469839 0.195439
GAM-R 0.562367 0.129012 0.7927 0.159316 0.489739 0.268465
LR(0) - R 0.575927 0.023896 0.879058 0.047827 0.273376 0.087937
DT 0.582279 0.071742 0.824516 0.171509 0.39713 0.376278
LR(2) - R 0.611735 0.040871 0.917511 0.047777 0.13531 0.077593
Code
fig, ax = plt.subplots(figsize=(7, 5))
ax = sns.boxplot(data = res.sort_values(by='expected_cost', ascending= True), y='model_name', x='expected_cost', hue='cost', palette=sns.color_palette('pastel'), order=res[res['cost'] == 1].sort_values(by='expected_cost', ascending= True)['model_name'].unique())
ax.set_ylabel("Model Name")
ax.set_xlabel("Expected Cost")
ax.set_xlim((0.2, 0.8))
plt.tight_layout()
save_fig(fig, "EC13")
plt.show()

By increasing the cost of False Negative and reducing the threshold for the model to predict In-Care treatment, there is a massive increase in sensitivity for all models, in exchange for specificity. From the table, we can see the top three model is actually SVM, Gradient Boost, and Random Forest. The Random forest has a much tighter box which helps the mean expected cost. All three perform similarly though.

Prediction wise, SVM have the best performance in both costs. But, do note that SVM model hyperparameter is not optimally tuned and it does takes a long time to train in comparison to all other models. Considering the time and requirements of SVM, I would say that Gradient Boost provides a consistent best prediction amongst all models but also requires a significant training time. With some performance trade off, I can also suggest using GAM-F model for some interpretability. Do note that GAM models required penalties to every features and in this project, it is not fully explored.

It is also important to note that for the ensemble methods, the probability of prediction works differently because of the nature of the model. Random forest model, by definition creates many deep decision trees and averages out the prediciton of every trees. This makes the random forest model lean more towards the center since it averages out the extreme by sheer number of trees. While Boosting model, sequentially create a low depth trees using the errors from previous trees, which pushes the probabilities toward extremes as the number of trees grows. Consequently, this does not align with the whole different threshold for the expected cost that was implemented. For example, when cost = 3, the probability threshold for the model to predict In-Care is 0.25, but as explained earlier, these ensemble methods probabilities are derived from the combination of number of trees and because of that, these models do not fully follow the behavior that I intend to induce by introducing the higher cost.

Regardless of the result, as stated in the beginnning during EDA, the distribution of every features overlaps for both of the treatments and these result are expected.