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

Прочитать данные

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

import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
sns.set_theme(rc={'figure.dpi': 144, 
                  'ytick.labelsize': 7, 
                  'axes.labelsize': 8, 
                  'xtick.labelsize': 7,
                  'axes.titlesize': 9})

dtype = {
    'source': 'category',
    'browser': 'category',
    'sex': 'category',
    'class': 'uint8'
}
df = pd.read_csv('fraud.csv', 
                 usecols=range(1, 12), 
                 dtype=dtype,
                 parse_dates=['signup_time', 'purchase_time'])
df.head()

Получить метаданные

  • 120 тыс. Строк, 11 столбцов
df.shape
(120000, 11)

Типы выходных данных

df.dtypes
user_id                    int64
signup_time       datetime64[ns]
purchase_time     datetime64[ns]
purchase_value             int64
device_id                 object
source                  category
browser                 category
sex                     category
age                        int64
ip_address               float64
class                      uint8
dtype: object

Проверить отсутствующие значения

Нет пропущенных значений

df.isna().sum()
user_id           0
signup_time       0
purchase_time     0
purchase_value    0
device_id         0
source            0
browser           0
sex               0
age               0
ip_address        0
class             0
dtype: int64

Изучите уникальность

Некоторые повторяющиеся идентификаторы устройств и ip_addresses, но в основном уникальные.

df.nunique()
user_id           120000
signup_time       120000
purchase_time     119729
purchase_value       120
device_id         110599
source                 3
browser                5
sex                    2
age                   57
ip_address        114135
class                  2
dtype: int64

Чтение данных сопоставления IP-адресов

Upper_bound_ip_address читается как целое число, но его необходимо преобразовать в число с плавающей запятой, чтобы функция merge_asof pandas работала с ним. Столбец страны преобразован в категориальный.

dtype = {
    'upper_bound_ip_address': 'float64',
    'country': 'category'
}
df_ip = pd.read_csv('IpAddress_to_Country.csv', dtype=dtype)
df_ip = df_ip.sort_values('lower_bound_ip_address')
df_ip.head()

Получить метаданные

df_ip.shape
(138846, 3)
df_ip.dtypes
lower_bound_ip_address     float64
upper_bound_ip_address     float64
country                   category
dtype: object

Все IP-адреса в справочной таблице уникальны.

df_ip.nunique()
lower_bound_ip_address    138846
upper_bound_ip_address    138846
country                      235
dtype: int64
df_ip.isna().sum()
lower_bound_ip_address    0
upper_bound_ip_address    0
country                   0
dtype: int64

Убедитесь, что IP-адреса отсортированы и не перекрываются

Мы хотим, чтобы каждый диапазон IP-адресов был сопоставлен только с одной страной. Адреса также необходимо отсортировать, чтобы merge_asof работал. Складываем их в один ряд ниже.

s = df_ip.iloc[:, :2].stack().droplevel(1)
s.head()
0    16777216.0
0    16777471.0
1    16777472.0
1    16777727.0
2    16777728.0
dtype: float64

Убедитесь, что они отсортированы и не перекрываются.

s.is_monotonic_increasing
True

Добавить страну

Функция merge_asof pandas позволяет вам объединить два DataFrames с ключами, которые не совпадают. Фреймы данных должны быть отсортированы по их left_on и right_on столбцам. Ниже левый (мошеннический) DataFrame будет соответствовать последней строке правого (IP-адреса) DataFrame, где lower_bound_ip_address меньше ip_address. Сначала мы сортируем данные, а затем выполняем слияние.

df = df.sort_values('ip_address')
df_all = pd.merge_asof(df, df_ip, left_on='ip_address', right_on='lower_bound_ip_address')
df_all.tail(2)

Хотя это работает для большинства строк, верхняя граница IP-адреса не проверяется. Как вы можете видеть выше, есть строки, в которых ip_address не входит в интервал. Ниже мы убираем значение страны в строках, где ip_address не находится между границами. Отсутствующие значения для страны заполняются строкой «Неизвестно». Затем данные сортируются по времени регистрации.

