ML-задача на 30 минут: гадаем по cookie

«Я тебя по IP вычислю!» — помните такую угрозу из интернета времен нулевых годов? Мы в Big Data МТС решили выяснить, можно ли составить хотя бы приблизительное представление о человеке, обладая информацией о сайтах, которые он посещает. Для этого мы сгенерировали полусинтетические данные, чтобы понять, насколько смелыми можно быть в этих ваших интернетах.

6ad35f7e83e874f1668cf9e181c4ad59.jpg

Информация о посещенных сайтах доступна не по конкретному Иван Иванычу, а по обезличенной сущности cookie, используемой в качестве id пользователя при обмене данными между рекламными DSP- и SSP- площадками.

Вопрос звучит так: сможем ли мы по цифровым следам пользователя (на каких сайтах с каких IP он сидел, сколько раз заходил, какое у него устройство) понять, кто этот пользователь? Студент или пенсионер? Мужчина или женщина? Как много информации мы вообще сможем получить?

Вообще в Digital-рекламе сегмент часто включает себя пол и один из бакетов по возрасту (<18, 18-24, 25-34, 35-44, 45-54, 55-64, 65+). Эта задача особенно актуальна для рекламных DSP-площадок, которые в OpenRTB запросах получают такие данные с частотой 200 000 запросов в секунду со всех сайтов, размещающих рекламу за деньги.

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

Ниже — наш baseline решения — без кросс-валидации, подбора гиперпараметров, feature engineering и прочих приятных сердцу вещей. Такое решение можно написать в метро по дороге от Речного вокзала до Технопарка (именно здесь находится офис МТС Digital). Этот путь займет чуть больше 30 минут.

5ad8616bd2b480d4c26d0514a76102ad.jpg

Знакомый всем DS-иконостас

Однако в этот раз мы оставили только самое нужное =) (ну почти).

import sys
import os
import warnings
os.environ['OPENBLAS_NUM_THREADS'] = '1'
warnings.filterwarnings('ignore')
import pandas as pd
import numpy as np
import time
import pyarrow.parquet as pq
import scipy
import implicit
import bisect
import sklearn.metrics as m
from catboost import CatBoostClassifier, CatBoostRegressor, Pool
from sklearn.model_selection import train_test_split
from sklearn.calibration import calibration_curve, CalibratedClassifierCV
LOCAL_DATA_PATH = './context_data/'
SPLIT_SEED = 42
DATA_FILE = 'competition_data_final_pqt'
TARGET_FILE = 'competition_target_pqt'

Прочитаем файлы

data = pq.read_table(f'{LOCAL_DATA_PATH}/{DATA_FILE}')
pd.DataFrame([(z.name, z.type) for z in data.schema], \
             columns = [['field', 'type']])

926da90b12428fa227c422344f6b209f.png

В таблице выше:

  • регион;

  • населенный пункт;

  • производитель устройства;

  • модель устройства;

  • домен, с которого пришел рекламный запрос;

  • тип устройства (смартфон или что-то другое);

  • операционка на устройстве;

  • оценка цены устройства;

  • дата;

  • время дня;

  • число запросов к домену в эту часть дня в эту дату;

  • id пользователя.

data.select(['cpe_type_cd']).to_pandas()['cpe_type_cd'].value_counts()

Для себя открыл слово фаблет.

targets = pq.read_table(f'{LOCAL_DATA_PATH}/{TARGET_FILE}')
pd.DataFrame([(z.name, z.type) for z in targets.schema], \
             columns = [['field', 'type']])

ну тут все яснону тут все ясно

Ок, нам лень генерить фичи

Вместо этого посмотрим на задачу, как на рекомендательную систему: факторизуем матрицу взаимодействий Users и Items (под которыми понимаем посещенные домены) — ожидаем, что получим полезный эмбеддинг, да и придумывать ничего не придется. Понятно, что мы потеряем последовательность и временные характеристики, но что делать, мы уже почти на Белорусской.

98bd565a3256809c906006eb888b8c14.jpg

%%time
data_agg = data.select(['user_id', 'url_host', 'request_cnt']).\
    group_by(['user_id', 'url_host']).aggregate([('request_cnt', "sum")])

1c2b89ae9cab9ba2850f5adeb184ce37.png

url_set = set(data_agg.select(['url_host']).to_pandas()['url_host'])
print(f'{len(url_set)} urls')
url_dict = {url: idurl for url, idurl in zip(url_set, range(len(url_set)))}
usr_set = set(data_agg.select(['user_id']).to_pandas()['user_id'])
print(f'{len(usr_set)} users')
usr_dict = {usr: user_id for usr, user_id in zip(usr_set, range(len(usr_set)))}

