Читать нас в Telegram
Иллюстратор: Женя Родикова

Введение

Word2vec — библиотека для получения векторных представлений слов на основе их совместной встречаемости в текстах. Системный Блокъ уже писал ранее о том, как работают эти модели, и вы можете освежить в памяти механизмы работы Word2vec, прочитав эту статью.

Сейчас мы займемся более практичными и приземленными вещами: научимся использовать Word2vec в своей повседневной работе. Мы будем использовать реализацию Word2vec в библиотеке Gensim для языка программирования Python.

Тьюториал состоит из двух частей:

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

Для прохождения тьюториала мы рекомендуем использовать Python3. Работоспособность кода для Python2 не гарантируется. Код из этого тьюториала также доступен в формате jupyter-тетрадки.

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

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

Однако в нашем тьюториале мы разберём, как выполнять более глубокую предобработку текста: лемматизацию и частеречный анализ. Это нужно для того, чтобы вы сами могли создавать модели, совместимые с уже готовыми моделями RusVectōrēs. Ну и просто знание о том, как устроена предобработка текста, может пригодиться во многих задачах обработки языка.

Обработка текста

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

  • сначала мы приведем все слова к начальной форме (лемматизируем) и удалим стоп-слова;
  • затем мы приведем все леммы к нижнему регистру;
  • для каждого слова добавим его частеречный тэг.

Давайте попробуем воссоздать процесс предобработки текста на примере рассказа О. Генри «Русские соболя». Для предобработки можно использовать различные тэггеры, мы сейчас будем использовать UDPipe, чтобы сразу получить частеречную разметку в виде Universal POS-tags. Сначала установим обертку UDPipe для Python с помощью питоновского пакет-менеджера pip:

pip install ufal.udpipe

UDPipe использует предобученные модели для лемматизации и тэггинга. Вы можете использовать уже готовую модель или обучить свою.

Чтобы загружать файлы, можно использовать питоновскую библиотеку wget:

pip install wget

Кусок кода ниже скачает рассказ О’Генри и модель UDPipe для лингвистической предобработки. Модель весит 40 мегабайт, поэтому ячейка может выполнятся некоторое время, особенно если у вас небыстрый интернет.

import wget
import sys
​
udpipe_url = 'https://rusvectores.org/static/models/udpipe_syntagrus.model'
text_url = 'https://rusvectores.org/static/henry_sobolya.txt'
​
modelfile = wget.download(udpipe_url)
textfile = wget.download(text_url)

Приступим к собственно предобработке текста. Попробуем лемматизировать текст и добавить частеречные тэги при помощи этой функции:

def process(pipeline, text='Строка', keep_pos=True, keep_punct=False):
   entities = {'PROPN'}
   named = False  # переменная для запоминания того, что нам встретилось имя собственное
   memory = []
   mem_case = None
   mem_number = None
   tagged_propn = []
​
   # обрабатываем текст, получаем результат в формате conllu:
   processed = pipeline.process(text)
​
   # пропускаем строки со служебной информацией:
   content = [l for l in processed.split('\n') if not l.startswith('#')]
​
   # извлекаем из обработанного текста леммы, тэги и морфологические характеристики
   tagged = [w.split('\t') for w in content if w]
