Автор статьи: Олег Блохин

Выпускник OTUS

В ходе поиска темы проектной работы, которой должен был завершиться курс Machine Learning. Professional, я решил поэкспериментировать с данными о фильмах, мультфильмах, сериалах и прочей схожей продукции. Немного сожалея, что времени смотреть кинопродукцию у меня почти нет, приступим.

Сбор данных

Попробуем взять данные (описания фильмов) с сайта Кинопоиск, а затем по описанию фильма определить жанр картины.

Структура адресной строки страницы со списком фильмов оказалась тривиальной:

А страница фильма выглядит тоже неплохо:

Немного потрудившись, был написан нехитрый алгоритм сбора данных.

Исходный код
import numpy as np   # Библиотека для матриц, векторов и линала
import pandas as pd  # Библиотека для табличек
import time          # Библиотека для времени

from selenium import webdriver 

browser = webdriver.Firefox()
time.sleep(0.3)
browser.implicitly_wait(0.3) 

from bs4 import BeautifulSoup
from lxml import etree
from tqdm.notebook import tqdm

def get_dom(page_link):
    browser.get(page_link)
    html = browser.page_source
    soup = BeautifulSoup(html, 'html.parser') 
    return etree.HTML(str(soup)) 

def get_listpage_links(page_no):
    # page_link = f'https://www.kinopoisk.ru/lists/movies/year--2010-2019/?page={page_no}'
    page_link = f'https://www.kinopoisk.ru/lists/movies/year--2021/?page={page_no}'
    dom = get_dom(page_link) 
    return dom.xpath("body//div[@data-tid='8a6cbb06']/a[@href]/@href")

def get_moviepage_info(movie_link):
    page_link = 'https://www.kinopoisk.ru' + movie_link
    dom = get_dom(page_link)

    elem = dom.xpath("body//div/span//span[@data-tid='939058a8']")
    rating = elem[0].text if elem else ''
    elem = dom.xpath("body//div//h1[@data-tid='f22e0093']/span")
    name = elem[0].text if elem else ''

    features = {}
    elem = dom.xpath("body//div/div[@data-test-id='encyclopedic-table' and @data-tid='bd126b5e']")[0]
    for child in elem.getchildren():
        #print(etree.tostring(child))
        feature = child.xpath('div[1]')[0].text
        ahrefs = child.xpath('div[position()>1]//a[text()] | div[position()>1]//div[text() and not(*)] | div[position()>1]//span[text()]')
        values = [ahr.text for ahr in ahrefs]
        features[feature] = values

    elem = dom.xpath("body//div/p[text() and not(*) and @data-tid='bfd38da2']")
    short_descr = elem[0].text if elem else ''

    elem = dom.xpath("body//div/p[text() and not(*) and @data-tid='bbb11238']")
    descr = ' '.join([x.text for x in elem])

    return (name, rating, short_descr, descr, features)

_df = pd.DataFrame(columns=['id', 'type', 'name', 'rating', 'short_descr', 'descr', 'features'])
for page_number in tqdm(range(1, 912), desc='List pages'):
    try:
        links = get_listpage_links(page_number)
        _df = pd.DataFrame(columns=['id', 'type', 'name', 'rating', 'short_descr', 'descr', 'features'])
        for movie_link in links:
          movie_id = movie_link.split('/')[1:3]
          name, rating, short_descr, descr, features = get_moviepage_info(movie_link)
          data_row = {'id':movie_id[1], 'type':movie_id[0], 'name':name, 'rating':rating, 'short_descr':short_descr, 'descr':descr, 'features': features}
          _df = pd.concat([_df, pd.DataFrame([data_row])], ignore_index=True)
        with open('kinopoisk_2010-2019.csv', 'a') as f:
            _df.to_csv(f, mode='a', header=f.tell()==0, index=False)
    except Exception as err:
        print(f"Unexpected {err=}, {type(err)=}")

Итогом работы алгоритма (если честно, то просто надоело ждать) стали свыше 50 тысяч записей о фильмах. Нам, для нашего исследования необходимы только описание и список ассоциированных с фильмов жанров.

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

