Анализ текстов нейронными сетями. Bag Of Words vs. Embedding.

Анализ текстовой информации — весьма полезный инструмент. Я ранее публиковал уже статьи на тему анализа текстов. Например, анализ авторства текста, генеративные сети с условием (в этих GAN-ах использовался слой embedding), чатботы и пр. Пора систематизировать способы представления текстов в числовой форме и попробовать понять что «под капотом», т.е. как это все работает.

One-hot encoding (OHE)

Самый простой подход :

  • Выделить в исходном тексте split-ом все слова.
  • Создать из них словарь.
  • Закодировать слова с помощью 0 и 1 («one-hot»). Это не двоичное представление индекса слова в словаре, поскольку единица в векторе только одна, остальные — 0!

Например, есть текст «The cat sat on the mat». Чтобы представить каждое слово для обучения нейронной сети, создадим вектор из нулей с длиной равной размеру словаря. Единица будет на позиции соответствующей слову.

Diagram of one-hot encodings

Чтобы создать вектор, который кодировал бы всю фразу достаточно объединить (concatenate) one-hot вектор каждого из слов. Очевидно, что при этом полностью теряется информация о последовательности слов в тексте. Остается только информация о наборе слов («bag of words»).

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

Кодирование словаря уникальными индексами

Другой распространенный подход — кодировать каждое слово некоторым уникальным числом. В примере выше можно было-бы слову «cat» сопоставить с 1, 2-ку присвоить для «mat» и т.д.

В этом случае можно было-бы закодировать фразу «The cat sat on the mat» плотным (dense) вектором, скажем, [5, 1, 4, 3, 5, 2]. Этот подход эффективен с т.з. использования памяти.

Однако, этот подход имеет несколько минусов:

  • Кодирование целыми числами не учитывает взаимосвязи между словами. Впрочем, one-hot encoding его тоже не учитывает.
  • Нейронная сеть плохо воспринимает целочисленное кодирование. Для нейронки число сложно интерпретировать, они для неё все как-бы одинаковые. Собственно, поэтому при классификации, например, картинок в тестовой базе MNIST при обучении номер на картинке кодируется в OHE. Например, методами tokenizer-а Keras: to_categorical.

Чтобы нейронная сеть корректно воспринимала выходные значения представленные в числовом виде без предварительного кодирования в OHE используется специальная функция loss: sparse_categorical_crossentropy. Она работает только для выходных значений и вычисляет cross-entropy между реальными и предсказанными сетью значениями.

Bag Of Words

Кодирование входных данных в OHE приводит к серьезной проблемой с нехваткой памяти при обработке данных. Например, рассмотрим для примера задачу эмоционального анализа текстов (sentiment snalysis). Например, есть отзывы клиентов на продукт. Они могут быть положительные, отрицательные и нейтральные.

Типовой механизм преобразования текстов для обработки нейронной сетью будет следующим:

  • Текст разбивается (split) на слова с параллельной очисткой от лишних символов. Например, используется токенизатор Keras:
tokenizer = Tokenizer(num_words=num_words, filters='!"#$%&()*+,-—./:;<=>?@[\\]^_`{|}~\t\n\xa0????????????☝️?✌️?????‍♂️♂️?????‍♂️‼️♥❤️?????????????????????', lower=True, split=' ', char_level=False) #, oov_token='unknown'
  • Словам в соответствие ставятся индексы (числовые значения):
tokenizer.texts_to_matrix(texts, mode='count')
  • По умолчанию в токенизаторе параметр reserve_zero=True, поэтому первым элементом в матрице индексов будет стоять 0, зарезервированный в качестве значения для максирования (masking). Следующий слой нейронной сети должен понимать, что 0 — это не реальные данные, а искусственно дополненные. Полученная матрица индексов будет длиннее по горизонтали на 1.
  • Предложения выравниваются по длине с помощью Keras pad_sequences, добавлением 0-ей в начале или конце массива (padding=’post’). Кроме того фраза может быть урезана по длине с начала или с конца (truncating=’post’).
x_train = pad_sequences(x_train_tokenized, maxlen=max_len) #, padding='post', truncating='post')
  • Полученная матрица индексов, выровненная по длине преобразуется в OHE. На выходе получится гигантская разреженная матрица (sparse) заполненная 0-ми и лишь одной 1-ей, стоящей в позиции равной индексу слова. Это удобно, поскольку если взять argmax от такого вектора, то он вернет позицию в которой стоит 1, т.е. индекс слова.
  • Каждому слову будет поставлен в соответствие вектор равный длине используемого словаря. Например, в обработанном тексте содержалось 20000 слов (словарь), соответственно, каждое слово будет представлено вектором длиной 20000.
