Как часто мы заходим в магазин, чтобы выбрать то, что нам нужно, и просто волшебным прикосновением расплачиваемся картой? Количество карточных платежей за услуги, как цифровые, так и физические, неуклонно растет. При этом количество случаев мошенничества с кредитными картами значительно увеличилось за последние годы. Европол рассматривает мошенничество с кредитными картами как низкорисковую, высокодоходную преступную деятельность, в основном происходящую в Интернете. В большинстве случаев жертвы изначально не знали об активности, пока она не была отмечена поставщиком платежных услуг. Преступники часто используют украденную информацию о карте для быстрых покупок на веб-сайтах электронной коммерции. Это часто представляет собой изменение обычного поведения пользователя. Однако огромное количество транзакций каждый месяц делает невозможным отслеживание поведения и платежей людей. Здесь мы можем использовать такие инструменты, как машинное обучение, для классификации случаев мошенничества с кредитными картами. Исторические данные о поведении клиента можно использовать для обучения модели, а затем для создания прогнозов для любых новых поступающих платежей.

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

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

import pathlib                                                     

import numpy as np
import pandas as pd                                                 

import matplotlib.pyplot as plt                                     
import seaborn as sns                                               

from sklearn.model_selection import StratifiedKFold                
from sklearn.model_selection import RandomizedSearchCV              

from sklearn.preprocessing import RobustScaler                      
from sklearn.linear_model import LogisticRegression                 
from sklearn.ensemble import RandomForestClassifier                 

from sklearn.metrics import roc_auc_score                           
from sklearn.metrics import recall_score                            
from sklearn.metrics import precision_score                         
from sklearn.metrics import f1_score                                
from sklearn.metrics import classification_report                   

from imblearn.over_sampling import SMOTE                            
from imblearn.pipeline import make_pipeline                         

color_palette = [
    "#4F2D7F", 
    "#00A7B5", 
    "#9BD732", 
    "#FF7D1E", 
    "#E92841", 
    "#CBC4BC"
    ]

Чтение и анализ данных

Данные были ранее загружены в виде файла CSV с Kaggle. Сначала мы прочитаем данные и взглянем на характеристики наборов данных. В этом учебном примере использования мы рассмотрим некоторые основные характеристики, которые следует рассмотреть с любым набором данных, прежде чем мы углубимся в визуальный осмотр. Мы уже знаем, что данные были преобразованы, удалив большую часть контекста и понимание, которое мы можем получить из базовых данных. Кроме того, данные охватывают только один месяц, что означает, что мы не будем исследовать какое-либо сезонное поведение.

# Set data path and read data to DataFrame.
base_path = pathlib.Path().cwd().parent    # Parent for jupyter notebook 
data_path = base_path.joinpath("raw_data", "creditcard.csv")
df = pd.read_csv(data_path)
df.head()

print(f"Column Names: {df.columns.tolist()}")
print("")
print(f"Shape of data: {df.shape}")
print("")
print(f"Number of missing values: {df.isna().sum().max()}")
print("")
print(f"Percentage of data that is normal (not fraud): {round(df['Class'].value_counts()[0] / len(df) * 100,1)}%")
print(f"Percentage of data that is fraud: {round(df['Class'].value_counts()[1] / len(df) * 100,1)}%")

## Column Names: ['Time', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6', 'V7', 'V8', 'V9', 'V10', 'V11', 'V12', 'V13', 'V14', 'V15', 'V16', 'V17', 'V18', 'V19', 'V20', 'V21', 'V22', 'V23', 'V24', 'V25', 'V26', 'V27', 'V28', 'Amount', 'Class']

## Shape of data: (284807, 31)

## Number of missing values: 0

## Percentage of data that is normal (not fraud): 99.8%
## Percentage of data that is fraud: 0.2%

Случаи мошенничества находятся в столбце «Класс» со значениями 0 и 1, где 1 указывает, что транзакция была мошеннической, а 0 — что это была обычная транзакция. Всего в нашем наборе данных 284 807 транзакций. Только 0,2% от общего числа транзакций являются мошенническими. Это означает, что наш набор данных сильно несбалансирован, и в большинстве случаев это обычные транзакции. Это очень часто встречается в случаях мошенничества, когда размер выборки фактического мошенничества по сравнению с обычным случаем довольно мал. Это потребует дополнительной обработки и оценки, прежде чем переходить к части моделирования.

