NBA Modeling
Задача NBA¶
Предсказание последующего действия¶
Задачу NBA будем решать на синтетическом датасете.
В данных содержится информация о клиентах вымышленного интернет-магазина
Клиентам рекламировали
- Ноутбук (б/у, очень старый)
- Телефон
- Зарядное устройство
Что нам предстоит сделать¶
- Построить модель NBA как композицию бинарный моделей отклика и сравить лучшее предложение на основе вероятностей с лучшим предложением с учетом NPV продукта.
- Те. мы будем строить модели которые будут предсказывать для одного товара, купил или не купил. Эти модели потом можно использовать для того чтобы понять что лучше предложить клиентам (как вариант новым)
Калибровка Модели¶
Что такое калибровка моделей:
- В бустинге используется псевдо вероятности на выходе
- Калибровка это процесс корректировки (преобразования) от псевдовероятностных значении так чтобы они соответсвовали истиным вероятностям событий
- Делается это для того чтобы обеспечить точность предсказанных вероятновтей
- напр. если модель предсказывает 0.8 для события то это событие должно происходить примерно 80% случаев
- необходимо калибровать выход каждой модели
Тип Калибровок:
В библиотеке scikit-learn есть два подхода:
Изотаническая регрессий (предпочтительнее)
- Упорядочивает предсказания модели и корректирует их так чтобы они лучше соответсвовали истинным вероятностям
Платт Калибровка
- Использует логистическую регрессию для калибровки выходных данных модели
- Преобразует оценки в вероятности используя сигмоидальную функцию
Загрузка данных¶
Загрузим и исследуем наши данные
import pandas as pd
import numpy as np
import seaborn as sns
import random
from matplotlib import pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.model_selection import GridSearchCV
from sklearn.metrics import precision_score, recall_score, f1_score, roc_auc_score
from sklearn.preprocessing import StandardScaler, OrdinalEncoder
from sklearn.utils import shuffle
import warnings
warnings.filterwarnings("ignore")
# Установка настроек для отображения всех колонок и строк при печати
pd.set_option('display.max_columns', None)
# pd.set_option('display.max_rows', None)
# заранее установим в константу random_state
random_state = 47
sns.set(style="whitegrid")
- На каждый товай одна выборка, загрузим и исследуем наши фичи. В каждой выборке у нас по 10000 записей (клиентов)
- Можно выделить колонкy Purchase Flag, это у нас результат взаимодействий с клиетном, либо клиент купил товар (1) либо не купил (0)
- Все остальные фичи как то характирезуют этого клиента
df_Laptop = pd.read_csv('synthetic_laptop_data.csv')
df_Charger = pd.read_csv('synthetic_charger_data.csv')
df_Phone = pd.read_csv('synthetic_mobile_phone_data.csv')
df = pd.concat([df_Laptop, df_Charger, df_Phone])
print(df.shape)
df.head()
(30000, 18)
Age | Gender | Geography | PreviousPurchases | Salary | NumChildren | Product | EducationLevel | MaritalStatus | EmploymentStatus | HousingStatus | CreditScore | InternetUsage | NumberOfCars | HealthStatus | ShoppingFrequency | MembershipDuration | PurchaseFlag | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 56 | Male | North America | 8386.985501 | 42217.503431 | 4 | Laptop | Bachelor | Divorced | Employed | Rent | 754 | 1.545836 | 0 | Fair | 6 | 9 | 1 |
1 | 69 | Female | South America | 7391.924025 | 44369.165492 | 3 | Laptop | Master | Single | Employed | Own | 745 | 8.005712 | 0 | Good | 1 | 3 | 1 |
2 | 46 | Female | South America | 7951.832329 | 85406.301432 | 3 | Laptop | High School | Widowed | Unemployed | Own | 552 | 8.154606 | 1 | Fair | 5 | 9 | 0 |
3 | 32 | Female | Africa | 2284.841225 | 116197.240945 | 4 | Laptop | PhD | Widowed | Retired | Living with Parents | 458 | 1.938812 | 1 | Fair | 17 | 2 | 1 |
4 | 60 | Male | Asia | 6520.780649 | 55461.014410 | 0 | Laptop | High School | Married | Employed | Living with Parents | 434 | 7.656888 | 0 | Fair | 10 | 15 | 0 |
Описание Фич¶
Описание фичей:
- Age: Возраст клиента
- Gender: Пол клиента
- Geography: Регион проживания клиента
- PreviousPurchases: Сумма предыдущих покупок клиента в валюте
- Salary: Годовая зарплата клиента в валюте
- NumChildren: Количество детей у клиента
- EducationLevel: Уровень образования клиента
- MaritalStatus: Семейное положение клиента
- EmploymentStatus: Статус занятости клиента
- HousingStatus: Жилищный статус клиента
- CreditScore: Кредитный рейтинг клиента
- InternetUsage: Время, проведенное в интернете, в часах в день
- NumberOfCars: Количество автомобилей у клиента
- HealthStatus: Состояние здоровья клиента
- ShoppingFrequency: Частота покупок клиента, количество покупок в месяц
- MembershipDuration: Длительность членства клиента в годах
- Product : Продукт, который рассматривается для покупки
- PurchaseFlag : Флаг покупки, указывает на то, совершил ли клиент покупку
Эти фичи содержат демографические данные клиентов, а также их покупательские и финансовые характеристики, которые используются для предсказания вероятности покупки продукта
Посмотрим на распределение класса PurchaseFlag, и убедились что классы достаточно сбалансированы
df.groupby(['Product', 'PurchaseFlag']).agg({'Gender': 'count'})
Gender | ||
---|---|---|
Product | PurchaseFlag | |
Charger | 0 | 6541 |
1 | 3459 | |
Laptop | 0 | 4594 |
1 | 5406 | |
Mobile Phone | 0 | 5049 |
1 | 4951 |
Можно и проверить ради интереса пропорцию успешной к неуспешных продаж этой колонки
df.groupby(['Product']).agg({'PurchaseFlag': 'mean'})
PurchaseFlag | |
---|---|
Product | |
Charger | 0.3459 |
Laptop | 0.5406 |
Mobile Phone | 0.4951 |
Кодирование¶
Нам нужно сделать предобработку данных
- Обучаться будем на бустинге. CatBoost гибок к типам данных.
- Это значит, что можно не заниматься кодиррванием переменных, а просто присвоить категориальный тип данных.
- Категориальные фичи надо будет упоменуть при обучении модели
# Конвертируем object в category
object_cols = list(df.drop(['Product'], axis=1).select_dtypes('object').columns)
print(f'we have {len(object_cols)} object_cols')
df[object_cols] = df[object_cols].astype('category')
we have 7 object_cols
Семплирование¶
df = df.rename(columns={'PurchaseFlag': 'target'})
df_Charger = df[df['Product'] == 'Charger']
df_Laptop = df[df['Product'] == 'Laptop']
df_Phone = df[df['Product'] == 'Mobile Phone']
print(f"Размеры датасетов:\n"
f"Charger: {df_Charger.shape[0]} записей и {df_Charger.shape[1]} фичей\n"
f"Laptop: {df_Laptop.shape[0]} записей и {df_Laptop.shape[1]} фичей\n"
f"Mobile Phone: {df_Phone.shape[0]} записей и {df_Phone.shape[1]} фичей\n")
print(f"Средняя доля покупок:\n"
f"Charger: {df_Charger.target.mean():.2f}\n"
f"Laptop: {df_Laptop.target.mean():.2f}\n"
f"Mobile Phone: {df_Phone.target.mean():.2f}\n")
Размеры датасетов: Charger: 10000 записей и 18 фичей Laptop: 10000 записей и 18 фичей Mobile Phone: 10000 записей и 18 фичей Средняя доля покупок: Charger: 0.35 Laptop: 0.54 Mobile Phone: 0.50
# убирает то что мы рекламировали а целевой переменной станет купил/не купил
# они все будут одинаковы для кажного subset
features_Charger = df_Charger.drop(['Product', 'target'], axis=1)
target_Charger = df_Charger['target']
features_Laptop = df_Laptop.drop(['Product', 'target'], axis=1)
target_Laptop = df_Laptop['target']
features_Phone = df_Phone.drop(['Product', 'target'], axis=1)
target_Phone = df_Phone['target']
Вспомогательная функция make_samples
разделяет данные на 3 подвыборки (train/valid/test)
def make_samples(features, target):
# отделяем 20% - пятую часть всего - на тестовую выборку
X_train_valid, X_test, y_train_valid, y_test = train_test_split(features, target,
test_size=0.2,
random_state=random_state)
# отделяем 25% - четвертую часть трейн+валид - на валидирующую выборку
X_train, X_valid, y_train, y_valid = train_test_split(X_train_valid, y_train_valid,
test_size=0.25,
random_state=random_state)
s1 = y_train.size
s2 = y_valid.size
s3 = y_test.size
print('Разбиение на выборки train:valid:test в соотношении '
+ str(round(s1/s3)) + ':' + str(round(s2/s3)) + ':' + str(round(s3/s3)))
print('target rate на разбиениях:', round(y_train.mean(), 4), round(y_valid.mean(), 4), round(y_test.mean(), 4))
return X_train, X_valid, X_test, y_train, y_valid, y_test
X_train_Charger, X_valid_Charger, X_test_Charger, y_train_Charger, y_valid_Charger, y_test_Charger = make_samples(features_Charger, target_Charger)
X_train_Laptop, X_valid_Laptop, X_test_Laptop, y_train_Laptop, y_valid_Laptop, y_test_Laptop = make_samples(features_Laptop, target_Laptop)
X_train_Phone, X_valid_Phone, X_test_Phone, y_train_Phone, y_valid_Phone, y_test_Phone = make_samples(features_Phone, target_Phone)
Разбиение на выборки train:valid:test в соотношении 3:1:1 target rate на разбиениях: 0.3532 0.3325 0.3375 Разбиение на выборки train:valid:test в соотношении 3:1:1 target rate на разбиениях: 0.5388 0.549 0.5375 Разбиение на выборки train:valid:test в соотношении 3:1:1 target rate на разбиениях: 0.4938 0.506 0.488
X_train_Charger.head(2)
Age | Gender | Geography | PreviousPurchases | Salary | NumChildren | EducationLevel | MaritalStatus | EmploymentStatus | HousingStatus | CreditScore | InternetUsage | NumberOfCars | HealthStatus | ShoppingFrequency | MembershipDuration | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
9731 | 23 | Female | South America | 6042.440703 | 67691.979045 | 2 | Master | Single | Student | Living with Parents | 655 | 4.588958 | 0 | Poor | 2 | 11 |
6920 | 64 | Male | North America | 2497.156731 | 35648.888199 | 1 | High School | Widowed | Student | Rent | 379 | 6.842913 | 0 | Poor | 11 | 1 |
# Функция для оценки модели
def calc_metrics(model, X_train, y_train, X_valid, y_valid, X_test, y_test):
# Обучение
y_train_pred = model.predict(X_train)
y_train_proba = model.predict_proba(X_train)[:, 1] if hasattr(model, 'predict_proba') else model.decision_function(X_train)
# Валидация
y_valid_pred = model.predict(X_valid)
y_valid_proba = model.predict_proba(X_valid)[:, 1] if hasattr(model, 'predict_proba') else model.decision_function(X_valid)
# Тестирование
y_test_pred = model.predict(X_test)
y_test_proba = model.predict_proba(X_test)[:, 1] if hasattr(model, 'predict_proba') else model.decision_function(X_test)
train_metrics = {
'precision': precision_score(y_train, y_train_pred),
'recall': recall_score(y_train, y_train_pred),
'f1': f1_score(y_train, y_train_pred),
'roc_auc': roc_auc_score(y_train, y_train_proba)
}
valid_metrics = {
'precision': precision_score(y_valid, y_valid_pred),
'recall': recall_score(y_valid, y_valid_pred),
'f1': f1_score(y_valid, y_valid_pred),
'roc_auc': roc_auc_score(y_valid, y_valid_proba)
}
test_metrics = {
'precision': precision_score(y_test, y_test_pred),
'recall': recall_score(y_test, y_test_pred),
'f1': f1_score(y_test, y_test_pred),
'roc_auc': roc_auc_score(y_test, y_test_proba)
}
return train_metrics, valid_metrics, test_metrics
def print_metrics(model, X_train, y_train, X_valid, y_valid, X_test, y_test):
res = calc_metrics(model, X_train, y_train, X_valid, y_valid, X_test, y_test)
metrics = pd.DataFrame(res, index=['train', 'valid', 'test'])
return metrics
Обучаем модели¶
Будем использовать градиентный бустинговую классификацию и optuna, так как она на много эффективнее для перебора гиперпараметров
Определяем параметры которые будем перебирать
param
- learning_rate : шаг в градиетном спуске
- max_depth : глубина деревьев
- l2_leaf_reg : параметр регуляризации
- subsample : важен если мало данных, искуственно увеличивает количество данных
- random_strength : параметр регуляризации
- min_data_in_leaf : сколько данных может быть в листике
Метрика оценка качества модели выбираем ROC-AUC (площадь под TPR/FPR кривой)
Для оптимизации перебора гиперпараметров с Optuna нам нужно указать "object function", выход из этой функции и будет наша метрика
!pip install catboost optuna -qqq
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 98.2/98.2 MB 6.8 MB/s eta 0:00:00 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 380.1/380.1 kB 17.7 MB/s eta 0:00:00 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 233.0/233.0 kB 10.1 MB/s eta 0:00:00 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 78.6/78.6 kB 4.6 MB/s eta 0:00:00
from catboost import CatBoostClassifier
import optuna
def objective(trial, X_train, y_train, X_valid, y_valid):
# словарь перебора параметров
param = {
"learning_rate": trial.suggest_float('learning_rate', 0.01, 0.9),
"max_depth": trial.suggest_int("max_depth", 2, 7),
"l2_leaf_reg":trial.suggest_float('l2_leaf_reg', 0.01, 2),
"subsample": trial.suggest_float('subsample', 0.01, 1),
"random_strength": trial.suggest_float('random_strength', 1, 200),
"min_data_in_leaf":trial.suggest_float('min_data_in_leaf', 1, 500)
}
# модель
cat = CatBoostClassifier(
logging_level="Silent",
eval_metric="AUC",
grow_policy="Lossguide",
random_seed=42,
**param)
# обучение
cat.fit(X_train, y_train,
cat_features=object_cols, # категориальные колонки
eval_set=(X_valid, y_valid),
verbose=False,
early_stopping_rounds=10
)
# Выводим вероятностное распределение
preds = cat.predict_proba(X_valid)[:,1]
auc = roc_auc_score(y_valid, preds)
return auc
optuna.logging.set_verbosity(optuna.logging.WARNING)
def train_model(X_train, y_train, X_valid, y_valid, X_test, y_test):
# создаем оптимизационный цикл
study = optuna.create_study(direction="maximize",
study_name='CatBoostClassifier')
# запускаем оптимизационные циклы
study.optimize(lambda trial: objective(trial, X_train, y_train, X_valid, y_valid), n_trials=100)
'''
на выходе получаем параметры с самым высоким ROC-AUC
'''
# Используем оптимизированные параметры
best_cat = CatBoostClassifier(**study.best_params, random_state=random_state)
# Обучаем с этими параметрами
best_cat.fit(X_train, y_train, cat_features=object_cols,
eval_set=(X_valid, y_valid),
verbose=False,
early_stopping_rounds=10
)
# Выводим метрики
res_cat = print_metrics(best_cat, X_train, y_train, X_valid, y_valid, X_test, y_test)
return res_cat, best_cat
# обучаем модели (все три модели)
res_Laptop, model_Laptop = train_model(X_train_Laptop, y_train_Laptop, X_valid_Laptop, y_valid_Laptop, X_test_Laptop, y_test_Laptop)
res_Charger, model_Charger = train_model(X_train_Charger, y_train_Charger, X_valid_Charger, y_valid_Charger, X_test_Charger, y_test_Charger)
res_Phone, model_Phone = train_model(X_train_Phone, y_train_Phone, X_valid_Phone, y_valid_Phone, X_test_Phone, y_test_Phone)
res_Laptop
precision | recall | f1 | roc_auc | |
---|---|---|---|---|
train | 0.631678 | 0.727807 | 0.676344 | 0.672488 |
valid | 0.655340 | 0.737705 | 0.694087 | 0.686782 |
test | 0.609277 | 0.720930 | 0.660418 | 0.635050 |
res_Phone
precision | recall | f1 | roc_auc | |
---|---|---|---|---|
train | 0.545998 | 0.400607 | 0.462137 | 0.548935 |
valid | 0.538163 | 0.411067 | 0.466106 | 0.541741 |
test | 0.508941 | 0.379098 | 0.434527 | 0.519428 |
res_Charger
precision | recall | f1 | roc_auc | |
---|---|---|---|---|
train | 0.639498 | 0.288815 | 0.397919 | 0.706019 |
valid | 0.553977 | 0.293233 | 0.383481 | 0.698588 |
test | 0.559659 | 0.291852 | 0.383642 | 0.667592 |
Калибровка Моделей¶
Обучив модели, нам всегда требуется калибровка модели, чтобы получить истинные значение вероятности, особенно если на надо сравнивать разные классификаторы.
# калиброваться будем на изотонике
from sklearn.isotonic import IsotonicRegression
# функция для калибровки
def calibrate(model, X_valid, y_valid):
y_pred = model.predict_proba(X_valid)[:, 1]
# y_pred2 = model.predict_proba(X_valid)[:,0]
iso_reg = IsotonicRegression(out_of_bounds='clip')
iso_reg.fit(y_pred, y_valid)
y_pred_iso = iso_reg.transform(y_pred)
return iso_reg
Все, получили наши откалиброванные модели для каждого товара
# обучаем калибровку на валидационном датасете (можно и на трейне)
iso_reg_Laptop = calibrate(model_Laptop, X_valid_Laptop, y_valid_Laptop)
iso_reg_Charger = calibrate(model_Charger, X_valid_Charger, y_valid_Charger)
iso_reg_Phone = calibrate(model_Phone, X_valid_Phone, y_valid_Phone)
Определяем NBA на новой выборке¶
- У нас осталась тестовая выборка которую мы не использовали для обучение наших моделей. Будем использовать эту выборку для пресказания последуюшего вействия.
- От бизнесса мы получили данные о ценности каждого товара (NPV), будем использовать эти метрики для совместно с вероятностями
# net present value
# ценность каждого товара
# у кажного продукта свои, финансисты этим занимаются
npv_values = {
'Laptop': 100.0,
'Phone': 300.0,
'Charger': 200.0
}
# сборка датасета для удобного хранения тестовых данных
cols = X_test_Charger.columns.tolist()
# даем тот же тэг продукта
X_test_Charger['product'] = 'Charger'
X_test_Laptop['product'] = 'Laptop'
X_test_Phone['product'] = 'Phone'
# объединяем тестовые выборки
X_test = pd.concat([X_test_Charger, X_test_Laptop, X_test_Phone])
y_test = pd.concat([y_test_Charger, y_test_Laptop, y_test_Phone])
test_df = X_test[['product']]
test_df['NPV'] = test_df['product'].map(npv_values)
test_df['target'] = y_test
test_df['predict_Charger'] = model_Charger.predict_proba(X_test[cols])[:, 1]
test_df['predict_Laptop'] = model_Laptop.predict_proba(X_test[cols])[:, 1]
test_df['predict_Phone'] = model_Phone.predict_proba(X_test[cols])[:, 1]
В данном случае, мы можем проверить точность предсказания на тестовой выборке
# проверим, что сборка прошла удачно, нигде не ошиблись, и ROC_AUC примерно ожидаемый
for product in ['Charger', 'Laptop', 'Phone']:
tmp = test_df[test_df['product'] == product]
print(f'ROC_AUC for {product}:', roc_auc_score(tmp['target'], tmp[f'predict_{product}']))
ROC_AUC for Charger: 0.6675924528301888 ROC_AUC for Laptop: 0.635050157133878 ROC_AUC for Phone: 0.5194281906378073
Воспользуемся откалиброванными моделями и предскажем для каждого клиента вероятность покупки каждого товара
# применяем калибровку к тестовым семплам
test_df['predict_Charger_calibrated'] = iso_reg_Charger.transform(test_df['predict_Charger'])
test_df['predict_Laptop_calibrated'] = iso_reg_Laptop.transform(test_df['predict_Laptop'])
9test_df['predict_Phone_calibrated'] = iso_reg_Phone.transform(test_df['predict_Phone'])
Каждое предсказание с учетом NPV (Max_Score_NPV_Product) и есть то что нам следует рекоммендовать нашему клиетну
# Названия продуктов и их соответствие скорам
product_scores = {
'predict_Charger_calibrated': 'Charger',
'predict_Laptop_calibrated': 'Laptop',
'predict_Phone_calibrated': 'Phone'
}
# Вычисление максимального скора и соответствующего продукта
test_df['Max_Score_Product'] = test_df[
['predict_Charger_calibrated', 'predict_Laptop_calibrated', 'predict_Phone_calibrated']
].idxmax(axis=1).map(product_scores)
# Вычисление максимального значения скор * NPV и соответствующего продукта
def calculate_max_score_npv_product(row):
scores_with_npv = {
'Charger': row['predict_Charger_calibrated'] * npv_values['Charger'],
'Laptop': row['predict_Laptop_calibrated'] * npv_values['Laptop'],
'Phone': row['predict_Phone_calibrated'] * npv_values['Phone']
}
max_product = max(scores_with_npv, key=scores_with_npv.get)
return max_product
test_df['Max_Score_NPV_Product'] = test_df.apply(calculate_max_score_npv_product, axis=1)
test_df[['product', 'NPV', 'target', 'predict_Charger_calibrated','predict_Laptop_calibrated',
'predict_Phone_calibrated', 'Max_Score_Product', 'Max_Score_NPV_Product']].sample(6)
product | NPV | target | predict_Charger_calibrated | predict_Laptop_calibrated | predict_Phone_calibrated | Max_Score_Product | Max_Score_NPV_Product | |
---|---|---|---|---|---|---|---|---|
528 | Phone | 300.0 | 0 | 0.383764 | 0.446309 | 0.530378 | Phone | Phone |
8662 | Phone | 300.0 | 1 | 0.264151 | 0.565217 | 0.489831 | Laptop | Phone |
2287 | Phone | 300.0 | 0 | 0.525836 | 0.700935 | 0.530378 | Laptop | Phone |
252 | Laptop | 100.0 | 1 | 0.216418 | 0.404255 | 0.530378 | Phone | Phone |
7716 | Charger | 200.0 | 0 | 0.216418 | 0.646552 | 0.489831 | Laptop | Phone |
5078 | Phone | 300.0 | 1 | 0.388889 | 0.700935 | 0.535545 | Laptop | Phone |
test_df['Max_Score_Product'].value_counts()
count | |
---|---|
Max_Score_Product | |
Laptop | 3606 |
Phone | 1966 |
Charger | 428 |
test_df['Max_Score_NPV_Product'].value_counts()
count | |
---|---|
Max_Score_NPV_Product | |
Phone | 5944 |
Charger | 56 |