Источник: (Marco Bonzanini, 2017)

Размерность полученной OHE матрицы:

  • Максимальная длина фразы в отзывах — 500 слов. После pad_sequences все фразы в матрице будут длиной 500.
  • Размер словаря — 20000 слов, те. каждое слово будет представлено вектором длиной 20000.
  • Отзывов — 10000 шт.

OHE_shape = 500 * 20000 x 10000 = 10 000 000 x 10000.

Визуализация такого способа подачи текстового представления на нейронную сеть:

Bag-Of-Words (BOW) representation of the input that is next follow ...

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

Сравнительно небольшой текст преобразовался в гигантскую матрицу, где каждая фраза представлена вектором из 0 и одной 1. Длина вектора 10 млн. и количество таких векторов 10 000. Под такую матрицу расходовалось бы гигантское количество памяти. На практике для подачи текста на нейронную сеть используют подход Bag of Words [BOW].

Bag-of-words
Визуализация Bag of Words [BOW]

При Bag Of Words последовательность слов в фразе убирается, поэтому фраза (на картинке Document1, Document2) представляется в виде вектора. Его длина равной размеру общего словаря всех анализируюмых текстов. В полученном векторе на позициях, соответствующих словам присутствующим в тексте стоят 1, а если слово в тексте не встретилось — 0.

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

Embedding

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

Рассмотрим как это выглядит в коде. Поскольку Dense слой — это матрица весов, сгенерируем такую рандомную матрицу:

hidden_size = 5
num_words = 10
dense_weights = np.random.normal(0, 1, (10, hidden_size))
array([[-1.32707206,  0.10119344,  1.79757198,  0.34618246, -1.54246334],
       [-1.71555483, -0.41435209,  1.55291255,  0.45605782, -0.38036262],
       [ 0.1468921 , -1.70855181, -0.11037113, -0.0230945 ,  0.08609408],
       [-2.00268987,  0.83066333, -0.11383054, -0.52439005,  1.92159762],
       [ 0.83877495, -1.13184209,  0.60702018, -0.13582864, -0.4720022 ],
       [-1.68540624,  0.4514039 ,  0.141728  , -0.56166716, -0.08567821],
       [-1.12092014, -0.89378575, -1.01579628,  0.28156886,  0.4657587 ],
       [-0.97334435, -0.00635427, -0.06353585, -0.44005828, -1.48547527],
       [-2.10256962,  0.80774305, -1.50179081,  0.63876112,  0.53071349],
       [ 0.7122738 ,  0.1074994 ,  0.57170802, -0.47366115, -2.34293163]])

На входе dense слоя (матрицы весов) подаются входные данные представленные в виде OHE. Например, для кодирования 10 слов используется следующая матрица OHE — по-сути, единичная квадратная матрица. У неё по диагонали стоят единицы:

I = np.eye(num_words)

