13.02.2019
Источник: Habr
В этой статье речь пойдет о том, как мы интегрировали разработанный Яндексом Томита-парсер в нашу систему, превратили его в динамическую библиотеку, подружили с Java, сделали многопоточной и решили с её помощью задачу классификации текста для оценки недвижимости.

Постановка задачи

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

Итак, у нас есть текст объявления, который необходимо классифицировать в одну из категорий согласно состоянию ремонта в квартире (без отделки, чистовой, средний, хороший, отличный, эксклюзивный). Про ремонт в объявлении может быть сказано одно-два предложения, пара слов или ничего, поэтому классифицировать текст полностью не имеет смысла. Ввиду специфичности текста и ограниченного набора слов, относящихся к контексту ремонта, единственным разумным решением стало извлечение всей необходимой информации из текста и классифицирование уже именно её.

image

Теперь надо научиться извлекать из текста все факты о состоянии отделки. Конкретно то, что напрямую касается ремонта, а также все, что может косвенно говорить о состоянии квартиры — наличие натяжных потолков, встроенной техники, пластиковых окон, джакузи, использование дорогих отделочных материалов и т.д.

При этом надо извлечь только информацию о ремонте в самой квартире, потому что состояние подъездов, подвалов и чердаков нас не интересует. Также надо ещё учесть то, что текст написан на естественном языке со всеми присущими ему ошибками, опечатками, сокращениями и прочими особенностями — лично я нашла три варианта написания слов “линолеум” и “ламинат” и пять вариантов написания слова “предчистовая”; некоторые люди не понимают, зачем нужны пробелы между словами, а другие не слышали про запятые. Поэтому самым простым и разумным решением стал парсер с контекстно свободными грамматиками.

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

Переходим к решению

Из доступных средств для извлечения фактов из текста на основе контекстно-свободных грамматик, способных работать с русским языком, наше внимание привлекли Томита-парсер и библиотека Yagry на питоне. Yagry сразу был забракован, так как написан целиком и полностью на питоне и вряд ли хорошо оптимизирован. А Томита изначально выглядела очень привлекательно: располагала подробной документацией для разработчика и большим количеством примеров, С++ обещал приемлемую скорость. Разобраться в правилах написания грамматик не составило труда, и первая версия классификатора с её использованием была готова уже на следующий день.

Примеры правил из наших грамматик, извлекающие прилагательные и глаголы, относящиеся к контексту ремонта:

RepairW -> "ремонт" | "состояние" | "отделка";
StopWords -> "подъезд" | "холл" | "фасад" | "вестибюль";

Repair -> RepairW<gnc-agr[1]> Adj<gnc-agr[1]>+ interp (Repair.AdjGroup {weight = 0.5});
Repair -> Verb<gnc-agr[1]> Adj<gnc-agr[1]>* interp (Repair.Verb) RepairW<gnc-agr[1]> {weight = 0.5};


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

Repair -> StopWords Verb* Prep* Adj* RepairW;
Repair -> Adj+ RepairW Prep* StopWords;


По умолчанию вес правила равен 1, присваивая меньший вес правилу мы устанавливаем очередность их выполнения.

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

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

Убедившись, что в первом приближении Томита обеспечивает нам необходимый функционал, мы поняли, что в виде консольного приложения использовать её не вариант: во-первых, консольное приложение оказалось нестабильным и время от времени падало по непонятным причинам, а во-вторых, не обеспечило бы необходимую нагрузку парсинга в несколько миллионов объявлений в сутки. Таким образом, стало определенно точно ясно, что надо делать из неё библиотеку.

Как мы сделали Томиту многопоточной библиотекой и подружили её с Java

Наша система написана на Java, tomita-parser на C++. Нам необходимо было получить возможность вызвать из Java парсинг текста объявления.

Разработку java-binding-ов для Томита-парсер условно можно разделить на два составляющих — реализация возможности использования Томиты как разделяемой библиотеки и, собственно, написание слоя интеграции с jvm. Основная сложность касалась первой части. Сама Томита изначально проектировалась для исполнения в отдельном процессе. Отсюда вытекало, что основными преградами на пути использования парсера в процессе приложения выступали два фактора.

  1. Обмен данных осуществлялся через разного рода IO. Требовалось реализовать возможность обмена данными с парсером через память. Причем сделать это было необходимо таким образом, чтобы минимально затронуть код самого парсера. Архитектура Томиты подсказала способ реализации чтения входных документов с памяти как реализацию интерфейсов CDocStreamBase и CDocListRetrieverBase. С выходными данными было сложнее — пришлось затронуть код xml-генератора.
  2. Второй фактор, вытекающий из принципа «один парсер — один процесс», — глобальное состояние, модифицируемое из разных инстансов парсера. Если заглянуть в файл src/util/generic/singleton.h, виден механизм использования разделяемого состояния. Нетрудно себе представить, что при использовании двух инстансов парсера в одном адресном пространстве будет возникать состояние гонки. Чтобы не переписывать весь парсер, было принято решение модифицировать данный класс, заменив глобальное состояние на локальное по отношению к потоку (thread_local). Соответственно, перед любым вызовом парсера в обертке JTextMiner мы выставляем эти thread_local переменные на текущий инстанс парсера, после чего код парсера работает с адресами текущего инстанса парсера.

