Готовил семинар студентам и почему-то нигде не могу найти этот простой и действенный способ именно в контектсе 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.

Комментарии (3)


  1. Andriljo
    25.01.2025 21:51

    Полезная на практике статья,лайк


  1. akhalat
    25.01.2025 21:51

    А в цикле, когда пороги перебираешь, не надо на каждой итерации is_inner_train (и метки) возвращать к исходному состоянию? Так там получается накапливаются псевдо-лейблы со всех предыдущих порогов.


    1. oksmoron Автор
      25.01.2025 21:51

      так там же df = save.copy() в конце каждой итерации -- ровно это и делаю (возвращаю к исходному состоянию)