filt = df_all['ip_address'].between(df_all['lower_bound_ip_address'], 
                                         df_all['upper_bound_ip_address'])
df_all['country'] = df_all['country'].where(filt)
df_all['country'] = df_all['country'].cat.remove_unused_categories() \
                               .cat.add_categories('Unknown').fillna('Unknown')
df_all = df_all.drop(columns=['lower_bound_ip_address', 'upper_bound_ip_address'])
df_all = df_all.sort_values('signup_time', ignore_index=True)
df_all.head()

Найдите количество пропущенных значений сейчас. Около 17 тысяч строк имеют IP-адреса, которых нет в нашей таблице.

(df_all['country'] == "Unknown").sum()
17418

Создание набора тестовых данных удержания

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

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

hold_out = len(df_all) // 5
df_train = df_all.iloc[:-hold_out]
df_test = df_all.iloc[-hold_out:]

Мы проверяем, произошло ли разделение, выводя хвост / голову новых DataFrames.

df_train.tail(3)

df_test.head(3)

Для дальнейшей проверки форма каждого из них приведена ниже.

df_train.shape
(96000, 12)
df_test.shape
(24000, 12)

Запишите набор данных на диск.

df_train.to_csv('train.csv', index=False)
df_test.to_csv('test.csv', index=False)

Сводная статистика простых непрерывных и категориальных столбцов

Сводная статистика для некоторых «более простых» непрерывных и категориальных столбцов:

  • Непрерывный
  • Purchase_value
  • возраст
  • Категоричный
  • источник
  • браузер
  • секс
  • страна
  • класс

Purchase_value

df_train[['purchase_value', 'age']].describe()

Гистограмма, KDE и коробчатая диаграмма созданы, чтобы лучше понять распределение стоимости покупки. Он кажется довольно однородным между 10 и 40, а затем быстро спадает.

fig, ax = plt.subplots(figsize=(5, 2))
sns.histplot(data=df_train, x='purchase_value', kde=True, bins=30, ax=ax);

Возраст распределяется более нормально.

fig, ax = plt.subplots(figsize=(5, 2))
sns.histplot(data=df_train, x='age', kde=True, bins=30, ax=ax);

fig, ax = plt.subplots(figsize=(4, 1.8))
sns.boxplot(data=df_train, x='purchase_value');

источник

df_train['source'].value_counts(normalize=True)
SEO       0.403167
Ads       0.395021
Direct    0.201813
Name: source, dtype: float64

браузер

df_train['browser'].value_counts()
Chrome     38981
IE         23260
FireFox    15690
Safari     15687
Opera       2382
Name: browser, dtype: int64

секс

df_train['sex'].value_counts()
M    56087
F    39913
Name: sex, dtype: int64

страна

s = df_train['country'].value_counts()
s.head()
United States     36977
Unknown           13882
China              7595
Japan              4622
United Kingdom     2869
Name: country, dtype: int64

Найдите процент стран, которые встречаются менее 50 раз.

round((s < 50).mean(), 3) * 100
61.0

класс

Получить частоту

df_train['class'].value_counts()
0    85845
1    10155
Name: class, dtype: int64

10,6% - мошенничество

df_train['class'].value_counts(normalize=True).round(3) * 100
0    89.4
1    10.6
Name: class, dtype: float64

Одномерное исследование

Изучите отношение к цели с помощью этих простых столбцов. Посмотрим, обнаружим ли мы какие-либо значения, которые существенно отличаются от мошенничества в 10,6%.

Здесь есть небольшой сигнал с источником. Директ - это на 1% больше мошенничества.

df_train.groupby('source').agg(count=('class', 'size'), perc_fraud=('class', 'mean'))

Браузер, похоже, не имеет сильного сигнала. IE имеет немного меньшее мошенничество.

df_train.groupby('browser').agg(count=('class', 'size'), perc_fraud=('class', 'mean'))

Чуть больше мошенничества со стороны мужчин.

