После исследовательского анализа данных в записной книжке (Не IMDb) Movie Reviews Dataset EDA мы готовы построить нашу первую модель классификации настроений, чтобы установить базовый уровень производительности.

В этой статье я прохожу:

  1. Импорт пакетов
  2. Настройка путей и параметров
  3. Подготовка данных
  4. Установка показателя оценки
  5. Изучение гиперпараметров
  6. Моделирование
  7. Сохранение воронки

Импорт пакетов

Что может быть проще, чем написать оператор импорта, когда вам нужно импортировать пакет?

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

Когда я пишу код в файлах .py, я просто использую пакет isort, чтобы убедиться, что мой импорт хорошо организован. Но когда я пишу код в Jupyter Notebook, все становится сложнее. Я по-прежнему использую доступные пакеты форматирования, такие как jupyterlab-code-formatter (isort+black по умолчанию). Кроме того, я разбиваю импорт на логические блоки. Для текущей записной книжки я разделил импорт на три блока: системные пакеты, пакеты DS/ML, пакеты ONNX.

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

Настройка путей и параметров

Второе, что я рекомендую, это создать отдельные разделы для путей и параметров. Также рекомендуется разделять пути на входные и выходные пути.

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

Подготовка данных

Загрузка

После прочтения набора данных я обычно печатаю информацию о наборе данных с помощью метода .info().

reviews = pd.read_parquet(
    os.path.join(sentiment_analysis_data_path, "split_reviews.parquet")
)

reviews.info()

>>> <class 'pandas.core.frame.DataFrame'>
RangeIndex: 206537 entries, 0 to 206536
Data columns (total 3 columns):
 #   Column     Non-Null Count   Dtype   
---  ------     --------------   -----   
 0   sentiment  206537 non-null  category
 1   review     206537 non-null  object  
 2   fold       206537 non-null  object  
dtypes: category(1), object(2)
memory usage: 3.3+ MB

Это показывает:

  • Количество рядов;
  • Информация о столбцах:
    - количество столбцов;
    - имена столбцов;
    - количество ненулевых значений;
    - типы столбцов;
  • Использование памяти.

После загрузки данных я разбиваю набор данных с помощью столбца fold на наборы данных train, dev и test и удаляю объект, потому что больше не буду его использовать:

train = reviews[reviews["fold"] == "train"]
test = reviews[reviews["fold"] == "test"]
val = reviews[reviews["fold"] == "val"]

del reviews

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

Последнее, что я делаю в этом разделе, — удаляю объекты train, dev и test, потому что они служат своей цели — код выглядит чище и читабельнее:

X_train, X_test, X_dev, y_train, y_test, y_dev = (
    train["review"].str.replace("<p>", " ").values.tolist(),
    test["review"].str.replace("<p>", " ").values.tolist(),
    dev["review"].str.replace("<p>", " ").values.tolist(),
    train["sentiment"].values.tolist(),
    test["sentiment"].values.tolist(),
    dev["sentiment"].values.tolist(),
)

len(X_train), len(X_test), len(X_dev), len(y_train), len(y_test), len(y_dev)
del train, test, dev

Разделение

На самом деле, я пропустил весь процесс разделения данных на подмножества.

Короче говоря, у меня есть выделенный блокнот, в котором я разделяю данные на наборы данных для обучения и тестирования/разработки (15% данных), а затем разбиваю набор данных для тестирования/разработки на отдельные наборы данных для тестирования и разработки (50/50 ~ по 7,5% данных):

train_df, test_dev_df = train_test_split(
    df, test_size=0.15, random_state=SEED, stratify=df["sentiment"]
)

test_df, dev_df = train_test_split(
    test_dev_df, test_size=0.5, random_state=SEED, 
    stratify=test_dev_df["sentiment"]
)

После этого я создаю новый столбец fold,объединяю наборы данных в один и сохраняю его для дальнейшего использования (раздел Загрузка):

train_df["fold"] = "train"
test_df["fold"] = "test"
dev_df["fold"] = "dev"

