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

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

  • One-hot encoding: представление слов как векторов из нулей и только одним значением 1, соответствующим индексу слова в словаре
  • Count-based encoding: представление слов как векторов, в которых элементы соответствуют количеству вхождений слова в текст.
  • TF-IDF encoding: представление слов как векторов, в которых элементы соответствуют произведению TF (частоты слова в документе) и IDF (обратной документной частоты)
  • Word Embeddings (Word2Vec, GloVe): основывается на взаимодействиях слов в корпусе текстов. Он оптимизирует метрику косинусной близости между векторами слов, чтобы выявлять семантические и синтаксические связи между словами

Изначально ковыряюсь с нейронкой я попытался реализовать кодирование One-hot encoding. Хотя этот метод наверное один из самых простых, он требует довольно много памяти т.к. каждое слово кодируется вектором с длинной равной длинне словаря всего текстового корпуса. У меня во время экспериментов модель сжирала все 32ГБ ОЗУ и уходила в своп.

Как работает one-hon encodding? Например у нас есть тексты:

['в корзине лежат румяные пирожки',
 'в корзине лежат красные яблоки']

Словарь будет состоять из 6 элементов

['корзина', 'лежать', 'пирожок', 'яблоко', 'румяный', 'красный']

В этом случае слово пирожок (индекс в словаре = 2) будет кодироваться следующим бинарным вектором:

[0, 0, 1, 0, 0, 0]

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

Загрузка данных

Данные для обучения взял отсюда. Корпус состоит примерно из 850K новостей с сайта lenta.ru. На этапе тестирования, чтобы не парсить все 2ГБ данных, сделаем небольшую выборку из 2000 последних новостей:

tail -n 2000 data/lenta-ru-news.csv > data/lenta-2000w.csv

С помощью pandas загружаем данные и указываем имена столбцов:

import pandas as pd
df = pd.read_csv('data/lenta-2000w.csv', names=['url',
    'title','text','topic','tags','date'])

Меня интересуют столбцы text и tags поэтому удаляем лишние столбцы и строки с NaN значениями:

df = df.drop(['url','title','topic','date'], axis=1)
df = df.dropna()
df
                                                   text	tags
0	Актриса Эмма Стоун выйдет замуж, о чем сообщил...	Кино
1	На Украине пытаются раскачать ситуацию к возвр...	Украина
2	9 декабря завершится период бесплатного проезд...	Регионы
3	Заместитель генерального директора Российского...	Летние виды
...	...	...
1969	Испытание США ранее запрещенной Договором о ли...	Политика
1970	В ближайшие дни в европейской части России пог...	Общество
1971	Ведущие футбольные чемпионаты ушли на зимние к...	Английский футбол
1958 rows × 2 columns

Токенизация текста

На этом этапе с помощью pymorphy3 и nltk сделаем следующее:

  • удалим из текста все символы за исключением А-Яа-я и пробелов
  • разобьем текст на слова с помощь split()
  • удалим из текста стоп-слова и получим их номальную форму

Подключаем библиотеки и скачиваем стоп-слова для русского языка:

import re
import pymorphy3 as pm
import nltk
from nltk.corpus import stopwords
nltk.download('stopwords', download_dir='data/nltk_data')
nltk.data.path.append('data/nltk_data')
stopwords_ru = stopwords.words('russian')
morph = pm.MorphAnalyzer(lang='ru')

Функция удаления ненужных символов и преобразования к нормальной форме:

def lemmatize(text):
    text = re.sub(r'[^А-Яа-я ]+', ' ', text)
    tokens = []
    for t in text.split():
        tn = morph.normal_forms(t)[0]
        if (tn not in stopwords_ru):
            tokens.append(tn)
    return tokens
print(df['text'][3][:200])
print(lemmatize(df['text'][3])[:18])
Заместитель генерального директора Российского антидопингового агентства (РУСАДА) Маргарита Пахноцкая рассказала, что число пойманных на возможных нарушениях антидопинговых правил россиян в 2019 году 
['заместитель', 'генеральный', 'директор', 'российский', 'антидопинговый', 'агентство', 'русад', 'маргарита', 'пахноцкий', 'рассказать', 'число', 'поймать', 'возможный', 'нарушение', 'антидопинговый', 'правило', 'россиянин', 'год']

Применяем lemmatize к данным и сохраняем в столбец tokens

df['tokens'] = df['text'].apply(lemmatize)
df
	                        text    tags     tokens
