Тема 10 Токенизация и лемматизация

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

Визуально процесс токенизации можно представить так40:

Токенизировать можно в базовом R, и Jockers (2014) прекрасно показывает, как это можно делать. Но вы воспользуемся двумя пакетами, которые предназначены специально для работы с текстовыми данными и разделяют идеологию tidyverse. Оба пакета придется загрузить отдельно.

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

Для их освоения рекомендую изучить две книги: Silge and Robinson (2017) и Hvitfeldt and Silge (2022). Обе доступны бесплатно онлайн. Обе содержат множество примеров для английских текстов. Для разнообразия я покажу, как это работает на русских текстах (потому что латинские и древнегреческие никому не интересны).

Для анализа я снова (ср. урок 6) загружу “Бедную Лизу” Карамзина, на этот раз полностью.

liza <- readLines(con = "files/karamzin_liza.txt") 
class(liza)
## [1] "character"
length(liza)
## [1] 46
nchar(liza)
##  [1] 1045  505 1524  218  285  999  254  658 1149  629  121  284
## [13]  170  252  701 1091  632  936 1726   96  698  167  985  316
## [25] 1323 1844  763 1104  617  959 1191  305 1433  119  830  414
## [37]  257 1218  977  225  513 1695  132  214  251  267
liza[1]
## [1] "    Может быть, никто из живущих в Москве не знает так хорошо окрестностей города сего, как я, потому что никто чаще моего не бывает в поле, никто более моего не бродит пешком, без плана, без цели -- куда глаза глядят -- по лугам и рощам, по холмам и равнинам. Всякое лето нахожу новые приятные места или в старых новые красоты. Но всего приятнее для меня то место, на котором возвышаются мрачные, готические башни Си...нова монастыря. Стоя на сей горе, видишь на правой стороне почти всю Москву, сию ужасную громаду домов и церквей, которая представляется глазам в образе величественного амфитеатра: великолепная картина, особливо когда светит на нее солнце, когда вечерние лучи его пылают на бесчисленных златых куполах, на бесчисленных крестах, к небу возносящихся! Внизу расстилаются тучные, густо-зеленые цветущие луга, а за ними, по желтым пескам, течет светлая река, волнуемая легкими веслами рыбачьих лодок или шумящая под рулем грузных стругов, которые плывут от плодоноснейших стран Российской империи и наделяют алчную Москву хлебом. "

10.1 Токенизация в tidytext

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

liza_tbl <- as_tibble(liza) %>% rename(text = value)
liza_tbl
## # A tibble: 46 × 1
##    text                                                            
##    <chr>                                                           
##  1 "    Может быть, никто из живущих в Москве не знает так хорошо …
##  2 "       На другой стороне реки видна дубовая роща, подле которо…
##  3 "       Часто прихожу на сие место и почти всегда встречаю там …
##  4 "       Но всего чаще привлекает меня к стенам Си...нова монаст…
##  5 "       Саженях в семидесяти от монастырской стены, подле берез…
##  6 "       Отец Лизин был довольно зажиточный поселянин, потому чт…
##  7 "       \"Бог дал мне руки, чтобы работать, -- говорила Лиза, -…
##  8 "       Но часто нежная Лиза не могла удержать собственных слез…
##  9 "       Прошло два года после смерти отца Лизина. Луга покрылис…
## 10 "       Лиза, пришедши домой, рассказала матери, что с нею случ…
## # ℹ 36 more rows

Этот текст мы передаем функции unnest_tokens(), которая принимает следующие аргументы:

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() с разными аргументами:

params <- tribble(
  ~tbl, ~output, ~input, ~token,
  liza_tbl[1,], "word", "text", "words", 
  liza_tbl[1,], "sentence", "text", "sentences",
  liza_tbl[1,], "char", "text", "characters", 
)