data = pd.concat([train_df, test_df, dev_df]).reset_index(drop=True)
data.to_parquet(os.path.join(sentiment_analysis_data_path, 
     "split_reviews.parquet"))

Установление оценочной метрики

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

Для подобных проблем с несколькими классами нам также необходимо решить, какой метод агрегирования выбрать: микро, макро или взвешенный:

  • Усредните элементы матрицы путаницы (TP, TN, FP, FN) между бинарными классификаторами. Затем, используя одну усредненную матрицу путаницы, рассчитайте F1-оценку. Это называется микроусреднением.
  • Рассчитайте балл F1 для каждой этикетки отдельно и усредните его. Это называется макро-усреднением.
  • Рассчитайте метрики для каждой метки отдельно и найдите их среднее значение, взвешенное по поддержке (количество истинных экземпляров для каждой метки). Это изменяет макрос для учета дисбаланса ярлыков; это может привести к результату F1, который не находится между точностью и полнотой.

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

Действительно ли это хорошая идея «учитывать дисбаланс»? Это зависит:

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

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

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

Посмотрим на целевое распределение:

target_counts = Counter(y_test + y_dev)

total = sum(target_counts.values(), 0.0)
for key in target_counts:
    target_counts[key] /= total

print(target_counts)
>>> Counter({'positive': 0.7202801717181498, 
             'neutral': 0.15002743617055614, 
             'negative': 0.12969239211129402})

Принимая во внимание распределение цели, я выберу оценку F1 с макроусреднением, и мы будем использовать оценку F1 с микроусреднением, чтобы проиллюстрировать разницу.

Изучение гиперпараметров

Для базовой модели я начал с TF-IDF и логистической регрессии. Поэтому, естественно, я хотел бы потратить некоторое время на то, чтобы разобраться и выбрать гиперпараметры для TfidfVectorizer и LogisticRegression.

Гиперпараметры TF-IDF

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

Lowercase

Lowercaseгиперпараметр по умолчанию имеет значение False. Давайте проверим, почему.

vectorizer = CountVectorizer(lowercase=False)
vectors_wo_lowercase = vectorizer.fit_transform(X_train)

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

vectorizer = CountVectorizer()
vectors_w_lowercase = vectorizer.fit_transform(X_train)

Размер набора данных поезда составляет (185883, 670194) с включенным строчным регистром. Разница в размере словаря между моделями с обложкой и без нее составляет более 100 000, поэтому лучше придерживаться строчных букв. Таким образом, мы уменьшим размерность набора данных и не потеряем в точности (хотя мы будем использовать показатель F1 для оценки производительности модели).

max_dfиmin_df

min_df используется для удаления терминов, которые появляются слишком редко. Например:

  • min_df = 0,01 означает «игнорировать термины, встречающиеся менее чем в 1 % документов».
  • min_df = 5 означает «игнорировать термины, встречающиеся менее чем в 5 документах».

По умолчанию min_df равно 1, что означает «игнорировать термины, встречающиеся менее чем в 1 документе». Таким образом, настройка по умолчанию не игнорирует никакие термины.

max_df используется для удаления терминов, которые появляются слишком часто, также известных как «стоп-слова для корпуса». Например:

  • max_df = 0,5 означает «игнорировать термины, встречающиеся более чем в 50 % документов».
  • max_df = 25 означает «игнорировать термины, встречающиеся в более чем 25 документах».

По умолчанию max_df равно 1,0, что означает «игнорировать термины, встречающиеся более чем в 100 % документов». Таким образом, настройка по умолчанию не игнорирует никакие термины.

