Анализ и обработка текстов на естественном языке является постоянно актуальной задачей, которая решалась, решается и будет решаться всеми доступными способами. На сегодня хотелось бы поговорить о средствах решения для решения этой задачи, именно, на языке Julia. Безусловно, в виду молодости языка, здесь нет столь развитых средств анализа, как, например Stanford CoreNLP, Apache OpenNLP, GATE и пр., как, например, для языка Java. Однако, даже уже разработанные библиотеки, вполне могут использоваться как для решения типовых задач, так и быть рекомендованными в качестве точки входа для студентов, которым интересна область обработки текстов. А синтаксическая простота Julia и её развитые математические средства, позволяют с лёгкостью погрузиться в задачи кластеризации и классификации текстов. Целью данной статьи является обзор средств обработки текстов на языке Julia с небольшими пояснениями об их использовании. Будем балансировать между кратким перечислением возможностей для тех, кто в теме NLP, но хотел бы увидеть именно средства Julia, и более подробными пояснениями и примерами применения для тех, кто решил впервые погрузиться в область NLP (Natural Language Processing) как таковую. Ну а сейчас, перейдём к обзору пакетов. Пакет TextAnalysis.jl является базовой библиотекой, реализующей минимальный набор типовых функций обработки текста. Именно с неё и начнём. Примеры частично взяты из документации. Базовой сущностью является документ.  Поддерживаются следующие типы: Или короткий вариант: Документ, также, можно создать просто при помощи обобщенного конструктора Document, а библиотека найдёт соответствующую реализацию документа. Как видим, тело документа состоит из текста/токенов и метаданных. Текст документа можно получить при помощи метода text(...): В примере продемонстрирован документ с автоматически разобранными токенами. Видим, что вызов text(td) выдал предупреждение о том, что текст лишь примерно восстановлен, поскольку TokenDocument не хранит разделители слов. Вызов же tokens(td) позволил получить именно выделенные слова. У документа можно запросить метаданные: И все они могут быть изменены соответствующими функциями. Нотация модифицирующих функций у Julia такая же как и у языка Ruby. Функция, которая модифицирует объект, имеет суффикс !: Julia поддерживает кодировку UTF-8 при обработке строк, поэтому проблем с ипользованием не латинских алфавитов у неё нет. Любые варианты по-символьной обработки, естественно, доступны. Однако надо помнить о том, индексы строки для Julia — это именно байты, а не символы. А каждый символ может быть представлен разным количеством байт. И для работы с UNICODE-символами есть отдельные методы. Подробнее см. Unicode-and-UTF-8. Но здесь рассмотрим простой пример. Зададим строку с математическими UNICODE-символами, отделёнными от x и y пробелами: Теперь обратимся по индексам: В примере хорошо видно, что индекс 1 позволил получить символ ∀. А вот все последующие индексы до 3 включительно, привели к ошибке. И только 4-й индекс выдал пробел, как следующий символ в строке. Впрочем, для определения границ символов по индексам в строке, есть полезные функции prevind (previous index), nextind (next index) и thisind (this index). Например для найденного выше пробела, спросим, где граница предыдущего: Получили индекс 1 как начало символа ∀. Проверили индекс 3 и получили тот же допустимый 1. Если же нам необходимо «пробежаться» по всем символам, то сделать это можно как минимум двумя простыми способами:
1) с использованием конструкции: 2) с использованием перечислителя eachindex: Если текст документа был получен из какого-то внешнего представления, то, вполне возможно, что в байтовом потоке могли быть ошибки кодировки. Для их устранения используется функция remove_corrupt_utf8!(sd). Аргументом является документ, рассмотренный выше. Основной функцией для обработки документов в пакете TextAnalysis является prepare!(...). Например, удалим знаки препинания из текста: Также, полезным этапом при обработке текстов является преобразование всех букв в нижний регистр, поскольку это упрощает дальнейшее сравнение слов между собой. При этом, общем случае, надо понимать, что мы можем потерять важную информацию о тексте, например факт того, что слово является именем собственным или слово является границей предложения. Но всё это зависит от модели дальнейшей обработки. Перевод в нижний регистр делает функция remove_case!(). Попутно можем удалить мусорные слова, то есть те, которые не несут пользы при информационном поиске и анализе на совпадения. Это можно сделать явно при помощи функции remove_words!(…) и массива этих стоп-слов. Среди слов, подлежащих удалению, есть ещё и артикли, предлоги, местоимения, числа и просто стоп-слова, которые по частоте встречаемости являются паразитными. Для каждого конкретного языка эти словари индивидуальны. И они задаются в пакете Languages.jl Числа же нам мешают потому, что в будущей модели терм-документ, они могут очень сильно увеличить размерность матрицы, никак не улучшая, например, кластеризацию текстов. Однако, в задачах поиска, например, уже не всегда можно отбрасывать числа. Среди доступных методов очистки есть следующие варианты: Опции можно комбинировать. Например, за один вызов prepare! одновременно удалить артикли, числа и теги html — prepare!(sd, strip_articles| strip_numbers| strip_html_tags) Еще один вид обработки — выделение основы слов, удаляя окончания и суффиксы. Это позволяет объединить разные словоформы и резко сократить размерность модели представления документа. Для этого необходимы словари, поэтому язык документов должен быть чётко указан. Пример обработки на русском языке: Под корпусом понимается совокупность документов, которые будут обрабатываться по одинаковым правилам. Пакет TextAnalysis реализует формирование матрицы терм-документ. А для её построения, нам необходимо сразу иметь полный набор документов. В простом примере для документов:
D1 = "I like databases"
D2 = "I hate databases" эта матрица выглядит как: Столбцы представлены словами документов, а строки — идентификаторами (или индексами) документов. Соответственно, в ячейке будет 0, если слово (терм) не встречается в документе. И 1, если встречается сколько угодно раз. Более сложные модели учитывают как частоту встречаемости (модель TF), так и значимость по отношению к ко всему корпусу (TF-IDF). Корпус мы можем построить при помощи конструктора Corpus(): Если запросим список термов сразу, то получим: А, вот, заставив библиотеку пересчитать все термы, входящие в состав корпуса при помощи update_lexicon!(crps), мы получим другой результат: То есть, мы можем видеть выделенные термы (слова и числа) и их количество вхождений в корпус документов.  При этом, можем уточнить частоту терма, например, «Document»: Также, можем построить обратный индекс, то есть, для каждого тема получить номера документов в корпусе. Этот индекс используется в информационном поиске, когда по списку термов необходимо найти список документов, где они встречаются: Для корпуса в целом можно применить функции предобработки, такие же как и для каждого отдельного документа. Используется другой метод функции prepare!, рассмотренной ранее. Здесь первым аргументом передаётся корпус. Также как и по отдельным документам, можно запросить метаданные для всего корпуса. Установить значения можно одинаковые для всего корпуса сразу или индивидуальные для конкретных документов, передав массив с поэлементными значениями для них. Выделение признаков — один из базовых этапов машинного обучения. Это не относится напрямую к теме данной статьи, но в документации к пакету TextAnalysis довольно большой раздел посвящен выделению признаков именно в такой формулировке. В этот раздел отнесены как, собственно, построение матрицы терм-документ, так и множество других методов. https://juliatext.github.io/TextAnalysis.jl/dev/features/ Коротко рассмотрим предлагаемые варианты. Базовая модель представления документов — это модель, где для каждого документа хранится набор слов. Причём позиции их не важны. Поэтому в англоязычной литераторе, этот вариант называется Bag of words. Для каждого слова важен лишь факт его наличия в документе, частота встречаемости (TF — Term Frequency) или модель, учитывающая частоту встречаемости терма в корпусе в целом (TF-IDF — Term Frequency — Inverse Document Frequency). Возьмём простейший пример с тремя документами, содержащими термы Document, 1, 2, 3. Предобработку использовать не будем. Но построим полный лексикон и матрицу терм-документ: Переменная m имеет значение с типом DocumentTermMatrix. В распечатанном результате видим, что размерность составляет 3 документа на 4 терма, куда вошли и слово Document, и числа 1, 2, 3. Для дальнейшего использования модели нам нужна матрица в традиционном представлении. Можем получить её при помощи метода dtm(): Этот вариант представлен типом SparseMatrixCSC, который экономичен в представлении сильно разреженной матрицы, но существует лишь ограниченное количество библиотек, его поддерживающих. Проблема размера матрицы терм-документ обусловлена тем, что количество термов очень быстро растёт с количеством обрабатываемых документов. Если не проводить никакую предобработку документов, то в эту матрицу будут попадать абсолютно все слова со всеми своими словоформами, числа, даты. Даже если количество словоформ уменьшено за счёт приведения к основной форме, количество оставшихся основ будет порядка тысяч — десятков тысяч. То есть, полная размерность матрицы терм-документ определяется полным произведением этого количества на количество обработанных документов. Полная матрица требует хранения не только единиц, но и нулей, однако она проще в использовании, чем SparseMatrixCSC. Получить её можно другим методом dtm(..., :dense) или же при помощи преобразования разреженной матрицы в полную методом collect(): Если распечатать массив термов, то в каждой строке легко увидеть исходный состав документов (исходный порядок термов не учитывается). Матрицу терм-документ для частотных моделей, можем получить при помощи методов tf() и tf_idf(): Легко увидеть значимость термов для каждого из документов. Два первых документа содержат по два терма. Последний — три. Значит их вес уменьшен. И для TF-IDF и метода tf_idf(): А в этой модели легко увидеть, что терм Document, который встречается во всех документах, имеет значимость 0. А вот терм 3 в третьем документе, приобрёл больший вес, чем 1 в том же документе, поскольку 1 встречается, также, и в первом документе. Полученные матрицы очень легко использовать, например, для решения задачи кластеризации документов. Для этого понадобится пакет Clustering. Используем простейший алгоритм кластеризации k-means, которому необходимо указать количество желаемых кластеров. Разобьем наши три документа на два кластера. Входной матрицей для kmeans является матрица с признаками, где строки представляют признаки, а столбцы — образцы. Поэтому выше полученные матрицы терм-документ надо транспонировать. В итоге, видим, что первый кластер содержит один документ, кластер номер 2 содержит два документа. Причем, матрица, содержащая центры кластеров R.centers, отчётливо показывает, что первый столбец «притянут» термом 2. Второй столбец определяется наличием термов 1 и 3. Пакет Clustering.jl содержит типовой набор алгоритмов кластеризации, среди них: K-means, K-medoids, Affinity Propagation, Density-based spatial clustering of applications with noise (DBSCAN), Markov Clustering Algorithm (MCL), Fuzzy C-Means Clustering, Hierarchical Clustering (Single, Average, Complete, Ward's Linkage). Но анализ их применимости выходит за рамки этой статьи. Пакет TextAnalysis.jl, в настоящее время, находится в активной разработке, поэтому часть функций будет доступна только при установке пакета напрямую из git-репозитория. Сделать это не сложно, но советовать это можно только тем, кто не планирует внедрять решение в эксплуатацию в ближайшее время: Однако, игнорировать эти функции в обзоре не стоит. Поэтому рассмотрим и их тоже.  Одно из улучшений — использование функции ранжирования Okapi BM25. По аналогии с предыдущими моделями tf. tf_idf, используем метод bm_25(m). Использование полученной матрицы аналогично предыдущим случаям. Анализ тональности текстов можно сделать при помощи методов: Причём, doc – это один из выше рассмотренных типов документов. handle_unknown – функция обработки неизвестных слов. Анализ тональности реализован при помощи пакета Flux.jl на основе корпуса IMDB. Возвращаемое значение находится в диапазоне от 0 до 1. Обобщение документа можно реализовать при помощи метода summarize(d, ns). Первым аргументом является документ. Вторым – ns= количество предложений в итоге. Весьма важным компонентом любой библиотеки анализа текстов является находящийся сейчас в разработке синтаксический парсер, выделяющий части речи — POS (part of speech). Есть несколько вариантов его использования. Подробнее см в разделе Parts of Speech Tagging
. Tagging оно называется потому, что для каждого слова в исходном тексте, формируется тег, означающий часть речи. В разработке находится два варианта реализации. Первый — Average Perceptron Algorithm. Второй основан на использовании архитектуры нейросетей LSTMs, CNN и методе CRF. Приведём пример простой разметки предложения. Список аббревиатур, означающих часть речи, взят из Penn Treebank. В частности, DT — Determiner, NN — Noun, singular or mass, VBZ — Verb, 3rd person singular present, Verb, past participle, IN — Preposition or subordinating conjunction, NNP — Proper noun, singular. Результаты этой разметки могут также быть использованы как дополнительные признаки для классификации документов. TextAnalysis предоставляет два варианта снижения размерности за счёт определения зависимых термов. Это латентный семантический анализ — LSA и латентное размещение Дирихле — LDA.  Основная задача LSA — получить разложение матрицы терм-документ (используется TF-IDF) на 3 матрицы, произведение которых примерно соответствует исходной.  В примере можем видеть, что в матрице терм-документ, модель TF-IDF выделила статистически значимые термы весом. В разложении же SVD, эти коэффициенты явно выделены позиционно, и представляют собой линейно независимые компоненты. Метод LDA может также быть использован для определения отношения документов к определённой тематике. Пример: Параметр k в вызове метода lda определяет количество тем, для которых рассчитываются вероятности отнесения термов и документов. Результатом являются матрицы ϕ и θ, первая из которых имеет размерность ntopics × nwords и показывает связи термов с темами, вторая — ntopics × ndocs и показывает связи документов с темами. Раздел классификации в настоящее время содержит только один готовый классификатор — Наивный байесовский классификатор. И его очень просто подключить в своём проекте. Для использования классификатора нам нужна модель со словарём и классами, к которым следует относить образцы. Модель можно создать при помощи конструктора NaiveBayesClassifier(). Обучение же модели — при помощи метода fit!(): Результат обучения можем проверить с помощь predict: Видим, что наиболее вероятно то, что проверяемый текст относится к классу :legal. Указанный классификатор входит в состав TextAnalysis.jl как один из самых простых классификаторов. Однако, список доступных для использования алгоритмов им не ограничивается. Некоторые реализованные классификаторы доступны в пакете MLJ.jl. Среди них AdaBoostClassifier, BaggingClassifier, BernoulliNBClassifier, ComplementNBClassifier, ConstantClassifier, XGBoostClassifier, DecisionTreeClassifier. Используя выше упомянутые модели терм-документ или результат их преобразования LSA, можно выполнить классификацию с помощью и этих методов тоже. Или реализовать свои собственные методы. В составе TextAnalysis.jl также декларируется классификатор CRF — Conditional Random Fields, реализованный на базе библиотеки для создания нейросетей Flux.jl, однако описание приводится довольно поверхностное. Поэтому рассматривать его здесь мы не будем. В составе рабочей ветки TextAnalysis.jl декларируется наличие средства распознавания именованных сущностей — NER. Посредством NERTagger() для каждого слова назначается метка отнесения к одному из классов сущностей: Пример использования: NERTagger может быть применён к различным документам TextAnalysis. А его выход может быть также использован для формирования признаков классификации документов. Полный анализ документов не всегда нужен. Иногда необходимо решать вполне прикладные задачи определения похожести строк, например, выявляя опечатки. Или неполные названия. Для сравнения строк между собой, удобно использовать пакет StringDistances.jl. Его использование очень простое: Возвращаемое методом compare значение — это близость строк. Соответственно, 1 — полное совпадение. 0 — отсутствие совпадения. Из распространённых в настоящее время метрик, часто используется Jaro-Winkler. Она является относительно быстрой, но точность этой метрики по современным меркам не велика. Метрика RatcliffObershelp, например, даёт большую точность определения похожести длинных строк с несколькими словами. Кроме того, она может использоваться в комбинации с предварительной обработкой. Например сортировкой токенов. В тех случаях, когда необходимо выполнять сравнение большого количества строк, следует предварительно сохранить результат предобработки. Например, вместо вызова TokenSort каждый раз, просто выполнить это однократно и хранить промежуточные результаты сортироки. Одно из достоинств Julia в данном случае — библиотека полностью реализована на Julia, следовательно код может быть легко использован для оптимизации или использования его частей в своей реализации. Пакет WordTokenizers.jl является одним из базовых пакетов для обработки текстов. И он, также, используется внутри TextAnalysis.jl. Основное назначение этого пакета — разбить исходный текст на токены. Поэтому, ключевым методом является tokenize(text). Второй функцией пакета WordTokenizers является разбивка текста на предложения. Доступны различные алгоритмы разбора: Выбор алгоритма токенизации осуществляется вызовом метода set_tokenizer(nltk_word_tokenize) Пакет Embeddings.jl реализует алгоритмы векторизации текстов в некоторые векторные пространства. Главное достоинство этих многомерных пространств заключается в том, что операции сложения или вычитания векторов, представляющих определённые слова, приводят к тому, что результат становится близок к другим словам, близких по контексту в корпусе текстов, на котором проводилось обеспечение. Одним из первых широко известных алгоритмов подобной векторизации был Word2Vec. Типичный пример операций над векторами, связанными со словами: king - man + woman = queen. В зависимости от того, какой набор данных используется, может настраиваться разная размерность этих пространств. Например, существуют наборы данных, обученные на новостях, на статьях Wikipedia, на только одном языке или на нескольких языках сразу. В результате, размерность пространства может меняться в широком диапазоне от сотен до тысяч измерений. В англоязычной литераторе эти пространства имеют название «semantic space», а расстояние между векторами, часто называется «semantic distance». Поскольку пространства эти построены исключительно на основе статистических принципов, а близость определяется контекстом слов в наборе, на котором проведено обучение, то использовать слова «смысл» и «семантика» в русском переводе мы не можем. Здесь надо понимать то, что поскольку речь не идёт о настоящей семантике в философском или лингвистическом смысле, то и ошибки определения точки в таком пространстве сильно зависят от разницы контекста, на котором проведено обучение и контекста использования программы. Главное достоинство методов векторизации, относимых к категории «embedding» является то, что любое слово, любое предложение или текст, можно представить как один вектор с фиксированной размерностью. Как результат, если необходимо решать задачу сопоставления документов, включая задачи кластеризации и классификации, сделать это можно гораздо быстрее, чем, например, на основе обратных индексов. Или точнее, чем в случае матрицы терм-документ, поскольку в ней никак не используется контекст слов. Отдельным вопросом является то, какая мера расстояния должна быть использована в каждом конкретном векторном пространстве. Обычно, используют косинусное расстояние. Но это зависит от свойств выбранного векторного пространства. Embeddings.jl предоставляет следующие методы векторизации: Word2Vec, GloVe (English only), FastText. Последний вариант предобучен для большого количества документов. Собственно, для любого метода векторизации можно переобучить модель на своём наборе данных, но время переобучения может быть очень большим. Один из недостатков большинства подобных методов векторизации — большой объём данных, включая словарь термов и представление векторов для каждого из них. Для использование, например, word2vec, просто необходимо иметь 8-16 ГБ оперативной памяти. В противном случае, матрица с векторами может просто не поместиться. Упомянем, также, пакет DataDeps.jl. Он используется для автоматизации загрузки больших наборов данных в тот момент, когда они потребовались ("ленивая загрузка данных"). Если запустить программу, использующую Embedding.jl в первый раз, то в текстовой консоли появится запрос о том, загружать ли или нет. Поскольку произойти это может и на сервере в фоновом процессе, то предусмотрена возможность автоматической загрузки зависимостей через установку переменной окружения. Единственное требование — наличие достаточного дискового пространства для сохранения скачиваемых файлов. Загруженные зависимости помещаются в директорию ~/.julia/datadeps по именам набора данных. Примеры использования. Первый этап — загрузка данных для необходимого метода векторизации: Второй этап — векторизация каждого отдельного слова или фразы: Слова могут быть получены токенизатором WordTokenizers или при помощи TextAnalysis, рассмотренных ранее. Сложить векторы можно при помощи типовых операций, уже встроенных в Julia: Алгоритмы кластеризации предоставляются пакетом Clustering.jl. Методы машинного обучения, включая методы классификации — см. пакет MLJ.jl. При желании же самостоятельно реализовать алгоритмы кластеризации, рекомендуем обратить внимание на пакет https://github.com/JuliaStats/Distances.jl, предоставляющий огромный набор алгоритмов вычисления векторного расстояния: Эти меры расстояния могут быть использованы при выполнении кластеризации или классификации. Transformers.jl — это чистая реализация на языке Julia архитектуры «Transformers», на основе которой разработана нейросеть BERT компании Google. Надо отметить, что именно эта нейросеть и её модификации стали сейчас весьма популярны для решения задачи NER — разметки именованных сущностей, а также для векторизации слов. Transformers.jl использует пакет Flux.jl, который, в свою очередь, является не только чистой Julia-библиотекой для реализации нейронных сетей, но и одной из самых быстрых реализаций для выполнения подобных операций. Flux.jl легко переключается с CPU на GPU, но это, скорее, общее требование для библиотек, реализующих функции нейронных сетей. В данной статье не будем вдаваться в особенности BERT или нейросетей как таковых, поскольку это уже было многократно сделано. Ограничимся примером кода для векторизации текстов: Определённый здесь метод vectorize будем использовать для векторизации текстов. Ну и его использование выглядит следующим образом: В примере выше, wordpiece, tokenizer — это преобученные модели нейросети. В обозначении 12 — количество слоёв. 768 — размерность векторного пространства. Список основных моделей см. https://chengchingwen.github.io/Transformers.jl/dev/pretrain/. В коде выше, макрос Transformers.Pretrain.@pretrain_str, который использован в форме pretrain"model-description:item" является лишь короткой формой кода загрузки конкретных моделей. Ну и в завершение этого раздела, хотелось бы в очередной раз отметить, что Transformers.jl позволяет выполнить векторизацию текста, а дальнейшее использование этих векторов полностью зависит от решаемых задач. Не стоит, пока что, ждать от Julia ничего принципиально нового. Прошел всего год с момента выпуска первой стабильной версии языка. Однако, видно, что интерес к языку и технологии в целом увеличивается. Если год назад программистов, имеющих опыт на Julia почти не было, то теперь они есть. И, уже не надо бояться начинать новые проекты именно на Julia. Конечно, если у вас уже есть сложившаяся команда, есть заказчик, который хочет получить какой-то конкретный продукт «вчера», не стоит бросаться на новые технологии. В этом случае, очевидно, всё будет сведено к поиску ближайшего подходящего «open source» продукта или, просто, покупка готового на стороне, но не погружение в новую технологию. Если же речь идёт о долгосрочном развитии продукта, особенно с большой долей собственных интеллектуальных разработок, Julia становится очень заманчивым вариантом. С одной стороны, интерактивные инструменты для исследователей типа Jupyter Notebook для быстрых набросков, как и в интеграционных языках программирования, с другой — среды интегрированной разработки типа Atom/Juno, VS Code, развитые средства разработки пакетов и управления их зависимостями. И, главное, чем хороша Julia — она позволяет не прыгать на 2-3 языка программирования при разработке, что типично для интеграционных языков программирования (то есть, пригодные лишь для связывания частей программы, написанных на быстрых языках программирования), где без С или C++ не обходится ни один серьёзный проект. Если же говорим о научных исследованиях, связанных с анализом и обработкой данных, то современных альтернатив Julia, в общем-то, и не видно. В тех исследованиях, где интеграционные языки программирования пытаются использовать как основной инструмент, программный код, как правило, заканчивает свой жизненный цикл с последней опубликованной статьёй. Когда исследователь пытается достичь пригодной для промышленного использования производительности, он вынужден становиться тем самым программистом на 2-3 языках вместо того, чтобы просто сконцентрироваться на своей предметной области и алгоритмах, используя лишь один основной инструмент. Julia же лишена этой главной проблемы интеграционных языков. Даже не зная готовую библиотеку или нужную функцию для какого-нибудь нового метода обработки матрицы, не будет особой проблемой просто написать цикл for и выполнить эту обработку в основном коде. Для интеграционных же языков — это «хождение по минному полю». Нет готовой функции, написанной на C, значит забудь о производительности. В Julia, в худшем случае, потеряете лишь какой-то процент производительности, поскольку и основной код программы, и код библиотек — это Julia-код. То есть, Julia — это не только простой для освоения и удобный в использовании современный язык программирования, но и экономия времени и сил при разработке принципиально новых алгоритмов и методов. Подводя итог, надеюсь, что идеи доступности средства анализа текстов и простоты их использования в Julia удалось донести. Предложения, замечания, а также новые статьи на эту тему приветствуются. Дополнительно хотелось бы отметить, что существует телеграм-канал русскоязычного сообщества Julia — @JuliaLanguage, где также можно задать вопросы или обсудить технологические вопросы.