df_train.groupby('sex').agg(count=('class', 'size'), perc_fraud =('class', 'mean'))

Посмотрите на самый высокий и самый низкий уровень мошенничества по странам с более чем 50 транзакциями. Здесь, кажется, намного больше сигнала (хотя и меньше).

df_country = df_train.groupby('country').agg(count=('class', 'size'), perc_fraud=('class', 'mean'))
df_country.query('count > 50').nlargest(15, 'perc_fraud')

df_country.query('count > 50').nsmallest(15, 'perc_fraud')

Если посмотреть на 15 крупнейших стран, большинство из них имеют средний показатель.

df_country.nlargest(15, 'count')

Здесь коэффициент корреляции Пирсона рассчитывается для всех числовых столбцов. Похоже, нет никаких отношений.

df_train.corr()['class']
user_id           0.001075
purchase_value    0.003553
age               0.006828
ip_address       -0.003796
class             1.000000
Name: class, dtype: float64

Мы все еще можем объединить числовые столбцы, чтобы увидеть, есть ли какие-либо отношения. Бункер с самой высокой ценой имеет самый низкий уровень мошенничества, в то время как контейнер 85–100 - значительно больше.

g = pd.cut(df_train['purchase_value'], bins=list(range(5, 105, 10)) + [200])
df_temp = df_train.groupby(g).agg(count=('class', 'size'), perc_fraud=('class', 'mean'))
df_temp['perc_fraud'] = df_temp['perc_fraud'].round(3) * 100
df_temp

Опять же, не сильно сигнал с возрастом.

g = pd.cut(df_train['age'], bins=list(range(15, 70, 5)) + [100])
df_temp = df_train.groupby(g).agg(count=('class', 'size'), perc_fraud=('class', 'mean'))
df_temp['perc_fraud'] = df_temp['perc_fraud'].round(3) * 100
df_temp

Мы даже можем проверить user_id, чтобы увидеть, есть ли там сигнал. Результат - то, что мы ожидаем от случайности.

df_train.groupby(pd.qcut(df_train['user_id'], 100))['class'].mean().nlargest()
user_id
(8177.98, 12054.85]       0.130208
(364266.09, 368258.32]    0.128125
(375982.12, 380034.25]    0.128125
(396081.03, 400000.0]     0.128125
(168224.74, 172349.71]    0.126042
Name: class, dtype: float64

Многовариантное исследование

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

def fraud_group(df, cols, min_count=50, n=10):
    df = df.groupby(cols) \
           .agg(count=('class', 'size'), 
                perc_fraud=('class', 'mean'), 
                avg_price=('purchase_value', 'mean'))
    df['perc_fraud'] = df['perc_fraud'].round(3) * 100
    return df.query('count > @min_count').nlargest(n, 'perc_fraud')

Группировка по браузеру, источнику и полу показывает только несколько комбинаций со слабым сигналом.

fraud_group(df_train, ['browser', 'source', 'sex'])

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

fraud_group(df_train, ['country', 'browser', 'source', 'sex'])

Мы также можем использовать контейнеры для определения возраста и стоимости покупки.

g = pd.cut(df_train['age'], bins=list(range(15, 70, 5)) + [100])
fraud_group(df_train, ['browser', 'source', g])

Узнайте время регистрации и покупки

Столбцы даты требуют другого подхода к анализу.

Секунды до покупки

Начнем с поиска секунд до покупки.

secs_to_purchase = (df_train['purchase_time'] - df_train['signup_time']).dt.total_seconds()
secs_to_purchase.head(3)
0    1.0
1    1.0
2    1.0
dtype: float64

Интересно, что в первых нескольких строках были покупки через одну секунду, и все они были мошенническими. Выберем все покупки, которые произошли за одну секунду.

filt = secs_to_purchase == 1
df_train.loc[filt, 'class'].agg(['mean', 'size'])
mean       1.0
size    6021.0
Name: class, dtype: float64

Примечательно, что каждая транзакция, совершавшаяся за 1 секунду, была отмечена как мошенничество.