Избавимся от тех жанров, где количество представителей меньше 100, а заодно уберем непонятный жанр "--". В результате такой фильтрации для экспериментов осталось 26 жанров и более 53 тысяч фильмов. Должно хватить :)

Изначально я использовал алгоритмы multi-class классификации, когда каждой записи присваивается единственная метка класса. При таком подходе значения метрик получались достаточно скромными (что интуитивно понятно, зачастую даже зрителю-человеку непросто понять, чего в картине больше - драмы, комедии или даже мелодрамы), поэтому не буду тратить время читателей на это. К тому же, с моей точки зрения, жанры, представленные на вышеупомянутом ресурсе, во многих случаях не являются классами в классическом математическом понимании задачи классификации: боевик может быть представлен в мультипликационной форме (а "мультфильм" и "боевик" - разные жанры в аннотации кинопоиска), а аниме - и вовсе всегда является мультфильмом (да простят мне мое невежество любители данного жанра, если я ошибаюсь :) ). В общем, примем как данность, что модель, когда каждый фильм характеризуется принадлежностью лишь к одному жанру, слишком упрощает реальность.

Будем решать задачу multilabel классификации, когда одному фильму присваиваются одна или более меток разных жанров.

Технически подготовить данные для решения этой задачи оказалось достаточно просто с помощью библиотеке sklearn. В нашем случае в DataFrame (pandas.DataFrame) была создана колонка genre_multi, которая содержала разделенные запятой названия жанров (к примеру "драма,криминал,биография,комедия"). Следующий код добавляет колонки, названия которых совпадают с названием жанра-класса, и содержат нули или единички, в зависимости от того, указан ли конкретный жанр для картины, или нет.

Исходный код

from sklearn.preprocessing import MultiLabelBinarizer
mlb = MultiLabelBinarizer()
mlb_result = mlb.fit_transform([str(data.loc[i,'genre_multi']).split(',') for i in range(len(data))])
data = pd.concat([data, pd.DataFrame(mlb_result, columns = list(mlb.classes_))], axis=1)

target_strings = mlb.classes_

Результат работы этого кода выглядит примерно так:

name

descr

lemmatized_descr

genre_multi

аниме

биография

боевик

вестерн

военный

детектив

...

мюзикл

приключения

реальное ТВ

семейный

спорт

ток-шоу

триллер

ужасы

фантастика

фэнтези

1+1 (2011)

Пострадав в результате несчастного случая, бог...

пострадать результат несчастный случай богатый...

драма,комедия,биография

0

1

0

0

0

0

...

0

0

0

0

0

0

0

0

0

0

Джентльмены (2019)

Один ушлый американец ещё со студенческих лет ...

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

криминал,комедия,боевик

0

0

1

0

0

0

...

0

0

0

0

0

0

0

0

0

0

Волк с Уолл-стрит (2013)

1987 год. Джордан Белфорт становится брокером ...

год джордан белфорт становиться брокер успешны...

драма,криминал,биография,комедия

0

1

0

0

0

0

...

0

0

0

0

0

0

0

0

0

0

Разделение данных на обучающую и тестовую выборки

Одна из первых сложностей, которая неизбежно возникает при попытке разделить данные на обучающую и тестовую выборки - огромное количество вариантов "меток": при multilabel-классификации мы пытаемся предсказать не просто метку класса, а вектор из нулей и единичек длины N, где N - количество жанров в нашем случае. В нашем случае количество теоретически возможных исходов равно 226, что многократно превышает размер всех наших данных.
Стандартный метод train_test_split с опцией stratify из sklearn.model_selection ожидаемо не справился с этой задачей. Поиск по всемирной сети подсказал следующий вариант, основанный на статье 2011 года:

Исходный код
from iterstrat.ml_stratifiers import MultilabelStratifiedShuffleSplit
from sklearn.utils import indexable, _safe_indexing
from sklearn.utils.validation import _num_samples
from sklearn.model_selection._split import _validate_shuffle_split
from itertools import chain

