Разбор задач квалификации: ML

Лучшие участники квалификационного раунда поделились своими подходами к решениям.

1-е место: Марк Баушенко

Анализ данных

Первым делом я решил посмотреть на данные и статистики по ним (ноутбук EDA.ipynb). Проверил на лик данных из трейна в тест по oid, его не оказалось. Во время просмотра данных глазами я заметил, что есть очень много повторяющихся текстов и, возможно, они имеют разные таргеты. Все такие тексты я удалил, в основном это была реклама. Затем такие тексты я убрал из тестовой выборки. После этого я полностью дропнул дубликаты текстов из трейна, чтобы при разбивке на трейн и вал не было лика в вал. Из теста я дубликаты не убирал, потому что это ломало бы мои предикты (ниже распишу инференс, станет понятнее). Распределение таргетов в сырых данных и в данных после обработки было почти одинаковым. Среди меток разница была в 1,5 процента, поэтому я не делал балансировку классов при обучении.

Обучение бейзлайна (train.ipynb)

Так как я хорошо разбираюсь в трансформерах и это сейчас сота для таких задач, то я зашёл на Hugging Face и выбрал первую попавшуюся предобученную модель на русском языке — sberbank-ai/sbert_large_nlu_ru. У меня был код для multilabel-регрессии, и я просто переиспользовал его, переписав класс модели и лосс под multiclass-классификацию. Так как тексты в постах достаточно длинные и моих ресурсов не хватит для обучения классификации группы, я решил, что буду классифицировать посты и постпроцессингом получать метку группы.

Логирование экспериментов производил в wandb. Чтобы сделать размер батча побольше, я снизил потребление памяти моделью с помощью automatic mixed precision (тут мы всё считаем в точности float point 16) и gradient checkpoint (тут мы храним не все градиенты, а через один, и при back propagation пересчитываем нужные), при этом пожертвовав скоростью обучения (bs побольше в таких задачах важен). Обучение было с использованием кросс-валидации по 4 фолдам с дальнейшим усреднением фолдов. Косинусный шедулер для learning rate очень хорош. Обучал 7 эпох с сохранением лучших весов по валидации метрики из соревнования (заимплементировал её). Только во время написания отчёта понял, что я применял эту метрику к постам, а не к группам :)

Модель у меня состояла из backbone — предобученный трансформер, head — MeanPooling + линейка на 13 выходов (количество классов). Софтмакс не применял, оптимизировал веса с кроссентропи лоссом по логитам из модели (у меня так часто лучше обучение происходит). Сохранял oof с логитами по всем классам для дальнейшего анализа ошибок или подбора весов для ансамбля.

Важно ещё отметить, что я обрезал текст, если он превышал 512 токенов, так как модели из классов BERT и RoBERTa имеют максимальную длину в 512 токенов. Sliding window для увеличения длины входящей последовательности я не применял, так как не было времени код пописать (конец года, дедлайны горят). А русских моделей DeBERTa, к сожалению, ещё нет — их входная последовательность может быть порядка 22 000 (из-за другого механизма внимания).

Инференс (inference.ipynb)

Для каждого поста в тесте я предсказал вероятности таргета для каждого из модели-фолдов и усреднил их. После этого для каждого поста я выбрал наибольшую вероятность с помощью аргмакса. Что мы имеем — смотрите ниже.

Разбор задач квалификации: ML, image #1

Далее я делал группировку по oid и category и суммировал вероятности. Затем для каждой группы я выбрал метку с наибольшей суммой вероятностей. Эта метка и шла в сабмит. Первый сабмит сразу же мне дал 0,9866 на паблике и поднял меня на 3-е место. Я понял, что данные слишком лёгкие, и чтобы избежать шейкапа, на привате решил уменьшить дисперсию и обучить ансамбль. Если пересказать кратко, то выбор метки я основывал на максимальной сумме вероятностей меток внутри каждой группы. Ещё я пробовал брать моду по меткам, но это давало результат хуже.

Обучение ансамбля (ensemble.ipynb)

Сохраняя все гиперпараметры, я просто менял предобученные backbone и обучил ещё три модели (в сумме четыре): DeepPavlov/rubert-base-cased, DeepPavlov/rubert-base-cased-sentence, sberbank-ai/sbert_large_nlu_ru, RussianNLP/ruRoBERTa-large-rucola. Веса для них подбирал с помощью техники Hill Climbing (hill_climbing.ipynb) по off-файлам. Предикт ансамбля ничем не отличается от инференса соло-модели, я просто после софтмакса сделал взвешенную сумму с подобранными весами. Ансамбль мне на паблике дал такой же скор, но я был уверен, что модель стала более стабильной, и на этом я прекратил участие.

Вот так я за пару-тройку часов занял первое место.

Мой код и мой телеграм-канал.

2-е место: Никита Трифонов

Я потратил на выполнение задания 2–3 дня. Файнтюнил модель DeepPavlov/rubert-base-cased-conversational, использовал 5-fold кросс-валидацию, сообщества между фолдами не пересекались.

На этих разбиениях обучил пять моделей, усреднил их предсказания на тестовой выборке. Параметры при обучении: batch_size 32, learning_rate 5e-5, 10 эпох, cosine_schedule_with_warmup.