vectorizer.get_feature_names_out()[:50]
>>> array(['00', '000', '0000', '00000', '000000',
       '000000000000000000попкорн000000000000', '000000000000001',
       '000000000000на', '00000000000во', '00000000000данной',
       '00000000000есть000000000000000',
       '00000000000есть000000000000000000', '0000000000жевать',
       '0000000000ненавижу00000000', '00000000016', '000000000надо',
       '000000000разговаривать0000000000', '00000000визуальная',
       '00000001', '00000громко', '00000точек', '00001', '00007', '0001',
       '0002', '000доктора', '000какой', '000косметические', '000р',
       '000теряются', '001', '002', '003', '00381', '006', '007', '00в',
       '00вых', '00м', '00по', '00с', '00х', '00ые', '00ых', '01', '011',
       '013', '014', '015', '01минуту'], dtype=object)

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

vectorizer = CountVectorizer(min_df=0.8)
vectors = vectorizer.fit_transform(X_train)

vectors.shape
>>> (185883, 7)

vectorizer.get_feature_names_out()
>>> array(['как', 'на', 'не', 'но', 'то', 'что', 'это'], dtype=object)

Эти слова есть в 80% всех отзывов, и это понятно, так как это частицы, предлоги и союзы.

MIN_DF = 0.01
vectorizer = CountVectorizer(min_df=MIN_DF)
vectors = vectorizer.fit_transform(X_train)

Размер набора данных поезда составляет (185883, 3284) с включенным lowercase и min_df=0.01.

vectorizer.get_feature_names_out()[:50]
>>> array(['10', '100', '11', '12', '13', '15', '16', '18', '20', '2012',
       '21', '30', '3d', '40', '50', '60', '70', '80', '90', 'dc',
       'marvel', 'of', 'the', 'абсолютно', 'аватар', 'автор', 'автора',
       'авторов', 'авторы', 'аж', 'актер', 'актера', 'актерам',
       'актерами', 'актерах', 'актеров', 'актером', 'актерская',
       'актерский', 'актерского', 'актерской', 'актерскую', 'актеры',
       'актриса', 'актрисы', 'актёр', 'актёра', 'актёров', 'актёрская',
       'актёрский'], dtype=object)

Теперь намного лучше. Мы удалили непопулярные токены и сократили словарный запас с ~670 тыс. до ~3,3 тыс. токенов.

Говоря о max_df — я оставлю его равным значению по умолчанию.

ngram_range

ngram_range — это кортеж (min_n,max_n) с нижней и верхней границей диапазона значений n для различных извлекаемых n-грамм.
Будут использоваться все значения n, такие что min_n ≤ n ≤ max_n.

Например, ngram_range (1, 1) означает, что будут использоваться только униграммы, (1, 2) — униграммы и биграммы, а (2, 2) — только биграммы.

NGRAM_RANGE = (1, 2)
MIN_DF = 0.01

vectorizer = TfidfVectorizer(ngram_range=NGRAM_RANGE, min_df=MIN_DF)
train_vectors = vectorizer.fit_transform(X_train)
test_vectors = vectorizer.transform(X_test)
dev_vectors = vectorizer.transform(X_dev)

Размер набора данных поезда составляет (185883, 4616) с включенным строчным регистром, min_df=0.01 и ngram_range=(1, 2). Таким образом, размер словаря увеличился примерно на 1 тыс. токенов, потому что мы добавили биграммы.

По моему опыту, обычно (1, 2) является хорошим значением по умолчанию для ngram_range. Иногда (1, 3) может быть немного лучше.

Другие гиперпараметры для TfidfVectorizer будут использоваться со значениями по умолчанию. Основные из них:

  • analyzer: "слово"
  • stop_words: None
  • norm: “l2”
  • use_idf: True
  • smooth_idf: True
  • sublinear_tf: False

Гиперпараметры логистической регрессии

Для логистической регрессии мы можем настроить силу регуляризации (C), solver и penalty тип. Мы будем использовать векторы текстового представления из предыдущего раздела для обучения и оценки модели.

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

log_reg = LogisticRegression(
    random_state=SEED, solver="saga", max_iter=1_000
)
log_reg.fit(train_vectors, y_train)

pred_labels = log_reg.predict(test_vectors)
f1_micro = f1_score(y_dev, pred_labels, average="micro")
f1_macro = f1_score(y_dev, pred_labels, average="macro")

