import logging
from fastai.tabular.all import *
import pandas as pd
import numpy as np
pd.options.mode.chained_assignment = None  # default='warn'

from sklearn.metrics import roc_auc_score
from IPython.utils import io  # using io.capture_output

Set up

Specify the folder which contains the original kaggle data (train.csv and test.csv) and the trained model (learn_0708.pkl)

path = Path('data/homesite-quote')
logger = logging.getLogger("load_pickled_model")
logging.basicConfig(level=logging.INFO)

Load Deep Learning model

I created my pickled deep learning model using notebook https://redditech.github.io/team-fast-tabulous/kaggle/fastai/2021/07/08/HomeSite-Quote-A-Fastai-Tabular-Approach.html and in the deep learning model section after cell

dl_roc_auc_score=roc_auc_score(to_np(targs), to_np(preds[:,1]))

I ran the command

save_pickle(path/"learn_0708.pkl", learn)

Now I can load that pickle into this notebook and check it gives the same dl_roc_auc_score which should be 0.963051

trained_dl_pkl = "learn_0708.pkl"
learn = load_pickle(path/trained_dl_pkl)
preds, targs = learn.get_preds()
print(f"Trained deep learning model {trained_dl_pkl} has a roc_auc_score of {roc_auc_score(to_np(targs), to_np(preds[:,1]))}")
Trained deep learning model learn_0708.pkl has a roc_auc_score of 0.9630509268687871

Load XGBoost model (optional)

I created my pickled xgboost model using notebook https://redditech.github.io/team-fast-tabulous/kaggle/fastai/2021/07/08/HomeSite-Quote-A-Fastai-Tabular-Approach.html and in the XGBoost model section after cell

plot_importance(xgb_model, height=1,max_num_features=20,)

I ran the commands

save_pickle(path/"to_0708.pkl", to)
save_pickle(path/"xgb_model_0708.pkl", xgb_model)

Now I can load those pickles into this notebook and check it gives the same xg_roc_auc_score which should be 0.964158

to = load_pickle(path/"to_0708.pkl")
trained_xgb_pkl = "xgb_model_0708.pkl"
xgb_model = load_pickle(path/trained_xgb_pkl)
xgb_preds = xgb_model.predict_proba(to.valid.xs)
print(f"Trained XGBoost model {trained_xgb_pkl} has a roc_auc_score of {roc_auc_score(to.valid.ys.values.ravel(), xgb_preds[:, 1])}")
Trained XGBoost model xgb_model_0708.pkl has a roc_auc_score of 0.9641575675307634

Create a DataFrame of all quotes, train and test

I want all quotes so I can call up any quote number. We have finished training so doesn't matter whether in train or test. We save the actual conversion flags from train in sr_conv for later reference.

df_train = pd.read_csv(path/'train.csv', low_memory=False, parse_dates=['Original_Quote_Date'], index_col="QuoteNumber")
df_test = pd.read_csv(path/'test.csv', low_memory=False, parse_dates=['Original_Quote_Date'], index_col="QuoteNumber")
sr_conv = df_train['QuoteConversion_Flag']
df_train.drop('QuoteConversion_Flag', inplace=True, axis=1)
df = pd.concat([df_train, df_test])
df = add_datepart(df, 'Original_Quote_Date')
print(df.shape, df_train.shape, df_test.shape, sr_conv.shape)
df_train = None
df_test = None
(434589, 309) (260753, 297) (173836, 297) (260753,)

Looking at actual conversion flags we can see a few which were successful, eg 25, 26, 32, 47

sr_conv.head(30)
QuoteNumber
1     0
2     0
4     0
6     0
8     0
12    0
13    0
14    0
18    0
19    0
20    0
22    0
25    1
26    1
28    0
29    0
30    0
32    1
35    0
37    0
38    0
40    0
41    0
44    0
45    0
47    1
50    0
51    0
53    0
57    0
Name: QuoteConversion_Flag, dtype: int64

Looking at sorted df we can see all quote numbers are represented, at least in the first 30 ;)