Кроме того, у нас есть «Время» в качестве столбца с указанием времени совершения транзакции. В столбце «Сумма» указана сумма транзакции. Наконец, у нас есть 28 анонимных столбцов. Широко распространено мнение, что для анализа данных их следует визуализировать для лучшего понимания основных тенденций. Во-первых, мы проверим общие распределения суммы и времени.

fig, axs = plt.subplots(1, 3, gridspec_kw={"width_ratios": [2, 2, 1]}, figsize=(16, 3))

sns.histplot(df["Amount"].values, ax=axs[0], kde=True, bins=50, stat="density", linewidth=0, color=color_palette[0])
axs[0].set_title("Distribution of amount of transactions", fontsize=12)
axs[0].set_xlim([min(df["Amount"].values), max(df["Amount"].values)])

sns.histplot(df["Time"].values, ax=axs[1], kde=True, bins=50, stat="density", linewidth=0, color=color_palette[1])
axs[1].set_title("Distribution of transaction time", fontsize=12)
axs[1].set_xlim([min(df["Time"].values), max(df["Time"].values)])

sns.countplot(data=df, x="Class", color=color_palette[0], ax=axs[2])
axs[2].set_title("Class Distribution", fontsize=12)


fig.tight_layout()
plt.show()

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

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

fig, ax = plt.subplots(figsize=(10, 4))

corr = df.corr()
sns.heatmap(corr, cmap=sns.cubehelix_palette(), annot_kws={"size": 20}, ax=ax)
ax.set_title("Raw Data Correlation Matrix", fontsize=12)

plt.show()

Матрица корреляции необработанных данных показывает, что корреляция между функциями и нашей целью не так велика в пределах +/- 0,2.

Далее, давайте сбалансируем данные путем недостаточной выборки. Для этого набор данных разбивается на случаи мошенничества и не мошенничества. Случаи, не связанные с мошенничеством, случайным образом перемешиваются перед выборкой того же количества точек данных, что и в наборе данных о мошенничестве. Затем два набора данных объединяются. Это упрощенный метод, позволяющий выявить потенциальную проблему несбалансированных данных. Позже, в части предварительной обработки, мы будем использовать более сложный метод с использованием пакета имбаланс-обучения.

# Filter the fraud cases and get the number of cases.
df_fraud = df[df['Class'] == 1].reset_index(drop=True)
count_fraud = int(len(df_fraud))

# Shuffle the dataframe and sample the non-fraud cases. Frac needs to be 1 to assure we shuffle across the columns.
df_nofraud = df.sample(frac=1)
df_nofraud = df_nofraud.loc[df_nofraud['Class'] == 0][:count_fraud].reset_index(drop=True)

# Combine to create undersampled dataframe
df_under = pd.concat([df_fraud, df_nofraud]).reset_index(drop=True)

# Compute the correlations.
corr = df_under.corr()

# Plot the correlation matrix.
fig, ax = plt.subplots(figsize=(10, 4))

sns.heatmap(corr, cmap=sns.cubehelix_palette(), annot_kws={"size": 20}, ax=ax)
ax.set_title("Undersampled Data Correlation Matrix", fontsize=12)

plt.show()

# Get the highest ranking correlations, return the names and remove the "Class" group.
corr_val = corr[(corr["Class"] >= 0.5) | (corr["Class"] <= -0.5)]["Class"]
corr_features = corr_val.index.tolist()
corr_features.remove("Class") 
print(corr_features)

## ['V3', 'V4', 'V9', 'V10', 'V11', 'V12', 'V14', 'V16', 'V17']

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

Эти особенности можно исследовать дальше. Для этого мы будем использовать блочные графики. Как правило, ящичные диаграммы являются хорошим методом визуализации для понимания разброса данных, выделения потенциальных выбросов и сравнения различных функций вместе. рамка диаграммы описывает нижний (25%) квартиль и верхний (75%) квартиль, а также медиану. Так называемые усы указывают минимальное и максимальное значения. Точки за пределами усов являются выбросами, основанными на нормальном распределении.

# Plot the boxplots of the featuers with the class.
fig, axs = plt.subplots(ncols=3, nrows=3, sharex=True, figsize=(12, 6))

sns.boxplot(data=df, x="Class", y="V3", palette=color_palette, ax=axs[0, 0])
axs[0, 0].set_title("V3 vs Class")

sns.boxplot(data=df, x="Class", y="V4", palette=color_palette, ax=axs[0, 1])
axs[0, 1].set_title("V4 vs Class")