def multilabel_train_test_split(*arrays,
                                test_size=None,
                                train_size=None,
                                random_state=None,
                                shuffle=True,
                                stratify=None):
    """
    Train test split for multilabel classification. Uses the algorithm from: 
    'Sechidis K., Tsoumakas G., Vlahavas I. (2011) On the Stratification of Multi-Label Data'.
    """
    if stratify is None:
        return train_test_split(*arrays, test_size=test_size,train_size=train_size,
                                random_state=random_state, stratify=None, shuffle=shuffle)
    
    assert shuffle, "Stratified train/test split is not implemented for shuffle=False"
    
    n_arrays = len(arrays)
    arrays = indexable(*arrays)
    n_samples = _num_samples(arrays[0])
    n_train, n_test = _validate_shuffle_split(
        n_samples, test_size, train_size, default_test_size=0.25
    )
    cv = MultilabelStratifiedShuffleSplit(test_size=n_test, train_size=n_train, random_state=random_state)
    train, test = next(cv.split(X=arrays[0], y=stratify))

    return list(
        chain.from_iterable(
            (_safe_indexing(a, train), _safe_indexing(a, test)) for a in arrays
        )
    )

Все приведенные в дальнейшем отчеты о результатах классификации будут приводиться на одной и той же тестовой выборке, размер которой составляет 20% от всех имеющихся данных.

Классические методы

Классические методы работают с предварительно обработанными данными. Использовалась стандартная техника лемматизации, единственная особенность - добавлено стоп-слово "фильм".

Исходный код
import nltk
nltk.download('stopwords')
stop_words = nltk.corpus.stopwords.words('russian')
stop_words.append('фильм')
#word_tokenizer = nltk.WordPunctTokenizer()

import re
regex = re.compile(r'[А-Яа-яA-zёЁ-]+')

def words_only(text, regex=regex):
    try:
        return " ".join(regex.findall(text)).lower()
    except:
        return ""

from pymystem3 import Mystem
from string import punctuation

mystem = Mystem() 

#Preprocess function
def preprocess_text(text):
    text = words_only(text)
    tokens = mystem.lemmatize(text.lower())
    tokens = [token for token in tokens if token not in stop_words\
              and token != " " \
              and token.strip() not in punctuation]
    
    text = " ".join(tokens)
    
    return text

Логистическая регрессия и TF-IDF

Для начала была обучена модель логистической регрессии, при этом векторизация текстов производилась методом TF-IDF. Multilabel классификация достигается путем "оборачивания" стандартной модели из sklearn в стандартный же MultiOutputClassifier из все той же библиотеки sklearn. Объединение всех этих компонентов в единый pipeline позволило произвести подбор гиперпараметров одновременно и для векторизатора, и самой модели логистической регрессии. Удобно!

Исходный код
pipe = Pipeline([
    ('tfidf', TfidfVectorizer(max_features=1700, min_df=0.0011, max_df=0.35, norm='l2')),
    ('logregr', MultiOutputClassifier(estimator= LogisticRegression(max_iter=10000, class_weight='balanced', multi_class='multinomial', C=0.009, penalty='l2'))),
])

pipe.fit(train_texts, train_y)

pred_y = pipe.predict(test_texts)
print(classification_report(y_true=test_y, y_pred=pred_y, target_names=target_strings))

Отчет о классификации представлен ниже:

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

Catboost + TF-IDF

Аналогично поступим с Catboost. Хотя Catboost сам "умеет в multilabel классификацию", мы пойдем другим путем (зачем мы так делаем - станет ясно чуть позже): точно так же "завернем" CatBoostClassifier в MultiOutputClassifier. Заодно посмотрим, как работает multilabel классификация в реализации Catboost. Забегая вперед: результаты классификации отличались мало, зато с MultiOutputClassifier алгоритм сработал на CPU за 89 минут против 150 минут средствами multilabel классификации Catboost.

Исходный код с MultiOutputClassifier
from catboost import CatBoostClassifier

pipe2 = Pipeline([
    ('tfidf', TfidfVectorizer(max_features=1700, min_df=0.0031, max_df=0.4, norm='l2')),
    ('gboost', MultiOutputClassifier(estimator= CatBoostClassifier(task_type='CPU', verbose=False))),
])

