На волне шумихи вокруг 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
После реализации и обучения модели она должна возвращать векторы такой же размерности, где каждый элемент вектора - это вероятность, что данный текст относится к данной категории/тэгу.
На этом на сегодня всё.