RusVectōrēs: семантические модели для русского языка

Елизавета Кузьменко, Андрей Кутузов

В этом тьюториале мы рассмотрим возможности использования веб-сервиса RusVectōrēs и векторных семантических моделей, которые этот веб-сервис предоставляет пользователям. Наша задача -- от "сырого" текста (т.е. текста без всякой предварительной обработки) прийти к данным, которые мы можем передать векторной модели и получить от неё интересующий нас результат.

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

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

Мы рекомендуем использовать Python3, работоспособность тьюториала для Python2 не гарантируется.

1. Предобработка текстовых данных

Функциональность RusVectōrēs позволяет пользователям делать запрос к моделям с одним конкретным словом или с несколькими словами. С помощью сервиса можно также анализировать отношения между бóльшим количеством слов. Но что делать, если вы хотите обработать очень большую коллекцию текстов или ваша задача не решается при помощи конкретных единичных запросов к серверу, которые можно сделать вручную?

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

Вы можете использовать наши готовые скрипты, чтобы из сырого текста получить текст в формате, который можно подавать на вход модели. Скрипты лежат здесь. Как следует из их названия, один из скриптов использует для предобработки UDPipe, а другой Mystem. Оба скрипта используют стандартные потоки ввода и вывода, принимают на вход текст, выдают тот же текст, только лемматизированный и с частеречными тэгами. Если же вы хотите детально во всем разобраться и понять, например, в чем разница между UDPipe и Mystem, то читайте далее :)

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

  • лемматизация и удаление стоп-слов;
  • приведение лемм к нижнему регистру;
  • добавление частеречного тэга для каждого слова.

Особого внимания заслуживает последний пункт предобработки. Как можно видеть из таблицы с описанием моделей, частеречные тэги для слов в различных моделях принадлежат к разным тагсетам. Первые модели использовали тагсет Mystem, затем мы перешли на Universal POS tags. В моделях на базе fastText частеречные тэги не используются вовсе.

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

pip install ufal.udpipe

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

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

pip install wget


In [ ]:
import wget

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)

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

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


In [ ]:
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
        token = clean_token(token, misc)
        lemma = clean_lemma(lemma, pos)
        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

Загружаем модель UDPipe, читаем текстовый файл и обрабатываем его. В файле должен содержаться необработанный текст (одно предложение на строку или один абзац на строку). Этот текст токенизируется, лемматизируется и размечается по частям речи с использованием UDPipe. На выход подаётся последовательность разделенных пробелами лемм с частями речи ("зеленый_NOUN трамвай_NOUN").


In [ ]:
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)
    for line in text:
        # line = unify_sym(line.strip()) # здесь могла бы быть ваша функция очистки текста
        output = process(process_pipeline, text=line)
        print(' '.join(output))

In [ ]:
text = open(textfile, 'r', encoding='utf-8').read()
tag_ud(text=text, modelfile=modelfile)

UDPipe позволяет нам распознавать имена собственные, и несколько идущих подряд имен мы можем склеить в одно. Вместо UDPipe возможно использовать и Mystem (удобнее использовать pymystem для Python), хотя Mystem имена собственные не распознает. Для того чтобы работать с последними моделями RusVectōrēs, понадобится сконвертировать тэги Mystem в UPOS. Кроме того, в данный момент мы не используем Mystem в своей работе, поэтому его совместимость с новыми моделями не гарантируется.

Сначала нужно установить библиотеку pymystem:

pip install pymystem3

Затем импортируем эту библиотеку и анализируем с её помощью текст:


In [ ]:
from pymystem3 import Mystem

def tag_mystem(text='Текст нужно передать функции в виде строки!'):  
    m = Mystem()
    processed = m.analyze(text)
    tagged = []
    for w in processed:
        try:
            lemma = w["analysis"][0]["lex"].lower().strip()
            pos = w["analysis"][0]["gr"].split(',')[0]
            pos = pos.split('=')[0].strip()
            tagged.append(lemma.lower() + '_' + pos)
        except KeyError:
            continue # я здесь пропускаю знаки препинания, но вы можете поступить по-другому
    return tagged

In [ ]:
processed_mystem = tag_mystem(text=text)
print(processed_mystem[:10])

Как видно, тэги Mystem отличаются от Universal POS-tags, поэтому следующим шагом должна быть их конвертация в Universal Tags. Вы можете воспользоваться вот этой таблицей конвертации:


In [ ]:
import requests
import re

url = 'https://raw.githubusercontent.com/akutuzov/universal-pos-tags/4653e8a9154e93fe2f417c7fdb7a357b7d6ce333/ru-rnc.map'

mapping = {}
r = requests.get(url, stream=True)
for pair in r.text.split('\n'):
    pair = re.sub('\s+', ' ', pair, flags=re.U).split(' ')
    if len(pair) > 1:
        mapping[pair[0]] = pair[1]

print(mapping)

Теперь усовершенствуем нашу функцию tag_mystem:


In [ ]:
def tag_mystem(text='Текст нужно передать функции в виде строки!'):  
    m = Mystem()
    processed = m.analyze(text)
    tagged = []
    for w in processed:
        try:
            lemma = w["analysis"][0]["lex"].lower().strip()
            pos = w["analysis"][0]["gr"].split(',')[0]
            pos = pos.split('=')[0].strip()
            if pos in mapping:
                tagged.append(lemma + '_' + mapping[pos]) # здесь мы конвертируем тэги
            else:
                tagged.append(lemma + '_X') # на случай, если попадется тэг, которого нет в маппинге
        except KeyError:
            continue # я здесь пропускаю знаки препинания, но вы можете поступить по-другому
    return tagged

In [ ]:
processed_mystem = tag_mystem(text=text)
print(processed_mystem[:10])

Теперь частеречные тэги в тексте, проанализированном при помощи Mystem, сравнимы с тэгами Unversal POS (хотя сам анализ оказался разным)!

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

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

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

Прежде чем переходить к работе непосредственно с RusVectōrēs, мы посмотрим на то, как работать с дистрибутивными моделями при помощи существующих библиотек.

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

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

pip install gensim

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

pip install gensim --upgrade

или

pip install gensim -U

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

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


In [ ]:
import sys
import gensim, logging

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

Работа с моделью

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

Модели для русского скачать можно здесь - https://rusvectores.org/ru/models/

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

Для новых моделей мы перешли на загрузку с использованием инфраструктуры Nordic Language Processing Laboratory. На практике это означает, что теперь по клику на модель вы скачиваете zip-архив с уникальным числовым идентификатором (например, 180.zip). Внутри архива всегда находится файл meta.json, содержащий в структурированном и стандартном виде информацию о модели и корпусе, на котором она обучена. word2vec-модели лежат в архивах сразу в двух word2vec-форматах: бинарном model.bin (удобен для быстрой загрузки) и текстовом model.txt (удобен для просмотра человеком). Давайте скачаем новейшую модель для русского языка, созданную на основе Национального корпуса русского языка (НКРЯ), и загрузим в её в память. Распаковывать скачанный архив для обычных моделей не нужно, так как его содержимое прочитается при помощи специальной инструкции:


In [ ]:
import zipfile
model_url = 'http://vectors.nlpl.eu/repository/11/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)

Модели fasttext в новой версии gensim загружаются при помощи следующей команды::

gensim.models.KeyedVectors.load("model.model")

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

Вернемся к нашей модели, созданной на основе НКРЯ. Скажем, нам интересны такие слова (пример для русского языка):


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

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


In [ ]:
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')

Находим косинусную близость пары слов:


In [ ]:
print(model.similarity('человек_NOUN', 'обезьяна_NOUN'))

Найди лишнее!


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

Реши пропорцию!


In [ ]:
print(model.most_similar(positive=['пицца_NOUN', 'россия_NOUN'], negative=['италия_NOUN'])[0][0])

3. Использование API сервиса RusVectōrēs

Помимо локального использования модели, вы можете также обратиться к RusVectōrēs через API, чтобы использовать наши модели в автоматическом режиме, не скачивая их (скажем, из ваших скриптов). В нашем API имеется две функции:

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

Для того чтобы получить список семантически близких слов, необходимо выполнить GET-запрос по адресу в следующем формате:

https://rusvectores.org/MODEL/WORD/api/FORMAT/

Разберемся с компонентами этого запроса. MODEL - идентификатор модели, к которой мы хотим обратиться. Идентификаторы можно посмотреть в таблице со всеми моделями нашего сервиса. WORD - слово, для которого мы хотим узнать соседей. Следует помнить, что частеречный тэг здесь тоже нужен (точнее, вы можете отправлять запросы и без него, но тогда части речи ваших слов сервер определит автоматически - и не всегда правильно). FORMAT - формат выходных данных, в настоящий момент это csv (с разделением через табуляцию) либо json.

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


In [ ]:
print(processed_ud[:15])
MODEL = 'ruscorpora_upos_cbow_300_20_2019'
FORMAT = 'csv'
WORD = processed_ud[1]

In [ ]:
def api_neighbor(m, w, f):
    neighbors = {}
    url = '/'.join(['http://rusvectores.org', m, w, 'api', f]) + '/'
    r = requests.get(url=url, stream=True)
    for line in r.text.split('\n'):
        try: # первые две строки в файле -- служебные, их мы пропустим
            word, sim = re.split('\s+', line) # разбиваем строку по одному или более пробелам
            neighbors[word] = sim
        except:
            continue
    return neighbors

In [ ]:
print(api_neighbor(MODEL, WORD, FORMAT))

API по умолчанию сообщает 10 ближайших соседей, изменить это количество в данный момент возможности нет.

Теперь рассмотрим вторую функцию, доступную в API - вычисление коэффициента близости между двумя словами. Запросы для неё должны выполняться в таком виде:

https://rusvectores.org/MODEL/WORD1__WORD2/api/similarity/

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


In [ ]:
def api_similarity(m, w1, w2):
    url = '/'.join(['https://rusvectores.org', m, w1 + '__' + w2, 'api', 'similarity/'])
    r = requests.get(url, stream=True)
    return r.text.split('\t')[0]

In [ ]:
MODEL = 'tayga_upos_skipgram_300_2_2019'
api_similarity(MODEL, WORD, 'мех_NOUN')

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