params %>% pmap(unnest_tokens) %>% head()
## [[1]]
## # A tibble: 159 × 1
##    word   
##    <chr>  
##  1 может  
##  2 быть   
##  3 никто  
##  4 из     
##  5 живущих
##  6 в      
##  7 москве 
##  8 не     
##  9 знает  
## 10 так    
## # ℹ 149 more rows
## 
## [[2]]
## # A tibble: 5 × 1
##   sentence                                                         
##   <chr>                                                            
## 1 может быть, никто из живущих в москве не знает так хорошо окрест…
## 2 всякое лето нахожу новые приятные места или в старых новые красо…
## 3 но всего приятнее для меня то место, на котором возвышаются мрач…
## 4 стоя на сей горе, видишь на правой стороне почти всю москву, сию…
## 5 внизу расстилаются тучные, густо-зеленые цветущие луга, а за ним…
## 
## [[3]]
## # A tibble: 846 × 1
##    char 
##    <chr>
##  1 м    
##  2 о    
##  3 ж    
##  4 е    
##  5 т    
##  6 б    
##  7 ы    
##  8 т    
##  9 ь    
## 10 н    
## # ℹ 836 more rows

Следующие значения аргумента token требуют также аргумента n:

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

params %>% pmap(unnest_tokens) %>% head()
## [[1]]
## # A tibble: 157 × 1
##    ngram                  
##    <chr>                  
##  1 может быть никто       
##  2 быть никто из          
##  3 никто из живущих       
##  4 из живущих в           
##  5 живущих в москве       
##  6 в москве не            
##  7 москве не знает        
##  8 не знает так           
##  9 знает так хорошо       
## 10 так хорошо окрестностей
## # ℹ 147 more rows
## 
## [[2]]
## # A tibble: 844 × 1
##    shingles
##    <chr>   
##  1 мож     
##  2 оже     
##  3 жет     
##  4 етб     
##  5 тбы     
##  6 быт     
##  7 ыть     
##  8 тьн     
##  9 ьни     
## 10 ник     
## # ℹ 834 more rows


Воспроизведите код из книги Silge and Robinson (2017) (ниже). Объясните, что делает каждая строчка кода. Разбейте книги на словесные и символьные энграмы.


library(janeaustenr)
library(dplyr)
library(tidytext)
library(stringr)

original_books <- austen_books() %>% 
  group_by(book) %>% 
  mutate(text = str_to_lower(text),
         chapter = cumsum(str_detect(text, "^chapter [\\divxlc]"))) %>% 
  ungroup()

tidy_books <- original_books %>% 
  unnest_tokens(word, text)


Какая словесная 3-грама чаще всего встречается в первой главе “Pride & Prejudice”? Впишите в поле ниже (после последней буквы не должно быть пробела). Если ответов несколько, впишите любой.


10.2 Токенизация в tokenizers

При работе с данными в текстовом формате unnest_tokens() опирается на пакет tokenizers, но tokenize_words требует на входе вектор, а не тиббл. Несколько полезных аргументов, о которых стоит помнить: strip_non_alphanum (удаляет пробельные символы и пунктуацию), strip_punct (удаляет пунктуацию), strip_numeric (удаляет числа).

words_no_punct <- tokenize_words(liza[1], strip_punct = T)
words_no_punct[[1]][25:40]
##  [1] "поле"   "никто"  "более"  "моего"  "не"     "бродит" "пешком"
##  [8] "без"    "плана"  "без"    "цели"   "куда"   "глаза"  "глядят"
## [15] "по"     "лугам"
words_punct <- tokenize_words(liza[1], strip_punct = F)
words_punct[[1]][25:40]
##  [1] "не"     "бывает" "в"      "поле"   ","      "никто"  "более" 
##  [8] "моего"  "не"     "бродит" "пешком" ","      "без"    "плана" 
## [15] ","      "без"


Вызовите документацию к unnest_tokens() и уточните, можно ли передать ей аргументы функций из пакета tokenizers. Модифицируйте код выше, чтобы узнать, сколько раз встречается запятая в пятой главе “Sense & Sensibility”.


