Хабрахабр

Julia NLP. Обрабатываем тексты

На сегодня хотелось бы поговорить о средствах решения для решения этой задачи, именно, на языке Julia. Анализ и обработка текстов на естественном языке является постоянно актуальной задачей, которая решалась, решается и будет решаться всеми доступными способами. Однако, даже уже разработанные библиотеки, вполне могут использоваться как для решения типовых задач, так и быть рекомендованными в качестве точки входа для студентов, которым интересна область обработки текстов. Безусловно, в виду молодости языка, здесь нет столь развитых средств анализа, как, например Stanford CoreNLP, Apache OpenNLP, GATE и пр., как, например, для языка Java. А синтаксическая простота Julia и её развитые математические средства, позволяют с лёгкостью погрузиться в задачи кластеризации и классификации текстов.

Будем балансировать между кратким перечислением возможностей для тех, кто в теме NLP, но хотел бы увидеть именно средства Julia, и более подробными пояснениями и примерами применения для тех, кто решил впервые погрузиться в область NLP (Natural Language Processing) как таковую. Целью данной статьи является обзор средств обработки текстов на языке Julia с небольшими пояснениями об их использовании.

Ну а сейчас, перейдём к обзору пакетов.

TextAnalysis.jl

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

Документ

Базовой сущностью является документ.

Поддерживаются следующие типы:

  • FileDocument — документ, представленный простым текстовым файлом на диске

julia> pathname = "/usr/share/dict/words" "/usr/share/dict/words" julia> fd = FileDocument(pathname)
A FileDocument * Language: Languages.English() * Title: /usr/share/dict/words * Author: Unknown Author * Timestamp: Unknown Time * Snippet: A A's AMD AMD's AOL AOL's Aachen Aachen's Aaliyah

  • StringDocument — документ, представленный UTF-8-строкой и хранимый в оперативной памяти. Структура StringDocument обеспечивает хранение текста в целом.

julia> str = "To be or not to be..." "To be or not to be..." julia> sd = StringDocument(str)
A StringDocument * Language: Languages.English() * Title: Untitled Document * Author: Unknown Author * Timestamp: Unknown Time * Snippet: To be or not to be...

  • TokenDocument — документ, представляющий собой последовательность UTF-8-токенов (выделенных слов). Структура TokenDocument хранит набор токенов, однако полный текст не может быть восстановлен без потерь.

julia> my_tokens = String["To", "be", "or", "not", "to", "be..."]
6-element Array{String,1}: "To" "be" "or" "not" "to" "be..." julia> td = TokenDocument(my_tokens)
A TokenDocument{String} * Language: Languages.English() * Title: Untitled Document * Author: Unknown Author * Timestamp: Unknown Time * Snippet: ***SAMPLE TEXT NOT AVAILABLE***

  • NGramDocument — документ, представленный как набор n-грамм в UTF8 представлении, то есть последовательности по n UTF-8 символов, и счётчик их вхождения. Этот вариант представления документа является одним из простейших способов избежать некоторых проблемах морфологии языков, опечаток и особенностей языковых конструкций в анализируемых текстах. Впрочем, плата за это — снижение качества анализа текста по сравнению с методами, где информация о языке учитывается.

julia> my_ngrams = Dict{String, Int}("To" => 1, "be" => 2, "or" => 1, "not" => 1, "to" => 1, "be..." => 1)
Dict{String,Int64} with 6 entries: "or" => 1 "be..." => 1 "not" => 1 "to" => 1 "To" => 1 "be" => 2 julia> ngd = NGramDocument(my_ngrams)
A NGramDocument{AbstractString} * Language: Languages.English() * Title: Untitled Document * Author: Unknown Author * Timestamp: Unknown Time * Snippet: ***SAMPLE TEXT NOT AVAILABLE***

Или короткий вариант:

julia> str = "To be or not to be..." "To be or not to be..."
julia> ngd = NGramDocument(str, 2)
NGramDocument{AbstractString}(Dict{AbstractString,Int64}("To be" => 1,"or not" => 1,"be or" => 1,"or" => 1,"not to" => 1,"not" => 1,"to be" => 1,"to" => 1,"To" => 1,"be" => 2…), 2,
TextAnalysis.DocumentMetadata(
Languages.English(), "Untitled Document", "Unknown Author", "Unknown Time"))

Документ, также, можно создать просто при помощи обобщенного конструктора Document, а библиотека найдёт соответствующую реализацию документа.

julia> Document("To be or not to be...")
A StringDocument{String} * Language: Languages.English() * Title: Untitled Document * Author: Unknown Author * Timestamp: Unknown Time * Snippet: To be or not to be...
julia> Document("/usr/share/dict/words")
A FileDocument * Language: Languages.English() * Title: /usr/share/dict/words * Author: Unknown Author * Timestamp: Unknown Time * Snippet: A A's AMD AMD's AOL AOL's Aachen Aachen's Aaliyah julia> Document(String["To", "be", "or", "not", "to", "be..."])
A TokenDocument{String} * Language: Languages.English() * Title: Untitled Document * Author: Unknown Author * Timestamp: Unknown Time * Snippet: ***SAMPLE TEXT NOT AVAILABLE*** julia> Document(Dict{String, Int}("a" => 1, "b" => 3))
A NGramDocument{AbstractString} * Language: Languages.English() * Title: Untitled Document * Author: Unknown Author * Timestamp: Unknown Time * Snippet: ***SAMPLE TEXT NOT AVAILABLE***

Текст документа можно получить при помощи метода text(...): Как видим, тело документа состоит из текста/токенов и метаданных.