Затем для сабмита усреднил предсказания по всем постам сообщества и исключил из сабмита несколько сообществ, в которых модель наименее уверена (низкая вероятность по предсказанному классу).

5-е место: Олег Черемисин

Решение основано на Bert, для которого делался fine-tuning под поставленную задачу.

Использовалась библиотека simpletransformers и две модели: DeepPavlov/rubert-base-cased (предобучена на корпусе русскоязычных новостей и Википедии) и bert-base-multilingual-cased (предобучена на 104 языках Википедии).

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

Решение заняло практически всё время, отведённое на квалификацию. Первые отправки делал в первый день, последние — в последний. Чистого времени — думаю, часов 50, при этом написание и изменение кода — часа три, основное время уходило на ожидание, когда нейронка доучится.

Дальше код:

# Код обучения в обоих случаях одинаковый:

!pip install simpletransformers

!wget https://cups.online/api_v2/task/1417/177
!wget https://cups.online/api_v2/task/1417/178
!wget https://cups.online/api_v2/task/1417/179

import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.metrics import f1_score, accuracy_score
from simpletransformers.classification import ClassificationModel

train = pd.read_csv('177')

# Получаем список классов и преобразуем классы их строк в номера

list_classes = train.category.unique()

dict_classes = dict()
i = 0
for c in list_classes:
  dict_classes[c] = i
  i+=1
 
train['pred_class'] = train.category.apply(lambda x: dict_classes[x])

df = train[['text','pred_class']]

train_df, test_df = train_test_split(df, test_size=0.10)

# Если проводите fine-tuning при ограниченном пространстве на диске (например на Kaggle Kernels или Google Collab),
# обратите внимание на параметры save_steps и save_model_every_epoch, если их не задать в явном виде,
# диск может переполниться сохраняемыми моделями и обучение закончится ошибкой

train_args ={"reprocess_input_data": True,
             "overwrite_output_dir": True,
             "save_steps": -1,
             "save_model_every_epoch": False,
             "fp16":False,
             "num_train_epochs": 30}

model = ClassificationModel(
    "bert", "DeepPavlov/rubert-base-cased",
    num_labels=len(list_classes),
    args=train_args
)

model.train_model(train_df)

def f1_multiclass(labels, preds):
    return f1_score(labels, preds, average='micro')

result, model_outputs, wrong_predictions = model.eval_model(test_df, f1=f1_multiclass, acc=accuracy_score)

print(result)

def get_key(d, value):
    for k, v in d.items():
        if v == value:
            return k

test = pd.read_csv('178')

res = model.predict(list(test.text.values))

items = list()

for _, row in t.iterrows():
    item = dict()
    proba = 0
    value = 0
    for idx in list(range(len(dict_classes))):
        if proba < row[idx]:
            proba = row[idx]
            value = get_key(dict_classes, idx)
    item['oid'] = row['oid']
    item['category'] = value
    item['proba'] = proba
    items.append(item)

out = pd.DataFrame(items)
out[['oid','category','proba']].to_csv('bert-base-multilingual-cased.csv', index=None)

# Объединение результатов моделей в файл для отправки

from tqdm import tqdm

df = pd.read_csv('rubert.csv').append(pd.read_csv('bert-base-multilingual-cased.csv'))

items = list()
for oid in tqdm(df.oid.unique()):
    item = dict()
    item['oid'] = oid
    item['category'] = df[df.oid==oid].groupby('category', as_index=False).sum().sort_values('proba', ascending=False).iloc[0].category
    item['proba'] = df[df.oid==oid].groupby('category', as_index=False).sum().sort_values('proba', ascending=False).iloc[0].proba
    items.append(item)
pd.DataFrame(items)

out = pd.DataFrame(items)
out[['oid','category']].to_csv('submission.csv', index=None)

6-е место: Александр Ничипоренко

В задаче предстояло по десяти постам из сообщества ВКонтакте определить, какой вид спорта в нём обсуждается:

• athletics;
• autosport;
• basketball;
• boardgames;
• esport;
• extreme;
• football;
• hockey;
• martial_arts;
• motosport;
• tennis;
• volleyball;
• winter_sport.

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

Если посмотреть на тексты постов, то можно заметить, что:

  1. Бывают совершенно разные темы постов, например:
'Арест экс главы СБР и ЦСП Александра Кравцова продлен до 1 марта. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Басманный суд Москвы продлил до 1 марта срок содержания под стражей экс президенту Союза биатлонистов России СБР бывшему главе ФГБУ Центр спортивной подготовки сборных команд России ЦСП Александру Кравцову. Свидетели обвинения вторично не явились на заседание. Следующее заседание по делу состоится 29 ноября. Кравцова арестовали в сентябре 2020 года. Экс главе СБР и ЦСП инкриминируют статью 160 Уголовного кодекса РФ присвоение или растрата в особо крупном размере. Максимальная санкция по этой статье – до 10 лет лишения свободы. спортс. Ру'
'ОМОН и Новый год К обеспечению безопасности гостей горнолыжного курорта Шерегеш в период новогодних праздников привлекли ОМОН. Для охраны общественного порядка и обеспечения безопасности гостей горнолыжного курорта задействуют силы и средства ОМОН. В новогоднюю ночь в регионе к охране общественного порядка были привлечены более 400 бойцов Росгвардии. Среди них подразделения ОМОН вневедомственной охраны и лицензионно разрешительной работы. Кроме Росгвардии правоохранительным органам оказали содействие работники частных охранных организаций а также представители добровольных народных дружин.' 