array([[1., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
       [0., 1., 0., 0., 0., 0., 0., 0., 0., 0.],
       [0., 0., 1., 0., 0., 0., 0., 0., 0., 0.],
       [0., 0., 0., 1., 0., 0., 0., 0., 0., 0.],
       [0., 0., 0., 0., 1., 0., 0., 0., 0., 0.],
       [0., 0., 0., 0., 0., 1., 0., 0., 0., 0.],
       [0., 0., 0., 0., 0., 0., 1., 0., 0., 0.],
       [0., 0., 0., 0., 0., 0., 0., 1., 0., 0.],
       [0., 0., 0., 0., 0., 0., 0., 0., 1., 0.],
       [0., 0., 0., 0., 0., 0., 0., 0., 0., 1.]])

Для обратного преобразования из матрицы OHE в порядковый номер слова в словаре (индекс) используется код:

np.argmax(I, axis = 1)

array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

Соответственно, чтобы получить OHE соответствующий произвольному индексу, нужно взять из матрицы строку или столбец по нему:

def custom_OHE(index, num_classes):
  return np.eye(num_classes)[index]
token = 5 #Индекс нужного слова
OHE_vector = custom_OHE(token, num_words)
OHE_vector @ dense_weights

array([-1.68540624,  0.4514039 ,  0.141728  , -0.56166716, -0.08567821])
Демонстрация устройства матриц embedding_а.

где @ — матричное умножение, аналог numpy.dot.

В параметрах dense слоя задается только размерность hidden size (в примере = 5). Вторую размерность (в данном случае = 10) dense слой берет из предыдущего слоя, выступающего для него в качестве входа.

Поскольку перемножение OHE на матрицу весов описывающих dense слой эквивалентно взятию из матрицы весов строки соответствующей индексу, то операция получения вектора (embedding) описыващего слово с выбранным индексом упрощается:

embedding = dense_weights
token = 5 #Индекс нужного слова
embedding [token]

array([-1.68540624,  0.4514039 ,  0.141728  , -0.56166716, -0.08567821])

По-сути, получаем словарь где слову с определенным индексом поставлен в соотвествие вектор с размерностью заданной для embedding-а.

model = Sequential()
model.add(Embedding(num_words, embedding_size, input_length=max_len))

где

  • num_words — количество слов в словаре,
  • embedding_size — размерность embedding-а равна размеру скрытого слоя (hidden_size) dense слоя внутри embedding.
  • max_len — размер входных данных. Например, тот-же max_len используется при выравнивании длины фраз x_train = pad_sequences(x_train_tokenized, maxlen=max_len).

Представление слов в виде embedding-ов — способ эффективно с точки зрения использования памяти представить слово в виде понятном для нейронной сети. При этом одинаковые слова имеют одинаковый вектор их представляющий.

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

Физический смысл embedding

Помимо значительно большей эффективности использования оперативной памяти embedding-и обладают ещё одним полезным свойством.

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

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

Например, был взят embedding_size = 2 и после тренировки embedding получен словарь двумерных векторов соответствующих слову. Их разместили на графике, отложив по оси X — одну координату вектора, а по Y — другую. После анализа слов получили, например, что ось X определяет определяет степень новизны слова, а по Y — насколько слово эмоционально положительное.

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

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

{\text{similarity}}=\cos(\theta )={A\cdot B \over \|A\|\|B\|}={\frac  {\sum \limits _{{i=1}}^{{n}}{A_{i}\times B_{i}}}{{\sqrt  {\sum \limits _{{i=1}}^{{n}}{(A_{i})^{2}}}}\times {\sqrt  {\sum \limits _{{i=1}}^{{n}}{(B_{i})^{2}}}}}}

Если угол между двумя векторами равен 0, то они сонаправлены и значение cos(0) будет равно 1, если ортогональны, то cos(pi/2) = 0, а если противоположно направлены, то cos(pi) = -1.

Косинусную меру можно использовать и как метрику и как loss. Как метрика она используется в исходном виде, как задан по формуле, поскольку метрика должна максимизироваться, т.е. стремится к 1.

В случае с loss его минимизируют, поэтому косинусную меру нужно вычесть из 1. В этом случае при максимальном сходстве векторов значение будет стремится к 0, когда вектора ортогональны, то к 1, а когда противоположно направлены = 2.

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

Поскольку embedding может быть предобучен на больших корпусах (объемах) текстов с использованием специальных моделей, а затем выгружен в виде словаря, где слову ставится в соответствие вектор, то появляется возможность получать качественные предобученные embedding-и без необходимости их повторного обучения (веса слоя можно заморозить trainable = False).

from keras.layers import Embedding

embedding_layer = Embedding(len(word_index) + 1,
                            EMBEDDING_DIM,
                            weights=[embedding_matrix],
                            input_length=MAX_SEQUENCE_LENGTH,
                            trainable=False)

Тренировка embedding в зависимости от его размера занимает немало времени, поэтому такая оптимизация крайне полезна.

Word2Vec

Теперь проверим насколько теория о том, что полученные нейронкой вектора embedding-а действительно отражают что-то. Для этого воспользуемся моделью word2vec. При тренировке модели использовалось два подхода. Первый использовал метод continuous bag of words (CBOW), который по контексту пытается предсказать слово. Второй — использовать слово для предсказания контекста — skip-gram.

diagrams

Word2Vec — это не предобученные эмбеддинги (файл с векторами), а подход к их формированию. Этот подход реализован, например, в библиотеке Gensim. Используя этот подход и большие корпуса текстов разного типа (например, новости) были созданы готовые файлы embedding-ов. Если посмотреть такой файл, то вначале идет слово, а затем набор чисел описывающих его векторное представление. Размерность embedding чаще всего от 100 до 1024.

Выше была дана формула косинусного расстояния. В Python она выглядит следующим образом:

def cos_distance(a , b): #метрика косинусного расстояния
  return a@b/((a@a)*(b@b))**0.5

Подгрузим embedding-и. Файл порядка 1,6 Гб, там только английские слова. При желании легко найти предобученные embedding-и для русского языка. В наименовании embedding указано 300 — это размерность векторов в embedding.

import gensim.downloader as api
wv = api.load('word2vec-google-news-300') #загружаем w2v

Широко известный культовый пример для модели Word2Vec.

Посмотрим косинусное расстояние между

cos_distance(wv['king']-wv['man'], wv['queen']-wv['woman'])

0.7580350416366332

Действительно получаем высокое значение косинусного расстояния. Найдем меру сходства между walking и walked:

cos_distance(wv['walking'], wv['walked'])

0.6706871263449815

или то-же самое вычислим с помощью встроенной функции Word2Vec:

wv.similarity('walking', 'walked')

0.6706871

В процессе обучения добавились того, чтобы если прибавление некоторого вектора к embedding слова «walking» приводил к появлению embedding слова «walked», то прибавление того-же вектора к слову swimming -> swam. Это называется «параллельный перенос«.

Найдем слово, которое выбивается из ряда слов:

print(wv.doesnt_match(['fire', 'water', 'land', 'man', 'sea', 'air']))

man

Или найдем слова, наиболее близкие по смыслу с точки зрения нейронной сети:

wv.similar_by_vector(wv['king'])

[('king', 0.9999999403953552),
 ('kings', 0.7138046026229858),
 ('queen', 0.6510956883430481),
 ('monarch', 0.6413194537162781),
 ('crown_prince', 0.6204219460487366),
 ('prince', 0.6159993410110474),
 ('sultan', 0.5864822864532471),
 ('ruler', 0.5797566771507263),
 ('princes', 0.5646551847457886),
 ('Prince_Paras', 0.543294370174408)]

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

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

Встраивание embedding в нейронную сеть

При работе с готовыми embedding-ами нужно их как-то подать в нейронную сеть. Используется два основных подхода. В первом исходные фразы конвертируются в массив векторов и уже этот массив подается на вход нейронной сети вместо комбинации OHE + Dense. В этом случае полученные вектора не будут тренироваться при тренировки нейронной сети. Они уже подготовлены и считается, что обучение произведено качественно, поскольку для этого использовались гигантские корпуса текстов и тренировка проводилась долго.

Сконвертируем слова в векторное представление:

sentence = 'I would like to have the flat'
x_sent = sentence.split(' ')
x_sent

['I', 'would', 'like', 'to', 'have', 'the', 'flat']

И затем:

x_emb = []
for word in x_sent:
  if word in wv.vocab:
    x_emb.append(wv[word])
x_emb = np.array(x_emb)

x_emb.shape

(6, 300)

Обращаю внимание на проверку if word in wv.vocab. Например, в примере частица to является стоп словом (stop-word) с минимальной для модели смысловой нагрузкой. Поэтому эта частица отсуствует в скаченном эмбеддинге.

Если запустить код без этой проверки, то он выпадет с ошибкой, что слово не найдено. Поэтому при трансформации я это слово пропускаю и поэтому в shape-е только 6, а не 7 векторов размерностью 300.

Второй подход — это встроить embedding в качестве слоя в архитектуру нейронной сети Keras. Для этого используется метод:

x = wv.get_keras_embedding(train_embeddings=True) #можно сразу получить embedding keras

Чтобы преобразовать слово в индекс в скаченном embedding используется следующий метод:

word_index = wv.vocab['king'].index #Преобразование слова -> индекс 
print("Индекс:", word_index)

word = wv.index2word[word_index] #Обратное преобразование индекс -> слово
print("Слово:", word)

Индекс: 6147
Слово: king

Встраивание предоубченного embedding в нейронную сетью (Keras)

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

Первый способ состоит в конвертации входного текста с помощью словаря embedding-а и подаче подготовленного текста на слой с весами embedding в модели:

  1. Предложения в тексте разбить на последовательность слов c помощью Keras text_to_word_sequence. Словарь частотности не формируется. Если нужно ограничить словарь, то придется использовать Tokenizer.
  2. Преобразовать слова в индексы с помощью словаря предобученного embedding-а.
  3. Получить слой embedding-а с помощью get_keras_embedding().
  4. Встроить его первым слоем в модель. С весами слоя ничего не делаем!
  5. Подать на вход модели массив индексов полученных в п. 2.

Второй способ состоит в том, чтобы для индексов полученных токенизатором, например, Keras-а, поменять веса в матрице embedding-а, чтобы индекс определенного слова в embedding-е соответствовал индексу слова после токенизатора:

  1. Получить массив индексов с помощью Tokenizer. В этом случае словарь упорядочен по частоте появления слов в тексте, поэтому можно обрезать словарь с конца если нужно получить укороченный вариант.
  2. Получить предобученный embedding (обучить самому или скачать готовый).
  3. Адаптировать матрицу весов слоя embedding таким образом, чтобы индексу определенного слова после Tokenizer-а в слое embedding соответствовало то-же слово из словаря embedding-а.

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

Слой полученный get_keras_embedding() принимает на вход индексы из скаченного embedding-а. Для преобразования текст в последовательность индексов на которых тренировался скаченный embedding:

padding_index = 0 #Just for example
x_sent_arr = []
x_sent_arr.append('I would like to have the flat'.split(' '))
x_sent_arr.append('I would like to eat the apple'.split(' '))

x_train = []
for index, line in enumerate(x_sent_arr):
  print(index, ':', line)
  sentence = []
  for word in line:
    if word in wv.vocab:
      sentence.append(wv.vocab[word].index)
    else:
      sentence.append(padding_index) # Do something. For example, leave it blank or replace with padding character's index.     
  
  x_train.append(sentence)

print(x_train)
0 : ['I', 'would', 'like', 'to', 'have', 'the', 'flat']
1 : ['I', 'would', 'like', 'to', 'eat', 'the', 'apple']
[[20, 47, 87, 0, 21, 11, 2532], [20, 47, 87, 0, 2785, 11, 13467]]

В некоторых случаях нужно подгрузить текст убрав лишние символы и пр. Если делать это Keras токенизатором, то для передачи индексов на embedding слой word2vec нужно слегка извратится, поскольку я не нашел нормального метода у Tokenizator для получения списка слов.

tokenizer = Tokenizer(filters='!–"—#$%&()*+,-./:;<=>?@[\\]^_`{|}~\t\n\r«»', lower=True, split=' ', char_level=False)
tokenizer.fit_on_texts(x_sent_arr)

seq = tokenizer.texts_to_sequences(x_sent_arr)
text = tokenizer.sequences_to_texts(seq)

text = [line.split() for line in text]  

print(text)

x_train = []
padding_index = 0
for line in text:
  sentence = []
  for word in line:
    if word in wv.vocab:
      sentence.append(wv.vocab[word].index)
    else:
      sentence.append(padding_index) # Do something. For example, leave it blank or replace with padding character's index.     
  
  x_train.append(sentence)

print(x_train)  
[['i', 'would', 'like', 'to', 'have', 'the', 'flat'], ['i', 'would', 'like', 'to', 'eat', 'the', 'apple']]
[[4501, 47, 87, 0, 21, 11, 2532], [4501, 47, 87, 0, 2785, 11, 13467]]

Или без использования Tokenizer, взяв функцию text_to_word_sequence:

from keras.preprocessing.text import text_to_word_sequence

x_sent_arr = []
x_sent_arr.append('I would like to have the flat')
x_sent_arr.append('I would like to eat the apple')

text = [text_to_word_sequence(line, filters='!"#$%&()*+,-./:;<=>?@[\\]^_`{|}~\t\n', lower=True, split=' ') for line in x_sent_arr]

print(text)

x_train = []
padding_index = 0
for line in text:
  sentence = []
  for word in line:
    if word in wv.vocab:
      sentence.append(wv.vocab[word].index)
    else:
      sentence.append(padding_index) # Do something. For example, leave it blank or replace with padding character's index.     
  
  x_train.append(sentence)

print(x_train)  

[['i', 'would', 'like', 'to', 'have', 'the', 'flat'], ['i', 'would', 'like', 'to', 'eat', 'the', 'apple']]
[[4501, 47, 87, 0, 21, 11, 2532], [4501, 47, 87, 0, 2785, 11, 13467]]

Если объем текста большой, а памяти GPU мало, то нужно использовать вариант с генератором. В этом случае данные будут подгружаться в нейронку batch-ами:

from keras.preprocessing.text import text_to_word_sequence

x_sent_arr = []
x_sent_arr.append('I would like to have the flat')
x_sent_arr.append('I would like to eat the apple')

def texts_to_sequences(texts, word2vec):
    for text in texts:
        tokens = text_to_word_sequence(text)
        yield [word2vec.vocab[w].index for w in tokens if w in word2vec.vocab]

sequence = texts_to_sequences(x_sent_arr, wv)

for b in sequence:
    print(b)

[4501, 47, 87, 21, 11, 2532]
[4501, 47, 87, 2785, 11, 13467]

Продолжение следует.

Полезные ссылки

Spread the love
Запись опубликована в рубрике IT рецепты с метками , , , . Добавьте в закладки постоянную ссылку.

Обсуждение закрыто.