julia> td = TokenDocument("To be or not to be...")
TokenDocument{String}(["To", "be", "or", "not", "to", "be"],
TextAnalysis.DocumentMetadata(
Languages.English(), "Untitled Document", "Unknown Author", "Unknown Time")) julia> text(td)
┌ Warning: TokenDocument's can only approximate the original text
└ @ TextAnalysis ~/.julia/packages/TextAnalysis/pcFQf/src/document.jl:111 "To be or not to be" julia> tokens(td)
6-element Array{String,1}: "To" "be" "or" "not" "to" "be"

Видим, что вызов text(td) выдал предупреждение о том, что текст лишь примерно восстановлен, поскольку TokenDocument не хранит разделители слов. В примере продемонстрирован документ с автоматически разобранными токенами. Вызов же tokens(td) позволил получить именно выделенные слова.

У документа можно запросить метаданные:

julia> StringDocument("This document has too foo words")
A StringDocument{String} * Language: Languages.English() * Title: Untitled Document * Author: Unknown Author * Timestamp: Unknown Time * Snippet: This document has too foo words julia> language(sd)
Languages.English() julia> title(sd) "Untitled Document" julia> author(sd) "Unknown Author" julia> timestamp(sd) "Unknown Time"

Нотация модифицирующих функций у Julia такая же как и у языка Ruby. И все они могут быть изменены соответствующими функциями. Функция, которая модифицирует объект, имеет суффикс !:

julia> using TextAnalysis.Languages
julia> language!(sd, Languages.Russian())
Languages.Russian () julia> title!(sd, "Документ") "Документ" julia> author!(sd, "Иванов И.И.") "Иванов И.И." julia> import Dates:now
julia> timestamp!(sd, string(now())) "2019-11-09T22:53:38.383"

Особенности строк с UTF-8

Любые варианты по-символьной обработки, естественно, доступны. Julia поддерживает кодировку UTF-8 при обработке строк, поэтому проблем с ипользованием не латинских алфавитов у неё нет. А каждый символ может быть представлен разным количеством байт. Однако надо помнить о том, индексы строки для Julia — это именно байты, а не символы. Подробнее см. И для работы с UNICODE-символами есть отдельные методы. Но здесь рассмотрим простой пример. Unicode-and-UTF-8. Зададим строку с математическими UNICODE-символами, отделёнными от x и y пробелами:

julia> s = "\u2200 x \u2203 y" "∀ x ∃ y"
julia> length(s) # символов!
7
julia> ncodeunits(s) # байт!
11

Теперь обратимся по индексам:

julia> s[1] '∀': Unicode U+2200 (category Sm: Symbol, math) julia> s[2]
ERROR: StringIndexError("∀ x ∃ y", 2)
[...] julia> s[3]
ERROR: StringIndexError("∀ x ∃ y", 3)
Stacktrace:
[...] julia> s[4] ' ': ASCII/Unicode U+0020 (category Zs: Separator, space)

А вот все последующие индексы до 3 включительно, привели к ошибке. В примере хорошо видно, что индекс 1 позволил получить символ . Впрочем, для определения границ символов по индексам в строке, есть полезные функции prevind (previous index), nextind (next index) и thisind (this index). И только 4-й индекс выдал пробел, как следующий символ в строке. Например для найденного выше пробела, спросим, где граница предыдущего:

julia> prevind(s, 4)
1

Получили индекс 1 как начало символа .

julia> thisind(s, 3)
1

Проверили индекс 3 и получили тот же допустимый 1.

Если же нам необходимо «пробежаться» по всем символам, то сделать это можно как минимум двумя простыми способами:
1) с использованием конструкции:

julia> for c in s print(c) end
∀ x ∃ y

2) с использованием перечислителя eachindex:

julia> collect(eachindex(s))
7-element Array{Int64,1}: 1 4 5 6 7 10 11
julia> for i in eachindex(s) print(s[i]) end
∀ x ∃ y

Предобработка документов

Для их устранения используется функция remove_corrupt_utf8!(sd). Если текст документа был получен из какого-то внешнего представления, то, вполне возможно, что в байтовом потоке могли быть ошибки кодировки. Аргументом является документ, рассмотренный выше.

Например, удалим знаки препинания из текста: Основной функцией для обработки документов в пакете TextAnalysis является prepare!(...).

julia> str = StringDocument("here are some punctuations !!!...") julia> prepare!(str, strip_punctuation) julia> text(str) "here are some punctuations "

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

julia> sd = StringDocument("Lear is mad")
A StringDocument{String} julia> remove_case!(sd) julia> text(sd) "lear is mad"

Это можно сделать явно при помощи функции remove_words!(…) и массива этих стоп-слов. Попутно можем удалить мусорные слова, то есть те, которые не несут пользы при информационном поиске и анализе на совпадения.

julia> remove_words!(sd, ["lear"]) julia> text(sd) " is mad"

Для каждого конкретного языка эти словари индивидуальны. Среди слов, подлежащих удалению, есть ещё и артикли, предлоги, местоимения, числа и просто стоп-слова, которые по частоте встречаемости являются паразитными. Однако, в задачах поиска, например, уже не всегда можно отбрасывать числа. И они задаются в пакете Languages.jl Числа же нам мешают потому, что в будущей модели терм-документ, они могут очень сильно увеличить размерность матрицы, никак не улучшая, например, кластеризацию текстов.

Среди доступных методов очистки есть следующие варианты:

  • prepare!(sd, strip_articles)
  • prepare!(sd, strip_indefinite_articles)
  • prepare!(sd, strip_definite_articles)
  • prepare!(sd, strip_preposition)
  • prepare!(sd, strip_pronouns)
  • prepare!(sd, strip_stopwords)
  • prepare!(sd, strip_numbers)
  • prepare!(sd, strip_non_letters)
  • prepare!(sd, strip_spares_terms)
  • prepare!(sd, strip_frequent_terms)
  • prepare!(sd, strip_html_tags)