pipe2.fit(train_texts, train_y)

pred_y = pipe2.predict(test_texts)
print(classification_report(y_true=test_y, y_pred=pred_y, target_names=target_strings))

Исходный код без MultiOutputClassifier
pipe3 = Pipeline([
    ('tfidf', TfidfVectorizer(max_features=1700, min_df=0.0031, max_df=0.4, norm='l2')),
    ('gboost', CatBoostClassifier(task_type='CPU', loss_function='MultiLogloss',  class_names=target_strings, verbose=False)),
])

pipe3.fit(train_texts, train_y)

pred_y = pipe3.predict(test_texts)
print(classification_report(y_true=test_y, y_pred=pred_y, target_names=target_strings))

Результаты классификации Catboost с MultiOutputClassifier :

Результаты классификации Catboost без MultiOutputClassifier :

Можно заметить, что у Catboost уклон скорее в сторону метрики precision, а метрика recall сильно проигрывает результатам логистической регрессии.

Примеры характерных слов

Теперь настало время объяснить, зачем мне был нужен MultiOutputClassifier даже для градиентного бустинга: таким образом можно извлечь из модели слова, характерные для конкретного жанра. Что мы сейчас и проделаем. А на результаты посмотрим в виде облаков слов :)

Исходный код
import matplotlib.pyplot as plt
from wordcloud import WordCloud

def gen_wordcloud(words, importances):
    d = {}
    for i, word in enumerate(words):
        d[word] = abs(importances[i])

    wordcloud = WordCloud()
    wordcloud.generate_from_frequencies(frequencies=d)

    return wordcloud

for idx, x in enumerate(target_strings):
    c1 = pipe['logregr'].estimators_[idx].coef_[0]
    words1 = pipe['tfidf'].get_feature_names_out()
    wc1 = gen_wordcloud(words1, c1)

    c2 = pipe2['gboost'].estimators_[idx].feature_importances_
    words2 = pipe2['tfidf'].get_feature_names_out()
    wc2 = gen_wordcloud(words2, c2)

    f, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 10))

    ax1.imshow(wc1, interpolation="bilinear")
    ax1.set_title(f'Log regr - {x}')
    ax1.axis('off')
    ax2.imshow(wc2, interpolation="bilinear")
    ax2.set_title(f'Catboost - {x}')
    ax2.axis('off')

    plt.tight_layout()

Остальные 23 жанра

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

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

Трансформерные модели

Кстати о них, то бишь о трансформерных моделях. Попробуем применить технику fine-tuning к предобученным трансформерным NLP моделям, с целью решить нашу задачу определения жанра фильма по описанию.

Эксперимент был проведен на следующих предобученных моделях с ресурса huggingface:

Трансформерные модели работают в связке с собственными векторизаторами (tokenizers). Исходный код токенизации текстов приведен ниже.

Исходный код
from transformers import BertTokenizer, AutoTokenizer


selected_model = 'ai-forever/ruBert-base'
# Load the tokenizer.
tokenizer = AutoTokenizer.from_pretrained(selected_model)

from torch.utils.data import TensorDataset


def make_dataset(texts, labels):
    # Tokenize all of the sentences and map the tokens to thier word IDs.
    input_ids = []
    attention_masks = []
    token_type_ids = []

    # For every sentence...
    for sent in texts:
        # `encode_plus` will:
        #   (1) Tokenize the sentence.
        #   (2) Prepend the `[CLS]` token to the start.
        #   (3) Append the `[SEP]` token to the end.
        #   (4) Map tokens to their IDs.
        #   (5) Pad or truncate the sentence to `max_length`
        #   (6) Create attention masks for [PAD] tokens.
        encoded_dict = tokenizer.encode_plus(
                            sent,                      # Sentence to encode.
                            add_special_tokens = True, # Add '[CLS]' and '[SEP]'
                            max_length = 500,          # Pad & truncate all sentences.
                            padding='max_length',
                            return_attention_mask = True,   # Construct attn. masks.
                            return_tensors = 'pt',     # Return pytorch tensors.
                            truncation=True,
                            return_token_type_ids=True
                    )

        # Add the encoded sentence to the list.
        input_ids.append(encoded_dict['input_ids'])

        token_type_ids.append(encoded_dict['token_type_ids'])

        # And its attention mask (simply differentiates padding from non-padding).
        attention_masks.append(encoded_dict['attention_mask'])

    # Convert the lists into tensors.
    input_ids = torch.cat(input_ids, dim=0)
    token_type_ids = torch.cat(token_type_ids, dim=0)
    attention_masks = torch.cat(attention_masks, dim=0)
    labels = torch.tensor(labels.values)
    dataset = TensorDataset(input_ids, token_type_ids, attention_masks, labels)

    return dataset