10.3 Скипграмы

Скипграмы, или n-грамы с пропусками, применяются в некоторых языковых моделях.

skipgrams <- tokenize_skip_ngrams(liza[1], n=3) 
skipgrams[[1]][1:10]
##  [1] "может"               "может быть"         
##  [3] "может никто"         "может быть никто"   
##  [5] "может быть из"       "может никто из"     
##  [7] "может никто живущих" "быть"               
##  [9] "быть никто"          "быть из"

Функция считает все энграмы длиной до трех включительно (при этом по умолчанию аргумент k, т.е. величина “пропуска” = 1). Чтобы считать только 3-грамы, надо немного поправить код:

skipgrams <- tokenize_skip_ngrams(liza[1], n=3, n_min = 3) 
skipgrams[[1]][1:10]
##  [1] "может быть никто"    "может быть из"      
##  [3] "может никто из"      "может никто живущих"
##  [5] "быть никто из"       "быть никто живущих" 
##  [7] "быть из живущих"     "быть из в"          
##  [9] "никто из живущих"    "никто из в"

Важно выбрать правильное значение n при использовании энграм.


Посчитайте скипграмы (n = 3, k = 1) в “Pride & Prejudice”. Сколько уникальных энграм вы получили?

10.4 Лемматизация и частеречная разметка

Помимо деления на токены, предварительная обработка текста может включать в себя лемматизацию, то есть приведение слов к начальной форме (лемме) и синтаксическую разметку.

Для аннотации мы воспользуемся морфологическим и синтаксическим анализатором UDPipe (Universal Dependencies Pipeline), который существует в виде одноименного пакета в R. В отличие от других анализаторов, доступных в R, он позволяет работать со множеством языков (всего 65), для многих из которых представлено несколько моделей, обученных на разных данных.

Прежде всего нужно выбрать и загрузить модель для (список). Модель GSD-Russian41, с которой мы начнем работу, обучена на статьях в Википедии, и, вероятно, не очень подойдет для наших задач – но можно попробовать.

library(udpipe)

# скачиваем модель в рабочую директорию
# udpipe_download_model(language = "russian-gsd")

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

Модели передается вектор с текстом.

liza_ann <- udpipe_annotate(russian_gsd, liza)

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

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


Переведение всех символов в нижний регистр может ухудшить качество лемматизации!


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

liza_df <- as_tibble(liza_ann) %>% 
  select(-sentence, -paragraph_id)

str(liza_df)
## tibble [6,447 × 12] (S3: tbl_df/tbl/data.frame)
##  $ doc_id       : chr [1:6447] "doc1" "doc1" "doc1" "doc1" ...
##  $ sentence_id  : int [1:6447] 1 1 1 1 1 1 1 1 1 1 ...
##  $ token_id     : chr [1:6447] "1" "2" "3" "4" ...
##  $ token        : chr [1:6447] "Может" "быть" "," "никто" ...
##  $ lemma        : chr [1:6447] "мочь" "быть" "," "никто" ...
##  $ upos         : chr [1:6447] "VERB" "AUX" "PUNCT" "PRON" ...
##  $ xpos         : chr [1:6447] "VBC" "VB" "," "DT" ...
##  $ feats        : chr [1:6447] "Aspect=Imp|Mood=Ind|Number=Sing|Person=3|Tense=Pres|VerbForm=Fin|Voice=Act" "Aspect=Imp|VerbForm=Inf" NA "Animacy=Anim|Case=Nom|Gender=Masc|Number=Sing" ...
##  $ head_token_id: chr [1:6447] "0" "4" "2" "10" ...
##  $ dep_rel      : chr [1:6447] "root" "cop" "punct" "nsubj" ...
##  $ deps         : chr [1:6447] NA NA NA NA ...
##  $ misc         : chr [1:6447] "SpacesBefore=\\s\\s\\s\\s" "SpaceAfter=No" NA NA ...