не самая большая матрица выйдетне самая большая матрица выйдет

%%time
values = np.array(data_agg.select(['request_cnt_sum']).to_pandas()['request_cnt_sum'])
rows = np.array(data_agg.select(['user_id']).to_pandas()['user_id'].map(usr_dict))
cols = np.array(data_agg.select(['url_host']).to_pandas()['url_host'].map(url_dict))
mat = scipy.sparse.coo_matrix((values, (rows, cols)), shape=(rows.max() + 1, cols.max() + 1))
als = implicit.approximate_als.FaissAlternatingLeastSquares(factors = 50, \
      iterations = 30, use_gpu = False, calculate_training_loss = False, regularization = 0.1)

%%time
als.fit(mat)
u_factors = als.model.user_factors 
d_factors = als.model.item_factors

30 итераций за 30 сек -- спасибо FAISS!30 итераций за 30 сек — спасибо FAISS!

Получим оценку по полу

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

%%time
inv_usr_map = {v: k for k, v in usr_dict.items()}
usr_emb = pd.DataFrame(u_factors)
usr_emb['user_id'] = usr_emb.index.map(inv_usr_map)
usr_targets = targets.to_pandas()
df = usr_targets.merge(usr_emb, how = 'inner', on = ['user_id'])
df = df[df['is_male'] != 'NA']
df = df.dropna()
df['is_male'] = df['is_male'].map(int)
df['is_male'].value_counts()

88abdf036571608234f73e83043a2746.png

%%time
x_train, x_test, y_train, y_test = train_test_split(\
    df.drop(['user_id', 'age', 'is_male'], axis = 1), df['is_male'], test_size = 0.33, random_state = SPLIT_SEED)
clf = CatBoostClassifier()
clf.fit(x_train, y_train, verbose = False)
print(f'GINI по полу {2 * m.roc_auc_score(y_test, clf.predict_proba(x_test)[:,1]) - 1:2.3f}')

Не так уж плохо! Но мы почти доехали до Павелецкой.

b0457767d2f6f316d7484a58b8ec07c1.jpg

Получим оценку по возрасту

import seaborn as sns
import matplotlib.pyplot as plt
import plotly.express as px

%matplotlib inline
sns.set_style('darkgrid')
def age_bucket(x):
    return bisect.bisect_left([18,25,35,45,55,65], x)
df = usr_targets.merge(usr_emb, how = 'inner', on = ['user_id'])
df = df[df['age'] != 'NA']
df = df.dropna()
df['age'] = df['age'].map(age_bucket)
sns.histplot(df['age'], bins = 7)

ca1322588c4325f15bd755259d60e584.png

x_train, x_test, y_train, y_test = train_test_split(\
    df.drop(['user_id', 'age', 'is_male'], axis = 1), \
    df['age'], test_size = 0.33, random_state = SPLIT_SEED)

clf = CatBoostClassifier()
clf.fit(x_train, y_train, verbose = False)
print(m.classification_report(y_test, clf.predict(x_test), \
    target_names = ['<18', '18-25','25-34', '35-44', '45-54', '55-65', '65+']))

d378373f712f4278712afd3e04c3acde.png

Критерии сравнения

Оказалось, что пол определить намного проще, чем возраст.
Поэтому в итоговый скор соревнования войдут Gini по полу (от 0 до 1) и f1 weighted по возрасту — с весом два:

score = Gini_{sex} + 2 weighted F1 _{age}

Итого общий скор: 0.665 + 2×0.41 = 1.485

Для поездки от Речного до Технопарка терпимо. =)

На этом все! Если же хочется более серьезных задач — ждем вас на соревнованиях по Machine Learning 30 января, это турнир по определению пола/возраста владельца cookie от МТС Digital. Призовой фонд MTC ML Cup — 650 000 рублей: победитель получит 350 000 рублей, обладатель серебра — 200 000 рублей, а третий призер станет богаче на 100 000 рублей. Регистрация уже открыта, простая анкета для участников и все подробности — на сайте. Увидимся на соревновании!

969425ed3a85cb98f5e0dfceb42724e2.png

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

P.S.

Возможно, вам полезно будет попробовать более новые и быстрые библиотеки по работе с табличками вместо Pandas и PyArrow:
Polars
CuDF

И библиотеки для RecSys:
MTS RecTools
LightFM
Transformers4Rec
ReChorus
RecBole

© Habrahabr.ru