Например, за один вызов prepare! одновременно удалить артикли, числа и теги html — prepare!(sd, strip_articles| strip_numbers| strip_html_tags) Опции можно комбинировать.

Это позволяет объединить разные словоформы и резко сократить размерность модели представления документа. Еще один вид обработки — выделение основы слов, удаляя окончания и суффиксы. Пример обработки на русском языке: Для этого необходимы словари, поэтому язык документов должен быть чётко указан.

julia> sd = StringDocument("мыши грызли сладкие сушки")
StringDocument{String}("мыши грызли сладкие сушки", TextAnalysis.DocumentMetadata(Languages.English(), "Untitled Document", "Unknown Author", "Unknown Time")) julia> language!(sd, Languages.Russian())
Languages.Russian() julia> stem!(sd) julia> text(sd) "мыш грызл сладк сушк"

Корпус документов

Пакет TextAnalysis реализует формирование матрицы терм-документ. Под корпусом понимается совокупность документов, которые будут обрабатываться по одинаковым правилам. В простом примере для документов:
D1 = "I like databases"
D2 = "I hate databases" А для её построения, нам необходимо сразу иметь полный набор документов.

эта матрица выглядит как:

Соответственно, в ячейке будет 0, если слово (терм) не встречается в документе. Столбцы представлены словами документов, а строки — идентификаторами (или индексами) документов. Более сложные модели учитывают как частоту встречаемости (модель TF), так и значимость по отношению к ко всему корпусу (TF-IDF). И 1, если встречается сколько угодно раз.

Корпус мы можем построить при помощи конструктора Corpus():

crps = Corpus([StringDocument("Document 1"), StringDocument("Document 2")])

Если запросим список термов сразу, то получим:

julia> lexicon(crps)
Dict{String,Int64} with 0 entries

А, вот, заставив библиотеку пересчитать все термы, входящие в состав корпуса при помощи update_lexicon!(crps), мы получим другой результат:

julia> update_lexicon!(crps) julia> lexicon(crps)
Dict{String,Int64} with 3 entries: "1" => 1 "2" => 1 "Document" => 2

То есть, мы можем видеть выделенные термы (слова и числа) и их количество вхождений в корпус документов.

При этом, можем уточнить частоту терма, например, «Document»:

julia> lexical_frequency(crps, "Document")
0.5

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

julia> update_inverse_index!(crps) julia> inverse_index(crps)
Dict{String,Array{Int64,1}} with 3 entries: "1" => [1] "2" => [2] "Document" => [1, 2]

Используется другой метод функции prepare!, рассмотренной ранее. Для корпуса в целом можно применить функции предобработки, такие же как и для каждого отдельного документа. Здесь первым аргументом передаётся корпус.

julia> crps = Corpus([StringDocument("Document ..!!"), StringDocument("Document ..!!")]) julia> prepare!(crps, strip_punctuation) julia> text(crps[1]) "Document " julia> text(crps[2]) "Document "

Также как и по отдельным документам, можно запросить метаданные для всего корпуса.

julia> crps = Corpus([StringDocument("Name Foo"), StringDocument("Name Bar")]) julia> languages(crps)
2-element Array{Languages.English,1}: Languages.English() Languages.English() julia> titles(crps)
2-element Array{String,1}: "Untitled Document" "Untitled Document" julia> authors(crps)
2-element Array{String,1}: "Unknown Author" "Unknown Author" julia> timestamps(crps)
2-element Array{String,1}: "Unknown Time" "Unknown Time"

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

julia> languages!(crps, Languages.German())
julia> titles!(crps, "")
julia> authors!(crps, "Me")
julia> timestamps!(crps, "Now")
julia> languages!(crps, [Languages.German(), Languages.English
julia> titles!(crps, ["", "Untitled"])
julia> authors!(crps, ["Ich", "You"])
julia> timestamps!(crps, ["Unbekannt", "2018"])

Выделение признаков

Это не относится напрямую к теме данной статьи, но в документации к пакету TextAnalysis довольно большой раздел посвящен выделению признаков именно в такой формулировке. Выделение признаков — один из базовых этапов машинного обучения. https://juliatext.github.io/TextAnalysis.jl/dev/features/ В этот раздел отнесены как, собственно, построение матрицы терм-документ, так и множество других методов.

Коротко рассмотрим предлагаемые варианты.

Причём позиции их не важны. Базовая модель представления документов — это модель, где для каждого документа хранится набор слов. Для каждого слова важен лишь факт его наличия в документе, частота встречаемости (TF — Term Frequency) или модель, учитывающая частоту встречаемости терма в корпусе в целом (TF-IDF — Term Frequency — Inverse Document Frequency). Поэтому в англоязычной литераторе, этот вариант называется Bag of words.

Возьмём простейший пример с тремя документами, содержащими термы Document, 1, 2, 3.

julia> using TextAnalysis julia> crps = Corpus([StringDocument("Document 1"), StringDocument("Document 2"), StringDocument("Document 1 3")])

Но построим полный лексикон и матрицу терм-документ: Предобработку использовать не будем.

julia> update_lexicon!(crps) julia> m = DocumentTermMatrix(crps)
DocumentTermMatrix( [1, 1] = 1 [3, 1] = 1 [2, 2] = 1 [3, 3] = 1 [1, 4] = 1 [2, 4] = 1 [3, 4] = 1, ["1", "2", "3", "Document"], Dict("1" => 1,"2" => 2,"Document" => 4,"3" => 3))

В распечатанном результате видим, что размерность составляет 3 документа на 4 терма, куда вошли и слово Document, и числа 1, 2, 3. Переменная m имеет значение с типом DocumentTermMatrix. Можем получить её при помощи метода dtm(): Для дальнейшего использования модели нам нужна матрица в традиционном представлении.

julia> dtm(m)
3×4 SparseArrays.SparseMatrixCSC{Int64,Int64} with 7 stored entries: [1, 1] = 1 [3, 1] = 1 [2, 2] = 1 [3, 3] = 1 [1, 4] = 1 [2, 4] = 1 [3, 4] = 1

Проблема размера матрицы терм-документ обусловлена тем, что количество термов очень быстро растёт с количеством обрабатываемых документов. Этот вариант представлен типом SparseMatrixCSC, который экономичен в представлении сильно разреженной матрицы, но существует лишь ограниченное количество библиотек, его поддерживающих. Даже если количество словоформ уменьшено за счёт приведения к основной форме, количество оставшихся основ будет порядка тысяч — десятков тысяч. Если не проводить никакую предобработку документов, то в эту матрицу будут попадать абсолютно все слова со всеми своими словоформами, числа, даты. Полная матрица требует хранения не только единиц, но и нулей, однако она проще в использовании, чем SparseMatrixCSC. То есть, полная размерность матрицы терм-документ определяется полным произведением этого количества на количество обработанных документов. Получить её можно другим методом dtm(..., :dense) или же при помощи преобразования разреженной матрицы в полную методом collect():

julia> dtm(m, :dense)
3×4 Array{Int64,2}: 1 0 0 1 0 1 0 1 1 0 1 1

Если распечатать массив термов, то в каждой строке легко увидеть исходный состав документов (исходный порядок термов не учитывается).

julia> m.terms
4-element Array{String,1}: "1" "2" "3" "Document"

Матрицу терм-документ для частотных моделей, можем получить при помощи методов tf() и tf_idf():

julia> tf(m) |> collect
3×4 Array{Float64,2}: 0.5 0.0 0.0 0.5 0.0 0.5 0.0 0.5 0.333333 0.0 0.333333 0.333333

Два первых документа содержат по два терма. Легко увидеть значимость термов для каждого из документов. Значит их вес уменьшен. Последний — три.

И для TF-IDF и метода tf_idf():

julia> tdm = tf_idf(m) |> collect
3×4 Array{Float64,2}: 0.202733 0.0 0.0 0.0 0.0 0.549306 0.0 0.0 0.135155 0.0 0.366204 0.0

А вот терм 3 в третьем документе, приобрёл больший вес, чем 1 в том же документе, поскольку 1 встречается, также, и в первом документе. А в этой модели легко увидеть, что терм Document, который встречается во всех документах, имеет значимость 0.

Для этого понадобится пакет Clustering. Полученные матрицы очень легко использовать, например, для решения задачи кластеризации документов. Разобьем наши три документа на два кластера. Используем простейший алгоритм кластеризации k-means, которому необходимо указать количество желаемых кластеров. Поэтому выше полученные матрицы терм-документ надо транспонировать. Входной матрицей для kmeans является матрица с признаками, где строки представляют признаки, а столбцы — образцы.

julia> using Clustering julia> R = kmeans(tdm', 2; maxiter=200, display=:iter) Iters objv objv-change | affected ------------------------------------------------------------- 0 1.386722e-01 1 6.933608e-02 -6.933608e-02 | 0 2 6.933608e-02 0.000000e+00 | 0
K-means converged with 2 iterations (objv = 0.06933608051588186)
KmeansResult{Array{Float64,2},Float64,Int64}(
[0.0 0.16894379504506848; 0.5493061443340549 0.0; 0.0 0.1831020481113516; 0.0 0.0],
[2, 1, 2],
[0.03466804025794093, 0.0, 0.03466804025794093],
[1, 2], [1, 2], 0.06933608051588186, 2, true) julia> c = counts(R) # получить размеры кластеров
2-element Array{Int64,1}: 1 2 julia> a = assignments(R) # получить распределение по кластерам
3-element Array{Int64,1}: 2 1 2 julia> M = R.centers # получить векторы центров кластеров
4×2 Array{Float64,2}: 0.0 0.168944 0.549306 0.0 0.0 0.183102
0.0 0.0

Причем, матрица, содержащая центры кластеров R.centers, отчётливо показывает, что первый столбец «притянут» термом 2. В итоге, видим, что первый кластер содержит один документ, кластер номер 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-репозитория.

julia> ]
(v1.2) pkg> add https://github.com/JuliaText/TextAnalysis.jl

Поэтому рассмотрим и их тоже. Однако, игнорировать эти функции в обзоре не стоит.

По аналогии с предыдущими моделями tf. Одно из улучшений — использование функции ранжирования Okapi BM25. Использование полученной матрицы аналогично предыдущим случаям. tf_idf, используем метод bm_25(m).

Анализ тональности текстов можно сделать при помощи методов:

model = SentimentAnalyzer(doc)
model = SentimentAnalyzer(doc, handle_unknown)

handle_unknown – функция обработки неизвестных слов. Причём, doc – это один из выше рассмотренных типов документов. Возвращаемое значение находится в диапазоне от 0 до 1. Анализ тональности реализован при помощи пакета Flux.jl на основе корпуса IMDB.

Первым аргументом является документ. Обобщение документа можно реализовать при помощи метода summarize(d, ns). Вторым – ns= количество предложений в итоге.

julia> s = StringDocument("Assume this Short Document as an example. Assume this as an example summarizer. This has too foo sentences.") julia> summarize(s, ns=2)
2-element Array{SubString{String},1}: "Assume this Short Document as an example." "This has too foo sentences."

Есть несколько вариантов его использования. Весьма важным компонентом любой библиотеки анализа текстов является находящийся сейчас в разработке синтаксический парсер, выделяющий части речи — POS (part of speech). Tagging оно называется потому, что для каждого слова в исходном тексте, формируется тег, означающий часть речи. Подробнее см в разделе Parts of Speech Tagging
.

Первый — Average Perceptron Algorithm. В разработке находится два варианта реализации. Приведём пример простой разметки предложения. Второй основан на использовании архитектуры нейросетей LSTMs, CNN и методе CRF.

julia> pos = PoSTagger() julia> sentence = "This package is maintained by John Doe." "This package is maintained by John Doe." julia> tags = pos(sentence)
8-element Array{String,1}: "DT" "NN" "VBZ" "VBN" "IN" "NNP" "NNP" "."

В частности, 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. Список аббревиатур, означающих часть речи, взят из Penn Treebank.

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

Методы снижения размерности

Это латентный семантический анализ — LSA и латентное размещение Дирихле — LDA. TextAnalysis предоставляет два варианта снижения размерности за счёт определения зависимых термов.

Основная задача LSA — получить разложение матрицы терм-документ (используется TF-IDF) на 3 матрицы, произведение которых примерно соответствует исходной.

julia> crps = Corpus([StringDocument("this is a string document"), TokenDocument("this is a token document")])
julia> update_lexicon!(crps)
julia> m = DocumentTermMatrix(crps)
julia> tf_idf(m) |> collect
2×6 Array{Float64,2}: 0.0 0.0 0.0 0.138629 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.138629 julia> F2 = lsa(m)
SVD{Float64,Float64,Array{Float64,2}}([1.0 0.0; 0.0 1.0], [0.138629, 0.138629], [0.0 0.0 … 0.0 0.0; 0.0 0.0 … 0.0 1.0])

В разложении же SVD, эти коэффициенты явно выделены позиционно, и представляют собой линейно независимые компоненты. В примере можем видеть, что в матрице терм-документ, модель TF-IDF выделила статистически значимые термы весом.

Пример: Метод LDA может также быть использован для определения отношения документов к определённой тематике.

julia> crps = Corpus([StringDocument("This is the Foo Bar Document"), StringDocument("This document has too Foo words")])
julia> update_lexicon!(crps)
julia> m = DocumentTermMatrix(crps)
julia> k = 2 # number of topics
julia> iterations = 1000 # number of gibbs sampling iterations julia> α = 0.1 # hyper parameter
julia> β = 0.1 # hyper parameter julia> ϕ, θ = lda(m, k, iterations, α, β)
( [2 , 1] = 0.333333 [2 , 2] = 0.333333 [1 , 3] = 0.222222 [1 , 4] = 0.222222 [1 , 5] = 0.111111 [1 , 6] = 0.111111 [1 , 7] = 0.111111 [2 , 8] = 0.333333 [1 , 9] = 0.111111 [1 , 10] = 0.111111, [0.5 1.0; 0.5 0.0])

Результатом являются матрицы ϕ и θ, первая из которых имеет размерность ntopics × nwords и показывает связи термов с темами, вторая — ntopics × ndocs и показывает связи документов с темами. Параметр k в вызове метода lda определяет количество тем, для которых рассчитываются вероятности отнесения термов и документов.

Классификация документов

И его очень просто подключить в своём проекте. Раздел классификации в настоящее время содержит только один готовый классификатор — Наивный байесовский классификатор. Модель можно создать при помощи конструктора NaiveBayesClassifier(). Для использования классификатора нам нужна модель со словарём и классами, к которым следует относить образцы. Обучение же модели — при помощи метода fit!():

using TextAnalysis: NaiveBayesClassifier, fit!, predict m = NaiveBayesClassifier([:legal, :financial]) fit!(m, "this is financial doc", :financial)
fit!(m, "this is legal doc", :legal)

Результат обучения можем проверить с помощь predict:

julia> predict(m, "this should be predicted as a legal document")
Dict{Symbol,Float64} with 2 entries: :legal => 0.666667 :financial => 0.333333

Видим, что наиболее вероятно то, что проверяемый текст относится к классу :legal.

Однако, список доступных для использования алгоритмов им не ограничивается. Указанный классификатор входит в состав TextAnalysis.jl как один из самых простых классификаторов. Среди них AdaBoostClassifier, BaggingClassifier, BernoulliNBClassifier, ComplementNBClassifier, ConstantClassifier, XGBoostClassifier, DecisionTreeClassifier. Некоторые реализованные классификаторы доступны в пакете MLJ.jl. Или реализовать свои собственные методы. Используя выше упомянутые модели терм-документ или результат их преобразования LSA, можно выполнить классификацию с помощью и этих методов тоже.

Поэтому рассматривать его здесь мы не будем. В составе TextAnalysis.jl также декларируется классификатор CRF — Conditional Random Fields, реализованный на базе библиотеки для создания нейросетей Flux.jl, однако описание приводится довольно поверхностное.

Распознавание сущностей

Посредством NERTagger() для каждого слова назначается метка отнесения к одному из классов сущностей: В составе рабочей ветки TextAnalysis.jl декларируется наличие средства распознавания именованных сущностей — NER.

  • PER: персона
  • LOC: географическое размещение
  • ORG: организация
  • MISC: другое
  • O: не именованная сущность

Пример использования:

julia> sentence = "This package is maintained by John Doe." "This package is maintained by John Doe." julia> tags = ner(sentence)
8-element Array{String,1}: "O" "O" "O" "O" "O" "PER" "PER" "O"

А его выход может быть также использован для формирования признаков классификации документов. NERTagger может быть применён к различным документам TextAnalysis.

StringDistances.jl

Иногда необходимо решать вполне прикладные задачи определения похожести строк, например, выявляя опечатки. Полный анализ документов не всегда нужен. Для сравнения строк между собой, удобно использовать пакет StringDistances.jl. Или неполные названия. Его использование очень простое:

using StringDistances compare("martha", "martha", Hamming())
#> 1.0 compare("martha", "marhta", Jaro()) #> 0.9444444444444445 compare("martha", "marhta", Winkler(Jaro())) #> 0.9611111111111111 compare("william", "williams", QGram(2)) #> 0.9230769230769231 compare("william", "williams", Winkler(QGram(2))) #> 0.9538461538461539

Соответственно, 1 — полное совпадение. Возвращаемое методом compare значение — это близость строк. 0 — отсутствие совпадения.

Она является относительно быстрой, но точность этой метрики по современным меркам не велика. Из распространённых в настоящее время метрик, часто используется Jaro-Winkler. Кроме того, она может использоваться в комбинации с предварительной обработкой. Метрика RatcliffObershelp, например, даёт большую точность определения похожести длинных строк с несколькими словами. Например сортировкой токенов.

compare("mariners vs angels", "angels vs mariners", RatcliffObershelp()) #> 0.44444 compare("mariners vs angels", "angels vs mariners", TokenSort(RatcliffObershelp()) #> 1.0 compare("mariners vs angels", "los angeles angels at seattle mariners", Jaro()) #> 0.559904 compare("mariners vs angels", "los angeles angels at seattle mariners", TokenSet(Jaro())) #> 0.944444 compare("mariners vs angels", "los angeles angels at seattle mariners", TokenMax(RatcliffObershelp())) #> 0.855

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

WordTokenizers.jl

И он, также, используется внутри TextAnalysis.jl. Пакет WordTokenizers.jl является одним из базовых пакетов для обработки текстов.

Поэтому, ключевым методом является tokenize(text). Основное назначение этого пакета — разбить исходный текст на токены.

julia> using WordTokenizers julia> text = "I cannot stand when they say \"Enough is enough.\""; julia> tokenize(text) |> print # Default tokenizer
SubString{String}["I", "can", "not", "stand", "when", "they", "say", "``", "Enough", "is", "enough", ".", "''"]

Второй функцией пакета WordTokenizers является разбивка текста на предложения.

julia> text = "The leatherback sea turtle is the largest, measuring six or seven feet (2 m) in length at maturity, and three to five feet (1 to 1.5 m) in width, weighing up to 2000 pounds (about 900 kg). Most other species are smaller, being two to four feet in length (0.5 to 1 m) and proportionally less wide. The Flatback turtle is found solely on the northerncoast of Australia."; julia> split_sentences(text)
3-element Array{SubString{String},1}: "The leatherback sea turtle is the largest, measuring six or seven feet (2 m) in length at maturity, and three to five feet (1 to 1.5 m) in width, weighing up to 2000 pounds (about900 kg). " "Most other species are smaller, being two to four feet in length (0.5 to 1 m) and proportionally less wide. " "The Flatback turtle is found solely on the northern coast of Australia." julia> tokenize.(split_sentences(text))
3-element Array{Array{SubString{String},1},1}: SubString{String}["The", "leatherback", "sea", "turtle", "is", "the", "largest", ",", "measuring", "six" … "up", "to", "2000", "pounds", "(", "about", "900", "kg", ")", "."] SubString{String}["Most", "other", "species", "are", "smaller", ",", "being", "two", "to", "four" … "0.5", "to", "1", "m", ")", "and", "proportionally", "less", "wide", "."] SubString{String}["The", "Flatback", "turtle", "is", "found", "solely", "on", "the", "northern", "coast", "of", "Australia", "."]

Доступны различные алгоритмы разбора:

  • Poorman's tokenizer — удалить все знаки препинания и разделить по пробелам. Иногда может работать даже хуже, чем просто split.
  • Punctuation space tokenize — улучшение предыдущего алгоритма за счёт отслеживания границ слов. Например, предотвращает разделение слов по дефису.
  • Penn Tokenizer — реализация токенизатора, использованная в корпусе Penn Treebank.
  • Improved Penn Tokenizer — модификация, реализованная по алгоритму из библиотеки NLTK.
  • NLTK Word tokenizer — типовой алгоритм, используемый библиотеке NLTK, который считается лучшим, по сравнению с предыдущими в части обработки UNICODE-символов и пр.
  • Reversible Tokenizer — токенизатор, результат которого может быть обращён для восстановления исходного текста. Выделяет в отдельные токены
  • TokTok Tokenizer — токенизатор, основанный на регулярных выражениях.
  • Tweet Tokenizer — токенизатор, который ориентирован на разбиение твитов, включая эмодзи, HTML-вставки и пр.

Выбор алгоритма токенизации осуществляется вызовом метода set_tokenizer(nltk_word_tokenize)

Embeddings.jl

Главное достоинство этих многомерных пространств заключается в том, что операции сложения или вычитания векторов, представляющих определённые слова, приводят к тому, что результат становится близок к другим словам, близких по контексту в корпусе текстов, на котором проводилось обеспечение. Пакет Embeddings.jl реализует алгоритмы векторизации текстов в некоторые векторные пространства. Типичный пример операций над векторами, связанными со словами: king - man + woman = queen. Одним из первых широко известных алгоритмов подобной векторизации был Word2Vec. Например, существуют наборы данных, обученные на новостях, на статьях Wikipedia, на только одном языке или на нескольких языках сразу. В зависимости от того, какой набор данных используется, может настраиваться разная размерность этих пространств. В англоязычной литераторе эти пространства имеют название «semantic space», а расстояние между векторами, часто называется «semantic distance». В результате, размерность пространства может меняться в широком диапазоне от сотен до тысяч измерений. Здесь надо понимать то, что поскольку речь не идёт о настоящей семантике в философском или лингвистическом смысле, то и ошибки определения точки в таком пространстве сильно зависят от разницы контекста, на котором проведено обучение и контекста использования программы. Поскольку пространства эти построены исключительно на основе статистических принципов, а близость определяется контекстом слов в наборе, на котором проведено обучение, то использовать слова «смысл» и «семантика» в русском переводе мы не можем.

Как результат, если необходимо решать задачу сопоставления документов, включая задачи кластеризации и классификации, сделать это можно гораздо быстрее, чем, например, на основе обратных индексов. Главное достоинство методов векторизации, относимых к категории «embedding» является то, что любое слово, любое предложение или текст, можно представить как один вектор с фиксированной размерностью. Отдельным вопросом является то, какая мера расстояния должна быть использована в каждом конкретном векторном пространстве. Или точнее, чем в случае матрицы терм-документ, поскольку в ней никак не используется контекст слов. Но это зависит от свойств выбранного векторного пространства. Обычно, используют косинусное расстояние.

Последний вариант предобучен для большого количества документов. Embeddings.jl предоставляет следующие методы векторизации: Word2Vec, GloVe (English only), FastText. Один из недостатков большинства подобных методов векторизации — большой объём данных, включая словарь термов и представление векторов для каждого из них. Собственно, для любого метода векторизации можно переобучить модель на своём наборе данных, но время переобучения может быть очень большим. В противном случае, матрица с векторами может просто не поместиться. Для использование, например, word2vec, просто необходимо иметь 8-16 ГБ оперативной памяти.

Он используется для автоматизации загрузки больших наборов данных в тот момент, когда они потребовались ("ленивая загрузка данных"). Упомянем, также, пакет DataDeps.jl. Поскольку произойти это может и на сервере в фоновом процессе, то предусмотрена возможность автоматической загрузки зависимостей через установку переменной окружения. Если запустить программу, использующую Embedding.jl в первый раз, то в текстовой консоли появится запрос о том, загружать ли или нет.

ENV["DATADEPS_ALWAYS_ACCEPT"] = true

Загруженные зависимости помещаются в директорию ~/.julia/datadeps по именам набора данных. Единственное требование — наличие достаточного дискового пространства для сохранения скачиваемых файлов.

Первый этап — загрузка данных для необходимого метода векторизации: Примеры использования.

using Embeddings
const embtable = load_embeddings(Word2Vec) # or load_embeddings(FastText_Text) or ... const get_word_index = Dict(word=>ii for (ii,word) in enumerate(embtable.vocab)) function get_embedding(word) ind = get_word_index[word] emb = embtable.embeddings[:,ind] return emb
end

Второй этап — векторизация каждого отдельного слова или фразы:

julia> get_embedding("blue")
300-element Array{Float32,1}: 0.01540828 0.03409082 0.0882124 0.04680265 -0.03409082
...

Сложить векторы можно при помощи типовых операций, уже встроенных в Julia: Слова могут быть получены токенизатором WordTokenizers или при помощи TextAnalysis, рассмотренных ранее.

julia> a = rand(5)
5-element Array{Float64,1}: 0.012300397820243392 0.13543646950484067 0.9780602985106086 0.24647179461578816 0.18672770774122105 julia> b = ones(5)
5-element Array{Float64,1}: 1.0 1.0 1.0 1.0 1.0 julia> a+b
5-element Array{Float64,1}: 1.0123003978202434 1.1354364695048407 1.9780602985106086 1.2464717946157882 1.186727707741221

Методы машинного обучения, включая методы классификации — см. Алгоритмы кластеризации предоставляются пакетом Clustering.jl. При желании же самостоятельно реализовать алгоритмы кластеризации, рекомендуем обратить внимание на пакет https://github.com/JuliaStats/Distances.jl, предоставляющий огромный набор алгоритмов вычисления векторного расстояния: пакет MLJ.jl.

  • Euclidean distance
  • Squared Euclidean distance
  • Periodic Euclidean distance
  • Cityblock distance
  • Total variation distance
  • Jaccard distance
  • Rogers-Tanimoto distance
  • Chebyshev distance
  • Minkowski distance
  • Hamming distance
  • Cosine distance
  • Correlation distance
  • Chi-square distance
  • Kullback-Leibler divergence
  • Generalized Kullback-Leibler divergence
  • Rényi divergence
  • Jensen-Shannon divergence
  • Mahalanobis distance
  • Squared Mahalanobis distance
  • Bhattacharyya distance
  • Hellinger distance
  • Haversine distance
  • Mean absolute deviation
  • Mean squared deviation
  • Root mean squared deviation
  • Normalized root mean squared deviation
  • Bray-Curtis dissimilarity
  • Bregman divergence

Эти меры расстояния могут быть использованы при выполнении кластеризации или классификации.

Transformers.jl

Надо отметить, что именно эта нейросеть и её модификации стали сейчас весьма популярны для решения задачи NER — разметки именованных сущностей, а также для векторизации слов. Transformers.jl — это чистая реализация на языке Julia архитектуры «Transformers», на основе которой разработана нейросеть BERT компании Google.

Flux.jl легко переключается с CPU на GPU, но это, скорее, общее требование для библиотек, реализующих функции нейронных сетей. Transformers.jl использует пакет Flux.jl, который, в свою очередь, является не только чистой Julia-библиотекой для реализации нейронных сетей, но и одной из самых быстрых реализаций для выполнения подобных операций.

Ограничимся примером кода для векторизации текстов: В данной статье не будем вдаваться в особенности BERT или нейросетей как таковых, поскольку это уже было многократно сделано.

using Transformers
using Transformers.Basic
using Transformers.Pretrain
using Transformers.Datasets
using Transformers.BidirectionalEncoder using Flux
using Flux: onehotbatch, gradient
import Flux.Optimise: update!
using WordTokenizers ENV["DATADEPS_ALWAYS_ACCEPT"] = true
const FromScratch = false #use wordpiece and tokenizer from pretrain
const wordpiece = pretrain"bert-uncased_L-12_H-768_A-12:wordpiece"
const tokenizer = pretrain"bert-uncased_L-12_H-768_A-12:tokenizer"
const vocab = Vocabulary(wordpiece) const bert_model = gpu( FromScratch ? create_bert() : pretrain"bert-uncased_L-12_H-768_A-12:bert_model"
)
Flux.testmode!(bert_model) function vectorize(str::String) tokens = str |> tokenizer |> wordpiece text = ["[CLS]"; tokens; "[SEP]"] token_indices = vocab(text) segment_indices = [fill(1, length(tokens) + 2);] sample = (tok = token_indices, segment = segment_indices) bert_embedding = sample |> bert_model.embed collect(sum(bert_embedding, dims=2)[:])
end

Ну и его использование выглядит следующим образом: Определённый здесь метод vectorize будем использовать для векторизации текстов.

using Distances
x1 = vectorize("Some test about computers")
x2 = vectorize("Some test about printers")
cosine_dist(x1, x2)

В обозначении 12 — количество слоёв. В примере выше, wordpiece, tokenizer — это преобученные модели нейросети. Список основных моделей см. 768 — размерность векторного пространства. В коде выше, макрос Transformers. https://chengchingwen.github.io/Transformers.jl/dev/pretrain/. Pretrain.@pretrain_str, который использован в форме pretrain"model-description:item" является лишь короткой формой кода загрузки конкретных моделей.

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

Заключение

Прошел всего год с момента выпуска первой стабильной версии языка. Не стоит, пока что, ждать от Julia ничего принципиально нового. Если год назад программистов, имеющих опыт на Julia почти не было, то теперь они есть. Однако, видно, что интерес к языку и технологии в целом увеличивается. И, уже не надо бояться начинать новые проекты именно на Julia.

В этом случае, очевидно, всё будет сведено к поиску ближайшего подходящего «open source» продукта или, просто, покупка готового на стороне, но не погружение в новую технологию. Конечно, если у вас уже есть сложившаяся команда, есть заказчик, который хочет получить какой-то конкретный продукт «вчера», не стоит бросаться на новые технологии. С одной стороны, интерактивные инструменты для исследователей типа Jupyter Notebook для быстрых набросков, как и в интеграционных языках программирования, с другой — среды интегрированной разработки типа Atom/Juno, VS Code, развитые средства разработки пакетов и управления их зависимостями. Если же речь идёт о долгосрочном развитии продукта, особенно с большой долей собственных интеллектуальных разработок, Julia становится очень заманчивым вариантом. И, главное, чем хороша Julia — она позволяет не прыгать на 2-3 языка программирования при разработке, что типично для интеграционных языков программирования (то есть, пригодные лишь для связывания частей программы, написанных на быстрых языках программирования), где без С или C++ не обходится ни один серьёзный проект.

В тех исследованиях, где интеграционные языки программирования пытаются использовать как основной инструмент, программный код, как правило, заканчивает свой жизненный цикл с последней опубликованной статьёй. Если же говорим о научных исследованиях, связанных с анализом и обработкой данных, то современных альтернатив Julia, в общем-то, и не видно. Julia же лишена этой главной проблемы интеграционных языков. Когда исследователь пытается достичь пригодной для промышленного использования производительности, он вынужден становиться тем самым программистом на 2-3 языках вместо того, чтобы просто сконцентрироваться на своей предметной области и алгоритмах, используя лишь один основной инструмент. Для интеграционных же языков — это «хождение по минному полю». Даже не зная готовую библиотеку или нужную функцию для какого-нибудь нового метода обработки матрицы, не будет особой проблемой просто написать цикл for и выполнить эту обработку в основном коде. В Julia, в худшем случае, потеряете лишь какой-то процент производительности, поскольку и основной код программы, и код библиотек — это Julia-код. Нет готовой функции, написанной на C, значит забудь о производительности. То есть, Julia — это не только простой для освоения и удобный в использовании современный язык программирования, но и экономия времени и сил при разработке принципиально новых алгоритмов и методов.

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

Дополнительно хотелось бы отметить, что существует телеграм-канал русскоязычного сообщества Julia — @JuliaLanguage, где также можно задать вопросы или обсудить технологические вопросы.

Ссылки

  • TextAnalysis.jl — базовые функции обработки текстов
  • Languages.jl — библиотека функций для работы с различными естественными языками
  • WordTokenizers.jl — библиотека алгоритмов токенизации
  • StringDistances.jl — библиотека алгоритмов вычисления расстояния между строками
  • Transformers.jl — реализация архитектуры Transformers для нейросети BERT.
  • Distances.jl — библиотека функций расчёта векторных расстояний.
  • Clustering.jl — библиотека функций алгоритмов кластеризации.
  • MLJ.jl — пакет, объединяющий различные методы машинного обучения, включая алгоритмы регрессивного анализа и классификации.
  • Flux.jl — библиотека функций для реализации нейросетей.
Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»