Выведем часть (!) столбцов для первого предложения:

liza_df %>% 
  filter(doc_id == "doc1") %>% 
  select(-sentence_id, -head_token_id, -deps, -dep_rel, -misc) %>% 
  DT::datatable()

Если полистать эту таблицу, можно заметить несколько ошибок, например странное существительное “пешко” (наречие “пешком” понято как форма творительного падежа). Но, как уже говорилось, для некоторых языков, в том числе русского, в uppide представлено несколько моделей, некоторые из которых лучше справляются с текстами определенных жанров. Попробуем использовать другую модель, обученную на корпусе СинТагРус (сокр. от англ. Syntactically Tagged Russian text corpus, «синтаксически аннотированный корпус русских текстов»)42.

#  скачиваем модель в рабочую директорию
# udpipe_download_model(language = "russian-syntagrus")

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

liza_ann <- udpipe_annotate(russian_syntagrus, liza)
liza_df <- as_tibble(liza_ann) %>% 
  select(-paragraph_id, -sentence, -xpos)

liza_df %>% 
  filter(doc_id == "doc1") %>% 
  select(-sentence_id, -head_token_id, -deps, -dep_rel, -misc) %>% 
  DT::datatable()

Здесь “пешком” корректно обозначено как наречие; в целом, кажется, вторая модель лучше справилась с задачей.

10.5 Морфологическая разметка

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

propn <- liza_df %>% 
  filter(upos == "PROPN") 

propn[,2:6]
## # A tibble: 164 × 5
##    sentence_id token_id token       lemma       upos 
##          <int> <chr>    <chr>       <chr>       <chr>
##  1           1 8        Москве      Москва      PROPN
##  2           3 16       Си...нова   Си...нов    PROPN
##  3           4 12       Москву      Москва      PROPN
##  4           5 45       Москву      Москва      PROPN
##  5           2 11       Данилов     Данилов     PROPN
##  6           2 23       Воробьевы   Воробьев    PROPN
##  7           3 21       Коломенское Коломенский PROPN
##  8           9 32       Москва      Москва      PROPN
##  9           1 14       Лизы        Лиза        PROPN
## 10           2 13       Лиза        Лиза        PROPN
## # ℹ 154 more rows

С помощью функции str_detect() можно выбрать конкретные формы, например, винительный падеж.

liza_df %>% filter(str_detect(feats, "Case=Acc"))
## # A tibble: 558 × 11
##    doc_id sentence_id token_id token    lemma    upos  feats       
##    <chr>        <int> <chr>    <chr>    <chr>    <chr> <chr>       
##  1 doc1             1 28       поле     поле     NOUN  Animacy=Ina…
##  2 doc1             2 4        новые    новый    ADJ   Animacy=Ina…
##  3 doc1             2 5        приятные приятный ADJ   Animacy=Ina…
##  4 doc1             2 6        места    место    NOUN  Animacy=Ina…
##  5 doc1             2 10       новые    новый    ADJ   Animacy=Ina…
##  6 doc1             2 11       красоты  красота  NOUN  Animacy=Ina…
##  7 doc1             4 3        сей      сей      DET   Case=Acc|Ge…
##  8 doc1             4 4        горе     горе     NOUN  Animacy=Ina…
##  9 doc1             4 11       всю      весь     DET   Case=Acc|Ge…
## 10 doc1             4 12       Москву   Москва   PROPN Animacy=Ina…
## # ℹ 548 more rows
## # ℹ 4 more variables: head_token_id <chr>, dep_rel <chr>,
## #   deps <chr>, misc <chr>


Аннотируйте первую главу романа “Гордость и предубеждение” с использованием English EWT. Достаньте все наречия и посчитайте их число. Какое наречие встречается чаще всего?


10.6 Распределение частей речи