Разделение 1-секундных транзакций

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

df_one_second = df_train[filt].reset_index(drop=True)
df_remaining = df_train[~filt].reset_index(drop=True)

Метаданные для новых наборов данных

df_one_second.shape
(6021, 12)
df_remaining.shape
(89979, 12)

Процент «неизвестных» стран показывает, что они похожи.

(df_one_second['country'] == "Unknown").mean() * 100
12.572662348447103
(df_remaining['country'] == "Unknown").mean() * 100
14.586736905277897

Количество уникальных значений по столбцу.

df_one_second.nunique()
user_id           6021
signup_time       6021
purchase_time     6021
purchase_value      80
device_id          758
source               3
browser              5
sex                  2
age                 44
ip_address         758
class                1
country             60
dtype: int64
df_remaining.nunique()
user_id           89979
signup_time       89979
purchase_time     89785
purchase_value      118
device_id         87735
source                3
browser               5
sex                   2
age                  56
ip_address        89979
class                 2
country             172
dtype: int64

Повторяющиеся IP-адреса

Сверху данные о покупке за одну секунду содержат множество повторяющихся IP-адресов.

df_one_second['ip_address'].value_counts().head()
3.874758e+09    18
1.797069e+09    16
2.586669e+09    16
1.502818e+09    16
5.760609e+08    16
Name: ip_address, dtype: int64

Фактически, все повторяющиеся IP-адреса находятся в односекундных данных. В остальных нет.

df_remaining['ip_address'].is_unique
True

Распределение транзакций по времени

Давайте посмотрим на количество транзакций в месяц.

df_one_second.resample('M', on='signup_time').size()
signup_time
2015-01-31    6021
Freq: M, dtype: int64

Все односекундные транзакции произошли в январе примерно с одинаковым количеством транзакций в день.

df_one_second.resample('D', on='signup_time').size()
signup_time
2015-01-01    470
2015-01-02    567
2015-01-03    427
2015-01-04    495
2015-01-05    430
2015-01-06    509
2015-01-07    597
2015-01-08    516
2015-01-09    416
2015-01-10    501
2015-01-11    516
2015-01-12    506
2015-01-13     71
Freq: D, dtype: int64

Все остальные транзакции, по-видимому, довольно равномерно распределены по месяцам.

df_remaining.resample('M', on='signup_time', kind='period').size()
signup_time
2015-01    15310
2015-02    13907
2015-03    15558
2015-04    15072
2015-05    15369
2015-06    14763
Freq: M, dtype: int64

Новый базовый уровень

Теперь, когда мы выделили одну секунду транзакции, мы пересчитаем процент мошенничества.

df_remaining['class'].mean()
0.04594405361250958

Мы сконцентрируемся на построении модели на оставшемся наборе данных и поищем группы, в которых мошенничество значительно выше, чем 4,6%.

Снова группа

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

fraud_group(df_remaining, 'sex')

fraud_group(df_remaining, 'source')

fraud_group(df_remaining, 'browser')

fraud_group(df_remaining, 'country')

g = pd.cut(df_remaining['purchase_value'], bins=list(range(5, 105, 5)) + [200])
fraud_group(df_remaining, g)

g = pd.cut(df_remaining['purchase_value'], bins=list(range(5, 105, 5)) + [200])
fraud_group(df_remaining, g)

g = pd.cut(df_remaining['age'], bins=list(range(15, 70, 5)) + [100])
fraud_group(df_remaining, g)

fraud_group(df_remaining, ['country', 'browser', 'source', 'sex'])

Больше возможностей для свиданий

Новые функции могут быть созданы для каждого столбца даты, например:

  • название дня
  • месяц
  • час
  • минута

Начнем с того, что посмотрим на название дня и время регистрации.

g = df_remaining['signup_time'].dt.day_name()
g.head()
0    Thursday
1    Thursday
2    Thursday
3    Thursday
4    Thursday
Name: signup_time, dtype: object

Расчет мошенничества по названию дня, похоже, не дает особого сигнала.

