Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Анализ и обработка текстов на естественном языке является постоянно актуальной задачей, которая решалась, решается и будет решаться всеми доступными способами. На сегодня хотелось бы поговорить о средствах решения для решения этой задачи, именно, на языке Julia. Безусловно, в виду молодости языка, здесь нет столь развитых средств анализа, как, например Stanford CoreNLP, Apache OpenNLP, GATE и пр., как, например, для языка Java. Однако, даже уже разработанные библиотеки, вполне могут использоваться как для решения типовых задач, так и быть рекомендованными в качестве точки входа для студентов, которым интересна область обработки текстов. А синтаксическая простота Julia и её развитые математические средства, позволяют с лёгкостью погрузиться в задачи кластеризации и классификации текстов.
Целью данной статьи является обзор средств обработки текстов на языке Julia с небольшими пояснениями об их использовании. Будем балансировать между кратким перечислением возможностей для тех, кто в теме NLP, но хотел бы увидеть именно средства Julia, и более подробными пояснениями и примерами применения для тех, кто решил впервые погрузиться в область NLP (Natural Language Processing) как таковую.
Ну а сейчас, перейдём к обзору пакетов.
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{String}
* 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)
В примере хорошо видно, что индекс 1
позволил получить символ ∀
. А вот все последующие индексы до 3 включительно, привели к ошибке. И только 4-й индекс выдал пробел, как следующий символ в строке. Впрочем, для определения границ символов по индексам в строке, есть полезные функции prevind
(previous index), nextind
(next index) и thisind
(this index). Например для найденного выше пробела, спросим, где граница предыдущего:
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"
эта матрица выглядит как:
I | like | hate | databases | |
---|---|---|---|---|
D1 | 1 | 1 | 0 | 1 |
D2 | 1 | 0 | 1 | 1 |
Столбцы представлены словами документов, а строки — идентификаторами (или индексами) документов. Соответственно, в ячейке будет 0, если слово (терм) не встречается в документе. И 1, если встречается сколько угодно раз. Более сложные модели учитывают как частоту встречаемости (модель TF), так и значимость по отношению к ко всему корпусу (TF-IDF).
Корпус мы можем построить при помощи конструктора 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/
Коротко рассмотрим предлагаемые варианты.
Базовая модель представления документов — это модель, где для каждого документа хранится набор слов. Причём позиции их не важны. Поэтому в англоязычной литераторе, этот вариант называется Bag of words. Для каждого слова важен лишь факт его наличия в документе, частота встречаемости (TF — Term Frequency) или модель, учитывающая частоту встречаемости терма в корпусе в целом (TF-IDF — Term Frequency — Inverse Document Frequency).
Возьмём простейший пример с тремя документами, содержащими термы 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))
Переменная m
имеет значение с типом DocumentTermMatrix
. В распечатанном результате видим, что размерность составляет 3 документа на 4 терма, куда вошли и слово Document
, и числа 1
, 2
, 3
. Для дальнейшего использования модели нам нужна матрица в традиционном предавлении. Можем получить её при помощи метода 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
А в этой модели легко увидеть, что терм Document
, который встречается во всех документах, имеет значимость 0. А вот терм 3
в третьем документе, приобрёл больший вес, чем 1
в том же документе, поскольку 1
встречается, также, и в первом документе.
Полученные матрицы очень легко использовать, например, для решения задачи кластеризации документов. Для этого понадобится пакет 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
В итоге, видим, что первый кластер содержит один документ, кластер номер 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-репозитория. Сделать это не сложно, но советовать это можно только тем, кто не планирует внедрять решение в эксплуатацию в ближайшее время:
julia> ]
(v1.2) pkg> add https://github.com/JuliaText/TextAnalysis.jl
Однако, игнорировать эти функции в обзоре не стоит. Поэтому рассмотрим и их тоже.
Одно из улучшений — использование функции ранжирования Okapi BM25. По аналогии с предыдущими моделями tf
. tf_idf
, используем метод bm_25(m)
. Использование полученной матрицы аналогично предыдущим случаям.
Анализ тональности текстов можно сделать при помощи методов:
model = SentimentAnalyzer(doc)
model = SentimentAnalyzer(doc, handle_unknown)
Причём, doc
– это один из выше рассмотренных типов документов. handle_unknown
– функция обработки неизвестных слов. Анализ тональности реализован при помощи пакета Flux.jl на основе корпуса IMDB. Возвращаемое значение находится в диапазоне от 0 до 1.
Обобщение документа можно реализовать при помощи метода 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). Есть несколько вариантов его использования. Подробнее см в разделе Parts of Speech Tagging
. 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"
"."
Список аббревиатур, означающих часть речи, взят из 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 матрицы, произведение которых примерно соответствует исходной.
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])
В примере можем видеть, что в матрице терм-документ, модель TF-IDF выделила статистически значимые термы весом. В разложении же SVD, эти коэффициенты явно выделены позиционно, и представляют собой линейно независимые компоненты.
Метод 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])
Параметр k
в вызове метода lda
определяет количество тем, для которых рассчитываются вероятности отнесения термов и документов. Результатом являются матрицы ϕ
и θ
, первая из которых имеет размерность ntopics × nwords
и показывает связи термов с темами, вторая — ntopics × ndocs
и показывает связи документов с темами.
Классификация документов
Раздел классификации в настоящее время содержит только один готовый классификатор — Наивный байесовский классификатор. И его очень просто подключить в своём проекте. Для использования классификатора нам нужна модель со словарём и классами, к которым следует относить образцы. Модель можно создать при помощи конструктора 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 как один из самых простых классификаторов. Однако, список доступных для использования алгоритмов им не ограничивается. Некоторые реализованные классификаторы доступны в пакете MLJ.jl. Среди них AdaBoostClassifier, BaggingClassifier, BernoulliNBClassifier, ComplementNBClassifier, ConstantClassifier, XGBoostClassifier, DecisionTreeClassifier. Используя выше упомянутые модели терм-документ или результат их преобразования LSA, можно выполнить классификацию с помощью и этих методов тоже. Или реализовать свои собственные методы.
В составе TextAnalysis.jl также декларируется классификатор CRF — Conditional Random Fields, реализованный на базе библиотеки для создания нейросетей Flux.jl, однако описание приводится довольно поверхностное. Поэтому рассматривать его здесь мы не будем.
Распознавание сущностей
В составе рабочей ветки TextAnalysis.jl декларируется наличие средства распознавания именованных сущностей — NER. Посредством NERTagger()
для каждого слова назначается метка отнесения к одному из классов сущностей:
- 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
Возвращаемое методом compare
значение — это близость строк. Соответственно, 1 — полное совпадение. 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
Пакет WordTokenizers.jl является одним из базовых пакетов для обработки текстов. И он, также, используется внутри TextAnalysis.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 реализует алгоритмы векторизации текстов в некоторые векторные пространства. Главное достоинство этих многомерных пространств заключается в том, что операции сложения или вычитания векторов, представляющих определённые слова, приводят к тому, что результат становится близок к другим словам, близких по контексту в корпусе текстов, на котором проводилось обеспечение. Одним из первых широко известных алгоритмов подобной векторизации был 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 в первый раз, то в текстовой консоли появится запрос о том, загружать ли или нет. Поскольку произойти это может и на сервере в фоновом процессе, то предусмотрена возможность автоматической загрузки зависимостей через установку переменной окружения.
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
...
Слова могут быть получены токенизатором WordTokenizers или при помощи TextAnalysis, рассмотренных ранее. Сложить векторы можно при помощи типовых операций, уже встроенных в Julia:
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. Методы машинного обучения, включая методы классификации — см. пакет MLJ.jl. При желании же самостоятельно реализовать алгоритмы кластеризации, рекомендуем обратить внимание на пакет https://github.com/JuliaStats/Distances.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
Transformers.jl — это чистая реализация на языке Julia архитектуры «Transformers», на основе которой разработана нейросеть BERT компании Google. Надо отметить, что именно эта нейросеть и её модификации стали сейчас весьма популярны для решения задачи NER — разметки именованных сущностей, а также для векторизации слов.
Transformers.jl использует пакет Flux.jl, который, в свою очередь, является не только чистой Julia-библиотекой для реализации нейронных сетей, но и одной из самых быстрых реализаций для выполнения подобных операций. Flux.jl легко переключается с CPU на GPU, но это, скорее, общее требование для библиотек, реализующих функции нейронных сетей.
В данной статье не будем вдаваться в особенности 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)
В примере выше, 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, где также можно задать вопросы или обсудить технологические вопросы.
Ссылки
- TextAnalysis.jl — базовые функции обработки текстов
- Languages.jl — библиотека функций для работы с различными естественными языками
- WordTokenizers.jl — библиотека алгоритмов токенизации
- StringDistances.jl — библиотека алгоритмов вычисления расстояния между строками
- Transformers.jl — реализация архитектуры Transformers для нейросети BERT.
- Distances.jl — библиотека функций расчёта векторных расстояний.
- Clustering.jl — библиотека функций алгоритмов кластеризации.
- MLJ.jl — пакет, объединяющий различные методы машинного обучения, включая алгоритмы регрессивного анализа и классификации.
- Flux.jl — библиотека функций для реализации нейросетей.