Читать нас в Telegram

Как специалисту по Data Science написать классификатор, если часть данных неверно размечена

Рубрика: Статьи
,
4160

Таня Пучило, ментор курса Wargaming Forge: Game Data Analytics

В разработке ПО в целом и видеоигр в частности важно всегда иметь возможность проанализировать работу системы и поведение пользователей. Для того чтобы аналитики имели возможность собрать информацию и дать полезные рекомендации, а разработчики — воспользоваться этими рекомендациями для улучшения продукта, нужно заранее позаботиться не только о корректном логировании, но и о правильной разметке данных. Так как это не всегда возможно, часть данных не используется при анализе, или, что ещё хуже, на их основе делаются некорректные выводы.

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

Что такое неверная разметка и почему это происходит?

Неверно размеченные данные — это данные, метки которых не соответствуют действительности. К примеру, у вас есть набор картинок котиков и собачек, но часть котиков почему-то оказывается собачками согласно разметке. Такая проблема может возникнуть по нескольким причинам: субъективность человека, размечающего данные; ошибки при получении данных, и, в случае косвенной разметки, выбор неверного алгоритма. Очевидно, такие проблемы могут возникнуть в абсолютно любых областях: в медицине, развлечениях, обучении — где угодно.

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

Входные данные, которые вам доступны, включают:

Кажется, что ничто не мешает вам обучить алгоритм на основе характеристик игроков для прогнозирования вероятности его участия в событии. Однако во время проведения события что-то идёт не так, и вы понимаете, что часть игроков, которые хотели в нём поучаствовать, не могут этого сделать. Если это покупка — они пытаются её совершить, но не могут, — количество предлагаемого контента ограничено; если это новый режим — они пытаются сыграть в него, но у них не получается зайти в бой по каким-то техническим проблемам. Эти игроки так и останутся в статусе «не поучаствовал, но хотел бы».

Вот незадача! Ведь вам, как аналитику, очень нужны точные данные об участниках события, чтобы использовать эту информацию для обучения алгоритма. А данные, которые у вас есть, выглядят следующим образом:

При этом третьего столбца на самом деле не существует. И нет никакого технического способа проверить, правильна метка или нет. Всё, что вы знаете, — это то, что часть игроков попала в ошибочный класс.

Что делать?

Вариант первый: ничего!

Ну не можем мы обучить модель и сделать прогноз, — бывает. Мы всегда можем посчитать описательные статистики по игрокам-участникам события (к примеру, среднее количество боёв в день) и выделить простые правила для отбора потенциальных участников в новом событии. В случае среднего количества боёв в день, может получиться так, что у группы участников значение метрики в среднем на 30% выше, чем у не участников. Вот мы и будем предполагать, что все игроки, с похожим значением метрики, как у участников события, станут потенциальными участниками следующего события.

Плюсы:

Минусы:

Вариант второй: обучить алгоритм на той разметке, которая есть

В качестве вектора признаков для характеристики игроков берём всевозможные метрики, которые приходят нам на ум (включая среднее количество боёв в день), а в качестве целевой переменной — участие игрока в событии, предполагая, что ошибок в данных у нас нет. На этих данных обучаем алгоритм, используя магию машинного обучения, и с помощью полученного алгоритма прогнозируем участие игроков в следующем событии.

Плюсы:

Минусы:

Вариант третий – переразметить игроков и обучить модель на переразмеченных данных

Прежде чем обучать модель на характеристиках игроков и целевой переменной, попытаемся получить новые, более точные, значения целевой переменной. И только после этого, используя обновлённые значения, обучим финальный алгоритм.

Плюсы:

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

Как найти в выборке неверно размеченные объекты?

Качество финальной модели зависит от двух вещей: от качества исходных данных (в частности — их разметки) и возможностей/настройки выбранной модели. В нашем случае основной упор делается на качество данных, поэтому заниматься оптимизацией характеристик моделей мы не будем. Однако это не повод этого не делать! Чтобы переразметить данные, нам понадобится:

Допустим, исходные данные выглядят следующим образом. Здесь X={x1,x2,…, xn} — вектор признаков, описывающих каждого игрока, а y — целевая переменная, соответствующая тому, участвовал игрок в событии или нет (1 — участвовал, 0 — не участвовал соответственно).

import pandas as pd
import numpy as np

data = pd.read_csv('../data.csv')
data.head(n=10)

data.y.value_counts(normalize=True)

Для начала обучим базовый классификатор для того, чтобы понимать качество модели на неверно размеченной выборке. В качестве классификатора выберем Random Forest и его реализацию в Sklearn.

from sklearn.model_selection import train_test_split
from sklearn.ensemble import  RandomForestClassifier

X_data, y_data = data.drop(['y'], axis = 1), data['y']
X_train, X_test, y_train, y_test = train_test_split(X_data, y_data, test_size=.3, random_state=RS)
clf = RandomForestClassifier(n_estimators=250, random_state=42, n_jobs=15)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)

Посмотрим на качество модели: выведем матрицу ошибок и основные метрики качества.

df_confusion = pd.crosstab(y_test, y_pred, rownames=['Actual'], colnames=['Predicted'], margins=True)
print(df_confusion)

from sklearn.metrics import classification_report
print(classification_report(y_test, y_pred))

Видим, что качество классификации для 1-го класса, т. е. участников события, очень низкая: recall1= 0,19, а f1-score= 0,29. Средний для модели f1-score= 0,62.

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