Хотя мы решили рассматривать только оценку F1 с макроусреднением, в данном случае важно также учитывать оценку микроусреднения:

  • Оценка F1 с микро-усреднением составляет 0,80279.
  • Оценка F1 с макро-усреднением составляет 0,62032.

Теперь добавим class_weight="balanced" :

log_reg = LogisticRegression(
    random_state=SEED, class_weight="balanced", solver="saga", max_iter=1_000
)
log_reg.fit(train_vectors, y_train)

pred_labels = log_reg.predict(test_vectors)
f1_micro = f1_score(y_dev, pred_labels, average="micro")
f1_macro = f1_score(y_dev, pred_labels, average="macro")
  • Оценка F1 с микро-усреднением составляет 0,72584.
  • Оценка F1 с макро-усреднением составляет 0,62215.

Микропоказатель уменьшился с 0,80 до 0,725, а макропоказатель увеличился с 0,62 до 0,622. Что это значит?

Это означает, что если мы оставим гиперпараметр class_weight нетронутым (None), веса, связанные с классами, будут одинаковыми и будут равны единице. Если мы укажем class_weight="balanced", веса будут скорректированы обратно пропорционально частотам классов во входных данных как n_samples / (n_classes * np.bincount(y)). Гиперпараметр class_weight будет влиять на то, как алгоритм взвешивает образцы, принадлежащие к разным классам, для вычисления потери во время обучения.

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

И это определенно так:

  • Для нейтральных меток: процент правильно классифицированных меток увеличился с 23 до 50 (хорошо).
  • Для минус-меток: 62% -> 72% (хорошо).
  • Для положительных меток: 95% -> 77% (плохо).

Давайте также вспомним распределение классов:

{'positive': 0.7202801717181498, 
 'neutral': 0.15002743617055614, 
 'negative': 0.12969239211129402}

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

Возвращаясь к настройке гиперпараметров, в этом блокноте я буду настраивать только первый с помощью Оптуна.

distributions = {
    "C": optuna.distributions.FloatDistribution(1e-7, 10.0, log=True),
}

study = optuna.create_study(direction="maximize")
n_trials = 20

for _ in range(n_trials):
    trial = study.ask(distributions)  # pass the pre-defined distributions.

    # two hyperparameters are already sampled from the pre-defined distributions
    C = trial.params["C"]

    clf = LogisticRegression(C=C, solver="saga", class_weight="balanced", max_iter=1_000)
    clf.fit(train_vectors, y_train)

    pred_labels = log_reg.predict(dev_vectors)
    f1 = f1_score(y_dev, pred_labels, average=F1_AVERAGING)

    study.tell(trial, f1)

study.best_params, study.best_value
>>> ({'C': 2.5221385900954424e-06}, 0.6221531301627613)

После настройки силы регуляризации (C) мы понимаем, что нет смысла менять значение по умолчанию C, потому что мы получаем те же результаты.

Давайте теперь создадим простой конвейер с двумя компонентами: TfidfVectorizer и LogisticRegression для сохранения модели в одном файле.

Моделирование

Я решил создать окончательную модель с помощью API Pipeline от sklearn.

# declaring list to store stages for a pipeline
stages = []

# TfidfVectorizer
vectorizer_params = {
    "min_df": 0.01,
    "ngram_range": (1, 2),
    "max_features": 10_000,
}
review_vectorizer = TfidfVectorizer(**vectorizer_params)
stages.append(("vectorizer", review_vectorizer))

# LogisticRegression
log_reg = LogisticRegression(
    C=1, random_state=SEED, n_jobs=-1, solver="saga", max_iter=10_000
)
stages.append(("classifier", log_reg))

# training
pipe = Pipeline(stages)
pipe.fit(X_train, y_train)

После обучения мы можем оценить качество модели.

pred_labels = pipe.predict(X_test)
f1 = f1_score(y_test, pred_labels, average=F1_AVERAGING)

