9  Токенизация, лемматизация, POS-тэггинг и синтаксический анализ

Основные этапы NLP включают в себя токенизацию, морфологический и синтаксический анализ, а также анализ семантики и прагматики. В этом уроке речь пойдет про первые три этапа. Мы научимся разбивать текст на токены (слова), определять морфологические характеристики слов и находить их начальные формы (леммы), а также анализировать структуру предложения с использованием синтаксических парсеров.

9.1 Токенизация

Токенизация — процесс разделения текста на составляющие (их называют «токенами»). Токенами могут быть слова, символьные или словесные энграмы (n-grams), то есть сочетания символов или слов, даже предложения или параграфы.

Токенизировать можно в базовом R с использованием регулярных выражений, и Jockers (2014) прекрасно показывает, как это можно делать. Но мы воспользуемся двумя пакетами, которые предназначены специально для работы с текстовыми данными и разделяют идеологию tidyverse: tidytext (Silge и Robinson 2017) и tokenizers (Hvitfeldt и Silge 2022).

library(tidyverse) 
library(tidytext)
library(tokenizers)

Для анализа воспользуемся датасетом 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() с разными аргументами:

test <- 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.")
params <- tribble(
  ~tbl, ~output, ~input, ~token,
  test, "word", "text", "words", 
  test, "sentence", "text", "sentences",
  test, "char", "text", "characters", 
)

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:

params <- tribble(
  ~tbl, ~output, ~input, ~token, ~n,
  test, "ngram", "text", "ngrams", 3,
  test, "shingles", "text", "character_shingles", 3
)

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_tokens <- caesar |> 
  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")

# загружаем модель
latin_perseus <- udpipe_load_model(file = "latin-perseus-ud-2.5-191206.udpipe")

# аннотируем
caesar_annotate <- udpipe_annotate(latin_perseus, caesar$text)

Результат возвращается в формате CoNLL-U; это широко применяемый формат представления результат морфологического и синтаксического анализа текстов. Вот пример разбора предложения:

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

  1. ID: индекс слова, целое число, начиная с 1 для каждого нового предложения; может быть диапазоном токенов с несколькими словами.
  2. FORM: словоформа или знак препинания.
  3. LEMMA: Лемма или основа словоформы.
  4. UPOSTAG: тег части речи из универсального набора проекта UD, который создавался для того, чтобы аннотации разных языков были сравнимы между собой.
  5. XPOSTAG: тег части речи, который выбрали исследователи под конкретные нужды языка
  6. FEATS: список морфологических характеристик.
  7. HEAD: идентификатор (номер) синтаксической вершины текущего токена. Если такой вершины нет, то ставят ноль (0).
  8. DEPREL: характер синтаксической зависимости.
  9. DEPS: Список вторичных зависимостей.
  10. MISC: любая другая аннотация.

Для работы данные удобнее трансформировать в прямоугольный формат.

caesar_pos <- as_tibble(caesar_annotate) |> 
  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, который ошибочно опознан как генитив единственного числа.

latin_perseus_new <- udpipe_load_model("../latin_model/la_perseus-2.13-20231115.udpipe")

caesar_annotate2 <- udpipe_annotate(latin_perseus_new, caesar$text[1])

caesar_pos2 <- as_tibble(caesar_annotate2) |> 
  select(-paragraph_id)
caesar_pos2

Для многих задач достигнутой точности хватит, но есть способы ее повысить (часто за пределами R). Например, для латинского языка разработан пайплайн под названием LatinPipe, в 2024 г. победивший в конкурсе как лучший парсер для латинского языка. Это сложная конфигурация из различных нейросетей, которые учатся не на одном, а сразу на нескольких трибанках, что позволяет достичь большой точности. Мы обучили подобную модель и передали ей “Записки Цезаря”. Результат возвращается в формате CoNLL-U: прочитаем его в окружение и посмотрим, что получилось (скачать можно здесь).

library(udpipe)
caesar_pos3 <- udpipe_read_conllu("../files/bg_latinpipe.conllu") |> 
  select(-paragraph_id)
caesar_pos3

Кельты признаны существительным, бельги мужского рода (в поле FEATS), а provinciae – генитив.

9.4 Поле UPOS

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

caesar_pos3 |> 
  filter(upos == "PRON") |> 
  select(token, lemma, upos, xpos)

Посчитать части речи можно так:

upos_counts <- caesar_pos3 |> 
  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() 

Отберем наиболее частотные имена и имена собственные.

nouns <- caesar_pos3  |> 
  filter(upos %in% c("NOUN", "PROPN")) |> 
  count(lemma) |> 
  arrange(-n) 

nouns
library(wordcloud)
Loading required package: RColorBrewer
library(RColorBrewer)

pal <- RColorBrewer::brewer.pal(8, "Dark2")

wordcloud(nouns$lemma, nouns$n, colors = pal, max.words = 130)

9.5 Поле FEATS

Допустим, нам нужны не все местоимения, а лишь определенные их формы: например, относительные.

rel_pron <- caesar_pos3  |> 
  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-тегов для латинского языка можно найти по ссылке.

Для удобства разобьем xpos на 9 столбцов.

caesar_pos3_sep <- caesar_pos3 |> 
  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

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

pron_rel_sum <- caesar_pos3_sep  |> 
  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",
                           casus == "g" ~ "gen",
                           casus == "d" ~ "dat",
                           casus == "a" ~ "acc",
                           casus == "b" ~ "abl")) |> 
  mutate(numerus = case_when(numerus == "s" ~ "sing",
                              numerus == "p" ~ "plur"))

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)

sent <- caesar_pos3 |> 
  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 Сочетания признаков

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

# адноминальные предложения
acl_ids <- caesar_pos3 |> 
  filter(str_detect(dep_rel, "acl:relcl")) |> 
  unite(id, c("doc_id", "sentence_id")) |> 
  pull(id)
acl <- caesar_pos3 |> 
  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

Посмотрим на одно из таких предложений, в котором проявилась характерная для Цезаря черта: повторять антецедент относительного местоимения в придаточном. Например, вместо “было два пути, которыми…” он говорит “было два пути, каковыми путями…”.

example_sentence <- acl |> 
  filter(id == "doc1_43") |> 
  select(-sentence, -deps, -misc) |> 
  relocate(dep_rel, .before = upos) |> 
  relocate(head_token_id, .before = upos)

example_sentence

Такие случаи можно попробовать выловить при помощи условия или нескольких условий, например достать такие относительные местоимения, сразу за которыми стоит их вершина:

out <- acl |> 
  filter(str_detect(feats, "PronType=Rel") & 
        dep_rel == "det" & 
        head_token_id == (token_id + 1)) |> 
  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 слов.

Код ниже позволяет выяснить, какие существительные встречаются в одном предложении:

caesar_subset <-  subset(caesar_pos3, upos == "NOUN")
cooc <- cooccurrence(caesar_subset, term = "lemma", group = c("doc_id", "sentence_id")) |>
  as_tibble() |> 
  filter(cooc > 25)

cooc

Этот результат легко визуализировать, используя пакет ggraph (подробнее о нем мы будем говорить в следующих уроках):

library(igraph)
library(ggraph)

wordnetwork <- graph_from_data_frame(cooc)
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")

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

cooc2 <- cooccurrence(caesar_subset$lemma, relevant = caesar_subset$upos %in% c("NOUN", "ADJ"), skipgram = 1) |> 
  as_tibble() |> 
  filter(cooc > 10)

cooc2
wordnetwork <- graph_from_data_frame(cooc2)

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()