Библиотека fransformers предоставляет набор классов, которые дооснащают модель инструментами решения стандартных задач. В частности, для решения нашей задачи подходит класс AutoModelForSequenceClassification. С помощью параметра problem_type="multi_label_classification" указываем, что нас интересует именно multilabel классификация. В этом случае будет использована следующая функция потерь: BCEWithLogitsLoss.

Исходный код
import transformers

model = transformers.AutoModelForSequenceClassification.from_pretrained(
    selected_model, 
    problem_type="multi_label_classification",
    num_labels = 26, # The number of output labels--2 for binary classification.
                    # You can increase this for multi-class tasks.
    output_attentions = False, # Whether the model returns attentions weights.
    output_hidden_states = False, # Whether the model returns all hidden-states.
)

Далее было произведено стандартное обучение нейронной сети. Чтобы отслеживать метрки, меняющеся по ходу обучения, я подключил прекрасную утилиту tensorboard. Графики, приведенные ниже, получены именно с помощью него.

Исходный код
from torch.utils.tensorboard import SummaryWriter
from sklearn import metrics

def log_metrics(writer, loss, outputs, targets, postfix):
    print(outputs)
    outputs = np.array(outputs)
    predictions = np.zeros(outputs.shape)
    predictions[np.where(outputs >= 0.5)] = 1
    outputs = predictions
    accuracy = metrics.accuracy_score(targets, outputs)
    f1_score_micro = metrics.f1_score(targets, outputs, average='micro')
    f1_score_macro = metrics.f1_score(targets, outputs, average='macro')
    recall_score_micro = metrics.recall_score(targets, outputs, average='micro')
    recall_score_macro = metrics.recall_score(targets, outputs, average='macro')
    precision_score_micro = metrics.precision_score(targets, outputs, average='micro', zero_division=0.0)
    precision_score_macro = metrics.precision_score(targets, outputs, average='macro', zero_division=0.0)

    writer.add_scalar(f'Loss/{postfix}', loss, epoch)
    writer.add_scalar(f'Accuracy/{postfix}', accuracy, epoch)
    writer.add_scalar(f'F1 (Micro)/{postfix}', f1_score_micro, epoch)
    writer.add_scalar(f'F1 (Macro)/{postfix}', f1_score_macro, epoch)
    writer.add_scalar(f'Recall (Micro)/{postfix}', recall_score_micro, epoch)
    writer.add_scalar(f'Recall (Macro)/{postfix}', recall_score_macro, epoch)
    writer.add_scalar(f'Precision (Micro)/{postfix}', precision_score_micro, epoch)
    writer.add_scalar(f'Precision (Macro)/{postfix}', precision_score_macro, epoch)

Обучение сети произведено стандарнтым способом. Посколько в моем распоряжении имелся компьютер с видеокартой NVIDIA GeForce RTX 2080 Ti (12 GB), обучение выполнялось с использование GPU. При этом для разных моделей приходилось использовать разные размеры batch_size, а время достижения минимума функции потерь различась в разы. Эти данные для удобства восприятия я собрал в табличке ниже.

Исходный код
optimizer = torch.optim.AdamW(model.parameters(),
                  lr = 2e-5, # args.learning_rate - default is 5e-5, our notebook had 2e-5
                  eps = 1e-8 # args.adam_epsilon  - default is 1e-8.
                )

from transformers import get_linear_schedule_with_warmup

