API синтаксического анализатора
Продолжаю свой предыдущий пост. Время сфокусироваться на деталях внутреннего устройства синтаксического анализатора. В качестве языка реализации я выбрал Go, поскольку хотел малой ценой получить параллельный (в смысле, использующий все доступные ядра CPU) производительный инструмент, без погружения в низкоуровневую пучину C++.
Полученный код предоставляет следующий API:
type Attribute struct {
Name string
Value string
}
type ParseMatch struct {
Text string
Nonterminal string
Rule string
Attributes []Attribute
Submatches []ParseMatch
Hypotheses []string
HypothesisCount uint
}
func Parse(text, nonterminal string, hypotheses_limit uint) []ParseMatch
Match ссылается на дочерние объекты того же типа, соотвествующие нетерминалам или лексическим терминалам подошедшего правила. В общем случае, из-за неоднозначности, присущей естественным языкам, тексту соответствует несколько разборов (например, из-за наличия омонимов). Поэтому функция Parse возвращает множество объектов Match. Вышеупомянутая неоднозначность синтаксического разбора должна устраняться на следующем (семантическом) уровне анализа текста.
Итак, функция Parse берёт text — текст для разбора, nonterminal — название нетерминала (например, «sentence»), а также максимальное число выдвигаемых гипотез hypotheses_limit (об этом чуть ниже). Параметр nonterminal может быть пустым. В этом случае тексту будет сопоставляться лексический терминал, найденный в морфологической базе.
В терминах данного анализатора гипотеза — это предположение того, что нарушенное ограничение значения атрибута вызвано случайной причиной. Если анализатор встречает несоответствие значения атрибута ограничению, заданному рассматриваемым в данный момент правилом, а число выдвинутых гипотез не достигло hypotheses_limit, то данное несоответствие игнорируется. В противном случае рассматриваемое правило отбрасывается. Данный механизм удобен для отладки правил, но должен избегаться в реальной работе, поскольку чудовищно замедляет процесс разбора.
Сопоставление нетерминалов
Прежде чем погружаться в описание этого процесса стоит отметить, что в общем случае сопоставляется не весь текст, а лишь его начало. Анализатор находит все возможные начальные части текста, соответствующие данному нетерминалу (или все лексические терминалы вначале текста).
Для данного нетерминала анализатор извлекает все соответствующие правила (исходя из грамматики) и пытается найти соответствия каждому из них. Так, для каждого такого правила анализатор проверяет наличие присвоения значений атрибутов ("@{...}", должно быть вначале правила). Далее вызывается рекурсивная функция parseRulePart, которая последовательно производит следующие действия:
- В случае наличия в текущем месте правила терминала сопоставляет его тексту.
- В случае наличия в текущем месте правила нетерминала или лексического терминала вызывает функцию Parse.
- Проверяет выполнение ограничений на значения атрибутов для полученных Match-объектов.
- Для каждого из подходящих Match-объектов функция вызывает саму себя в новом потоке (goroutine) для сопоставления оставшейся части правила.
- Match-объекты, возвращённые созданными потоками, дополняются данными из текущего Match-объекта и возвращаются.
Сопоставление лексических терминалов
С одной стороны лексические терминалы могут легко извлекаться из текста путём поиска символов, отделяющих слова друг от друга (пробел, запятая, и т.д.). С другой стороны существуют устойчивые словосочетания, содержащие в себе такие символы. Рассматриваясь в качестве неделимых лексических единиц они могут иметь другие значения атрибутов и даже другой смысл (что особенно важно для последующего семантического анализа). Поэтому сопоставление лексических терминалов производится следующим образом:
- Из текста извлекается терминал, ограниченный символом, разделяющим слова.
- В морфологической базе находятся все лексические терминалы, начинающиеся с извлечённого терминала.
- Из полученных лексических терминалов выбираются те, что соответствуют данному тексту.
Морфологическая база
Я использую морфологический словарь русского языка, скачанный с сайта OpenCorpora. Для использования синтаксическим анализатором эти данные должны быть минимизированы и индексированы. Сначала я пробовал для этой цели использовать SQLite, но полученное решение имело неудовлетворительную производительность (даже после включения кэширования). Поэтому пришлось реализовывать специализированную морфологическую базу. Замеры показали примерно 9-кратное ускорение поиска и более чем 300-кратное ускорение начальной индексации.
Формат файла морфологической базы достаточно прямолинеен: заголовок + две большие части. Первая часть состоит из словоформ, разделённых символом '\0'. Второй частью является сортированный массив структур, содержащих 32-битное смещение текста соответствующей словоформы и упакованные в 32-бита значения её атрибутов. Для высокой скорости поиска обе части морфологической базы полностью загружаются в оперативную память.
Полученный код предоставляет следующий API:
func BuildMorph(txt_filename, morph_filename string) error
func InitMorph(morph_filename string) error
func FinalizeMorph()
func FindTerminals(prefix, separator string) []ParseMatch
Функция FindTerminals принимает дополнительный параметр разделителя, поскольку в случае устойчивого словосочетания он является его неотъемлемой частью, в противном же случае разделитель оказывается ненужным и должен быть отброшен.
Кэш разбора
Даже после ускорения поиска словоформ общая производительность разбора оставляла желать лучшего (разбор распространённых предложений длился несколько секунд). Поскольку структура разработанной грамматики предполагает многократные сопоставления текста одним и тем же нетерминалам (наличие множества похожих правил), то напрашивалось применение кэширования. Поэтому, прежде чем начинать разбор, функция Parse проверяет наличие такого (произведенного ранее) разбора в кэше.
Поскольку в основе кэша лежит хэш-таблица, то для поддержки параллельного доступа требуется защищающий мьютекс. Однако один глобальный мьютекс может стать узким местом, поэтому кэш разбивается на нужное число банков, каждый из которых состоит из хэш-таблицы и соответствующего защищающего мьютекста.
Использование анализатора
Для использования разработанного анализатора требуется:
- Скачать морфологический словарь.
- Собрать на его основе морфологическую базу (morph.bin, см. main.go).
- Дополнять/изменять правила грамматики в файле rules.yaml.
- Быть любознательным и ловить кайф от исследований.
Комментарии (14)
excoder
14.04.2015 18:57+1Почему бы не использовать одну из разновидностей минимального автомата? Например, как здесь: habrahabr.ru/post/190694.
ababo Автор
14.04.2015 19:05Просмотрел статью, выглядит привлекательно. Я особо не заморачивался морфологией, поскольку скорописное решение «в лоб» меня полностью удовлетворяло. Но, повторюсь, идея любопытная (до сих пор не могу понять, как с помощью этой методики удаётся всё уместить в 8 мегабайт, поразительно).
kmike
15.04.2015 13:36А какой алгоритм парсинга используется, какие грамматики поддерживаются — LL(?), LR(?), любые контекстно-свободные, ...? Есть ли какие-то «хорошие» ограничения на сложность разбора — например, O(N) для некоторых однозначных и не больше чем O(N^3) для любых (хотя из-за атрибутов это может быть сложно..)? Можно ли парсить «снизу вверх», а не «сверху вниз», не требуя обязательного сопоставления стартовому символу, извлекая все поддеревья за один проход?
Ну и интересно, какая скорость морф. анализа по словарю получилась :)
Насчет упаковки данных в автомат есть простой способ — автомат строить библиотекой code.google.com/p/dawgdic (для кого-то может быть проще использовать питонью обертку github.com/kmike/DAWG), а потом написать читалку для полученного формата на Go. Там алгоритм построения автомата сложный, а вот сама читалка — довольно простая, портировать на Go должно быть нетрудно.ababo Автор
15.04.2015 15:07А какой алгоритм парсинга используется
Об этом сказано в статье.
Разбор нисходящий, грамматика контекстно-свободная (BNF-подобная). На счёт сложности не готов ответить. Сложность морфологического поиска Log2(n), где n — число словоформ (где-то 5 миллионов), но в относительных величинах, думаю, отстаёт от решений, написанных на C/C++. Количественно, увы, не помню (можете скачать и замерить :)). Я морфологию почти не оптимизировал, писал почти «в лоб». Скорость синтаксического разбора для сложных предложений — где-то порядка 0.1-0.2 секунды, для простых — в несколько раз быстрее.
ababo Автор
15.04.2015 15:31Ради интереса измерил скорость морфологического поиска. Получилось чуть больше 200.000/сек. (если, конечно, где-то не ошибся). Учитывая, что у меня морфологический поиск несколько более продвинутый — он ищет множество словоформ по заданному префиксу и разделителю, а также совершенно не оптимизированный, то результат, кажется, совсем не плохой.
Aracon
19.04.2015 22:28+1Тут жена-филолог подсказывает (к списку правил грамматики) почитать про «структурные схемы синтаксиса русского языка». В частности, посмотреть «Российская грамматика 70, 80» (1970 и 1980 года издания, они так и называются), работы Белошапковой, Богданова, Золотова, Шмелёва, где примерно в 15 типов структурных схем укладывается все многообразие предложений русского языка. Возможно, поможет обойти часть велосипедов.
Mopper
Ну и на сколько быстрее стало работать из того что вы используете «Параллельность» GO?
ababo Автор
Примерно в 2.5 раза на 4-ядерном процессоре.
Mopper
Как мне кажется Вы немножко лукавите насчето прироста скорости.
Посмотрел ваш репозиторий там нигде нет вызова
https://github.com/ababo/idiot/search?utf8=%E2%9C%93&q=GOMAXPROCS
Без его вызова у вас по умолчанию только один поток используется, несмотря на то что goroutин много.
http://stackoverflow.com/questions/17853831/what-is-the-gomaxprocs-default-value.
Вообще я так понимаю go придумали не столько как инструмент параллельной обработки данных сколько как инструмент «одновременной работы».
http://blog.golang.org/concurrency-is-not-parallelism
Я просто сам с этим столкнулся. у меня без этого вызова все в одном потоке крутилось.
ababo Автор
Ничего я не лукавлю. С установкой GOMAXPROCS=4 скорость анализа возрастает где-то в 2.5 раза.
neolink
по умолчанию используется значение из одноименной переменной окружения