Зачем в Look-a-like pseudolabelling (или самый простой метод PU-learning на службе у рекламщиков)

от автора

Готовил семинар студентам и почему-то нигде не могу найти этот простой и действенный способ именно в контектсе Look-a-Like (если не прав — поделитесь, пожалуйста, в комментариях ссылкой).

Бизнес-задача

Представьте задачу:

К вам пришел предприниматель, говорит вот у меня есть 200-300 действующих клиентов, а хочу в 10 раз больше! Бюджет ограничен, подсвети еще 1000 потенциальных клиентов — я их обзвоню.

То есть среди всех возможных в базе найти «максимально похожих».
Никогда не любил задачи в такой формулировке, но business first.

Давайте посмотрим как делают в рекламных агенствах (для краткости не будут про аналитиков, а задача сразу попала к Data Scientist).

Будем упражняться хоть и с игрушечным, но кодом.

Генерация данных

Итак, импортируем библиотеки

from sklearn.datasets import make_classification import pandas as pd import numpy as np from sklearn.ensemble import RandomForestClassifier from tqdm import tqdm import warnings warnings.filterwarnings("ignore") from sklearn.metrics import precision_score, recall_score import matplotlib.pyplot as plt import seaborn as sns 

и сгенерим данные:

np.random.seed(42) n_features = 10 n_samples = 100_000  df, y = make_classification(n_samples = n_samples, n_features =n_features, random_state = 42, flip_y = 0.03, weights = [0.99]) df = pd.DataFrame(df, columns = [f'feature_{k}' for k in range(n_features)]) df['y'] = y cnt = df[df['y'] == 1]['y'].count() print(f'исходное число единичек {cnt}') >>> исходное число единичек 2554 

Итак, у нас есть выборка из 100 000 людей (по каждому из которых известно 10 признаков-фичей), из которых релевантными нашему предпринимателю (единичками) являются только 2554, и у трех процентов из всех (то есть у 3 000 клиентов) перепутаны местами метки.

Но погодите, 2554 это неплохо!
В затравке я писал что обычно дают 200-300.

Ну что ж, тогда давайте сделаем вид что про остальные мы не знаем — и задача будет как раз в том чтобы их найти:

N = 2200 # число единичек, про которые забудем   def unlabel(df, hidden_size):   # на всякий случай подстрахуемся резервной копией   df_orig = df.copy()    df.loc[       np.random.choice(           df[df['y'] == 1].index,           replace = False,           size = hidden_size       )   , 'y'] = 0   return df, df_orig  df, df_orig = unlabel(df, N) print('после сокрытия единичек', df[df['y'] == 1]['y'].count()) df['truth'] = df_orig['y'] >>> после сокрытия единичек 354 

Еще раз про бизнес-задачу

Вот это уже похоже на бизнес-задачу!
У нас есть база 100 000 людей, про 354 из них мы знаем что они уже клиенты нашего предпринимателя, и у него есть бюджет на 2200 рекламных коммуникаций — нам надо очень тщательно выбрать из (100 000 — 354 известных) = 99 646 эти 2200 чтобы прокоммуницировать! При этом в нашей игрушечной задаче они (релевантные потенциальные клиенты) гарантировано есть (мы их только что скрыли — сделали вид что про них не знаем).
То есть среди тех, кто в датасете записан ноликами — есть единички, только мы про них не знаем.
То есть у нас две части данных: известная (или размеченная) — и она представлена только единичками. И неразмеченная — пока она обозначена ноликами.

Если случайно взять одного неразмеченныго, то шанс что он окажется релевантным (единичкой) 2.2%:

df[(df['y'] == 0) & (df['truth'] == 1)].shape[0] / df[df['y'] == 0].shape[0] >>> 0.022 

Как часто решают задачу LaL DS-работники в рекламных агенствах?

Строят модель бинарной классификации — берут размеченные единички и случайно каких-то ребят из неразмеченных, объявляя их ноликами (случайное равномерное сэмплирование) — действительно, шанс что при этом не того обзовешь ноликом 2.2% в нашей задаче (если забыть про случайный шум, который мы задавали через параметр flip_y в make_classification).
Сколько таких брать? Пусть будет 30% по велению левой пятки.

# отложим train -- все доступные единички + 30% от неразмеченной выборки count_zeros = df[df['y'] == 0]['y'].count() rate = 0.3 df['is_train'] = 0 train_index = pd.concat([df[df['y'] == 1].copy(), df[df['y'] == 0].sample(int(count_zeros * rate), random_state=1)]).index df.loc[df.index.isin(train_index), 'is_train'] = 1 df[df['is_train'] == 1].shape[0] >>> 30247 