fraud_group(df_remaining, g)

Точно так же нет сигнала о времени покупки, название дня.

fraud_group(df_remaining, df_remaining['purchase_time'].dt.day_name())

Час, похоже, тоже не имеет значения.

fraud_group(df_remaining, df_remaining['purchase_time'].dt.hour)

fraud_group(df_remaining, df_remaining['purchase_time'].dt.minute)

Идентификатор устройства

Теперь обратим внимание на device_id. По количеству уникальных значений мы знаем, что некоторые из них повторяются. Давайте сначала посмотрим на них.

df_remaining['device_id'].value_counts()[lambda x: x > 1].head()
WENNLJYHVVSCR    3
CGLAEGEJMRFXY    3
TBEXEPAUWGUWW    3
KZYECBRGTWQDJ    3
TUTIBAJWVRPPI    3
Name: device_id, dtype: int64

Повторяющиеся идентификаторы устройств присваиваются переменной. df_train DataFrame используется для проверки наличия дубликатов во всем наборе данных.

repeat_device_id = df_train['device_id'].value_counts()[lambda x: x > 1].index
repeat_device_id[:10]
Index(['ITUMJCKWEYNDD', 'KIPFSCNUGOLDP', 'EQYVNEGOFLAWK', 'ZUSVMDEZRBDTX',
       'IXNWEKWJGNLNH', 'CDFXVYHOIHPYP', 'UFBULQADXSSOG', 'SDJQRPKXQFBED',
       'IGKYVZDBEGALB', 'SUEKLSZWLASFR'],
      dtype='object')

Все транзакции для этих повторов размещаются в собственном DataFrame.

df_repeat_device = df_remaining.query('device_id in @repeat_device_id') \
                               .sort_values('device_id', ignore_index=True)
df_repeat_device.head(4)

df_repeat_device.shape
(5052, 12)

Более высокий уровень мошенничества из-за повторного идентификатора устройства

В целом, таких транзакций гораздо больше мошенничества. В среднем 21% составляют мошенничество.

df_repeat_device['class'].mean()
0.21357878068091846

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

df_repeat_device['device_ct'] = df_repeat_device.groupby('device_id').cumcount() + 1
df_repeat_device[['device_id', 'device_ct', 'class']].head(6)

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

df_repeat_device.groupby('device_ct')['class'].agg(['size', 'mean'])

Полосы мошеннических транзакций

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

df_remaining.groupby(df_remaining['class'].shift())['class'].mean()

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

g = df_remaining['class'].rolling(100).sum().shift() > 5
df_remaining.groupby(g)['class'].agg(['size', 'mean'])

Давайте проведем тот же анализ, но отсортируем по времени покупки.

df_remaining_purch = df_remaining.sort_values('purchase_time', ignore_index=True)
df_remaining_purch.head(3)

Здесь нет сигнала.

df_remaining_purch.groupby(df_remaining_purch['class'].shift())['class'].mean()
g = df_remaining_purch['class'].rolling(100).sum().shift() > 5
df_remaining_purch.groupby(g)['class'].agg(['size', 'mean'])

Простейшая модель без машинного обучения

Мне нравится формулировать модели без машинного обучения, которые могут выступать в качестве основы. Пока что есть только два важных сигнала:

  • Одна секунда до покупки
  • Повторяющийся идентификатор устройства

Поскольку одна секунда до покупки привела к 100% мошенническим транзакциям, мы уже выделили ее в отдельный DataFrame. Любая будущая транзакция, которая происходит в течение одной секунды, будет помечена. Хотя в качестве предостережения, все эти транзакции произошли в январе. Так что за будущими односекундными событиями нужно будет внимательно следить.

Ожидаемая стоимость отмеченного мошенничества

Было выявлено, что повторные идентификаторы устройств являются поддельными на 22%, что намного выше базового уровня в 4,6%. Но мы должны учитывать стоимость неправильно помеченной транзакции (8 долларов). Даже зная, что идентификатор устройства повторяется, мы будем ошибаться в 78% случаев. Для обеспечения безубыточности цена покупки, умноженная на вероятность мошенничества, должна более чем в 8 раз превышать вероятность отсутствия мошенничества. У нас есть следующее уравнение:

$$ P * p_{f} > (1 — p_{f}) * 8$$

Решаем по минимальной закупочной цене.

INCORRECT_FRAUD_COST = 8
def calc_min_price(p):
    min_purchase_price = (1 - p) * INCORRECT_FRAUD_COST / p
    return min_purchase_price

p = df_repeat_device['class'].mean()
calc_min_price(p)

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

def calc_income(df, min_price):
    df = df[['purchase_value', 'class']].copy()
    df['flag'] = 0
    df['cost'] = 0
    df['saved'] = 0
    df['revenue'] = 0
    is_flag = df['purchase_value'] >= min_price
    is_fraud = df['class'] == 1
    df.loc[is_flag, 'flag'] = 1
    false_pos = is_flag & ~is_fraud
    false_neg = ~is_flag & is_fraud
    true_pos = is_flag & is_fraud
    df.loc[false_pos, 'cost'] = -INCORRECT_FRAUD_COST
    df.loc[false_neg, 'cost'] = -df['purchase_value']
    df.loc[true_pos, 'saved'] = df['purchase_value']
    df.loc[df['class'] == 0, 'revenue'] = df['purchase_value']
    df['income'] = df['revenue'] + df['cost']
    return df

Запуск этой функции с минимальной ценой 29 долларов дает следующие результаты. Первые две транзакции ошибочно помечены как мошенничество, каждый раз теряя компании 8 долларов. Следующие транзакции правильно помечены как мошенничество, сэкономив компании 32 доллара. 18-я транзакция является мошеннической, но не помеченной, что обошлось компании в 19 долларов.

df_income = calc_income(df_repeat_device, 29)
df_income.head(20)

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

s = pd.Series({i: calc_income(df_repeat_device, i)['cost'].sum() for i in range(9, 100)})
s.head(10)

Цена, минимизирующая затраты, составляет 31, что очень близко к нашему расчету в 29,5.

s.idxmax()

Мы можем построить график изменения стоимости в зависимости от пороговых значений. Порог в 100 по сути не является порогом (почти не отмечены случаи мошенничества), так как очень мало транзакций совершается выше этой ценовой отметки.

def plot_threshold(df, col, title):
    s = pd.Series({i: calc_income(df, i)[col].sum() for i in range(9, 100)})
    ax = s.plot(figsize=(5, 2.5))
    ax.set_ylabel('Cost')
    ax.set_xlabel('Minimum Price Threshold')
    ax.set_title(title);
plot_threshold(df_repeat_device, 'cost', 'Repeated Device Fraud Threshold')

Уникальный идентификатор устройства

Давайте поместим транзакции, поступающие с уникального идентификатора устройства, в их собственный DataFrame.

df_unique_device = df_remaining.query('device_id not in @repeat_device_id')
df_unique_device.head(3)
df_unique_device['device_id'].is_unique
df_unique_device.shape

Мы знаем, что есть три взаимоисключающих DataFrames, которые содержат все данные:

  • df_one_second
  • df_repeat_device
  • df_unique_device

Убедимся, что количество строк равно 96 000.

len(df_one_second) + len(df_repeat_device) + len(df_unique_device)

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

df_unique_device['class'].mean()

Он снизился до 3,6%. Здесь может быть сложно найти группу, которая стоит того, чтобы отмечать это как мошенничество. Даже при группировке по 4 переменным ниже самый высокий процент мошенничества составляет 10%.

fraud_group(df_unique_device, ['country', 'source', 'browser', 'sex'])

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

calc_min_price(df_unique_device['class'].mean())

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

plot_threshold(df_unique_device, 'cost', 'Unique Device Fraud Threshold')

Формальное машинное обучение