df.sort_index().head(30)
Field6 Field7 Field8 Field9 Field10 Field11 Field12 CoverageField1A CoverageField1B CoverageField2A ... Original_Quote_Day Original_Quote_Dayofweek Original_Quote_Dayofyear Original_Quote_Is_month_end Original_Quote_Is_month_start Original_Quote_Is_quarter_end Original_Quote_Is_quarter_start Original_Quote_Is_year_end Original_Quote_Is_year_start Original_Quote_Elapsed
QuoteNumber
1 B 23 0.9403 0.0006 965 1.0200 N 17 23 17 ... 16 4 228 False False False False False False 1.376611e+09
2 F 7 1.0006 0.0040 548 1.2433 N 6 8 6 ... 22 1 112 False False False False False False 1.398125e+09
3 E 16 0.9364 0.0006 1,487 1.3045 N 4 4 4 ... 12 1 224 False False False False False False 1.407802e+09
4 F 7 1.0006 0.0040 548 1.2433 N 7 12 7 ... 25 0 237 False False False False False False 1.408925e+09
5 F 11 0.9919 0.0038 564 1.1886 N 8 14 8 ... 7 5 250 False False False False False False 1.378512e+09
6 J 10 0.9769 0.0004 1,165 1.2665 N 3 2 3 ... 15 0 105 False False False False False False 1.365984e+09
7 F 15 0.8945 0.0038 564 1.0670 N 11 18 11 ... 29 4 88 False False False False False False 1.364515e+09
8 E 23 0.9472 0.0006 1,487 1.3045 N 8 13 8 ... 25 5 25 False False False False False False 1.390608e+09
9 K 21 0.8870 0.0004 1,113 1.2665 Y 14 22 15 ... 21 5 80 False False False False False False 1.426896e+09
10 B 25 0.9153 0.0007 935 1.0200 N 4 5 4 ... 10 2 344 False False False False False False 1.418170e+09
11 B 24 0.9403 0.0006 965 1.0200 N 11 18 11 ... 19 4 200 False False False False False False 1.374192e+09
12 E 14 0.9472 0.0006 1,487 1.3045 N 13 20 13 ... 18 5 18 False False False False False False 1.390003e+09
13 J 23 0.9258 0.0004 1,165 1.2665 N 16 23 17 ... 1 4 305 False True False False False False 1.383264e+09
14 B 25 0.9153 0.0007 935 1.0200 N 5 8 5 ... 14 2 134 False False False False False False 1.400026e+09
15 E 23 0.9392 0.0006 1,487 1.3045 N 5 6 5 ... 28 0 209 False False False False False False 1.406506e+09
16 B 25 0.9153 0.0007 935 1.0200 N 6 9 6 ... 20 1 20 False False False False False False 1.421712e+09
17 J 23 0.8928 0.0004 1,113 1.2665 N 12 20 12 ... 28 2 148 False False False False False False 1.401235e+09
18 J 10 0.9691 0.0004 1,165 1.2665 N 5 8 6 ... 19 2 170 False False False False False False 1.371600e+09
19 F 11 0.9919 0.0038 564 1.1886 N 11 19 11 ... 18 1 169 False False False False False False 1.371514e+09
20 B 23 0.9403 0.0006 965 1.0200 N 4 4 4 ... 24 1 267 False False False False False False 1.379981e+09
21 J 23 0.9691 0.0004 1,165 1.2665 N 3 3 3 ... 11 3 192 False False False False False False 1.373501e+09
22 B 23 0.9403 0.0006 965 1.0200 N 8 14 9 ... 11 1 162 False False False False False False 1.370909e+09
23 B 25 0.9403 0.0007 935 1.0200 N 9 15 9 ... 5 2 36 False False False False False False 1.391558e+09
24 B 25 0.9153 0.0007 935 1.0200 N 17 23 17 ... 27 4 58 False False False False False False 1.424995e+09
25 J 20 0.9497 0.0004 1,165 1.2665 N 18 23 18 ... 22 1 295 False False False False False False 1.382400e+09
26 B 24 0.9403 0.0006 965 1.0200 N 5 6 5 ... 20 1 232 False False False False False False 1.376957e+09
27 B 23 0.9403 0.0006 965 1.0200 N 5 7 5 ... 14 3 318 False False False False False False 1.384387e+09
28 F 22 0.9893 0.0040 548 1.2433 N 10 17 10 ... 24 1 55 False False False False False False 1.424736e+09
29 J 26 0.8793 0.0004 1,113 1.2665 N 8 14 8 ... 27 0 300 False False False False False False 1.414368e+09
30 B 25 0.9153 0.0007 935 1.0200 N 6 8 6 ... 6 4 37 False False False False False False 1.423181e+09