Теперь у нас в руках «тренировочная выборка» размером в 30% базы и будем учить модель:

# учим на трейне первый алгоритм и предсказываем на всем датасете rf = RandomForestClassifier(n_estimators = 100, n_jobs = -1, random_state = 42) rf.fit(df[df['is_train'] == 1].drop(['y', 'truth', 'is_train'], axis = 1), df[df['is_train'] == 1]['y']) preds = rf.predict_proba(df.drop(['y', 'truth', 'is_train'], axis = 1))[:, 1] df['rf1_preds'] = preds 

Так как мы знаем истинные метки, давайте посмотрим как бы она сработала с реальным клиентом:
раз у нас есть скор по всей базе, отберем N (в нашем случае 2200) людей с самым высоким скором и сделаем с ними коммуникацию.
Какие шансы что мы попадем?
Давайте посчитаем:

df_sorted = df[df['is_train'] == 0].sort_values(by=['rf1_preds'], ascending=False).head(N).copy() df_sorted['truth'].mean() >>> 0.1536 

Целых 15%! Уже в 6.8 раз лучше чем если сделать рассылку случайно!

На этом многие агенства успокаиваются (и считают CTR 15% очень неплохим).
Но не мы.

Давайте пойдем чуть дальше и применим сто лет известную на kaggle
технику pseudolabbeling

Для того чтобы пойти дальше в нашей обучающей выборке придется выделить внутреннюю обучающую выборку (а на той части что в нее не войдет будем тестироваться):

# от трейна отделим выборку, ее будем обогащать неразмеченными ноликами, # на тесте выбирать порог, потом с этим порогом обучимся и посмотрим как поменялись шансы count_train = df[df['is_train'] == 1].shape[0] rate = 0.7 df['is_inner_train'] = 0 inner_train_index = df[df['is_train'] == 1].sample(int(count_train * rate), random_state=1).index df.loc[df.index.isin(inner_train_index), 'is_inner_train'] = 1 df.loc[df['is_inner_train'] == 1].shape[0] >>> 21172 

Наш алгоритм:

  1. учить первую модель как в рекламном агенстве (на внутреннем трейне)

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

  3. задавать порог (который в цикле перебирать будем)

  4. все предикты первой модели на этом тесте, которые ниже порога, красим в 0 и добавляем во внутренний трейн (внутреннюю обучающую выборку)

  5. все предикты первой модели на этом тесте, которые от 1 остоят на величину не больше порога, красим в 1 и добавляем во внутренний трейн (внутреннюю обучающую выборку)

  6. наш внутренний трейн стал больше за счет добавления новых 1 и 0, полученных моделью — вот эти добавленные элементы и называют псевдо-размеченными, а значение целевой переменной на них (0 или 1) — псевдометками

  7. на таком трейне с добавленными псевдоразмеченными элементами учим еще одну модель

  8. размечаем ею неразмеченную часть трейна и считаем нашу метрику (концентрация честных единиц среди топ-N) по скору

  9. выбираем порог, при котором метрика максимальна

  10. повторяем все действия при выбранном пороге, размечаем всю базу (100_000 — 324 известных), выбираем топ-N и отдаем клиенту

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

save = df.copy() res = [] for tr in [0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1, 0.15, 0.2, 0.22, 0.25, 0.3, 0.35, 0.4]:   rf = RandomForestClassifier(n_estimators = 100, n_jobs = -1, random_state = 42)   rf.fit(df[df['is_inner_train'] == 1].drop(['y', 'truth', 'is_train', 'is_inner_train'], axis = 1), df[df['is_inner_train'] == 1]['y'])   preds = rf.predict_proba(df.drop(['y', 'truth', 'is_train', 'is_inner_train'], axis = 1))[:, 1]   df['rf1_preds'] = preds    df.loc[(df['is_train'] == 1) & (df['is_inner_train'] == 0) & (df['rf1_preds'] <= tr), 'y'] = 0   df.loc[(df['is_train'] == 1) & (df['is_inner_train'] == 0) & (df['rf1_preds'] > 1- tr), 'y'] = 1   df.loc[(df['is_train'] == 1) & (df['is_inner_train'] == 0) & ((df['rf1_preds'] <= tr)), 'is_inner_train'] = 1   df.loc[(df['is_train'] == 1) & (df['is_inner_train'] == 0) & ((df['rf1_preds'] > 1 - tr)), 'is_inner_train'] = 1    # print(df[df['is_inner_train'] == 1].shape)   rf2 = RandomForestClassifier(n_estimators = 100, n_jobs = -1, random_state = 42)   rf2.fit(df[df['is_inner_train'] == 1].drop(['y', 'truth', 'is_train', 'rf1_preds', 'is_inner_train'], axis = 1), df[df['is_inner_train'] == 1]['y'])   preds2 = rf2.predict_proba(df.drop(['y', 'truth', 'is_train', 'rf1_preds', 'is_inner_train'], axis = 1))[:, 1]   df['rf2_preds'] = preds2   test = df[(df['is_train'] == 1)&(df['is_inner_train'] == 0)][['y', 'rf2_preds']].copy()    small_N = int(df[(df['is_inner_train'] == 1) & (df['y'] == 1)].shape[0] * (N / df.loc[(df['is_train'] == 1) & (df['y'] == 1)].shape[0]))   small_df_sorted = df[(df['is_train'] == 1) & (df['is_inner_train'] == 0)].sort_values(by=['rf2_preds'], ascending=False).head(small_N).copy()   new_chances = small_df_sorted['y'].mean()   #print(tr, new_chances)   res.append([tr, round(new_chances, 3)])   df = save.copy()  res_df = pd.DataFrame(res, columns = ['tr', 'chance']).sort_values(by = 'chance', ascending = False) res_df = res_df[res_df['tr'] < 0.14].copy() # чуть обрежем график для удобства sns.scatterplot(x=res_df['tr'], y=res_df['chance']) 
график зависимости нашей метрики от значения порога из процедуры