В этом разделе мы рассмотрим более формальный подход к построению моделей с использованием сложных предварительно созданных алгоритмов машинного обучения из библиотеки python scikit-learn. Как и выше, мы будем медленно строить модели, постепенно увеличивая сложность. Мы будем работать со всем набором обучающих данных, чтобы показать, как можно автоматизировать преобразования и передать их через конвейер машинного обучения.

df_train.head(3)
from sklearn.preprocessing import OneHotEncoder, FunctionTransformer, KBinsDiscretizer
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression

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

def is_one_second(df, return_frame=True):
    s = (df['purchase_time'] - df['signup_time']).dt.total_seconds() == 1
    if return_frame:
        return s.to_frame()
    return s

def is_dupe_device(s, return_frame=True):
    s = s.duplicated(keep=False)
    if return_frame:
        return s.to_frame()
    return s

ft_one_second = FunctionTransformer(is_one_second)
ft_dupe_device = FunctionTransformer(is_dupe_device)

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

ct = ColumnTransformer([
    ('cat', OneHotEncoder(), ['source', 'browser', 'sex']),
    ('one_second', ft_one_second, ['signup_time', 'purchase_time']),
    ('dev', ft_dupe_device, 'device_id'),
])
lr = LogisticRegression(max_iter=1000)
pipe = Pipeline([
    ('ct', ct), 
    ('lr', lr)
])

Конвейер соответствует требованиям (переменные преобразованы и модель обучена), и возвращены вероятности мошенничества.

y_true = df_train['class']
pipe.fit(df_train, y=y_true);
probs = pipe.predict_proba(df_train)
probs[:5]

Вероятность мошенничества содержится во втором столбце и присваивается переменной с именем y_pred.

y_pred = probs[:, 1]
y_pred

Максимальная вероятность мошенничества составила более 99%.

y_pred.max()

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

df_train[y_pred > .99].head()

Это те же 6021 строка, найденные ранее.

df_one_second.shape

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

filt1 = is_dupe_device(df_train['device_id'], return_frame=False)
filt2 = is_one_second(df_train, return_frame=False)
filt = filt1 & ~filt2
prob_fraud_dupe_device = y_pred[filt]
prob_fraud_dupe_device[:10]

Диапазон вероятностей составляет от 20% до 26%, что соответствует нашим расчетам 21% мошенничества без машинного обучения.

prob_fraud_dupe_device.min(), prob_fraud_dupe_device.max()

Матрица путаницы

Давайте посмотрим на все комбинации событий - истинные положительные / отрицательные и ложные положительные / отрицательные, создав матрицу путаницы. Сначала мы используем нашу функцию calc_income, чтобы получить окончательное решение.

min_price = calc_min_price(y_pred)
df_income = calc_income(df_train, min_price)
df_income.head()

Используйте scikit-learn для создания матрицы путаницы.

from sklearn.metrics import confusion_matrix
def create_confusion(y_true, df_income, filt=None):
    y_pred = df_income['flag']
    if filt is not None:
        y_true = y_true[filt]
        y_pred = y_pred[filt]
    df_conf = pd.DataFrame(confusion_matrix(y_true, y_pred))
    df_conf.index.name = 'actual'
    df_conf.columns.name = 'predicted'
    return df_conf

create_confusion(y_true, df_income)
  • 3446 транзакций были мошенническими, что наша модель не обнаружила (ложноотрицательные). Стоимость равна Purchase_value
  • Согласно нашей модели, 2385 транзакций были предсказаны как мошеннические, но это не так (ложное срабатывание). Стоимость 8.
  • 6 709 были правильно спрогнозированы мошенническими транзакциями, что позволило сэкономить Purchase_value
  • Остальные были правильно предсказаны как не мошеннические.

Фильтрация односекундных транзакций

Отфильтровывая односекундные транзакции (с более чем 99% прогнозируемого мошенничества), мы получаем следующую матрицу путаницы.

filt = y_pred < .99
create_confusion(y_true, df_income, filt)

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

Расчет средней стоимости