Литературоведам может быть интересно распределение различных частей речи в повести: так, Бен Блатт задался целью проверить, применительно к англоязычной прозе, знаменитый афоризм Стивена Кинга о том, что «дорога в ад вымощена наречиями». Правда ли, что великие писатели реже используют наречия на -ly? Он получил достаточно любопытные результаты, в частности выяснилось, что Генри Мелвилл и Джейн Остин представляют собой скорее исключение из этого правила, но с двумя важными оговорками: во-первых, в 19 в. наречия в целом используют чаще, чем 20-м; а во-вторых, в признанных шедеврах отдельных авторов наречий, действительно, бывает меньше. Например, в романе Стейнбека «Зима тревоги нашей» их меньше всего. Больше всего наречий у авторов фанфиков, непрофессиональных писателей.

Посчитать части речи (расшифровка тегов UPOS по ссылке) можно так:

liza_df %>% 
  group_by(upos) %>% 
  count() %>% 
  filter(upos != "PUNCT") %>% 
  arrange(-n)
## # A tibble: 14 × 2
## # Groups:   upos [14]
##    upos      n
##    <chr> <int>
##  1 NOUN   1063
##  2 VERB    988
##  3 PRON    619
##  4 ADP     520
##  5 ADJ     408
##  6 ADV     321
##  7 DET     272
##  8 CCONJ   230
##  9 PART    187
## 10 PROPN   164
## 11 SCONJ   142
## 12 AUX      71
## 13 NUM      34
## 14 INTJ     30

Столбиковая диаграмма позволяет наглядно представить такого рода данные:

liza_df %>% 
  group_by(upos) %>% 
  count() %>% 
  filter(upos != "PUNCT") %>% 
  ggplot(aes(x = reorder(upos, n), y = n, fill = upos)) +
  geom_bar(stat = "identity", show.legend = F) +
  coord_flip() +
  theme_bw()

Обратите внимание на некоторое заметное число междометий. Какое междометие встречается здесь чаще всего, можно догадаться 😊


Постройте такую же диаграмму… вы уже поняли, для какого романа 💔 (или одной главы).


Можно отобрать наиболее частотные слова для любой части речи.

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

head(nouns, 10)
## # A tibble: 10 × 2
##    lemma       n
##    <chr>   <int>
##  1 Лиза      107
##  2 Эраст      41
##  3 сердце     24
##  4 глаз       23
##  5 мать       21
##  6 человек    18
##  7 день       17
##  8 рука       17
##  9 друг       16
## 10 слеза      15
library(wordcloud)
library(RColorBrewer)

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

nouns %>%
  with(wordcloud(lemma, n, max.words = 50, colors = pal))

Можно заметить, что в тексте часто встречаются слова “мать”, “матушка”, “старушка” (42 раза): Лизина мать упоминается в тексте так же часто, как Эраст, и чаще, чем слово “сердце” (24). В любовной повести Карамзин чуть ли не чаще говорит о матери героини, чем о её возлюбленном!

10.7 Совместная встречаемость слов

Функция cooccurence() из пакета udpipe позволяет выяснить, сколько раз некий термин встречается совместно с другим термином, например:

  • слова встречаются в одном и том же документе/предложении/параграфе;

  • слова следуют за другим словом;

  • слова находятся по соседству с другим словом на расстоянии n слов.

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

x <-  subset(liza_df, upos %in% c("NOUN", "ADJ"))
cooc <- cooccurrence(x, term = "lemma", group = c("doc_id", "sentence_id"))
head(cooc)
##       term1   term2 cooc
## 1   молодой человек    8
## 2      глаз    друг    6
## 3 последний     раз    6
## 4      глаз   слеза    6
## 5      друг     час    6
## 6      день  другой    4

Этот результат легко визуализировать, используя пакет ggraph:

library(igraph)
library(ggraph)

wordnetwork <- head(cooc, 30)
wordnetwork <- graph_from_data_frame(wordnetwork)
ggraph(wordnetwork, layout = "fr") +
  geom_edge_link(aes(width = cooc, edge_alpha = cooc), edge_colour = "pink") +
  geom_node_text(aes(label = name), col = "darkblue", size = 4) +
  theme_graph(base_family = "Arial Narrow") +
  theme(legend.position = "none") +
  labs(title = "Совместная встречаемость слов", subtitle = "Существительные и прилагательные")

