Локализуем игру в слова с искусственным интеллектом
Даже на русском языке игра не самая простая
Все началось с коллеги, который закинул в локальный чат сообщение, что он сыграл в игру #59 и угадал слово с 33 попыток и одной подсказки. Игра оказалась простая и сложная одновременно: сайт загадал слово и нужно его отгадать. В поле ввода отправляешь слово, а искусственный интеллект на сайте определяет, насколько отправленное слово близко по смыслу к загаданному.
Интересная игра, тренирующая ассоциативное мышление и умение строить связи. Новое слово появляется каждый день, что в некотором смысле выглядит ограничителем. Также игра доступна только на португальском и английском языках. С одной стороны, это дополнительная практика, а с другой — сомнения «а знаю ли я это слово?» смазывают впечатления от игры.
Так я задумался о локализации игры на русский язык. Свою игру «Русо контексто» я разместил на объектном хранилище, которое более устойчиво примет читателей Хабра.
Дисклеймер. Оригинальная игра расположена по адресу contexto.me. В процессе подготовки статьи я узнал о существовании русскоязычной версии guess-word.com. Но эта версия имеет более ограниченную функциональность.
Как работает игра?
У сайта минималистичный интерфейс:
- Сведения об игре: номер, количество попыток и количество подсказок.
- Поле ввода слова.
- Список отгаданных слов в виде полосы загрузки. Чем ближе, тем более она заполнена. Номер справа обозначает расстояние в словах, но его можно отключить.
В выпадающем меню есть настройки и дополнительные игровые опции:
- Выбрать игру.
- Взять подсказку.
- Сдаться.
Если отгадать слово, то игра предложит поделиться результатом и взглянуть на ближайшие 500 слов. Игра очень быстро возвращает ответ и умеет определять начальную форму слова. Иными словами, cat и cats считаются одним словом и выводиятся как cat. Все введенные слова трактуются как существительные, и в списке 500 ближайших слов глагола не встретить.
Это наводит на мысль, что список ближайших слов формируется отдельно, а игра просто обращается к списку. Остается вопрос: как составить список ближайших слов?
Текстовые эмбеддинги
Изначально компьютеры не владеют ни одним человеческим языком. Но человек делает все возможное, чтобы это исправить. Человек может сказать одну команду, используя разные слова и в разном порядке. Машине нужно уметь не просто различать слова, но и понимать смысл, который прячется за этими словами.
Здесь на помощь приходят текстовые эмбеддинги. Если упрощать, то эмбеддинг — это превращение слова в набор чисел, который называют кортежем или вектором. Эти числа задают положение слова в виде точки в пространстве, но не в трехмерном, а в многомерном. Чем ближе две точки, тем ближе слова по смыслу, а компьютеры умеют вычислять.
В рамках данной статьи оставим процесс сопоставления слов векторам в виде черного ящика, которым мы хотим пользоваться, но нам неинтересно, как он работает. Однако если любопытство берет верх, то рекомендую ознакомиться со статьями из секции дополнительного чтения в конце текста.
После операции сопоставления появляется модель — файл, который описывает соответствие «слово — вектор» или как-то описывает правила сопоставления или вычисления. Для работы модели нужно программное обеспечение, которое понимает формат модели.
Проще и быстрее всего «потрогать» эмбеддинги на языке Python. Библиотека gensim реализует один из самых популярных подходов — word2vec. Для работы необходима модель, обученная на достаточном количестве текстов. В документации gensim есть ссылки на англоязычные модели, но нас это не устраивает.
К счастью, проект RusVectores предоставляет модели на русском языке. На сайте представлены контекстуализированные и статические модели. Так как игра принимает на вход одно слово, то нам подходит статическая модель.
Я использовал модель, обученную на Национальном Корпусе Русского Языка (НКРЯ), ее название — ruscorpora_upos_cbow_300_20_2019. Скачиваем архив и распаковываем. Модель представлена в двух видах: бинарном (model.bin) и текстовом (model.txt).
Попробуем воспользоваться этой моделью. Сперва загружаем.
from gensim.models import KeyedVectors
model = KeyedVectors.load_word2vec_format("model.txt", binary=False)
Теперь можем найти слова, ближайшие к слову «провайдер»:
>>> model.most_similar(positive=["провайдер"])
…
KeyError: "Key 'провайдер' not present in vocabulary"
К сожалению, такого слова не нашлось. Дело в том, что данная модель принимает слова вместе с меткой, которая определяет часть слова. Это сделано для различия слов с одинаковым написанием. Например, «печь» можно представить как «печь_NOUN» и «печь_VERB», то есть как существительное и глагол соответственно.
>>> model.most_similar(positive=["провайдер_NOUN"])
[
('ip_PROPN', 0.677890419960022),
('internet_PROPN', 0.6627045273780823),
('интернет_PROPN', 0.6595873832702637),
('интернет_NOUN', 0.6567919850349426),
('веб_NOUN', 0.6510902047157288),
('сервер_NOUN', 0.6460723280906677),
('модем_NOUN', 0.6433334946632385),
('трафик_NOUN', 0.6332165002822876),
('безлимитный_ADJ', 0.6230701208114624),
('ритейлер_NOUN', 0.6218529939651489)
]
Также возьмем более простой пример с несколькими словами. Зададим два слова: король и женщина. Человек догадается, что женщина-король — это скорее всего королева.
>>> model.most_similar(positive=["король_NOUN", "женщина_NOUN"], topn=1)
[
('королева_NOUN', 0.6674807071685791),
('королева_ADV', 0.6368524432182312),
('принцесса_NOUN', 0.6262999176979065),
('герцог_NOUN', 0.613500714302063),
('герцогиня_NOUN', 0.5999450087547302)
]
Метод most_similar выводит список наиболее похожих слов и некоторую метрику расстояния до этого слова. Чем ближе метрика к единице, тем ближе слово. Список слов отсортирован по убыванию этой метрики. Так как сортировка производится при выводе, то значение метрики далее мы использовать не будем.
Аргумент topn позволяет задать количество слов, которые мы хотим получить. Таким образом можно запросить какое-нибудь большое количество слов и получить список, необходимый для создания игры. Давайте зададим более современное слово «киберпространство» и посмотрим на ближайшее слово и на слово, например, на десятитысячной позиции.
>>> result = model.most_similar(positive=["киберпространство_NOUN"], topn=10000)
>>> result[0]
('виртуальный_ADJ', 0.39892229437828064)
>>> result[9998]
('европбыть_VERB', 0.12139307707548141)
>>> result[9999]
('татуировкий_NOUN', 0.12139236181974411)
Татуировкий_NOUN. Кажется, это новый химический элемент. Наличие специфичных слов, которые могут шуткой, опечаткой, ошибкой в парсинге или локальным жаргонизмом, неприятно влияет на игру.
>>> model.most_similar(positive=["европа_NOUN"], topn=10)
[
('максимилиан::александрович_PROPN', 0.3658076822757721),
('фамилие_NOUN', 0.36153605580329895),
('санюшка_NOUN', 0.35595449805259705),
('емельян::ильич_PROPN', 0.35401633381843567),
('автостоп_NOUN', 0.35294172167778015),
('юрген_PROPN', 0.3491175174713135),
('чарльз::диккенс_PROPN', 0.3454093337059021),
('когда-тотец_NOUN', 0.3360745906829834),
('городбыть_VERB', 0.3332841098308563),
('владлен_VERB', 0.33179953694343567)
]
Пояснение: Европа — имя собственное, поэтому тег должен быть PROPN.
Нужно очистить словарь от странных слов и оставить только существительные.
Если вам понравится этот текст, у меня есть еще:→ Подбираем скины в Counter-Strike: Global Offensive в цвет сумочки
→ Делаем тетрис в QR-коде, который работает
→ Делаем радио из Cyberpunk 2077
Обработка словаря
Один из способов хранения модели word2vec — текстовый. Формат прост: в первой строке задаются два числа — количество строк в документе и количество чисел в векторе. Далее на каждой строке задается слово и далее числа, обозначающие вектор.
Здесь удобно воспользоваться особенностью этой модели, а именно тегами. Существительные имеют тег _NOUN, что позволяет убрать из модели ненужные слова. Удалить не существительные легко, но как поступить с опечатками и странными словами? Здесь на помощь приходит другой эмбеддинг, который обучался на литературе.
Это эмбеддинг Navec (навек) из проекта Natasha. Ссылку на русскоязычную модель можно увидеть в репозитории проекта. Скачиваем и загружаем модель:
from navec import Navec
path = 'navec_hudlit_v1_12B_500K_300d_100q.tar'
navec = Navec.load(path)
Теперь можно проверять слова простым синтаксисом:
>>> "виртуальный" in navec
True
>>> "европбыть" in navec
False
>>> "татуировкий" in navec
False
Таким образом можно отсеять немалое количество слов, которым в игре не место.
цидулка
зачатокать
магазей
антитезть
завоевателий
налицотец
прируба
бислой
цвть
громадий
межрайонец
англиканствый
скудетто
выбытий
делаловек
чтобль
агрокомплекс
кейтеринг
фемтосекунда
углепластик
электромашиностроение
мурмолка
реанимобиль
Алгоритм очистки модели следующий:
- Если у слова тег не NOUN, то отбрасываем это слово.
- Удаляем из слова последовательность _NOUN.
- Проверяем «чистое слово» на наличие в эмбеддинге Navec. Если его там нет, слово отбрасываем.
- Слово, которое прошло все проверки, записываем в файл.
После обработки всех слов в первую строку новой модели записываем два числа: количество оставшихся строк и размерность вектора. Размерность вектора при данной обработке остается неизменной. Если все сделано правильно, то очищенную модель получится загрузить:
model = KeyedVectors.load_word2vec_format("noun_model.txt", binary=False)
Стало ли после этого лучше?
>>> result = model.most_similar(positive=["киберпространство"], topn=10000)
>>> result[0]
('виртуальность', 0.4715898633003235)
>>> result[9998]
('компаунд', 0.15783849358558655)
>>> result[9999]
('хитрость', 0.15783214569091797)
Определенно. Для статистики: исходная модель содержит 248 978 токенов, из них 59 104 токенов имеют метку существительног. И только 36 269 прошли «сито» второго эмбеддинга.
Время заняться бэкэндом и фронтендом игры.
Умный бэкэнд
Так как Python является моим рабочим языком программирования, бэкэнд я решил реализовать на нем. Поговорим об обработке входных данных. Обрезать пробелы и перевести текст в нижний регистр — само собой разумеющееся. Но как получить начальную форму слова?
Здесь можно воспользоваться инструментом MyStem. Для Python есть обертка pymystem3. Крайне простой инструмент для получения начальной формы слова:
import pymystem3
mystem = pymystem3.Mystem()
Метод lemmatize принимает на вход строку-предложение и возвращает список слов в начальной форме.
>>> mystem.lemmatize("кот коты котов котах кота")
['кот', ' ', 'кот', ' ', 'кот', ' ', 'кот', ' ', 'кот', '\n']
На первый взгляд даже производительность на достойном уровне: на моей виртуальной машине лемматизация одного слова занимает до 10 мс. По меркам современного веба это достаточно быстро.
Пока я работал над бэкэндом, по работе пришлось познакомиться с объектным хранилищем, среди функций которого есть возможность размещения статических сайтов. И тут мне пришла интересная мысль.
Игра на объектном хранилище
При разработке бэкэнда я продумывал способы защититься от нечестной игры:
- Сдаться нельзя.
- Список топ-500 ближайших слов получить можно, только предоставив загаданное слово.
- Подсказку можно получить по слову и позиции.
Но вскоре мне показалось это слишком суровым.
На данный момент единственное назначение бэкэнда — приведение слов к начальной форме. Правда, как показало тестирование на коллегах, и это не обязательно: все и так старались писать начальные формы слов. Да и модель эмбеддингов не лемматизирована, то есть игра понимает слова не только в начальной форме.
Получается, игру можно полностью перенести в браузер?
Так как я бэкэнд-разработчик, то отказ от бэкэнда в угоду фронтэнду — это стресс. Однако от бэкэнда полностью отказаться не получится: генератор близких слов где-то нужно запускать. Генератор принимает на вход загаданное слово и формирует текстовый файл, где на каждой строке по одному слову в порядке смыслового убывания от загаданного. Содержимое этого файла также дублируется в JSON-словарь, где каждому слову соответствует его дистанция от загаданного слова.
JSON-файл на каждую игру занимает до 2 МБ. При открытии игры файл скачивается в браузер и JavaScript реализует логику игры. Этот способ не самый производительный, но после загрузки файла позволяет играть без подключения к интернету.
Я разместил игру в облачном хранилище Selectel, которое более устойчиво к наплыву посетителей.
Заключение
Итоговый результат доступен по адресу words.f1remoon.com, а исходный код — в репозитории.
Дополнительное чтение
Как работают текстовые эмбеддинги?
→ Чудесный мир Word Embeddings: какие они бывают и зачем нужны? (от пользователя madrugado)
→ Word2vec в картинках (от пользователя m1rko)