Мы можем адаптировать нашу предыдущую функцию calc_income сверху, чтобы возвращать среднюю стоимость каждой транзакции.

  • Минимальная цена для отметки рассчитывается с помощью функции calc_min_price, определенной выше.
  • Транзакция помечается, если значение Purchase_value больше этой минимальной цены.
  • Найдена стоимость ложноположительного (-8) и ложноотрицательного (-purchase_value)
  • Возвращается средняя стоимость всех транзакций.
def mean_cost(y_true, y_pred, purchase_value):
    min_price = calc_min_price(y_pred)
    is_flag = purchase_value > min_price
    is_fraud = y_true == 1
    false_pos = is_flag & ~is_fraud
    false_neg = ~is_flag & is_fraud
    
    false_pos_cost = false_pos * -INCORRECT_FRAUD_COST
    false_neg_cost = false_neg * -purchase_value
    cost = false_pos_cost + false_neg_cost 
    return cost.mean()

Средняя стоимость -1,46 - результат этой модели.

mean_cost(y_true, y_pred, df_train['purchase_value'])

Кодировка страны

В нашем наборе данных более 100 разных стран, но многие из них встречаются всего несколько раз. Давайте посмотрим на страны, которые встречаются менее 100 раз.

df_train['country'].value_counts()[lambda x: x < 100]

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

def encode_country(s, min_count=100, return_frame=True):
    low_ct_countries = s.value_counts()[lambda x: x < min_count].index
    s = s.mask(s.isin(low_ct_countries)).cat.remove_unused_categories()
    if return_frame:
        return s.to_frame()
    return s

ft_encode_country = FunctionTransformer(encode_country)

Для обработки этого столбца и горячего кодирования используется отдельный конвейер. Новые пропущенные значения (страны, которые встречаются менее 100 раз) будут рассматриваться как одна и та же категория.

pipe_countries = Pipeline([
    ('country_agg', ft_encode_country),
    ('country_ohe', OneHotEncoder(handle_unknown='ignore'))
])

Возраст биннинга

Преобразователь KBinsDiscretizer может автоматически отбирать переменную возраста.

Оставить Purchase_value как непрерывное

Мы «пропускаем» столбец Purchase_value, не преобразуя его, чтобы оставить его как непрерывную переменную.

Добавление в конвейер

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

ct = ColumnTransformer([
    ('cat', OneHotEncoder(), ['source', 'browser', 'sex']),
    ('one_second', ft_one_second, ['signup_time', 'purchase_time']),
    ('dev', ft_dupe_device, 'device_id'),
    ('country', pipe_countries, 'country'),
    ('age_bin', KBinsDiscretizer(n_bins=5, strategy='quantile'), ['age']),
    ('cont', 'passthrough', ['purchase_value'])
])
lr = LogisticRegression(max_iter=1000)
pipe = Pipeline([
    ('ct', ct), 
    ('lr', lr)
])

Переобучаем новую модель и еще раз рассчитываем среднюю стоимость.

pipe.fit(df_train, y_true);
y_pred = pipe.predict_proba(df_train)[:, 1]
mean_cost(y_true, y_pred, df_train['purchase_value'])

Эта стоимость почти идентична более простой модели, что говорит о том, что было изменено очень мало решений.

min_price = calc_min_price(y_pred)
df_income = calc_income(df_train, min_price)
df_income.head()

Убедимся, что матрица неточностей очень похожа.

create_confusion(y_true, df_income)

Оценка модели на тестовом наборе данных

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

y_test_true = df_test['class']
y_test_pred = pipe.predict_proba(df_test)[:, 1]
mean_cost(y_test_true, y_test_pred, df_test['purchase_value'])

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

Резюме

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

Будущая работа

  • Перекрестная проверка для настройки гиперпараметров модели (штраф за логистическую регрессию, количество ячеек для возраста, минимальное количество стран и т. Д.)
  • Оцените значимость каждой переменной. Могут ли какие-либо переменные, кроме времени покупки и идентификатора устройства, служить сигналом?
  • Различные модели машинного обучения, такие как случайные леса
  • Автоматизировать весь рабочий процесс и сериализовать модель на диске

Среднее членство

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