30 rows × 309 columns

Check that we have every quote number between 1 and 434589 inclusive. We do.

df.index.min(), df.index.max(), df.index.max() - df.index.min() + 1, df.shape[0], df.index.nunique()
(1, 434589, 434589, 434589, 434589)

To select a quote, use DataFrame.loc with the quote number in square brackets. For example quote number 1

df.loc[1]
Field6                                        B
Field7                                       23
Field8                                   0.9403
Field9                                   0.0006
Field10                                     965
                                       ...     
Original_Quote_Is_quarter_end             False
Original_Quote_Is_quarter_start           False
Original_Quote_Is_year_end                False
Original_Quote_Is_year_start              False
Original_Quote_Elapsed             1376611200.0
Name: 1, Length: 309, dtype: object

Similarly to select an "actual" quote conversion use Series of conversions sr_conv with quote number in square brackets

sr_conv[2]
0

We can see that our model predicts that quote 25 to be sucessful and it was

qn = 25
prd = learn.predict(df.loc[qn])
print("Predicted success", prd[1], "with confidence", prd[2])
print("Actual success", sr_conv[qn])
Predicted success tensor(1) with confidence tensor([0.0107, 0.9893])
Actual success 1

Create a sensitivity analysis tool

A field is sensitive if changing the value of the field can change the outcome of the predicted quote success

While logging is INFO some logging will occur during a normal run. Setting logging level to WARNING will only log if an unknown dtype is encountered. See setup above to set level.

def sensitivity_analysis(qn):
    """Using data from quote number qn do a sensitivity analysis on all independent variables"""
    # Independent variables
    ind_original = df.loc[qn]
    prd = learn.predict(ind_original)
    # Predicted quote conversion flag
    qcf_original = prd[1].item()
    # Probability that quote conversion flag is as predicted
    prb_original = prd[2][qcf_original].item()
    logger.info(f"Sensitivity Analysis for Quote {qn}")
    # Check if we actually know the correct answer
    if qn in sr_conv.index:
        logger.info(f"Actual QuoteConversion_Flag {sr_conv[qn]}")

    def tf_sensitive(f, v_original, lst_v, p_original):
        """predicts quote success after changing field f from v_original to each value in lst_v. 
        If prediction changes then quote is sensitive to the value of this field and True is returned"""
        # Create a DataFrame which has every row identical except for field in question
        # Field f iterates through every value provided
        ind_other = df.loc[qn:qn].copy().drop(f, axis=1)  # fields other than f
        ind_f = pd.DataFrame(data={f: lst_v}, index=[qn] * len(lst_v))
        # Merge these two DataFrames to create one with all rows identical except field f
        ind = pd.merge(ind_other, ind_f, right_index=True, left_index=True)
        # Copy lines from learn.predict() because we want to predict several rows at once (faster than one at a time)
        dl = learn.dls.test_dl(ind)
        dl.dataset.conts = dl.dataset.conts.astype(np.float32)
        # stop learn.get_preds() printing blank lines
        with io.capture_output() as captured:
            # using get_preds() rather than predict() because get_preds can do multiple rows at once
            inp,preds,_,dec_preds = learn.get_preds(dl=dl, with_input=True, with_decoded=True)
        tf = False
        # Check if any predictions changed
        for i, dp in enumerate(dec_preds):
            qcf = dp.item()
            if qcf != qcf_original:
                prb = preds[i][qcf].item()
                logger.info(f"Changing {f} from {val_original} to {lst_v[i]} changes predicted quote conversion flag "
                            f"from {prb_original:.2%} {qcf_original} to {prb:.2%} {qcf}")
                tf = True
        return tf

    set_sensitive = set()
    # Loop through all fields. Check different values of each field to see if result is sensitive to it.
    for field in df.columns:
        ind = ind_original.copy()
        val_original = ind[field]
        tf_important = False
        num_unique = df[field].nunique()
        # If number of unique values is under 30 then try every value (or for objects try every value)
        if num_unique < 30 or df.dtypes[field] == 'O':
            lst_unique = df[field].unique()
            if tf_sensitive(field, val_original, lst_unique, prb_original):
                tf_important = True
            if tf_important:
                logger.info(f"Possible values of {field} are {lst_unique}")
                set_sensitive.add(field)
        else:
            if df.dtypes[field] == "int64":
                vmin = df[field].min()
                vmax = df[field].max()
                lst_val = [vmin + (vmax - vmin) * i // 10 for i in range(11)]
                logger.debug(f"{field} {num_unique} {df.dtypes[field]!r} {vmin} {vmax} {lst_val}")
                if tf_sensitive(field, val_original, lst_val, prb_original):
                    tf_important = True
            elif df.dtypes[field] == "float64":
                vmin = df[field].min()
                vmax = df[field].max()
                lst_val = [vmin + (vmax - vmin) * i / 10 for i in range(11)]
                logger.debug(f"{field} {num_unique} {df.dtypes[field]!r} {vmin} {vmax} {lst_val}")
                if tf_sensitive(field, val_original, lst_val, prb_original):
                    tf_important = True
            else:
                logger.warning(f"Unknown type {field} {num_unique} {df.dtypes[field]!r}")
            if tf_important:
                set_sensitive.add(field)
    # return the set of fields which had individual effects on the prediction
    return set_sensitive

Use the sensitivity tool

Here are the results of running the sensitivity analysis on quote number 2.

Sensitivity Analysis for Quote 2

Changing SalesField5 from 5 to 3 changes predicted quote conversion flag from 97.04% 0 to 63.74% 1
Changing SalesField5 from 5 to 4 changes predicted quote conversion flag from 97.04% 0 to 51.11% 1
Changing PersonalField2 from 1 to 0 changes predicted quote conversion flag from 97.04% 0 to 65.11% 1
Changing PersonalField13 from 2 to 1 changes predicted quote conversion flag from 97.04% 0 to 68.25% 1
Changing PropertyField29 from nan to 10.0 changes predicted quote conversion flag from 97.04% 0 to 100.00% 1
Changing PropertyField37 from N to Y changes predicted quote conversion flag from 97.04% 0 to 95.74% 1
sensitivity_analysis(2)
INFO:load_pickled_model:Sensitivity Analysis for Quote 2
INFO:load_pickled_model:Actual QuoteConversion_Flag 0
INFO:load_pickled_model:Changing SalesField5 from 5 to 3 changes predicted quote conversion flag from 97.04% 0 to 63.74% 1
INFO:load_pickled_model:Changing SalesField5 from 5 to 4 changes predicted quote conversion flag from 97.04% 0 to 51.11% 1
INFO:load_pickled_model:Possible values of SalesField5 are [5 3 2 4 1]
INFO:load_pickled_model:Changing PersonalField2 from 1 to 0 changes predicted quote conversion flag from 97.04% 0 to 65.11% 1
INFO:load_pickled_model:Possible values of PersonalField2 are [0 1]
INFO:load_pickled_model:Changing PersonalField13 from 2 to 1 changes predicted quote conversion flag from 97.04% 0 to 68.25% 1
INFO:load_pickled_model:Possible values of PersonalField13 are [2 1 4 3]
INFO:load_pickled_model:Changing PropertyField29 from nan to 10.0 changes predicted quote conversion flag from 97.04% 0 to 100.00% 1
INFO:load_pickled_model:Possible values of PropertyField29 are [ 0. nan  1. 10.]
INFO:load_pickled_model:Changing PropertyField37 from N to Y changes predicted quote conversion flag from 97.04% 0 to 95.74% 1
INFO:load_pickled_model:Possible values of PropertyField37 are ['N' 'Y' ' ']
{'PersonalField13',
 'PersonalField2',
 'PropertyField29',
 'PropertyField37',
 'SalesField5'}