library(tidyverse)
library(tidytext)
library(tokenizers)
9 Токенизация, лемматизация, POS-тэггинг и синтаксический анализ
Основные этапы NLP включают в себя токенизацию, морфологический и синтаксический анализ, а также анализ семантики и прагматики. В этом уроке речь пойдет про первые три этапа. Мы научимся разбивать текст на токены (слова), определять морфологические характеристики слов и находить их начальные формы (леммы), а также анализировать структуру предложения с использованием синтаксических парсеров.
9.1 Токенизация
Токенизация — процесс разделения текста на составляющие (их называют «токенами»). Токенами могут быть слова, символьные или словесные энграмы (n-grams), то есть сочетания символов или слов, даже предложения или параграфы.
Токенизировать можно в базовом R с использованием регулярных выражений, и Jockers (2014) прекрасно показывает, как это можно делать. Но мы воспользуемся двумя пакетами, которые предназначены специально для работы с текстовыми данными и разделяют идеологию tidyverse
: tidytext
(Silge и Robinson 2017) и tokenizers
(Hvitfeldt и Silge 2022).
Для анализа воспользуемся датасетом c латинским текстом “Записок о Галльской войне”, который мы подготовили в предыдущем уроке. Его можно забрать отсюда.
load("../data/caesar.RData")
<- caesar |>
caesar rename(text = value) |>
select(-link)
caesar
Функция unnest_tokens()
из пакета tidytext
принимает на входе тиббл, название столбца, в котором хранится текст для токенизации, а также название нового столбца, куда будут “сложены” отдельные токены (зачастую это слова, но не обязательно).
unnest_tokens(
tbl,
output,
input,
token = "words",
format = c("text", "man", "latex", "html", "xml"),
to_lower = TRUE,
drop = TRUE,
collapse = NULL,
...
)
Аргумент token
принимает следующие значения:
- “words” (default),
- “characters”,
- “character_shingles”,
- “ngrams”,
- “skip_ngrams”,
- “sentences”,
- “lines”,
- “paragraphs”,
- “regex”,
- “ptb” (Penn Treebank).
Используя уже знакомую функцию map
, можно запустить unnest_tokens()
с разными аргументами:
<- tibble(text = "Gallia est omnis divisa in partes tres, quarum unam incolunt Belgae, aliam Aquitani, tertiam qui ipsorum lingua Celtae, nostra Galli appellantur. Hi omnes lingua, institutis, legibus inter se differunt.") test
<- tribble(
params ~tbl, ~output, ~input, ~token,
"word", "text", "words",
test, "sentence", "text", "sentences",
test, "char", "text", "characters",
test,
)
params
|>
params pmap(unnest_tokens)
[[1]]
# A tibble: 29 × 1
word
<chr>
1 gallia
2 est
3 omnis
4 divisa
5 in
6 partes
7 tres
8 quarum
9 unam
10 incolunt
# ℹ 19 more rows
[[2]]
# A tibble: 2 × 1
sentence
<chr>
1 gallia est omnis divisa in partes tres, quarum unam incolunt belgae, aliam aq…
2 hi omnes lingua, institutis, legibus inter se differunt.
[[3]]
# A tibble: 166 × 1
char
<chr>
1 g
2 a
3 l
4 l
5 i
6 a
7 e
8 s
9 t
10 o
# ℹ 156 more rows
Следующие значения аргумента token
требуют также аргумента n
:
<- tribble(
params ~tbl, ~output, ~input, ~token, ~n,
"ngram", "text", "ngrams", 3,
test, "shingles", "text", "character_shingles", 3
test,
)
|>
params pmap(unnest_tokens) |>
head()
[[1]]
# A tibble: 27 × 1
ngram
<chr>
1 gallia est omnis
2 est omnis divisa
3 omnis divisa in
4 divisa in partes
5 in partes tres
6 partes tres quarum
7 tres quarum unam
8 quarum unam incolunt
9 unam incolunt belgae
10 incolunt belgae aliam
# ℹ 17 more rows
[[2]]
# A tibble: 164 × 1
shingles
<chr>
1 gal
2 all
3 lli
4 lia
5 iae
6 aes
7 est
8 sto
9 tom
10 omn
# ℹ 154 more rows
Дальше мы будем работать со словами, поэтому сохраним токенизированный текст “Записок” в виде “опрятного” датасета (одно наблюдение - один ряд).
<- caesar |>
caesar_tokens unnest_tokens("word", "text")
caesar_tokens
При работе с данными в текстовом формате unnest_tokens()
опирается на пакет tokenizers
, из которого в нашем случае подтягивает функцию tokenize_words
. У этой функции есть несколько полезных аргументов: strip_non_alphanum
(удаляет пробельные символы и пунктуацию), strip_punct
(удаляет пунктуацию), strip_numeric
(удаляет числа).
Эти аргументы мы тоже можем задать через unnest_tokens()
, поскольку у функции есть аргумент ...
(загляните в документацию, чтобы убедиться).
|>
caesar unnest_tokens("word", "text", strip_punct = FALSE)
9.2 Лемматизация и частеречная разметка
Лемматизация – приведение слов к начальной форме (лемме). Как правило, она проводится одновременно с частеречной разметкой (POS-tagging). Все это умеет делать UDPipe – обучаемый конвейер (trainable pipeline), для которого существует одноименный пакет в R.
Основным форматом файла для него является CoNLL-U. Файлы в таком формате хранятся в так называемых трибанках, то есть коллекциях уже размеченных текстов (название объясняется тем, что синтаксическая структура предложений представлена в них в виде древовидных графов). Файлы CoNLL-U используются для обучения нейросетей, но для большинства языков доступны хорошие предобученные модели, работать с которыми достаточно просто.
Пакет udpipe
позволяет работать со множеством языков (всего 65), для многих из которых представлено несколько моделей, обученных на разных трибанках. Среди этих языков есть и латинский.
Прежде всего нужно выбрать и загрузить модель (список); в нашем случае это модель Perseus, но можно попробовать и другие доступные на сайте https://universaldependencies.org/.
library(udpipe)
# скачиваем модель в рабочую директорию
udpipe_download_model(language = "latin-perseus")
# загружаем модель
<- udpipe_load_model(file = "latin-perseus-ud-2.5-191206.udpipe")
latin_perseus
# аннотируем
<- udpipe_annotate(latin_perseus, caesar$text) caesar_annotate
Результат возвращается в формате CoNLL-U; это широко применяемый формат представления результат морфологического и синтаксического анализа текстов. Вот пример разбора предложения:
Cтроки слов содержат следующие поля:
ID
: индекс слова, целое число, начиная с 1 для каждого нового предложения; может быть диапазоном токенов с несколькими словами.FORM
: словоформа или знак препинания.LEMMA
: Лемма или основа словоформы.UPOSTAG
: тег части речи из универсального набора проекта UD, который создавался для того, чтобы аннотации разных языков были сравнимы между собой.XPOSTAG
: тег части речи, который выбрали исследователи под конкретные нужды языкаFEATS
: список морфологических характеристик.HEAD
: идентификатор (номер) синтаксической вершины текущего токена. Если такой вершины нет, то ставят ноль (0).DEPREL
: характер синтаксической зависимости.DEPS
: Список вторичных зависимостей.MISC
: любая другая аннотация.
Для работы данные удобнее трансформировать в прямоугольный формат.
<- as_tibble(caesar_annotate) |>
caesar_pos select(-paragraph_id)
caesar_pos
9.3 Обучение модели
Можно заметить, что модель Perseus 2.5 справилась не безупречно: все бельги оказались женского рода, а кельты и вовсе признаны глаголом. Есть ошибки в падежах и числах: например, “provinciae” в четвертом предложении, конечно, не именительный, а родительный падеж. Множество топонимов не опознано в качестве имен собственных.
Здесь есть два пути. Первый: пробовать другие модели, доступные в пакете udpipe
. Например, для латыни это PROIEl, обученная не только на классических авторах, но и на Вульгате, или ITTB, обученная на сочинениях Фомы. Но если тексты в трибанках не очень похожи на ваш корпус, то это вряд ли сработает.
Второй путь - обучить модель самостоятельно. Например, для трибанка Perseus доступны более свежие версии (2.13 на момент написания этой главы) на GitHub. Вот некоторые изменения:
- появилась метка
dep_rel
для ablativus absolutus (advcl:abs
); - исправлены аннотации для супина (
VerbForm=Conv
,Aspect=Prosp
), а также герундия и герундива (VerbForm=Part
,Aspect=Prosp
); - добавлен тип для местоимения (
PronType
) и вид для глагола (Aspect
) и др.
Инструкцию по обучению модели при помощи udpipe
можно найти здесь. Следуя этой инструкции и используя трибанк Perseus 2.13, мы обучили новую модель (это заняло около 8 часов на персональном компьютере), которую можно загрузить и использовать для аннотации.
Надо иметь в виду, что само по себе обновление трибанка еще не гарантирует того, что модель будет лучше справляться с парсингом: многое зависит от параметров обучения. В нашем случае, впрочем, некоторые улучшения есть: например, “provinciae” корректно опознано как родительный падеж. Но есть и потери: “fortissimi” в том же предложении выше - это nominativus pluralis, который ошибочно опознан как генитив единственного числа.
<- udpipe_load_model("../latin_model/la_perseus-2.13-20231115.udpipe")
latin_perseus_new
<- udpipe_annotate(latin_perseus_new, caesar$text[1])
caesar_annotate2
<- as_tibble(caesar_annotate2) |>
caesar_pos2 select(-paragraph_id)
caesar_pos2
Для многих задач достигнутой точности хватит, но есть способы ее повысить (часто за пределами R). Например, для латинского языка разработан пайплайн под названием LatinPipe, в 2024 г. победивший в конкурсе как лучший парсер для латинского языка. Это сложная конфигурация из различных нейросетей, которые учатся не на одном, а сразу на нескольких трибанках, что позволяет достичь большой точности. Мы обучили подобную модель и передали ей “Записки Цезаря”. Результат возвращается в формате CoNLL-U: прочитаем его в окружение и посмотрим, что получилось (скачать можно здесь).
library(udpipe)
<- udpipe_read_conllu("../files/bg_latinpipe.conllu") |>
caesar_pos3 select(-paragraph_id)
caesar_pos3
Кельты признаны существительным, бельги мужского рода (в поле FEATS
), а provinciae – генитив.
9.4 Поле UPOS
Морфологическая аннотация, которую мы получили, дает возможность выбирать и группировать различные части речи. Например, местоимения.
|>
caesar_pos3 filter(upos == "PRON") |>
select(token, lemma, upos, xpos)
Посчитать части речи можно так:
<- caesar_pos3 |>
upos_counts group_by(upos) |>
count() |>
arrange(-n)
upos_counts
Столбиковая диаграмма позволяет наглядно представить результаты подсчетов:
|>
upos_counts ggplot(aes(x = reorder(upos, n), y = n, fill = upos)) +
geom_bar(stat = "identity", show.legend = F) +
coord_flip() +
labs(x = NULL) +
theme_bw()
Отберем наиболее частотные имена и имена собственные.
<- caesar_pos3 |>
nouns filter(upos %in% c("NOUN", "PROPN")) |>
count(lemma) |>
arrange(-n)
nouns
library(wordcloud)
Loading required package: RColorBrewer
library(RColorBrewer)
<- RColorBrewer::brewer.pal(8, "Dark2")
pal
wordcloud(nouns$lemma, nouns$n, colors = pal, max.words = 130)
9.5 Поле FEATS
Допустим, нам нужны не все местоимения, а лишь определенные их формы: например, относительные.
<- caesar_pos3 |>
rel_pron filter(str_detect(feats, "PronType=Rel")) |>
as_tibble()
rel_pron
Посмотрим на некоторые местоимения в контексте.
|>
rel_pron filter(row_number() %in% c(1, 7)) |>
mutate(html_token = paste0("<mark>", token, "</mark>")) |>
mutate(html_sent = str_replace(sentence, token, html_token)) |>
pull(html_sent)
[1] “Gallia est omnis divisa in partes tres, quarum unam incolunt Belgae, aliam Aquitani, tertiam qui ipsorum lingua Celtae, nostra Galli appellantur.”
[2] “Eorum una pars, quam Gallos obtinere dictum est, initium capit a flumine Rhodano, continetur Garumna flumine, Oceano, finibus Belgarum, attingit etiam ab Sequanis et Helvetiis flumen Rhenum, vergit ad septentriones.”
9.6 Поле XPOS
Чтение xpos
требует сноровки: например причастие sublata там описывается так: v-srppfb-
, где
v
= verbum;-
на месте лица;s
= singularis;r
= perfectum (неp
, потому чтоp
= praesens);p
= participium;p
= passivum;f
= femininum;b
= ablativus (неa
, потому чтоa
= accusativus).
Сравним с описанием личной формы глагола differunt v3ppia---
:
v
= verbum;3
= 3. persona;p
= pluralis;p
= praesens;i
= indicativus;a
= activum;--
на месте рода и падежа, т.к. форма личная.
Последнее “место” (Degree
) у глаголов всегда свободно; в первой книге там стоит s
(superlativus) лишь у florentissimis, что явно ошибка, потому что это не глагол.
Для удобства разобьем xpos на 9 столбцов.
<- caesar_pos3 |>
caesar_pos3_sep separate(xpos, into = c("POS", "xpos"), sep = 1) |>
separate(xpos, into = c("persona", "xpos"), sep = 1) |>
separate(xpos, into = c("numerus", "xpos"), sep = 1) |>
separate(xpos, into = c("tempus", "xpos"), sep = 1) |>
separate(xpos, into = c("modus", "xpos"), sep = 1) |>
separate(xpos, into = c("vox", "xpos"), sep = 1) |>
separate(xpos, into = c("genus", "xpos"), sep = 1) |>
separate(xpos, into = c("casus", "gradus"), sep = 1)
caesar_pos3_sep
Эти столбцы тоже можно использовать для поиска конкретных признаков. Посмотрим, например, в каком числе и падеже чаще всего стоит относительное местоимения.
<- caesar_pos3_sep |>
pron_rel_sum filter(upos == "PRON") |>
filter(str_detect(feats, "PronType=Rel")) |>
group_by(numerus, casus) |>
summarise(n = n()) |>
arrange(-n)
pron_rel_sum
Для удобства преобразуем сокращения.
<- pron_rel_sum |>
pron_rel_sum filter(casus != "-") |>
mutate(casus = case_when(casus == "n" ~ "nom",
== "g" ~ "gen",
casus == "d" ~ "dat",
casus == "a" ~ "acc",
casus == "b" ~ "abl")) |>
casus mutate(numerus = case_when(numerus == "s" ~ "sing",
== "p" ~ "plur"))
numerus
pron_rel_sum
Функция facet_wrap
позволяет разбить график на две части на основании значения переменной numerus
.
|>
pron_rel_sum ggplot(aes(casus, n, fill = casus)) +
geom_bar(stat = "identity", show.legend = FALSE) +
coord_flip() +
theme_light() +
facet_wrap(~numerus) +
labs(x = NULL, y = NULL, title = "Относительные местоимения в BG 1-7")
9.7 Поле DEP_REL
Аналогичным образом можно отбирать синтаксические признаки и их комбинации, а также визуализировать деревья зависимостей для отдельных предложений.
Дерево зависимостей – это направленный граф, который имеет единственную корневую вершину (сказуемое главного предложения) без входящих дуг (рёбер), при этом все остальные вершины имеют ровно одну входящую дугу. Иными словами, каждое слово зависит от другого, но только от одного. Это выглядит примерно так:
library(textplot)
<- caesar_pos3 |>
sent filter(doc_id == "doc1", sentence_id == 10)
|>
sent distinct(sentence) |>
pull(sentence)
[1] "Apud Helvetios longe nobilissimus fuit et ditissimus Orgetorix."
textplot_dependencyparser(sent, size = 3)
Прилагательные “nobilissiumus” и “ditissimus” верно опознаны в качестве именной части сказуемого при подлежащем “Оргеториг”. Информация, которая на графе представлена стрелками, хранится в таблице в полях token_id
и head_token_id
и dep_rel
. Корень синтаксического дерева всегда имеет значение 0
, то есть ни от чего не зависит.
|>
sent select(token_id, token, head_token_id, dep_rel)
9.8 Сочетания признаков
Добудем все сложные предложения, в состав которых входят придаточные относительные (адноминальные).
# адноминальные предложения
<- caesar_pos3 |>
acl_ids filter(str_detect(dep_rel, "acl:relcl")) |>
unite(id, c("doc_id", "sentence_id")) |>
pull(id)
<- caesar_pos3 |>
acl unite(id, c("doc_id", "sentence_id")) |>
filter(id %in% acl_ids) |>
as_tibble() |>
mutate(token_id = as.numeric(token_id),
head_token_id = as.numeric(head_token_id))
acl
Посмотрим на одно из таких предложений, в котором проявилась характерная для Цезаря черта: повторять антецедент относительного местоимения в придаточном. Например, вместо “было два пути, которыми…” он говорит “было два пути, каковыми путями…”.
<- acl |>
example_sentence filter(id == "doc1_43") |>
select(-sentence, -deps, -misc) |>
relocate(dep_rel, .before = upos) |>
relocate(head_token_id, .before = upos)
example_sentence
Такие случаи можно попробовать выловить при помощи условия или нескольких условий, например достать такие относительные местоимения, сразу за которыми стоит их вершина:
<- acl |>
out filter(str_detect(feats, "PronType=Rel") &
== "det" &
dep_rel == (token_id + 1)) |>
head_token_id select(id, token_id, token, sentence)
|>
out mutate(html_token = paste0("<mark>", token, "</mark>")) |>
mutate(html_sent = str_replace(sentence, token, html_token)) |>
pull(html_sent) |>
head(5)
[1] “Erant omnino itinera duo, quibus itineribus domo exire possent:”
[2] “Omnibus rebus ad profectionem comparatis diem dicunt, qua die ad ripam Rhodani omnes conveniant.”
[3] “Ubi de eius adventu Helvetii certiores facti sunt, legatos ad eum mittunt nobilissimos civitatis, cuius legationis Nammeius et Verucloetius principem locum obtinebant, qui dicerent sibi esse in animo sine ullo maleficio iter per provinciam facere, propterea quod aliud iter haberent nullum:” [4] “Ita sive casu sive consilio deorum immortalium quae pars civitatis Helvetiae insignem calamitatem populo Romano intulerat, ea princeps poenam persolvit.”
[5] “cuius legationis Divico princeps fuit, qui bello Cassiano dux Helvetiorum fuerat.”
Так мы кое-что полезное поймали, но не все, потому что между местоимением и его антецедентом возможны другие слова (например, “каковыми опасными путями”). С другой стороны, есть и кое-что лишнее, а именно случаи инкорпорации антецедента в придаточное предложение (“quae pars …, ea” вместо “ea pars, quae…” ). В общем, условие можно дальше дорабатывать, но мы пока не будем этого делать.
9.9 Совместная встречаемость слов
Функция cooccurence()
из пакета udpipe
позволяет выяснить, сколько раз некий термин встречается совместно с другим термином, например:
- слова встречаются в одном и том же документе/предложении/параграфе;
- слова следуют за другим словом;
- слова находятся по соседству с другим словом на расстоянии n слов.
Код ниже позволяет выяснить, какие существительные встречаются в одном предложении:
<- subset(caesar_pos3, upos == "NOUN")
caesar_subset <- cooccurrence(caesar_subset, term = "lemma", group = c("doc_id", "sentence_id")) |>
cooc as_tibble() |>
filter(cooc > 25)
cooc
Этот результат легко визуализировать, используя пакет ggraph
(подробнее о нем мы будем говорить в следующих уроках):
library(igraph)
library(ggraph)
<- graph_from_data_frame(cooc)
wordnetwork ggraph(wordnetwork, layout = "fr") +
geom_edge_link(aes(width = cooc), alpha = 0.8, edge_colour = "grey90", show.legend=FALSE) +
geom_node_label(aes(label = name), col = "#1f78b4", size = 4) +
theme_void() +
labs(title = "Совместная встречаемость существительных", subtitle = "De Bello Gallico 1-7")
Чтобы узнать, какие слова чаще стоят рядом, используем ту же функцию, но с другими аргументами:
<- cooccurrence(caesar_subset$lemma, relevant = caesar_subset$upos %in% c("NOUN", "ADJ"), skipgram = 1) |>
cooc2 as_tibble() |>
filter(cooc > 10)
cooc2
<- graph_from_data_frame(cooc2)
wordnetwork
ggraph(wordnetwork, layout = "fr") +
geom_edge_link(aes(width = cooc), edge_colour = "grey90", edge_alpha=0.8, show.legend = F) +
geom_node_label(aes(label = name), col = "#1f78b4", size = 4) +
labs(title = "Слова, стоящие рядом в тексте", subtitle = "De Bello Gallico 1-7") +
theme_void()