Милый друг, глубокий пруд. Грустная история!

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

cooc <- cooccurrence(x$lemma, relevant = x$upos %in% c("NOUN", "ADJ"), skipgram = 1)
head(cooc)
##       term1   term2 cooc
## 1   молодой человек    8
## 2     берег    река    4
## 3      друг    друг    4
## 4 последний     раз    4
## 5      Лиза    мать    4
## 6      глаз   слеза    3
wordnetwork <- head(cooc, 30)
wordnetwork <- graph_from_data_frame(wordnetwork)

ggraph(wordnetwork, layout = "fr") +
  geom_edge_link(aes(width = cooc, edge_colour = "salmon", edge_alpha=0.7), show.legend = F) +
  geom_node_text(aes(label = name), col = "darkgreen", size = 4, angle=15, repel = T) +
  theme_graph(base_family = "Arial Narrow") +
  labs(title = "Слова, стоящие рядом в тексте", subtitle = "Существительные и прилагательные")

Постройте граф совместной встречаемости для любого романа Джейн Остин.

10.8 Синтаксическая разметка

Для анализа выберем одно предложение.

liza_synt <- liza_ann %>% 
  as.data.frame() 
liza_synt_sel <- liza_synt %>% 
  filter(doc_id == "doc17", sentence_id == 15) %>% 
  filter(token != "-")

liza_synt_sel[,c("token", "token_id", "head_token_id", "dep_rel")]
##        token token_id head_token_id dep_rel
## 1       Лиза        3             5   nsubj
## 2         не        4             5  advmod
## 3 договорила        5             0    root
## 4       речи        6             5     obl
## 5      своей        7             6     det
## 6          .        8             5   punct

Связь между токенами определяется в полях token_id и head_token_id, отношение зависимости определено в dep_rel. Корневой токен имеет значение 0, то есть ни от чего не зависит. Графически изобразить связи поможет пакет textplot.

library(textplot)
textplot_dependencyparser(liza_synt_sel)

Построить граф можно и при помощи библиотек igraph и ggraph:

liza_synt_sel <- liza_synt %>% 
  filter(doc_id == "doc17", sentence_id == 1)

e <- subset(liza_synt_sel, head_token_id != 0, select = c("token_id", "head_token_id", "dep_rel"))
e
##    token_id head_token_id dep_rel
## 2         2             1     obl
## 3         3             7   punct
## 4         4             7      cc
## 5         5             6    amod
## 6         6             7   nsubj
## 7         7             1    conj
## 8         8             9  advmod
## 9         9             7   xcomp
## 10       10             1   punct
e$label <- e$dep_rel
gr <- graph_from_data_frame(e, vertices = liza_synt_sel[, c("token_id", "token", "lemma", "upos", "xpos", "feats")], directed = TRUE)

a <- grid::arrow(type = "closed", length = unit(.1, "inches"))

ggraph(gr, layout = "fr") + 
  geom_edge_link(aes(edge_alpha=0.7, label = dep_rel), 
                 arrow = a, 
                 end_cap = circle(0.07, 'inches'), 
                 show.legend = F,
                 label_colour = "grey30",
                 edge_color = "grey") + 
  geom_node_point(color = "lightblue", size = 4) +
  theme_void(base_family = "") +
  geom_node_text(ggplot2::aes(label = token), nudge_y = 0.2)

Литература

Hvitfeldt, Emil, and Julia Silge. 2022. Supervised Machine Learning for Text Analysis in R. Taylor; Francis.
Jockers, Matthew L. 2014. Text Analysis with r for Students of Literature. Springer.
Silge, Julia, and David Robinson. 2017. Text Mining with r. O’Reilly. http://www.tidytextmining.com.