# Number of training epochs. The BERT authors recommend between 2 and 4.
# We chose to run for 4, but we'll see later that this may be over-fitting the
# training data.
epochs = model_setup[selected_model]['epochs']

# Total number of training steps is [number of batches] x [number of epochs].
# (Note that this is not the same as the number of training samples).
total_steps = len(train_dataloader) * epochs

# Create the learning rate scheduler.
scheduler = get_linear_schedule_with_warmup(optimizer,
                                            num_warmup_steps = 0, # Default value in run_glue.py
                                            num_training_steps = total_steps)

from tqdm import tqdm

def train(epoch):
    # print(f'Epoch {epoch+1} training started.')
    total_train_loss = 0
    model.train()
    fin_targets=[]
    fin_outputs=[]
    with tqdm(train_dataloader, unit="batch") as tepoch:
        for data in tepoch:
            tepoch.set_description(f"Epoch {epoch+1}")
            ids = data[0].to(device, dtype = torch.long)
            mask = data[2].to(device, dtype = torch.long)
            token_type_ids = data[1].to(device, dtype = torch.long)
            targets = data[3].to(device, dtype = torch.float)

            res = model(ids,
                             token_type_ids=None,
                             attention_mask=mask,
                             labels=targets)
            loss = res['loss']
            logits = res['logits']

            optimizer.zero_grad()
            total_train_loss += loss.item()
            fin_targets.extend(targets.cpu().detach().numpy().tolist())
            fin_outputs.extend(torch.sigmoid(logits).cpu().detach().numpy().tolist())

            optimizer.zero_grad()
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
            optimizer.step()
            scheduler.step()
            tepoch.set_postfix(loss=loss.item())
    
    return total_train_loss / len(train_dataloader), fin_outputs, fin_targets


def validate(epoch):
    model.eval()
    fin_targets=[]
    fin_outputs=[]
    total_loss = 0.0
    with torch.no_grad():
        for step, data in enumerate(test_dataloader, 0):
            ids = data[0].to(device, dtype = torch.long)
            mask = data[2].to(device, dtype = torch.long)
            token_type_ids = data[1].to(device, dtype = torch.long)
            targets = data[3].to(device, dtype = torch.float)
            res = model(ids,
                             token_type_ids=None,
                             attention_mask=mask,
                             labels=targets)
            loss = res['loss']
            logits = res['logits']
            total_loss += loss.item()
            fin_targets.extend(targets.cpu().detach().numpy().tolist())
            fin_outputs.extend(torch.sigmoid(logits).cpu().detach().numpy().tolist())
    return total_loss/len(test_dataloader), fin_outputs, fin_targets

writer = SummaryWriter(comment= '-' + selected_model.replace('/', '-'))

for epoch in range(epochs):
    avg_train_loss, outputs, targets = train(epoch)

    log_metrics(writer, avg_train_loss, outputs, targets, 'train')
    
    avg_val_loss, outputs, targets = validate(epoch)
    log_metrics(writer, avg_val_loss, outputs, targets, 'val')

А теперь давайте посмотрим на графики. Для удобства я привел рядом "легенду", по которой нетрудно догадаться, к какой модели относятся графики.Начнем с функции потерь.

Видно, что наилучший результ получился у "среднеразмерной" модели "ai-forever/ruBert-base". "cointegrated/rubert-tiny2" остался далеко позади от победителя, что и понятно. Интересно, что "большие" модели "ai-forever/ruBert-large" и "ai-forever/ruRoberta-large" уступили в качестве базовой модели. В случае с "ai-forever/ruBert-large" это вызвано, скорее всего, не самыми точными параметрами обучения, и, к примеру, снижение скорости обучения могло бы вывести эту модель в лидеры.

Посмотрим также на прочие графики. Не зря же я тратил на них время :)

Видно, что несмотря на то, что loss-функция на валидационной выборке начинала уже возрастать, метрики recall и f1 продолжали улучшаться и далее, а вот с метрикой precision незначительно ухудшалась.

А теперь обещанная табличка. Жирным шрифтом указаны лучшие значения. Последняя колонка - время до достижения минимума функции потерь на валидационной выборке.

Имя модели

Loss