график зависимости нашей метрики от значения порога из процедуры

Итак, наша метрика максимальна при значении порога в 0.09

tr = 0.09 save = df.copy() # учим на трейне первый алгоритм и предсказываем на всем датасете rf = RandomForestClassifier(n_estimators = 100, n_jobs = -1, random_state = 43) rf.fit(df[df['is_train'] == 1].drop(['y', 'truth', 'is_train','is_inner_train'], axis = 1), df[df['is_train'] == 1]['y']) preds = rf.predict_proba(df.drop(['y', 'truth', 'is_train','is_inner_train'], axis = 1))[:, 1] df['rf1_preds'] = preds   df.loc[(df['is_train'] == 0) & (df['rf1_preds'] <= tr), 'y'] = 0 df.loc[(df['is_train'] == 0) & (df['rf1_preds'] > 1 - tr), 'y'] = 1 df.loc[(df['is_train'] == 0) & ((df['rf1_preds'] <= tr) | (df['rf1_preds'] > 1 - tr) ), 'is_train'] = 1 df.loc[(df['is_train'] == 0) & ((df['rf1_preds'] > 1 - tr) ), 'is_train'] = 1   rf2 = RandomForestClassifier(n_estimators = 100, n_jobs = -1, random_state = 43) rf2.fit(df[df['is_train'] == 1].drop(['y', 'truth', 'is_train', 'rf1_preds','is_inner_train'], axis = 1), df[df['is_train'] == 1]['y']) preds2 = rf2.predict_proba(df.drop(['y', 'truth', 'is_train', 'rf1_preds','is_inner_train'], axis = 1))[:, 1] df['rf2_preds'] = preds2   df_sorted = df[df['is_train'] == 0].sort_values(by=['rf2_preds'], ascending=False).head(N).copy() times = round(df_sorted['truth'].mean() / random_send, 3) print(f'tr = {tr}, ', 'шансы набрать единичек в топ-N ', round(df_sorted['truth'].mean(), 3), f', они лучше случайных в {times} раз') df = save.copy() >>> tr = 0.09,  шансы набрать единичек в топ-N  0.804 , они лучше случайных в 35.645 раз 

А было 6,8 раз лучше случайного!

Итого

Шансов набрать N единичек из неразмеченной выборки для рекламы:

  • Ищем случайно — 2,3%

  • Ищем моделью — 15,4% (в 6,8 раз лучше случайного выбора)

  • c PL — 80% (в 35,6 раза лучше случайного выбора и в 5.2 раза лучше случая рекламных агенств)

Чтобы не было путаницы в терминологии:

  • Бизнес-задача: Look-a-Like

  • DS-задача: Positive-unlabelled learning

  • Способ решения: Pseudolabelling

Здесь я пишу редко и более-менее по делу, менее формальные вещи в своем канале в тг, но там такой длинный пост особо не разместишь.

А задача выше — кусочек семинара для курсов «ML в бизнесе»/ «Прикладное машинное обучение», которые мы с товарищем читаем в паре известных столичных вузов (мне лень согласовывать публикацию с их пиар-службами, поэтому просто не буду указывать названия). Плюс недавно начали еще и в онлайн-школе, где пытаемся покрыть все кейсы в которых ML реально на нашем рынке приносит деньги бизнесу — потому как доменные знания и подход к проблеме часто более важны чем сам ML.


ссылка на оригинал статьи https://habr.com/ru/articles/876646/