Простая модель, отмечающая все транзакции, которые произошли за одну секунду, и те, у которых был повторяющийся идентификатор устройства с ценой выше 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 долларов, по-видимому, отражает большую часть потенциальной ценности. Необходимо провести дополнительное расследование, чтобы определить, смогут ли другие модели найти в данных больше сигналов, чтобы пометить больше транзакций как мошенничество.
Будущая работа
- Перекрестная проверка для настройки гиперпараметров модели (штраф за логистическую регрессию, количество ячеек для возраста, минимальное количество стран и т. Д.)
- Оцените значимость каждой переменной. Могут ли какие-либо переменные, кроме времени покупки и идентификатора устройства, служить сигналом?
- Различные модели машинного обучения, такие как случайные леса
- Автоматизировать весь рабочий процесс и сериализовать модель на диске
Среднее членство
Если вам понравились подробные руководства, подобные этому, подумайте о регистрации в среднем членстве, чтобы получить доступ ко всем моим статьям и руководствам, а также к тысячам других.