print(f"F1 score with {F1_AVERAGING}-averaging is {f1.round(5)}")
>>> F1 score with micro-averaging is 0.80139

Теперь у нас есть конвейер, и мы можем сохранить его, чтобы использовать позже. У нас есть несколько вариантов: ONNX, joblib, pickle.

Сохранение воронки

В настоящее время моим основным решением для сохранения моделей машинного обучения является ONNX. Поэтому, естественно, я решил сначала проверить его, но в итоге выбрал другой способ.

Читайте дальше, чтобы узнать, почему!

ОННКС

Во-первых, давайте создадим переменную для хранения пути, по которому мы будем сохранять модель.

pipe_path = os.path.join(SAVED_MODELS_PATH, 
    f"TfIdfLogRegSentiment-{VERSION}.onnx")

Теперь мы можем преобразовать его:

initial_type = [("input", StringTensorType([None, 1]))]
seps = {
    TfidfVectorizer: {
        "separators": [
            " ",
            ".",
            "\\?",
            ",",
            ";",
            ":",
            "!",
            "\\(",
            "\\)",
            "\n",
            '"',
            "'",
            "-",
            "\\[",
            "\\]",
            "@",
        ]
    }
}

model_onnx = convert_sklearn(
    pipe, "tfidf", initial_types=initial_type, options=seps, target_opset=12
)

Текущая реализация TfidfVectorizer в ONNX поддерживает только тот список итераторов, который я использовал в приведенном выше фрагменте кода.

Мы можем сохранить модель, используя следующий код:

with open(pipe_path, "wb") as f:
    f.write(model_onnx.SerializeToString())

Файл весит ~ 618 МБ.

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

sess = rt.InferenceSession(pipe_path)

input_name = sess.get_inputs()[0].name
label_name = sess.get_outputs()[0].name

inputs = {"input": [[input] for input in X_test]}

pred_onx = sess.run(None, inputs)
f1 = f1_score(y_test, pred_onx[0], average=F1_AVERAGING)

print(f"F1 score with {F1_AVERAGING}-averaging is {f1.round(3)}")
>>> F1 score with micro-averaging is 0.72

Мы видим такое большое падение производительности, потому что у ONNX есть некоторые проблемы с преобразованием моделей TF-IDF.

Вот что пишут на своем сайте авторы пакета:

Трудно воспроизвести точно такое же поведение токенизатора, если токенизатор исходит из космоса, gensim или nltk. Стандартный метод, используемый scikit-learn, использует регулярные выражения и в настоящее время реализуется.

Попробую альтернативные способы сохранить пайплайн.

Соленый огурец

Сохранить пайплайн с помощью pickle очень просто:

pipe_path = os.path.join(SAVED_MODELS_PATH, 
    f"TfIdfLogRegSentiment-{VERSION}.pkl")

with open(pipe_path, "wb") as f:
    pickle.dump(pipe, f)

Файл весит ~638 МБ, что на 20 МБ больше, чем .onnx файл.

Давайте проверим производительность модели, сохраненной в виде файла pickle:

with open(pipe_path, "rb") as f:
    loaded_pipe = pickle.load(f)

loaded_pipe
>>> Pipeline(steps=[('vectorizer',
                 TfidfVectorizer(max_features=10000, min_df=0.01,
                                 ngram_range=(1, 2))),
                ('classifier',
                 LogisticRegression(C=1, max_iter=10000, n_jobs=-1,
                                    random_state=42, solver='saga'))])
pred_labels_loaded = loaded_pipe.predict(X_test)
f1_loaded = f1_score(y_test, pred_labels_loaded, average=F1_AVERAGING)

print(f"F1 score with {F1_AVERAGING}-averaging is {f1_loaded.round(5)}")
>>> F1 score with micro-averaging is 0.80139

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

В этой статье мы прошли весь процесс построения модели, от поиска гиперпараметров до сохранения модели для последующего использования.

Спасибо за прочтение!

Код для воспроизведения тех же шагов можно найти здесь:

Ссылки: