Объяснимый ИИ — очень важный аспект в ML и DL. Он заключается в том, чтобы интерпретировать модель так, чтобы можно было около прозрачно объяснить ее решения. Потому что это довольно частая необходимость как у конечного заказчика, ведь для них это просто «черный ящик», так и у разработчиков непосредственно (например, для отладки модели). На русском языке таких статей не так много (для тех, кто знает английский проблем с этим нет, на нем таких статей много, например, Kaggle), поэтому я решил, что статья покажется актуальной, и сегодня я попробую рассказать про это и показать на конкретном примере, как его можно реализовать.

Но, перед тем как объяснять, необходимо написать эффективную модель, а для этого нужно провести исследовательский анализ данных.

Исследовательский анализ данных

Сегодня я буду использовать датасет с данными о мошеннических операциях с банковскими картами.

Для начала стоить посмотреть, как выглядит сам исходный датасет

Показать код
import pandas as pd
from pylab import rcParams
import matplotlib.pyplot as plt
import numpy as np
from warnings import filterwarnings

rcParams['figure.figsize'] = 20, 8
filterwarnings('ignore')

df = pd.read_csv('credit_card_fraud_dataset.csv')

df.head()
исходный датасет
исходный датасет

Теперь надо его преобразовать, а именно, удалить столбец TransactionID, так как прямого отношения к факту мошеннической операции он не имеет (по факту это просто столбец с индексами строк, начинающимися с 1, тогда как MerchantID - категориальный признак), но при этом будет вести к переобучению модели в будущем. Также я считаю, что необходимо разбить колонку TransactionDate на колонки с конкретным днем месяца (TransactionDate), днем недели (DayOfWeek), часом (TransactionTime), имеет прямое отношение именно час, так как минуты и секунды слишком малы в рамках суток, чтобы иметь существенное значение для предсказания модели, но тем не менее будет вести к переобучению модели в будущем

Показать код
df['TransactionTime'] = pd.to_datetime(df['TransactionDate']).dt.hour
df['TransactionMonth'] = pd.to_datetime(df['TransactionDate']).dt.month
df['DayOfWeek'] = pd.to_datetime(df['TransactionDate']).dt.dayofweek
df['TransactionDate'] = pd.to_datetime(df['TransactionDate']).dt.day
df.drop(columns=['TransactionID'], axis=1, inplace=True)

df.head()
преобразованный датасет
преобразованный датасет

Теперь можно составить список вопросов, основываясь на которых можно будет сделать какой-то вывод, касательно датасета и признаков, влияющих на факт мошенничества. Для начала я посмотрю на корреляцию штата и количества мошеннических операций; также я заведу отедельную функцию для построения графиков, так как они все будут примерно одинаково написаны

Показать код
def fraudPlotBasedOnQuestion(df: pd.DataFrame, group: list) -> plt.plot:
    """Builds plot of the amount of frauds per passed group"""

    return df.groupby(group)['IsFraud'].agg([np.mean]).sort_values(by='mean', ascending=False).plot(kind='bar',
                                                                                                    grid=True, rot=0)

fraudPlotBasedOnQuestion(df=df, group=['Location'])
зависимость количества мошеннических операций от штата
зависимость количества мошеннических операций от штата

Итак, исходя из графика видно, что штат оказывает существенное влияние на факт мошенничества, но, теперь появляются мысли насчет дисбаланса классов

Показать код
df['IsFraud'].value_counts()
дисбаланс классов
дисбаланс классов

Теперь виден огромный дисбаланс классов, но, перед тем, как разбираться с ним надо дальше проверить корреляции. Следующий вопрос: как коррелируют между собой время суток и количество мошеннических операций, потому что вполне возможно, что большинство мошеннических операций проводятся вечером

Показать код
fraudPlotBasedOnQuestion(df=df, group=['TransactionTime'])
зависимость количества мошеннических операций от времени суток
зависимость количества мошеннических операций от времени суток

Из этого графика видно, что время суток также оказывает существенное влияние на количество мошеннических операций: Наибольшее количество, с весьма большим отрывом, забирают 6 вечера, 1 час ночи, 8 утра, 12 ночи, 4 утра Далее из графика и топа 5 видно, что большинство мошеннических операций проводятся в часы, когда люди наиболее плохо соображают (за некоторым исключением) Теперь имеет смысл посмотреть на эту же корреляцию, но в зависимости от дня недели

Показать код
fraudPlotBasedOnQuestion(df=df, group=['DayOfWeek'])
зависимость количества мошеннических операций от дня недели
зависимость количества мошеннических операций от дня недели

Из этого графика хорошо видно, что день недели также влияет на количество мошеннических операций, самое большое количество - воскресенье, самое маленькое - суббота, но в остальном. чем ближе к выходным. тем выше количество операций растет Теперь посмотрим на медианное значение трат в зависимости от типы операции (возврат / покупка), тк в теории, если деньги будут отмывать, то скорее будут делать возрат

Показать код
df.groupby(['TransactionType'])['Amount'].agg([np.median]).sort_values(by='median',
                                                                        ascending=False).plot(kind='bar',
                                                                                              grid=True,
                                                                                                  rot=0)
зависимость суммы от типа операции
зависимость суммы от типа операции

Отсюда видно, что медианное (среднее и дисперсия также примерно равны) значение суммы операции не зависит от типа операции. Теперь надо посмотреть на количество мошеннических операций в зависимости от типа операции

Показать код
fraudPlotBasedOnQuestion(df=df, group=['TransactionType'])
зависимость количества мошеннических операций от типа операции
зависимость количества мошеннических операций от типа операции

Отталкиваясь от графика видно, что количество мошеннических операций при возврате больше, но при этом и самих возвратов тоже больше, поэтому имеет смысл посмотреть на среднее количество мошеннических операций в зависимости от типа операции

Обучение модели

Теперь, когда EDA проведен можно приступить к обучению модели. В сегодняшнем случае я буду использовать RandomForestClassifier, потому что он сам по себе довольно простой и очень хорошо интерпретируется. Это происходит из-за его нехитрого принципа работы, а именно: Он строится на том, что датасет разбивается на отдельные небольшие датасеты с слегка другим набором данных, на каждом из которых обучается отдельное дерево решений, далее, все результаты, полученные деревьями усредняются с помощью бэггинга и выносится финальное решение. Отсюда и вытекает его хорошая интерпретируемость, также его, при желании, можно разбить просто на набор условий. Перед началом обучения модели необходимо разобраться с дисбалансом классов, который мы увидели, а также преобразовать, чтобы избежать переобучения (если разобраться с дисбалансом классов недообучение отпадет само собой из-за большого размера выборки). К тому же надо еще разобраться с категориальными признаками, так как RFC из sklearn нативно с ними не работает.

Показать код
from abc import ABC, abstractmethod
from sklearn.model_selection import train_test_split
from imblearn.over_sampling import RandomOverSampler
from imblearn.pipeline import Pipeline
from sklearn.preprocessing import OrdinalEncoder

encoder = OrdinalEncoder()
df['Location'] = encoder.fit_transform(df[['Location']])
df['TransactionType'] = encoder.fit_transform(df[['TransactionType']])


class Model(ABC):

    @abstractmethod
    def createTest(df: pd.DataFrame) -> tuple:
        """splits dataframe for test and reduces class imbalance"""

        x = df.drop(['IsFraud'], axis=1)
        y = df['IsFraud']
        cat_features = ['Location', 'TransactionType', 'TransactionTime', 'TransactionMonth', 'TransactionDate',
                        'DayOfWeek', 'MerchantID']
        cat_features_dict = {i: (x[i].min(), x[i].max()) for i in cat_features}

        pipeline = Pipeline(steps=[
            ('over', RandomOverSampler(random_state=52)),
        ])

        x, y = pipeline.fit_resample(x, y)

        # защита от того, что механизмы сэмплеров могут выйти за границы исходных данных
        for i in cat_features:
            x[i] = x[i].clip(lower=cat_features_dict[i][0], upper=cat_features_dict[i][1])

        X_train, X_test, y_train, y_test = train_test_split(x, y, test_size=0.3, random_state=17, shuffle=True)

        return X_train, X_test, y_train, y_test

    @abstractmethod
    def learn(df: pd.DataFrame) -> None:
        return
    

теперь же можно обучить модель, а также оценить ее, делать это я буду при помощи classification_report, так как тут можно увидеть сразу все основные метрики, такие как precision, recall, f1-score, а также микро и макро усреднения эти метрик

Показать код
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report

class RandomForest(Model):

    def learn(df: pd.DataFrame) -> RandomForestClassifier:
        """builds Random Forest Classifier"""

        X_train, X_test, y_train, y_test = Model.createTest(df)

        clf = RandomForestClassifier(n_estimators=100, random_state=52)
        clf.fit(X_train, y_train)

        return clf

X_test = Model.createTest(df)[1]
y_test = Model.createTest(df)[3]
print(classification_report(y_test, RandomForest.learn(df).predict(X_test)))
результаты обученной модели
результаты обученной модели

Теперь, модель обучена и ее результаты нас вполне устраивают, можно переходить к следующему шагу

Интерпретация модели

Для интерпретации я буду использовать библиотеку shap и модуль AnchorTabular из библиотеки alibi (хотя alibi и LIME очень схожи и конкретно в этом случае можно использовать LIME, лучше все же alibi, потому что проблема с LIME в том, что она только охватывает местный регион, который может дать чрезмерную уверенность в объяснении и может не подходить для экземпляра, который не подготовлен).

shap

Для начала необходимо уменьшить выборку, потому что результат для выборки свыше 100 принципиально не меняется, но при это время выполнения кратно увеличивается, для того, чтобы графики выглядели более плотно и их было чуть проще интепретировать, размер выборки я сделаю равным 200.

Показать код
import shap
from alibi.explainers import AnchorTabular
from alibi.utils import gen_category_map
import tqdm
import re


X_train = Model.createTest(df)[0]
X_test = Model.createTest(df)[1]
model = RandomForest.learn(df)
sampleSize = 200

cat_map = gen_category_map(X_train)
X_train = shap.sample(X_train, sampleSize)
X_test = shap.sample(X_test, sampleSize)
features = X_train.columns.tolist()

теперь получим значения Шепли (они оценивают то, какой вклад внес каждый отдельно взятый признак в итоговое решение модели) , потому что сначала с их помощью мы будем смотреть на то, на какие признаки модель смотрит больше всего для этого я буду использовать KernelExplainer, потому что он тут работает не слишком долго и при этом - это самый универсальный способ для объяснения, так что этот код будет работать и для любой другой модели (если надо конкретно для "деревянных" моделей можно использовать TreeExplainer)

Показать код
ex = shap.KernelExplainer(model.predict, X_train)
shap_values = ex.shap_values(X_test)
expected_value = ex.expected_value
shap_explained = shap.Explanation(shap_values, feature_names=features)

Теперь, когда значения получены можно посмотреть на сами графики этих значение

Показать код
shap.summary_plot(shap_explained, X_test, feature_names=features)

shap.plots.bar(shap_values=shap_explained, max_display=len(features))

shap.plots.decision(base_value=expected_value, shap_values=shap_values,
                            feature_names=features)
shap summary plot
shap summary plot
shap столбчатый график
shap столбчатый график
shap график решений
shap график решений

Интерпретация графиков

1. В графике №1 слева даны названия входных признаков, отсортированные по среднему значению признаков шепли (сверху вниз), также это распределение будет совпадать с распределением для столбчатого графика, который будет описан ниже, справа же, каждая строка представлена в виде отдельно взятой точки, которые распределены вдоль линии по их значениям шепли, также в местах с высокой плотностью значений точки собираются вертикально, также в зависимости от цвета точки можно опеределить, насколько большой вклад в итоговое решение модели оказал конкретно этот признак (в этой конкретной точке).

2. второй график показывает средние значения шепли для каждого конкретного признака в модели, также, для наглядности, строит отсортированный горизонтальный столбчатый график этих значений (распределение графиков сверху вниз совпадает с распределением из 1 графика).

3. третий график показывает то, как модели приходят к итоговому результату, как модель меняет решение на конкретном признаке, чем более резко меняются линии, тем большее значение для конкретного решения модели оказал конкретный признак, если же линии оказались примерно ровными, значит все значения имеют примерно равное значение Итак, исходя из графиков видно, что для модели все данные имеют примерно равное значение, также можно сделать вывод, что в некоторых штатах уровень мошеннических операций выше, также как и в зависимости даты, также, чем больше сумма операции, тем выше вероятность мошенничества Теперь сравним эти графики с графиком частоты появления каждого признака в объяснениях AnchorTabular.

AnchorTabular

начало тут примерно такое же, отличия пойдут только с момента создания объекта AnchorTabular и его обучение, код для этого представлен ниже.

Показать код
X_train = X_train.to_numpy()
X_test = X_test.to_numpy()

ex = AnchorTabular(model.predict, feature_names=features, categorical_names=cat_map)
ex.fit(X_train)

Итак, ввиду того, что AnchorTabular выдает ответ для одной конкретной строки, придется написать простенький алгоритм, который будет получать объяснение для каждой строки, далее по списку с объяснениями пробегаться и, посредством регулярного выражения, получать каждый отдельный признак и подсчитывать его частоту встречаемости потом записывать ее в словарь, также потом сразу отсортируем этот словарь по значениям и построим график

Показать код
res = {x: 0 for x in features}
for i in tqdm.tqdm(range(sampleSize)):

    explanation = ex.explain(X=X_test[i], threshold=0.98)
    temp = explanation.data['anchor']

    if len(temp) > 0:
        for x in temp:
            res[re.search('[a-z]+', x, flags=re.IGNORECASE).group()] += 1
  
anchorAppearences = sorted(res.items(), key=lambda x: x[1], reverse=True)
data = pd.DataFrame(data=anchorAppearences, columns=['Feature', 'Score'])
data.plot(kind='barh', y='Score', x='Feature', grid=True)
plt.show()      
график частоты встречаемости каждого из признако в объяснениях от AnchorTabular
график частоты встречаемости каждого из признако в объяснениях от AnchorTabular

Графики получились весьма разные, смотря на этот график, наименьшее значение имеет штат, хотя, исходя из первых графиков, его важность была чуть ниже среднего (на фоне остальных признаков), но, тем не менее, он привносиь большое значение для итогового решения в модели, также это подтверждает график частоты мошеннических операций в зависимости от местоположения, но с другой стороны получается, что самое большое значение для модели оказал признак типа операции (возврат/покупка), я думаю, что это могло произойти из‑за того, что AnchorTabular просто подбирает такой набор значений, чтобы максимизировать охват при ограничении точности, тогда как shap оценивает именно вклад в итоговый результат для самой модели, поэтому и могут быть такие различия в графиках В конце можно подытожить, что хоть тип транзации и появлялся в интерпретациях AnchorTabular чаще всего, вклад в итоговые решения модели он оказал самый маленький, а признак места наоборот, хоть и встречался реже всего, но тем не менее, для модели значил больше, чем тип транзакции (это же подтверждают графики из EDA). Но при всем при этом признак часа, в котором проводилась операция оказался на втором месте по частоте встречаемости, тогда как в итоговое решение модели он оказал самое большое значение. Тоже самое применимо и для остальных признаков также, для большинства признаков вклад примерно совпадает с графиками из EDA, так что эти показатели можно считать более-менее достоверными.

Итог

В качестве итога хочу сказать, что методы интерпретации мл моделей помогают заглянуть в «черный ящик» и лучше понять то, какие признаки для модели оказываются наиболее важными, так же с их помощью можно улучшать уже имеющиеся модели, это очень помогает в отладке моделей, а также в ее представлении итоговому заказчику, надеюсь, моя статья была полезной для вас.

Комментарии (0)