​
   for t in tagged:
       if len(t) != 10: # если список короткий — строчка не содержит разбора, пропускаем
           continue
       (word_id,token,lemma,pos,xpos,feats,head,deprel,deps,misc) = t 
       if not lemma or not token: # если слово пустое — пропускаем
           continue
       if pos in entities: # здесь отдельно обрабатываем имена собственные — они требуют особого обращения
           if '|' not in feats:
               tagged_propn.append('%s_%s' % (lemma, pos))
               continue
           morph = {el.split('=')[0]: el.split('=')[1] for el in feats.split('|')}
           if 'Case' not in morph or 'Number' not in morph:
               tagged_propn.append('%s_%s' % (lemma, pos))
               continue
           if not named:
               named = True
               mem_case = morph['Case']
               mem_number = morph['Number']
           if morph['Case'] == mem_case and morph['Number'] == mem_number:
               memory.append(lemma)
               if 'SpacesAfter=\\n' in misc or 'SpacesAfter=\s\\n' in misc:
                   named = False
                   past_lemma = '::'.join(memory)
                   memory = []
                   tagged_propn.append(past_lemma + '_PROPN ')
           else:
               named = False
               past_lemma = '::'.join(memory)
               memory = []
               tagged_propn.append(past_lemma + '_PROPN ')
               tagged_propn.append('%s_%s' % (lemma, pos))
       else:
           if not named:
               if pos == 'NUM' and token.isdigit():  # Заменяем числа на xxxxx той же длины
                   lemma = num_replace(token)
               tagged_propn.append('%s_%s' % (lemma, pos))
           else:
               named = False
               past_lemma = '::'.join(memory)
               memory = []
               tagged_propn.append(past_lemma + '_PROPN ')
               tagged_propn.append('%s_%s' % (lemma, pos))
​
   if not keep_punct: # обрабатываем случай, когда пользователь попросил не сохранять пунктуацию (по умолчанию она сохраняется)
       tagged_propn = [word for word in tagged_propn if word.split('_')[1] != 'PUNCT']
   if not keep_pos:
       tagged_propn = [word.split('_')[0] for word in tagged_propn]
   return tagged_propn

Эту функцию можно также изменить под конкретную задачу. Например, если частеречные тэги нам не нужны, в функции ниже выставим keep_pos=False. Если необходимо сохранить знаки пунктуации, можно выставить keep_punct=True.

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

from ufal.udpipe import Model, Pipeline
import os
import re
​
def tag_ud(text='Текст нужно передать функции в виде строки!', modelfile='udpipe_syntagrus.model'):
   udpipe_model_url = 'https://rusvectores.org/static/models/udpipe_syntagrus.model'
   udpipe_filename = udpipe_model_url.split('/')[-1]
​
   if not os.path.isfile(modelfile):
       print('UDPipe model not found. Downloading...', file=sys.stderr)
       wget.download(udpipe_model_url)
​
   print('\nLoading the model...', file=sys.stderr)
   model = Model.load(modelfile)
   process_pipeline = Pipeline(model, 'tokenize', Pipeline.DEFAULT, Pipeline.DEFAULT, 'conllu')
​
   print('Processing input...', file=sys.stderr)
   lines = text.split('\n')
   tagged = []
   for line in lines:
       # line = unify_sym(line.strip()) # здесь могла бы быть ваша функция очистки текста
       output = process(process_pipeline, text=line)
       tagged_line = ' '.join(output)
       tagged.append(tagged_line)
   return '\n'.join(tagged)

text = open(textfile, 'r', encoding='utf-8').read()
processed_text = tag_ud(text=text, modelfile=modelfile)
print(processed_text[:350])
with open('my_text.txt', 'w', encoding='utf-8') as out:
   out.write(processed_text)

> русский_PROPN соболь_NOUN о.::генри_PROPN
когда_SCONJ синий_ADJ как_SCONJ ночь_NOUN глаз_NOUN Молли_VERB Мак-Кивер_PROPN класть_VERB малыш::Брэди_PROPN на_ADP оба_NUM лопатка_NOUN он_PRON вынужденный_ADJ быть_AUX покидать_VERB ряд_NOUN банда_NOUN «Дымовый_ADJ труба«_NOUN таков_ADJ власть_NOUN нежный_ADJ укор_NOUN подружка_NOUN и_CCONJ она_PRON

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

Итак, в ходе этой части тьюториала мы научились от «сырого текста» приходить к лемматизированному тексту с частеречными тэгами, который уже можно подавать на вход модели! Теперь теперь попробуем натренировать векторную модель.

Тренировка модели

Для работы с эмбеддингами слов существуют различные библиотеки: gensim, keras, tensorflow, pytorch. Мы будем работать с библиотекой gensim.

Gensim — изначально библиотека для тематического моделирования текстов. Однако помимо различных алгоритмов для topic modeling в ней реализованы на python и алгоритмы из тулкита word2vec (который в оригинале был написан на C++). Прежде всего, если gensim у вас на компьютере не установлен, нужно его установить:

pip install gensim

Gensim регулярно обновляется, так что не будет лишним удостовериться, что у вас установлена последняя версия, а при необходимости проапдейтить библиотеку:

pip install gensim --upgrade

При подготовке этого тьюториала использовался gensim версии 3.7.0.

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

import sys
import gensim, logging
​
logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)

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

f = 'my_text.txt'
data = gensim.models.word2vec.LineSentence(f)

Инициализируем модель. Параметры в скобочках:

  • data — данные,
  • size — размер вектора,
  • window — размер окна наблюдения,
  • min_count — мин. частотность слова в корпусе, которое мы берем,
  • sg — используемый алгоритм обучения (0 — CBOW, 1 — Skip-gram)
model = gensim.models.Word2Vec(data, size=500, window=10, min_count=2, sg=0)

Мы создаем модель, в которой размерность векторов — 500, размер окна наблюдения — 10 слов, алгоритм обучения — CBOW, слова, встретившиеся в корпусе только 1 раз, не используются. После тренировки модели можно нормализовать вектора, тогда модель будет занимать меньше RAM. Однако после этого её нельзя дотренировать.

model.init_sims(replace=True)

Смотрим, сколько в модели слов (корпус у нас был небольшой):

print(len(model.wv.vocab))
> 252

И сохраняем нашу модель!

model.save('my.model')

Работа с векторными моделями при помощи библиотеки Gensim

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

Модели для русского скачать можно здесь.

Существуют несколько форматов, в которых могут храниться модели. Во-первых, данные могут храниться в нативном формате word2vec, при этом модель может быть бинарной или не бинарной. Для загрузки модели в формате word2vec в классе KeyedVectors (в котором хранится большинство относящихся к дистрибутивным моделям функций) существует функция load_word2vec_format, а бинарность модели можно указать в аргументе binary (внизу будет пример). Помимо этого, модель можно хранить и в собственном формате gensim, для этого существует класс Word2Vec с функцией load. Поскольку модели бывают разных форматов, то для них написаны разные функции загрузки; бывает полезно учитывать это в своем скрипте. Наш код определяет тип модели по её расширению, но вообще файл с моделью может называться как угодно, жестких ограничений для расширения нет.

Давайте скачаем новейшую модель для русского языка, созданную на основе Национального корпуса русского языка (НКРЯ), и загрузим в её в память (поскольку zip-архив с моделью весит почти 500 мегабайт, следующая ячейка выполнится у вас не сразу!). Распаковывать скачанный архив для обычных моделей не нужно, так как его содержимое прочитается при помощи специальной инструкции:

import zipfile
model_url = 'http://vectors.nlpl.eu/repository/20/180.zip'
m = wget.download(model_url)
model_file = model_url.split('/')[-1]
with zipfile.ZipFile(model_file, 'r') as archive:
   stream = archive.open('model.bin')
   model = gensim.models.KeyedVectors.load_word2vec_format(stream, binary=True)

Ищем семантическую близость

Допустим, нам интересны такие слова (пример для русского языка):

words = [’день_NOUN’, ’ночь_NOUN’, ’человек_NOUN’, ’семантика_NOUN’, ’студент_NOUN’, ’студент_ADJ’]

Попросим у модели 10 ближайших соседей для каждого слова и коэффициент косинусной близости для каждого:

for word in words:
   # есть ли слово в модели? Может быть, и нет
   if word in model:
       print(word)
       # выдаем 10 ближайших соседей слова:
       for i in model.most_similar(positive=[word], topn=10):
           # слово + коэффициент косинусной близости
           print(i[0], i[1])
       print('\n')
   else:
       # Увы!
       print(word + ' is not present in the model')

> день_NOUN
неделя_NOUN 0.7375996112823486
день_PROPN 0.7067666053771973
месяц_NOUN 0.7037326097488403
час_NOUN 0.6643950343132019
утро_NOUN 0.6526744365692139
вечер_NOUN 0.6038411855697632
сутки_NOUN 0.5923081040382385
воскресенье_NOUN 0.5842781066894531
полдень_NOUN 0.5743687748908997
суббота_NOUN 0.5345946550369263