Оба поста относятся к тематике winter_sport, но новости посвящены косвенно связанным со спортом событиям.

  1. Если смотреть на популярные слова для одной категории, то также сложно понять не только к какой спортивной тематике они относятся, но и вообще что речь идёт о спорте: несколько слов из всего множества дают такое представление. Например, для того же winter_sport имеем такое облако слов:
Разбор задач квалификации: ML, image #2

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

Не обратив внимания на то, что нужно классифицировать сообщество по 10 постам, сначала стал решать задачу классификации каждого поста. Предобработка данных была очень простая: приведение всех букв к нижнему регистру: str.lower(). Текст переводился в данные, пригодные для обучения моделей с помощью TfidfVectorizer, обычный CountVectorizer работал хуже. TfidfVectorizer строился по словам со следующими гиперпараметрами:

Разбор задач квалификации: ML, image #3

Для валидации модели все посты случайным образом были разбиты на пять подмножеств (5 ShuffleKFold), на которых оценивалась точность модели. В качестве модели был выбран линейный SVM со следующими параметрами:

Разбор задач квалификации: ML, image #4

На локальной валидации такое решение показывало по метрике соревнования результат 0,7527, а при удалении постов с низкой уверенностью предсказаний по модели — 0,7799, что было ниже примера решения от организаторов, оно давало 0,88. Посмотрев на пример решения, я понял, что необходимо предсказывать категорию не для каждого поста, а для сообщества. Таким образом, предстояла задача на основе предсказаний модели по постам сделать общий прогноз вида спорта для сообщества.

Такой переход можно сделать с помощью эвристики: для каждого из десяти постов выбираем наиболее вероятную категорию (для SVM — на основе отступа от разделяющей поверхности), а далее снова на основе максимального прогноза определяем финальную категорию:

Разбор задач квалификации: ML, image #5
Разбор задач квалификации: ML, image #6

Данный подход на локальной валидации по сообществам давал 0,9607, на публичном лидерборде — 0,9628, а на привате — 0,97, то есть примерно 103–108 место в финальном рейтинге.

Логичным развитием этого решения была замена эвристики на модель второго уровня, которая по прогнозам модели первого уровня для каждого сообщества будет определять его категорию. Чтобы сформировать датасет для обучения такой модели, брались oof-предсказания SVM, которые агрегрировались по сообществу таким образом:

Разбор задач квалификации: ML, image #7

Считались различные статистики по значению отступа SVM для каждого вида спорта: среднее, медиана, минимум, максимум, стандартное отклонение, 20-й и 80-й квантили — всего 91 признак.

Для модели второго уровня был выбран LightGBM c простыми гиперпараметрами:

Разбор задач квалификации: ML, image #8

Для прогнозирования категории сообщества на тестовом датасете делался прогноз SVM по постам, потом они агрегировались по сообществам, а далее LightGBM прогнозировал уже тематику сообщества. Такое решение на локальной валидации уже давало 0,9855, 0,9761 на паблике, а на привате — 0,9906, что уже позволяло занять 6-е место в финальном рейтинге. Если убрать предсказания, что было можно делать в этом соревновании для оптимизации метрики, где максимальная вероятность по категории меньше 0,5, то результат на паблике увеличивался до 0,9791, но на приват это не влияло.

В дальнейшем я изменил способ валидации первой модели: вся выборка была разделена на пять частей так, чтобы посты из одного сообщества были только в одной части данных (5 GroupKFold). Замена одной модели второго уровня на пять, обученных на разных подмножествах сообществ, и их усреднение позволило преодолеть на паблике барьер в 0,98, но на привате результат снизился до 0,9898.

Тюнинг параметров TfidfVectorizer на локальной валидации (в первую очередь, переход со слов на n-граммы букв) и удаление предсказаний с низкой уверенностью (там, где максимальная вероятность ниже 0,5) позволили достичь на паблике значения 0,9836, но ещё чуть ухудшили качество на привате — 0,9882. Результаты всех решений представлены ниже.

Разбор задач квалификации: ML, image #9

Естественно, в такой задаче стоило попробовать предобученную SOTA-модель, но в силу того, что было необходимо обучать пять тяжёлых моделей под каждый фолд для формирования oof-выборок, такое действие превращалось в очень долгий процесс, поэтому не было доведено в моём решении до конца. По идее, добавление таких прогнозов в модель второго уровня должно было повысить общее качество ансамбля. Интересно, что в данной задаче качество классификации поста на одном фолде ruBERT’a после finetuning’a получалось сопоставимым с SVM, что говорит о том, что классические методы работы с текстовыми данными работали здесь достаточно хорошо.

1601 views·16 shares