После устранения этих двух факторов парсер был доступен для использования в качестве разделяемой библиотеки из любого окружения. Написать jni-биндинги и java-обертку не составило уже никакого труда.

Томита-парсер перед использованием должен быть сконфигурирован. Параметры конфигурации аналогичны тем, которые используются при вызове консольной утилиты. Непосредственно парсинг заключается в вызове метода parse(), который принимает на вход документы для парсинга и возвращает xml в виде строки с результатами работы парсера.

Многопоточный вариант Томиты — TomitaPooledParser использует для парсинга пул объектов TomitaParser, одинаковым образом сконфигурированных. Для парсинга используется первый свободный парсер. Поскольку количество создаваемых парсеров равно количеству потоков в пуле, то всегда будет как минимум один доступный парсер для задачи. Метод parse выполняет асинхронный парсинг предоставленных документов в первом свободном парсере.

Пример вызова Томита-библиотеки из Java:

/**
     * @param threadAmount number of threads in the pool
     * @param tomitaConfigFilename tomita config.proto
     * @param configDirname dir with configs: grammars, gazetteer, facttypes.proto
     */

tomitaPooledParser = new TomitaPooledParser(threadAmount, new File(configDirname), new String[]{tomitaConfigFilename});

Future<String> result = tomitaPooledParser.parse(documents);
String response = result.get();


В response — XML-строка с результатом парсинга.

Проблемы, с которыми мы столкнулись и как мы их решали

Итак, библиотека готова, запускаем сервис с её использованием на большом объеме данных и вспоминаем про проблему не извлечения некоторых слов, понимая, что это очень критично для нашей задачи.

Среди таких слов оказались “предчистовая”, а также “сделан”, “произведен” и другие сокращенные причастия. То есть слова, которые встречаются в объявлении очень часто, и подчас это единственная или очень важная информация о ремонте. Причина такого поведения — слово “предчистовая” оказалось словом с неизвестной морфологией, то есть Томита просто не может определить, какая это часть речи, и, соответственно, не может его извлечь. А для сокращенных причастий пришлось написать отдельное правило, и проблема решилась, но было потрачено определенное время на то, чтобы разобраться в том, что это именно сокращенные причастия, для извлечения которых нужно специальное правило. А для многострадальной “предчистовой” отделки пришлось написать отдельное правило как для слова с неизвестной морфологией.

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

TAuxDicArticle "adjNonExtracted"
{
    key = "предчистовая" | "пред-чистовая"
}


Для сокращенных деепричастий используем грамматические характеристики partcp,brev.

И теперь можем написать правила для этих случаев:

Repair -> RepairW<gnc-agr[1]> Word<gram="partcp,brev",gnc-agr[1]> interp (Repair.AdjGroup) {weight = 0.5};
Repair -> Word<kwtype="adjNonExtracted",gnc-agr[1]> interp (Repair.AdjGroup) RepairW<gnc-agr[1]> Prep* Adj<gnc-agr[1]>+;


И последняя из обнаруженных нами проблем — сервис с многопоточным использованием Томита-библиотеки плодит процессы myStem, которые не уничтожаются и спустя некоторое время заполняют всю память. Самым простым решением оказалось ограничение максимального и минимального количества потоков в Tomcat.

Пара слов о классификации

Итак, теперь мы имеем информацию о ремонте, извлеченную из текста. Классифицировать её с помощью одного из алгоритмов градиентного бустинга не составило труда. Не будем здесь останавливаться надолго на этой теме, об этом сказано и написано уже очень много и ничего кардинально нового в этой области нами сделано не было. Приведу только показатели качества классификации, которые были нами получены на тестах:

  • Accuracy = 95%
  • F1 score = 93%

Заключение

Реализованный сервис с использованием Томита-парсера в режиме библиотеки в данный момент стабильно работает, парсит и классифицирует несколько миллионов объявлений в сутки.

P.S.

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

Вернуться в раздел