0  Актриса Эмма Стоун выйдет замуж...  Кино     [актриса, эмма, стоун, выйти, замуж, сообщить,...
1  На Украине пытаются раскачать ...   Украина  [на, украина, пытаться, раскачать, ситуация, в...
2  9 декабря завершится период ...     Регионы  [декабрь, завершиться, период, бесплатный, про...
3  Заместитель генерального дирек...   Летние   [заместитель, генеральный, директор, российски...
...
1958 rows × 3 columns
data = df['tokens']

Создание словаря по датасету

from collections import Counter

counter = Counter()
for tokens in data:
    for t in tokens:
        counter[t]+=1

Самые частые слова:

sorted(counter, key=counter.get, reverse=True)[:10]
['это', 'год', 'который', 'россия', 'декабрь', 'также', 'украина', 'российский', 'свой', 'слово']

Обучение Word2Vec модели для кодирования

Для создания word2vec модели будем использовать библиотеку gensim:

from gensim.models import Word2Vec

word2vec = Word2Vec(min_count=10, window=2, vector_size=300, 
                    negative = 10, alpha=0.03, min_alpha=0.0007, 
                    sample=6e-5, sg=1)
  • min_count — игнорировать все слова с частотой встречаемости меньше, чем это значение.
  • windоw — размер контекстного окна, обозначает диапазон контекста.
  • vector_size — размер векторного представления слова (word embedding).
  • negative — сколько неконтекстных слов учитывать в обучении, используя negative sampling.
  • alpha — начальный learning_rate, используемый в алгоритме обратного распространения ошибки (Backpropogation).
  • min_alpha — минимальное значение learning_rate, на которое может опуститься в процессе обучения.
  • sg — если 1, то используется реализация Skip-gram; если 0, то CBOW.

Строим словарь:

word2vec.build_vocab(data)

Обучаем модель:

word2vec.train(data, total_examples=word2vec.corpus_count, epochs=30, report_delay=1)

Выведем вектор слова музыка:

word2vec.wv['музыка']
array([ 1.55311257e-01, -3.71625006e-01,  1.79840580e-01,  2.16249511e-01,
       -9.86272246e-02,  7.42120668e-02,  2.38929778e-01,  2.27044001e-01,
       -7.15749860e-02,  1.49555743e-01, -2.27603436e-01, -3.49608958e-01,
       -1.36643231e-01, -1.11060306e-01,  1.99536532e-01, -2.14455217e-01,
...
        8.56719539e-02, -6.46252707e-02,  4.63180207e-02,  4.69253622e-02],
      dtype=float32)

Протестируем модель, выведем близкие слова к слову фильм:

word2vec.wv.most_similar(positive=["фильм"])
[('хит', 0.7354637384414673),
 ('композиция', 0.7144984006881714),
 ('голливудский', 0.7110325694084167),
 ('кино', 0.7107347249984741),
 ('сериал', 0.7041939496994019),
 ('хип', 0.6918789148330688),
 ('сняться', 0.6890391111373901),
 ('съёмка', 0.6883667707443237),
 ('питта', 0.6828123927116394),
 ('оскар', 0.6809055209159851)]

Чем больше коэфициент - тем слова расположены ближе в векторном пространстве.

Интересно, что значит питта в этом списке? В контексте кино наверное речь о Брэде Питте, морфологический анализатор не справился с именем. Вообще, выше, где мы собирали словарь для обучения word2vec модели использовалась первая возможная нормальная форма слова morph.normal_forms(t)[0], хотя на самом деле их может быть несколько и без анализа контекста не всегда понятно какая форма правильная. Это задача со звёздочкой ⭐

Выведем ближайшее к кино слово из списка:

word2vec.wv.most_similar_to_given("кино", 
    ["выборы", "прокат", "актёр", "открытие"])
'прокат'

Ближайшее к слову наука:

word2vec.wv.most_similar_to_given("наука", 
    ["выборы", "прокат", "актёр", "открытие"])
'открытие'

One-hot кодирование тэгов

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

Создаём словарь тэгов:

from torchtext.vocab import Vocab

tags_counter = Counter()
for tag in df['tags']:
    tags_counter[tag.lower()]+=1
tags_vocab = Vocab(tags_counter)

Используя словарь можно получить номер тэга в этом словаре:

print("index:", tags_vocab['культура'])
index: 46

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

def tag_to_vect(tag):
    t_vec = [0] * len(tags_vocab)
    t_vec[tags_vocab[tag.lower()]] = 1
    return t_vec

t_vect = tag_to_vect('культура')
print(t_vect)
print("tag vector len:", len(t_vect))
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
tag vector len: 74

Словарь будет включать два дополнительных элемента unk и pad.

Переводим тэги датасета в бинарные векторы:

data_y = df['tags'].apply(tag_to_vect)
data_y
0       [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...
1       [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...
                              ...                        
1969    [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...
1970    [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...
1971    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...
Name: tags, Length: 1958, dtype: object

После реализации и обучения модели она должна возвращать векторы такой же размерности, где каждый элемент вектора - это вероятность, что данный текст относится к данной категории/тэгу.

На этом на сегодня всё.