Precision (micro/ macro)

Recall (micro/ macro)

F1 (micro/ macro)

Batch size

Время, мин

cointegrated/rubert-tiny2

0.1819

0.6307 / 0.4246

0.373 / 0.2136

0.4688 / 0.2661

32

25

ai-forever/ruBert-base

0.1553

0.6863 / 0.5963

0.5039 / 0.4105

0.5811 / 0.4907

8

57

ai-forever/ruBert-large

0.1582

0.6673 / 0.5817

0.4922 / 0.3824

0.5665 / 0.4482

2

112

ai-forever/ruRoberta-large

0.1644

0.6672 / 0.6275

0.5457 / 0.4672

0.6004 / 0.5285

2

320

Можно заметить, что "ai-forever/ruRoberta-large" набрала набольшее количество лучших показателей метрик, несмотря на не самое лучшее значение функции потерь. Если бы не длительность обучения, я бы, пожалуй, объявил победителем ее. Но все же, победителем объявляется "ai-forever/ruBert-base".

Далее будем рассматривать результаты только этой модели.

Отчет о классификации ruBERT-base

Значения метрик выглядят поприятнее, чем у классических моделей.

Сравнение classification report

Сравним результаты, посмотрев на таблицы отчетов о классификации.

Логистическая регрессия

CatBoost

ruBert-base

Значение метрики recall, как ранее уже было сказано, наилучшее у нашей самой простой модели - логистической регрессии. Значениях метрик precision и f1 лучшие у трансформерной модели.

Примеры

Рассмотрим несколько примеров.

Джентльмены (2019)

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

Аннотация кинопоиска: криминал,комедия,боевик
Логистическая регрессия: биография,боевик,документальный,криминал
Catboost: криминал
BERT: драма,криминал

Как приручить дракона (2010)

Вы узнаете историю подростка Иккинга, которому не слишком близки традиции его героического племени, много лет ведущего войну с драконами. Мир Иккинга переворачивается с ног на голову, когда он неожиданно встречает дракона Беззубика, который поможет ему и другим викингам увидеть привычный мир с совершенно другой стороны…

Аннотация кинопоиска: мультфильм,фэнтези,комедия,приключения,семейный

Логистическая регрессия: аниме,военный,документальный,история,короткометражка,мультфильм,приключения,семейyый,фэнтези
Catboost: драма
BERT: мультфильм,приключения,семейный,фэнтези

Как прогулять школу с пользой (2017)

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

Аннотация кинопоиска: драма,комедия,семейный

Логистическая регрессия: аниме,детский,мультфильм,мюзикл,приключения,семейный,фэнтези
Catboost:
BERT: мультфильм,семейный

Мой комментарий: фильм настолько странный, что Catboost отказался его классифицировать :)

Как я встретил вашу маму

Действие сериала происходит в двух временах: в будущем - 2034 году, где папа рассказывает детям о знакомстве с мамой и этапах создания их семьи, и в настоящем, где мы можем видеть, как все начиналось. Герой сегодня - молодой архитектор Дима, который еще не знает, как сложится его жизнь. Однажды ему даже кажется, что он встретил девушку своей мечты… Но так ли это на самом деле? Разобраться в этом Диме помогают его друзья: Паша и Люся – молодая пара, собирающаяся вот-вот пожениться, а также их общий друг Юра, ортодоксальный холостяк и циник, чей идеал – случайная связь на одну ночь. Он считает, что нет ничего глупее долгих отношений, а брак – устаревшее понятие.

Аннотация кинопоиска: комедия
Логистическая регрессия: драма,комедия,мелодрама
Catboost: драма,мелодрама
BERT: комедия

Заключение

Данные, использованные в моем эксперименте, как это зачастую бывает, несовершенны. Например, для мультипликационного фильма "Как приручить дракона" на мой субъективный взляд, из описания комедийность не просматривается. Да и писали это описание вовсе не с целью подготовить хороший набор данных для машинного обучения :) А информация о жанрах лишь дополняет описание. Да и она, скорее, субъективна.

Тем не менее, эксперимент получился интересным. Надеюсь, что для читателей тоже :)