В этой статье речь пойдет о том, как мы интегрировали разработанный Яндексом Томита-парсер в нашу систему, превратили его в динамическую библиотеку, подружили с 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.


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

Комментарии (15)


  1. sshikov
    13.02.2019 14:34

    А что, Томита сильно лучше имеющихся Java решений, или вы просто такие не нашли?


    1. KarinaErzina Автор
      13.02.2019 15:40

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


      1. Mishiko
        15.02.2019 11:36

        А Lucene с поддержкой руской морфологии (RussianAnalyzer) не пробовали? И, если пробовали, то что не устроило?


    1. alatushkin
      13.02.2019 18:42

      Приходилось решать похожую задачу. Ничего готового для русского языка не нашли (пришлось на котлине делать свой dsl для граматик + парсер текста).
      Если вам известны решения для русского языка на jvm — поделитесь многим будет полезно


      1. eaa
        13.02.2019 19:09

        А насколько универсальное решение получилось? Оно доступно для использования или закрытое?


        1. alatushkin
          14.02.2019 13:56

          Для наших целей — достаточно универсальное. В паблик так и не вышло (и не уверен что выйдет) т.к. подчистить и «упаковать» тоже требует усилий


      1. sshikov
        13.02.2019 19:37

        Я могу навскидку назвать два решения — Spark NLP, и порт pymorphy2. По-моему русский также поддерживается и в Apache OpenNLP.

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


  1. rgs350
    14.02.2019 13:06

    Всегда интересовало как подобные парсеры относятся к объявлениям вида:

    Продается прекрасная квартира. Евроремонт, встроенная кухня, кафель и плитка, импортная французская сантехника, встроенные зеркальные шкафы-купе, стеклопакеты — отсутствуют.

    Просто КМК десять неполных объявлений принесут репутации меньше вреда, чем одно ложное.


    1. eaa
      14.02.2019 22:04

      А Вы в рельности такие объявления встречали или это чисто умозрительный пример? Люди продают и стараются адекватно (насколько умеют) описать ситуацию, а не постебаться. Хотя такого типа бред попадается: «Продам лужу. Большого размера. Много людей поместиться может. Очищенная, фильтрованная. Всей семьей проверяли! Гарантия возврата 15 минут. В комплекте с лужей идет: 1)Бесплатная вода (первые 4 дня, потому что замерзнет скоро), 2)Жена (беременная), 3)Сосед-вейпер+жижа в подарок, 4)Мусорный бак. Лужа просвященная священником, но есть сосед сектант, так что из лужи выходить не стоит. По этому и снизили цену до 500000.»
      Но таких мало, так что особого вреда они вряд ли нанесут.


      1. rgs350
        15.02.2019 13:45
        -1

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

        1. Без автоматизации.
        — Баба Зина читает объявление.
        — Делает экспертную оценку (в уме) и принимает решение: «Можно увеличить цену на N тыс.»

        2. С автоматизацией.
        — Баба Зина читает объявление (она не может на 100% доверять парсеру).
        — Смотрит на результат работы парсера и понимает что он не совсем точен.
        — Вносит коррективы.
        — Подтверждает изменения.

        Кому как, но, по моему, первый способ предпочтительней.


        1. eaa
          15.02.2019 18:29

          Баба Зина может прочитать одно объявление, ну 100, но несколько миллионов даже 50 баб Зин не осилят.

          Кстати, пробовали одно и то же объявление давать трем разным «бабам Зинам». Представте себе, они дали 3 разных ответа по классификации ремонта. Вопрос: какой из трех верить?


          1. rgs350
            15.02.2019 19:17

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

            Баба Зина может прочитать одно объявление, ну 100, но несколько миллионов даже 50 баб Зин не осилят.

            Куда эффективней определить формальный параметры, сделать их в виде списка/чекбоксов и стимулировать пользователей самостоятельно выбирать нужные критерии.

            Кстати, пробовали одно и то же объявление давать трем разным «бабам Зинам».

            Ну она же не случайный человек (погрешность, конечно, будет, но она будет меньше, чем у парсера).


            1. KarinaErzina Автор
              15.02.2019 20:36

              Давали классифицировать специально обученным людям и сравнивали с парсером. Сами удивились, что парсер оказался более объективным в большинстве случаев.


          1. rgs350
            15.02.2019 20:13

            Дополнение к комментарию выше:

            Куда эффективней определить формальный параметры, сделать их в виде списка/чекбоксов и стимулировать пользователей самостоятельно выбирать нужные критерии.

            А вот здесь, кстати, ваше решение может быть полезным. Что-то вроде этого:
            Пользователь пишет объявление -> элементы списка/чекбоксы заполняются.


  1. Mishiko
    15.02.2019 13:10

    Пишут про то что у Tomita «особенная» лицензия из-за использования в продукте библиотеки MyStem, у которой в лицензии Яндекс написал:

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

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


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