В этой статье речь пойдет о том, как мы интегрировали разработанный Яндексом Томита-парсер в нашу систему, превратили его в динамическую библиотеку, подружили с Java, сделали многопоточной и решили с её помощью задачу классификации текста для оценки недвижимости.
В оценке недвижимости большое значение имеет анализ объявлений о продаже. Из объявления можно получить всю необходимую информацию об объекте недвижимости, в том числе и информацию о состоянии ремонта в квартире. Обычно эта информация содержится в тексте объявления. Она очень важна при оценке, так как хороший ремонт может добавить к цене за квадратный метр несколько тысяч.
Итак, у нас есть текст объявления, который необходимо классифицировать в одну из категорий согласно состоянию ремонта в квартире (без отделки, чистовой, средний, хороший, отличный, эксклюзивный). Про ремонт в объявлении может быть сказано одно-два предложения, пара слов или ничего, поэтому классифицировать текст полностью не имеет смысла. Ввиду специфичности текста и ограниченного набора слов, относящихся к контексту ремонта, единственным разумным решением стало извлечение всей необходимой информации из текста и классифицирование уже именно её.
Теперь надо научиться извлекать из текста все факты о состоянии отделки. Конкретно то, что напрямую касается ремонта, а также все, что может косвенно говорить о состоянии квартиры — наличие натяжных потолков, встроенной техники, пластиковых окон, джакузи, использование дорогих отделочных материалов и т.д.
При этом надо извлечь только информацию о ремонте в самой квартире, потому что состояние подъездов, подвалов и чердаков нас не интересует. Также надо ещё учесть то, что текст написан на естественном языке со всеми присущими ему ошибками, опечатками, сокращениями и прочими особенностями — лично я нашла три варианта написания слов “линолеум” и “ламинат” и пять вариантов написания слова “предчистовая”; некоторые люди не понимают, зачем нужны пробелы между словами, а другие не слышали про запятые. Поэтому самым простым и разумным решением стал парсер с контекстно свободными грамматиками.
Таким образом, по мере решения сформировалась вторая большая и интересная задача — научиться извлекать всю достаточную и необходимую информацию о ремонте из объявления, а именно обеспечить быстрый синтаксический и морфологический анализ текста, который сможет работать параллельно под нагрузкой в режиме библиотеки.
Из доступных средств для извлечения фактов из текста на основе контекстно-свободных грамматик, способных работать с русским языком, наше внимание привлекли Томита-парсер и библиотека Yagry на питоне. Yagry сразу был забракован, так как написан целиком и полностью на питоне и вряд ли хорошо оптимизирован. А Томита изначально выглядела очень привлекательно: располагала подробной документацией для разработчика и большим количеством примеров, С++ обещал приемлемую скорость. Разобраться в правилах написания грамматик не составило труда, и первая версия классификатора с её использованием была готова уже на следующий день.
Примеры правил из наших грамматик, извлекающие прилагательные и глаголы, относящиеся к контексту ремонта:
Правила, служащие для того, чтобы информация о состоянии мест общего пользования не извлекалась:
По умолчанию вес правила равен 1, присваивая меньший вес правилу мы устанавливаем очередность их выполнения.
Немного смущало то, что в общий доступ выложено только консольное приложение и тонна кода на С++. Но несомненным плюсом стали удобство использования и быстрый результат на экспериментах. Поэтому про возможные сложности внедрения её в нашу систему было решено подумать ближе к самому внедрению.
Практически сразу удалось добиться качественного извлечения почти всей необходимой информации о ремонте. “Почти”, потому что изначально некоторые слова не извлекались ни при каких условиях и грамматиках. Однако сразу оценить масштаб этой проблемы, насколько она сможет повлиять на качество решения задачи классификации в целом, было сложно.
Убедившись, что в первом приближении Томита обеспечивает нам необходимый функционал, мы поняли, что в виде консольного приложения использовать её не вариант: во-первых, консольное приложение оказалось нестабильным и время от времени падало по непонятным причинам, а во-вторых, не обеспечило бы необходимую нагрузку парсинга в несколько миллионов объявлений в сутки. Таким образом, стало определенно точно ясно, что надо делать из неё библиотеку.
Наша система написана на Java, tomita-parser на C++. Нам необходимо было получить возможность вызвать из Java парсинг текста объявления.
Разработку java-binding-ов для Томита-парсер условно можно разделить на два составляющих — реализация возможности использования Томиты как разделяемой библиотеки и, собственно, написание слоя интеграции с jvm. Основная сложность касалась первой части. Сама Томита изначально проектировалась для исполнения в отдельном процессе. Отсюда вытекало, что основными преградами на пути использования парсера в процессе приложения выступали два фактора.
После устранения этих двух факторов парсер был доступен для использования в качестве разделяемой библиотеки из любого окружения. Написать jni-биндинги и java-обертку не составило уже никакого труда.
Томита-парсер перед использованием должен быть сконфигурирован. Параметры конфигурации аналогичны тем, которые используются при вызове консольной утилиты. Непосредственно парсинг заключается в вызове метода parse(), который принимает на вход документы для парсинга и возвращает xml в виде строки с результатами работы парсера.
Многопоточный вариант Томиты — TomitaPooledParser использует для парсинга пул объектов TomitaParser, одинаковым образом сконфигурированных. Для парсинга используется первый свободный парсер. Поскольку количество создаваемых парсеров равно количеству потоков в пуле, то всегда будет как минимум один доступный парсер для задачи. Метод parse выполняет асинхронный парсинг предоставленных документов в первом свободном парсере.
Пример вызова Томита-библиотеки из Java:
В response — XML-строка с результатом парсинга.
Итак, библиотека готова, запускаем сервис с её использованием на большом объеме данных и вспоминаем про проблему не извлечения некоторых слов, понимая, что это очень критично для нашей задачи.
Среди таких слов оказались “предчистовая”, а также “сделан”, “произведен” и другие сокращенные причастия. То есть слова, которые встречаются в объявлении очень часто, и подчас это единственная или очень важная информация о ремонте. Причина такого поведения — слово “предчистовая” оказалось словом с неизвестной морфологией, то есть Томита просто не может определить, какая это часть речи, и, соответственно, не может его извлечь. А для сокращенных причастий пришлось написать отдельное правило, и проблема решилась, но было потрачено определенное время на то, чтобы разобраться в том, что это именно сокращенные причастия, для извлечения которых нужно специальное правило. А для многострадальной “предчистовой” отделки пришлось написать отдельное правило как для слова с неизвестной морфологией.
Для того, чтобы решить проблемы парсинга с помощью грамматик, добавляем в газеттир слово с неизвестной морфологией:
Для сокращенных деепричастий используем грамматические характеристики partcp,brev.
И теперь можем написать правила для этих случаев:
И последняя из обнаруженных нами проблем — сервис с многопоточным использованием Томита-библиотеки плодит процессы myStem, которые не уничтожаются и спустя некоторое время заполняют всю память. Самым простым решением оказалось ограничение максимального и минимального количества потоков в Tomcat.
Итак, теперь мы имеем информацию о ремонте, извлеченную из текста. Классифицировать её с помощью одного из алгоритмов градиентного бустинга не составило труда. Не будем здесь останавливаться надолго на этой теме, об этом сказано и написано уже очень много и ничего кардинально нового в этой области нами сделано не было. Приведу только показатели качества классификации, которые были нами получены на тестах:
Реализованный сервис с использованием Томита-парсера в режиме библиотеки в данный момент стабильно работает, парсит и классифицирует несколько миллионов объявлений в сутки.
Весь код Томиты, написанный нами в рамках этого проекта выложен на гитхаб. Надеюсь, это пригодится кому-нибудь, и этот человек сэкономит немного времени на что-нибудь ещё более полезное.
Постановка задачи
В оценке недвижимости большое значение имеет анализ объявлений о продаже. Из объявления можно получить всю необходимую информацию об объекте недвижимости, в том числе и информацию о состоянии ремонта в квартире. Обычно эта информация содержится в тексте объявления. Она очень важна при оценке, так как хороший ремонт может добавить к цене за квадратный метр несколько тысяч.
Итак, у нас есть текст объявления, который необходимо классифицировать в одну из категорий согласно состоянию ремонта в квартире (без отделки, чистовой, средний, хороший, отличный, эксклюзивный). Про ремонт в объявлении может быть сказано одно-два предложения, пара слов или ничего, поэтому классифицировать текст полностью не имеет смысла. Ввиду специфичности текста и ограниченного набора слов, относящихся к контексту ремонта, единственным разумным решением стало извлечение всей необходимой информации из текста и классифицирование уже именно её.
Теперь надо научиться извлекать из текста все факты о состоянии отделки. Конкретно то, что напрямую касается ремонта, а также все, что может косвенно говорить о состоянии квартиры — наличие натяжных потолков, встроенной техники, пластиковых окон, джакузи, использование дорогих отделочных материалов и т.д.
При этом надо извлечь только информацию о ремонте в самой квартире, потому что состояние подъездов, подвалов и чердаков нас не интересует. Также надо ещё учесть то, что текст написан на естественном языке со всеми присущими ему ошибками, опечатками, сокращениями и прочими особенностями — лично я нашла три варианта написания слов “линолеум” и “ламинат” и пять вариантов написания слова “предчистовая”; некоторые люди не понимают, зачем нужны пробелы между словами, а другие не слышали про запятые. Поэтому самым простым и разумным решением стал парсер с контекстно свободными грамматиками.
Таким образом, по мере решения сформировалась вторая большая и интересная задача — научиться извлекать всю достаточную и необходимую информацию о ремонте из объявления, а именно обеспечить быстрый синтаксический и морфологический анализ текста, который сможет работать параллельно под нагрузкой в режиме библиотеки.
Переходим к решению
Из доступных средств для извлечения фактов из текста на основе контекстно-свободных грамматик, способных работать с русским языком, наше внимание привлекли Томита-парсер и библиотека 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. Основная сложность касалась первой части. Сама Томита изначально проектировалась для исполнения в отдельном процессе. Отсюда вытекало, что основными преградами на пути использования парсера в процессе приложения выступали два фактора.
- Обмен данных осуществлялся через разного рода IO. Требовалось реализовать возможность обмена данными с парсером через память. Причем сделать это было необходимо таким образом, чтобы минимально затронуть код самого парсера. Архитектура Томиты подсказала способ реализации чтения входных документов с памяти как реализацию интерфейсов CDocStreamBase и CDocListRetrieverBase. С выходными данными было сложнее — пришлось затронуть код xml-генератора.
- Второй фактор, вытекающий из принципа «один парсер — один процесс», — глобальное состояние, модифицируемое из разных инстансов парсера. Если заглянуть в файл 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.
Весь код Томиты, написанный нами в рамках этого проекта выложен на гитхаб. Надеюсь, это пригодится кому-нибудь, и этот человек сэкономит немного времени на что-нибудь ещё более полезное.