sns.boxplot(data=df, x="Class", y="V9", palette=color_palette, ax=axs[0, 2])
axs[0, 2].set_title("V9 vs Class")


sns.boxplot(data=df, x="Class", y="V10", palette=color_palette, ax=axs[1, 0])
axs[1, 0].set_title("V10 vs Class")

sns.boxplot(data=df, x="Class", y="V11", palette=color_palette, ax=axs[1, 1])
axs[1, 1].set_title("V11 vs Class")

sns.boxplot(data=df, x="Class", y="V12", palette=color_palette, ax=axs[1, 2])
axs[1, 2].set_title("V12 vs Class")


sns.boxplot(data=df, x="Class", y="V14", palette=color_palette, ax=axs[2, 0])
axs[2, 0].set_title("V14 vs Class")

sns.boxplot(data=df, x="Class", y="V16", palette=color_palette, ax=axs[2, 1])
axs[2, 1].set_title("V16 vs Class")

sns.boxplot(data=df, x="Class", y="V17", palette=color_palette, ax=axs[2, 2])
axs[2, 2].set_title("V17 vs Class")

plt.tight_layout()
plt.show()

После оценки коробочных диаграмм мы можем рассмотреть возможность удаления некоторых выбросов за счет уменьшения размера набора данных. В большинстве методов удаления выбросов используются предположения о данных и их распределении, чтобы установить критерии, по которым мы можем удалить отдельные точки данных. Однако в этом примере мы признаем выброс, не удаляя его. Распространенным подходом к удалению выбросов является межквартильный диапазон (IQR). Тем не менее, на практике этот шаг всегда следует учитывать и обсуждать, поскольку он часто обеспечивает более высокую производительность, а в некоторых случаях лучшее обобщение модели. Прежде чем удалять выбросы, всегда следует учитывать стабильность будущего набора данных в производстве. Это не дается при обнаружении мошенничества, потому что люди со злым умыслом всегда будут пытаться найти новые способы мошеннического поведения. Кроме того, за последний период у нас было изменение поведения клиентов среди многих кризисов, что привело к другому «нормальному» поведению.

Предварительная обработка

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

# Set the scaler objects
robust_scaler = RobustScaler()

df['Amount_scaled'] = robust_scaler.fit_transform(df['Amount'].values.reshape(-1,1))
df['Time_scaled'] = robust_scaler.fit_transform(df['Time'].values.reshape(-1,1))

df.drop(['Time','Amount'], axis=1, inplace=True)

Далее мы разделяем данные на обучение и проверку. Для этого мы будем использовать метод перекрестной проверки Stratified-K-Fold, возвращая стратифицированные складки данных обучения и проверки.

X = df.drop("Class", axis=1)
y = df["Class"]

# Create the stratified kfold object.
stratified_k = StratifiedKFold(n_splits=7, random_state=None, shuffle=False)

for train_ix, val_ix in stratified_k.split(X, y):
    actual_X_train, actual_X_val = X.iloc[train_ix], X.iloc[val_ix]
    actual_y_train, actual_y_val = y.iloc[train_ix], y.iloc[val_ix]

# We convert the values into arrays for the model.
actual_X_train = actual_X_train.values
actual_X_val = actual_X_val.values
actual_y_train = actual_y_train.values
actual_y_val = actual_y_val.values

# See if both the train and test label distribution are similarly distributed
train_unique, train_counts = np.unique(actual_y_train, return_counts=True)
val_unique, val_count = np.unique(actual_y_val, return_counts=True)

print("Label distributions after split:")
print("-" * 100)
print("Train: ", np.around(train_counts / len(actual_y_train), 4))
print("Test:  ", np.around(val_count / len(actual_y_val), 4))
print("-" * 100)
print(f"Length of X values used for training: {len(actual_X_train)} | Length of y values used for training: {len(actual_y_train)}")
print(f"Length of X values used for validation: {len(actual_X_val)} | Length of y values used for validation: {len(actual_y_val)}")
print("-" * 100)

Построение и оценка модели

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

Для оценки модели мы сосредоточимся на F1-Score как на показателе производительности. Однако рекомендуется оценивать и понимать несколько показателей производительности, чтобы понять ограничения возможностей моделей.

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

# Generate lsits that will be populated in the loop.
accuracies = []
precisions = []
recalls = []
f1s = []
aucs = []

# Set the hyperparamter parameters.
params_dict = {
    "n_estimators": [int(x) for x in np.linspace(start=10, stop=50, num=10)],
    "max_features": ["sqrt", "log2"],
    "max_depth": [2, 4],
    "min_samples_split": [2, 5],
    "min_samples_leaf": [1, 2],
    "bootstrap": [True, False]
}

# Set the classifier and the hyperparameters.
clf = RandomForestClassifier()
rand_clf = RandomizedSearchCV(clf, params_dict, n_iter=3)

Затем снова применяется стратифицированная k-кратность для создания сгибов данных обучения и тестирования модели. Важно понимать, что данные тестирования — это не то же самое, что данные проверки. Проверка содержит данные, которые модель не видела, но которые были скрыты до конца, чтобы проверить лучшую модель после оптимизации гиперпараметров. Имея это в виду, мы строим конвейер модели, используя метод увеличения случаев мошенничества, называемый методом передискретизации синтетического меньшинства (SMOTE). Мы можем использовать этот метод как часть пакета несбалансированного обучения для избыточной выборки несбалансированных наборов данных классификации. Как следует из названия, мы генерируем синтетический набор данных путем избыточной выборки примеров случаев мошенничества и создания большего набора данных, чем у нас есть.

# SMOTE with cross-validation.
stratified_k = StratifiedKFold(n_splits=4, random_state=None, shuffle=False)
print("Start SMOTE and cross-validation.")
print("-" * 100, "\n")
for train_ix, test_ix in stratified_k.split(actual_X_train, actual_y_train):
    print(f"     Length training {len(train_ix)} | Length testing {len(test_ix)}.")
    print(f"     [x] Generating pipeline and training model.")
    pipeline = make_pipeline(SMOTE(sampling_strategy='minority'), rand_clf)
    print(f"     [x] Pipeline built sucessfull.")
    model = pipeline.fit(actual_X_train[train_ix], actual_y_train[train_ix])
    print(f"     [x] Model training sucessfull.")
    selected_estimator = rand_clf.best_estimator_
    print(f"     [x] Selected best estimator model.")
    
    # Generate the predictions with best estimator.
    predictions = selected_estimator.predict(actual_X_train[test_ix])
    print(f"     [x] Generated predictions")
    
    precisions.append(precision_score(actual_y_train[test_ix], predictions))
    recalls.append(recall_score(actual_y_train[test_ix], predictions))
    f1s.append(f1_score(actual_y_train[test_ix], predictions))
    aucs.append(roc_auc_score(actual_y_train[test_ix], predictions))

    print(f"     [x] Computed performance")
    print("-" * 100, "\n")
    
print('---' * 50, "\n")
print(f"Overall precision:  {round(np.mean(precisions), 3)}")
print(f"Overall recall:     {round(np.mean(recalls), 3)}")
print(f"Overall F1:         {round(np.mean(f1s), 3)}", "\n")
print('---' * 50)

Наконец, мы можем представить результаты в отчете о классификации.

labels = ['No Fraud', 'Fraud']
final_predictions = selected_estimator.predict(actual_X_val)

print(classification_report(actual_y_val, final_predictions, target_names=labels))

По данным проверки модель получила оценку F1 0,5. Это можно дополнительно улучшить, изменив некоторые допущения и шаги, которые мы предприняли.

Первый шаг к повышению производительности — анализ событий, выпадающих из числа функций V1–12. Хотя это, безусловно, может повысить точность обучения, это может вызвать проблемы при развертывании в рабочей среде, поэтому необходимо тщательно продумывать правильную обработку выбросов от варианта использования до развертывания. Другой вариант — обучить и сравнить несколько моделей. Затем можно расширить настройку гиперпараметров и изменить или изменить предположения для Startified-K-Fold и SMOTE.

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

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

Краткое содержание

В этой статье мы обучили классификатор случайного леса на анонимных данных кредитных карт от Kaggle. Мы рассмотрели аспекты несбалансированных данных и способы их обхода с помощью SMOTE.

Ну не совсем. Производство модели было одной частью. Теперь работа начинается с преобразования его в ценность для бизнеса и прокладки пути для интеграции в платформу. Еще одно соображение заключается в том, что этот пример варианта использования будет трудно перевести в производство. Учитывая, что у нас есть данные только за один месяц, истинное историческое количество транзакций будет намного выше. Сентябрь не является особым периодом в Европе, не считая праздников и прочего. Фактическое количество транзакций за год будет исчисляться миллионами. Модели также будут обучаться на куче исторических данных.

Вы можете ознакомиться с полной версией Jupyter Notebook в этой статье на моем Github.