Будем надеяться, что вы решили идти дальше. Схематично вся переразметка данных сведётся к следующему. Исходные данные разобьём на N частей с равным распределением объектов из 0-го и 1-го классов. На каждых (N-1) частях обучим 5 или более методов машинного обучения, желательно разных по архитектуре и предсказывающих вероятность. В нашем случае используем уже знакомый Random Forest, а также Logistic regression, Naive Bayes, XGBoost, CatBoost.

Для этого инициализируем модели с нужными параметрами. Параметры, к слову, уже на этом этапе лучше выбирать путем оптимизации гиперпараметров.

from catboost import CatBoostClassifier
from sklearn.naive_bayes import GaussianNB
from sklearn.linear_model import LogisticRegression
from xgboost import XGBClassifier

clfs = {}

logreg_model = LogisticRegression(C=100)
clfs['LogReg'] = {'clf': LogisticRegression(), 'name':'LogisticRegression', 'model': logreg_model}

rf_model = RandomForestClassifier(n_estimators=250, max_depth=18, n_jobs=15)
clfs['RandomForest'] = {'clf': RandomForestClassifier(), 'name':'RandomForest', 'model': rf_model}

xgb_model = XGBClassifier(n_estimators=500, max_depth=10, learning_rate=0.1, n_jobs=15)
clfs['XGB'] = {'clf': XGBClassifier(), 'name': 'XGBClassifier', 'model': xgb_model}

catb_model = CatBoostClassifier(learning_rate=0.2, iterations=500, depth=10, thread_count=15, verbose=False)
clfs['CatBoost'] = {'clf': CatBoostClassifier(), 'name': 'CatBoostClassifier', 'model': catb_model}

nb_model = GaussianNB()
clfs['NB'] = {'clf': GaussianNB(), 'name':'GaussianNB', 'model': nb_model}

Далее исходные данные разбиваем на 5 частей с равномерным распределением примеров 0-го и 1-го классов.

data_0 = np.array_split(data[data['y'] == 0].sample(frac=1), 5)
data_1 = np.array_split(data[data['y'] == 1].sample(frac=1), 5)

dfs = {i: data_0[i].append(data_1[i]) for i in range(5)}

И наконец, производим переразметку данных, итерируясь по каждой из 5-и частей выборки и используя для прогнозирования каждую из 5-и вышеобъявленных моделей.

from sklearn.preprocessing import  StandardScaler

threshold = 0.5
relabeled_data = pd.DataFrame()
for i in range(5):
    # test - i-й dataframe, train - все оставшиеся кроме i-го
    df_test = dfs[i]
    df_train = pd.concat([value for key, value in dfs.items() if key != i])
    X_train, y_train = df_train.drop(['y'], axis=1), df_train['y']
    X_test, y_test = df_test.drop(['y'], axis=1), df_test['y']
    
    df_w_predicts = df_test.copy()
    # обучение каждой модели на train и прогноз на test
    for value in clfs.values():
        model = value['model']
        if value['name'] in ['LogisticRegression', 'GaussianNB']:
            model.fit(StandardScaler().fit_transform(X_train), y_train)
            predicts = (model.predict_proba(StandardScaler().fit_transform(X_test)
                                               )[:, 1] >= threshold).astype(bool)
        else:
            model.fit(X_train, y_train)
            predicts = (model.predict_proba(X_test)[:, 1] >= threshold).astype(bool)
            
        df_w_predicts[value['name']] = predicts
        relabeled_data = relabeled_data.append(df_w_predicts)

В результате переразметки каждая модель предскажет вероятность того, что игрок был участником события. Переразметка целевой переменной происходит в том случае, если все модели предсказали вероятность выше некоторого порога (threshold). В текущем примере threshold=0.5. Данные будут выглядеть следующим образом:

Возникает логичный вопрос: как проверить качество переразметки? Как вариант, построить распределения признаков, характеризующих игроков, в разрезе реальных участников события, потенциальных участников (т. е. тех, кого мы переразметили с 0-го класса в 1-й), и не участников события. В результате вы получите следующее:

Отчётливо видно, что распределения основных метрик потенциальных участников (в прошлом «не участников») практически совпадают с распределениями реальных участников.

Снова обучаем Random Forest, чтобы сравнить качества моделей до и после переразметки. Также полученную модель уже можно использовать для прогнозирования участия новых игроков в следующем событии.

relabeled_data.drop(['y_old'], axis=1, inplace=True)
X_data, y_data = relabeled_data.drop(['y_new'], axis = 1), relabeled_data['y_new']
X_train, X_test, y_train, y_test = train_test_split(X_data, y_data, test_size=.3, random_state=42)
clf = RandomForestClassifier(n_estimators=250, random_state=42, n_jobs=15)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)

df_confusion = pd.crosstab(y_test, y_pred, rownames=['Actual'], colnames=['Predicted'], margins=True)
print(df_confusion)

print(classification_report(y_test, y_pred))

Видим, что качество классификации, f1-score, вырос до 0.84, т. е. на 35%! Также теперь recall1= 0,64, при этом мы не потеряли в recall0. А значит, мы начали гораздо правильнее классифицировать потенциальных участников события.

Что дальше?

Я рассказала об одном из вариантов повышения качества исходных данных. Чтобы улучшить финальный алгоритм классификации, можно ещё поэкспериментировать:

Надеюсь, в вашей работе такие ситуации будут встречаться очень редко, но если и будут, то данный материал окажется вам полезным!