ночь_NOUN
ночь_PROPN 0.8310786485671997
вечер_NOUN 0.7183679342269897
рассвет_NOUN 0.696594774723053
ночи_NOUN 0.692021906375885
полночь_NOUN 0.6704976558685303
ночь_VERB 0.6615264415740967
утро_NOUN 0.6263935565948486
ночной_ADJ 0.6024709939956665
полдень_NOUN 0.5835086107254028
сумерки_NOUN 0.5671443343162537

человек_NOUN
человек_PROPN 0.7850059270858765
человеческий_ADJ 0.5915265679359436
существо_NOUN 0.573693037033081
народ_NOUN 0.5354466438293457
личность_NOUN 0.5296981334686279
человечество_NOUN 0.5282931327819824
человкъ_PROPN 0.5047001838684082
индивидуум_NOUN 0.5000404119491577
нравственный_ADJ 0.4972919821739197
потому_ADV 0.49293622374534607

семантика_NOUN
семантический_ADJ 0.8019332885742188
синтаксический_ADJ 0.7569340467453003
модальный_ADJ 0.7296056747436523
семантически_ADV 0.7209396958351135
смысловой_ADJ 0.7159026861190796
референция_NOUN 0.7135108709335327
ноэтический_ADJ 0.7080267071723938
языковой_ADJ 0.7067198753356934
лингвистический_ADJ 0.6928658485412598
предикат_NOUN 0.68775475025177

студент_NOUN
преподаватель_NOUN 0.6743764281272888
студенческий_ADJ 0.6486333608627319
университетский_ADJ 0.6442699432373047
заочник_NOUN 0.6423174142837524
первокурсник_NOUN 0.6409708261489868
курсистка_NOUN 0.636457085609436
дипломник_NOUN 0.6341054439544678
аспирант_NOUN 0.6337910890579224
университет_NOUN 0.6302101612091064
студентка_NOUN 0.6299037337303162

студент_ADJ is not present in the model

Как видим, модель выдала по 10 ближайших семантических «соседей» для каждого слова. Кроме неизвестного ей (и нам тоже) прилагательного студент (’студент_ADJ’). Прилагательное студенческий она, разумеется, знает:

model.most_similar(positive=['студенческий_ADJ'], topn=10)

[(’университетский_ADJ’, 0.6642225384712219),
(’студент_NOUN’, 0.6486333012580872),
(’студенчество_NOUN’, 0.6344770789146423),
(’институтский_ADJ’, 0.6142880320549011),
(’гимназический_ADJ’, 0.5510081648826599),
(’аспирантский_ADJ’, 0.5403808951377869),
(’школьный_ADJ’, 0.5198260545730591),
(’студентский_ADJ’, 0.5004373788833618),
(’ифли_PROPN’, 0.48894092440605164),
(’молодежный_ADJ’, 0.47924578189849854)]

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

print(model.similarity('человек_NOUN', 'обезьяна_NOUN'))
> 0.22025344

Более сложные операции над векторами

Помимо более простых операций над векторами (нахождение косинусной близости между двумя векторами и ближайших соседей вектора) gensim позволяет выполнять и более сложные операции над несколькими векторами. Так, например, мы можем найти лишнее слово в группе. Лишним словом является то, вектор которого наиболее удален от других векторов слов.

print(model.doesnt_match('яблоко_NOUN груша_NOUN виноград_NOUN банан_NOUN лимон_NOUN картофель_NOUN'.split()))
> картофель_NOUN

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

print(model.most_similar(positive=['пицца_NOUN', 'россия_NOUN'], negative=['италия_NOUN'])[0][0])
> гамбургер_NOUN

Заключение

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

  • осуществлять предобработку текстовых данных, что может пригодиться во многих задачах обработки естественного языка;
  • тренировать векторные семантические модели. Формат моделей совместим с моделями, представленными на веб-сервисе RusVectōrēs;
  • осуществлять простые операции над векторами слов.

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

Код этого тьюториала в формате тетрадки Jupyter

Источники