7  Регулярные выражения

Есть старая шутка, ее приписывают программисту Джейми Завински: если у вас есть проблема, и вы собираетесь ее решать при помощи регулярных выражений, то у вас две проблемы. Регулярные выражения – это формальный язык, который используется для того, чтобы находить, извлекать и заменять части текста.

Регулярные выражения (regex, regexp) объединяют буквальные символы (литералы) и метасимволы (символы-джокеры, англ. wildcard characters).

Для поиска используется строка-образец (англ. pattern, по-русски её часто называют “шаблоном”, “маской”), которая задает правило поиска. Строка замены также может содержать в себе специальные символы.

Отличный путеводитель по миру регулярных выражений можно найти здесь.

7.1 Regex в базовом R

В базовом R за работу со строками отвечают, среди прочего, такие функции, как grep() и grepl(). При этом grepl() возвращает TRUE, если шаблон найден в соответствующей символьной строке, а grep() возвращает вектор индексов символьных строк, содержащих паттерн.

Обеим функциям необходим аргумент pattern и аргумент x, где pattern - регулярное выражение, по которому производится поиск, а аргумент x - вектор символов, по которым следует искать совпадения.

Функция gsub() позволяет производить замену и требует также аргумента replacement.

7.2 Литералы и классы

Буквальные символы – это то, что вы ожидаете увидеть (или не увидеть – для управляющих и пробельных символов); можно сказать, что это символы, которые ничего не “имеют в виду”. Их можно объединять в классы при помощи квадратных скобок, например, так: [abc].

vec <- c("a", "d", "c")
grepl("[abc]", vec)
[1]  TRUE FALSE  TRUE
grep("[abc]", vec)
[1] 1 3

Для некоторых классов есть специальные обозначения.

Класс Эквивалент Значение
[:upper:] [A-Z] Символы верхнего регистра
[:lower:] [a-z] Символы нижнего регистра
[:alpha:] [[:upper:][:lower:]] Буквы
[:digit:] [0-9], т. е. \d Цифры
[:alnum:] [[:alpha:][:digit:]] Буквы и цифры
[:word:] [[:alnum:]_], т. е. \w Символы, образующие «слово»
[:punct:] [-!“#$%&’()*+,./:;<=>?@[\]_`{|}~] Знаки пунктуации
[:blank:] [\s\t] Пробел и табуляция
[:space:] [[:blank:]\v\r\n\f], т. е. \s Пробельные символы
[:cntrl:] Управляющие символы (перевод строки, табуляция и т.п.)
[:graph:] Печатные символы
[:print:] Печатные символы с пробелом

Эти классы тоже можно задавать в качестве паттерна.

vec <- c("жираф", "верблюд1", "0зебра")
gsub( "[[:digit:]]",  "", vec)
[1] "жираф"   "верблюд" "зебра"  


Задание

В пакете stringr есть небольшой датасет words. Найдите все слова с последовательностью символов wh. Сколько слов содержат два гласных после w?


В качестве классов можно рассматривать и следующие обозначения:

Представление Эквивалент Значение
\d [0-9] Цифра
\D [^\\d] Любой символ, кроме цифры
\w [A-Za-zА-Яа-я0-9_] Символы, образующие «слово» (буквы, цифры и символ подчёркивания)
\W [^\\w] Символы, не образующие «слово»
\s [ \t\v\r\n\f] Пробельный символ
\S [^\\s] Непробельный символ
gsub( "\\d",  "", vec) # вторая косая черта "экранирует" первую
[1] "жираф"   "верблюд" "зебра"  

Внутри квадратных скобор знак ^ означает отрицание:

gsub( "[^[:digit:]]",  "", vec) 
[1] ""  "1" "0"


Задание

Найдите все слова в words, в которых за w следует согласный. Замените всю пунктуацию в строке “tomorrow?and-tomorrow_and!tomorrow” на пробелы.


7.3 Якоря

Якоря позволяют искать последовательности символов в начале или в конце строки. Знак ^ (вне квадратных скобок!) означает начало строки, а знак $ – конец. Мнемоническое правило: First you get the power (^) and then you get the money ($).

vec <- c("The spring is a lovely time", 
         "Fall is a time of peace")
grepl("time$", vec)
[1]  TRUE FALSE


Задание

Найдите все слова в words, которые заканчиваются на x. Найдите все слова, которые начинаются на b или на g.


7.4 Метасимволы

Все метасимволы представлены в таблице ниже.

Описание Символ
открывающая квадратная скобка [
закрывающая квадратная скобка ]
обратная косая черта \
карет ^
знак доллара $
точка .
вертикальная черта |
знак вопроса ?
астериск *
плюс +
открывающая фигурная скобка {
закрывающая фигурная скобка }
открывающая круглая скобка (
закрывающая круглая скобка )

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

vec <- c("жираф", "верблюд1", "0зебра")
grep(".б", vec) 
[1] 2 3


Задание

Найдите все слова в words, в которых есть любые два символа между b и k.


7.5 Экранирование

Если необходимо найти буквальную точку, буквальный знак вопроса и т.п., то используется экранирование: перед знаком ставится косая черта. Но так как сама косая черта – это метасимвол, но нужно две косые черты, первая из которых экранирует вторую.

vec <- c("жираф?", "верблюд.", "зебра")
grep("\\?", vec) 
[1] 1
grepl("\\.", vec)
[1] FALSE  TRUE FALSE


Задание

Узнайте, все ли предложения в sentences (входит в stringr) кончаются на точку.


7.6 Квантификация

Квантификатор после символа, символьного класса или группы определяет, сколько раз предшествующее выражение может встречаться. Квантификатор может относиться более чем к одному символу в регулярном выражении, только если это символьный класс или группа.

Представление Число повторений Эквивалент
? Ноль или одно {0,1}
* Ноль или более {0,}
+ Одно или более {1,}

Пример:

vec <- c("color", "colour", "colouur")
grepl("ou?r", vec) # ноль или одно 
[1]  TRUE  TRUE FALSE
grepl("ou+r", vec) # одно или больше
[1] FALSE  TRUE  TRUE
grepl("ou*r", vec) # ноль или больше
[1] TRUE TRUE TRUE

Точное число повторений (интервал) можно задать в фигурных скобках:

Представление Число повторений
{n} Ровно n раз
{m,n} От m до n включительно
{m,} Не менее m
{,n} Не более n
vec <- c("color", "colour", "colouur", "colouuuur")
grepl("ou{1}r", vec)
[1] FALSE  TRUE FALSE FALSE
grepl("ou{1,2}r", vec)
[1] FALSE  TRUE  TRUE FALSE
grepl("ou{,2}r", vec) # это включает и ноль!
[1]  TRUE  TRUE  TRUE FALSE

Часто используется последовательность .* для обозначения любого количества любых символов между двумя частями регулярного выражения.

Задание

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

7.7 Жадная и ленивая квантификация

В регулярных выражениях квантификаторам соответствует максимально длинная строка из возможных (квантификаторы являются жадными, англ. greedy). Это может оказаться значительной проблемой. Например, часто ожидают, что выражение <.*> найдёт в тексте теги HTML. Однако если в тексте есть более одного HTML-тега, то этому выражению соответствует целиком строка, содержащая множество тегов.

vec <- c("<p><b>Википедия</b> — свободная энциклопедия, в которой <i>каждый</i> может изменить или дополнить любую статью.</p>")
gsub("<.*>", "", vec) # все исчезло!
[1] ""

Чтобы этого избежать, надо поставить после квантификатора знак вопроса. Это сделает его ленивым.

regex значение
?? 0 или 1, лучше 0
*? 0 или больше, как можно меньше
+? 1 или больше, как можно меньше
{n,m}? от n до m, как можно меньше

Пример:

gsub("<.*?>", "", vec) # все получилось!
[1] "Википедия — свободная энциклопедия, в которой каждый может изменить или дополнить любую статью."
Задание

Дана строка “tomorrow (and) tomorrow (and) tomorrow”. Необходимо удалить первые скобки с их содержанием.

7.8 Regex в stringr: основы

Пакет stringr не является частью tidyverse, хотя и разделяет его принципы1. Его надо загружать отдельно:

library(stringr)

Это очень удобный инструмент для работы со строками. Вот так можно узнать длину строки или объединить ее с другими строками:

vec <- c("жираф", "верблюд")
str_length(vec)
[1] 5 7
str_c("красивый_", vec)
[1] "красивый_жираф"   "красивый_верблюд"

Элементы вектора можно объединить в одну строку:

str_c(vec, collapse = ", ") # теперь у них общие кавычки
[1] "жираф, верблюд"

С помощью str_sub() и str_sub_all() можно выбрать часть строки2.

vec <- c("жираф", "верблюд")
str_sub(vec, 1, 3)
[1] "жир" "вер"
str_sub(vec, 1, -2)
[1] "жира"   "верблю"

Функции ниже меняют начертание с прописного на строчное или наоборот:

VEC <- str_to_upper(vec)
VEC
[1] "ЖИРАФ"   "ВЕРБЛЮД"
str_to_lower(VEC)
[1] "жираф"   "верблюд"
str_to_title(vec)
[1] "Жираф"   "Верблюд"

Одна из полезнейших функций в этом пакете – str_view(); она помогает увидеть, что поймало регулярное выражение – до того, как вы внесете какие-то изменения в строку.

str_view(c("abc", "a.c", "bef"), "a\\.c")
[2] │ <a.c>

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

str_view(c("grey", "gray"), "gr(e|a)y")
[1] │ <grey>
[2] │ <gray>


Задание

Создайте тиббл с двумя столбцами: letters и numbers (1:26). Преобразуйте, чтобы в третьем столбце появился результат соединения первых двух через подчеркивание, например a_1. Отфильтруйте, чтобы остались только ряды, где есть цифра 3 или буква x.


7.9 str_detect() и str_count()

Аналогом grepl() в stringr является функция str_detect()

library(rcorpora)
data("fruit")
head(fruit)
[1] "apple"       "apricot"     "avocado"     "banana"      "bell pepper"
[6] "bilberry"   
str_detect(head(fruit), "[aeiou]$")
[1]  TRUE FALSE  TRUE  TRUE FALSE FALSE
# какая доля слов заканчивается на гласный?
mean(str_detect(fruit, "[aeiou]$"))
[1] 0.35
# сколько всего слов заканчивается на гласный?
sum(str_detect(fruit, "[aeiou]$"))
[1] 28

Отрицание можно задать двумя способами:

data("words")

no_vowels1 <- !str_detect(words, "[aeiou]") # слова без гласных

no_vowels2 <- str_detect(words, "^[^aeiou]+$") # слова без гласных

sum(no_vowels1 != no_vowels2)
[1] 0

Логический вектор можно использовать для индексирования:

words[!str_detect(words, "[aeiou]")]
[1] "by"  "dry" "fly" "mrs" "try" "why"

Эту функцию можно применять вместе с функцией filter() из пакета dplyr:

library(dplyr)
gods <- corpora(which = "mythology/greek_gods")

df <- tibble(god = as.character(gods$greek_gods), 
             i = seq_along(god)
             )

df |> 
  filter(str_detect(god, "s$"))

Вариацией этой функции является str_count():

str_count(as.character(gods$greek_gods), "[Aa]")
 [1] 1 1 1 1 2 0 0 1 1 1 0 1 0 0 1 2 1 0 0 0 0 1 2 1 0 2 3 2 1 0 0

Эту функцию удобно использовать вместе с mutate() из dplyr:

df |> 
  mutate(
    vowels = str_count(god, "[AEIOYaeiou]"),
    consonants = str_count(god, "[^AEIOYaeiou]")
  )
Задание

Преобразуйте sentences из пакета stringr в тиббл; в новом столбце сохраните количество пробелов в каждом предложении.

7.10 str_extract(), str_subset() и str_match()

Функция str_extract() извлекает совпадения3.

Сначала зададим паттерн для поиска.

colours <- c(" red", "orange", "yellow", "green", "blue", "purple")
colour_match <- str_c(colours, collapse = "|")
colour_match
[1] " red|orange|yellow|green|blue|purple"

И применим к предложениями. Используем str_extract_all(), т.к. str_extract() возвращает только первое вхождение.

has_colour <- str_subset(sentences, colour_match)
matches <- str_extract_all(has_colour, colour_match)
head(unlist(matches))
[1] "blue"   "blue"   "blue"   "yellow" "green"  " red"  

Круглые скобки используются для группировки. Например, мы можем задать шаблон для поиска существительного или прилагательного с артиклем.

noun <- "(a|the) ([^ ]+)" # как минимум один непробельный символ после пробела

has_noun <- sentences |>
  str_subset(noun) |>
  head(10)
has_noun
 [1] "The birch canoe slid on the smooth planks."       
 [2] "Glue the sheet to the dark blue background."      
 [3] "It's easy to tell the depth of a well."           
 [4] "These days a chicken leg is a rare dish."         
 [5] "The box was thrown beside the parked truck."      
 [6] "The boy was there when the sun rose."             
 [7] "The source of the huge river is the clear spring."
 [8] "Kick the ball straight and follow through."       
 [9] "Help the woman get back to her feet."             
[10] "A pot of tea helps to pass the evening."          

Дальше можно воспользоваться уже известной функцией str_extract() или применить str_match. Результат будет немного отличаться: вторая функция вернет матрицу, в которой хранится не только сочетание слов, но и каждый компонент отдельно.

has_noun |> 
  str_extract(noun)
 [1] "the smooth" "the sheet"  "the depth"  "a chicken"  "the parked"
 [6] "the sun"    "the huge"   "the ball"   "the woman"  "a helps"   
has_noun |> 
  str_match(noun)
      [,1]         [,2]  [,3]     
 [1,] "the smooth" "the" "smooth" 
 [2,] "the sheet"  "the" "sheet"  
 [3,] "the depth"  "the" "depth"  
 [4,] "a chicken"  "a"   "chicken"
 [5,] "the parked" "the" "parked" 
 [6,] "the sun"    "the" "sun"    
 [7,] "the huge"   "the" "huge"   
 [8,] "the ball"   "the" "ball"   
 [9,] "the woman"  "the" "woman"  
[10,] "a helps"    "a"   "helps"  

Функция tidyr::extract работает похожим образом, но требует дать имена для каждого элемента группы. Этим удобно пользоваться, если ваши данные хранятся в виде тиббла.

tibble(sentence = sentences) |> 
  tidyr::extract(
    sentence, c("article", "noun"), "(a|the) ([^ ]+)", 
    remove = FALSE
  )


Задание

Найдите в sentences все предложения, где есть to, и выберите следующее за этим слово. Переведите в нижний регистр. Узнайте, сколько всего уникальных сочетаний.


7.11 str_replace

Функции str_replace() и str_replace_all() позволяют заменять совпадения на новые символы.

x <- c("apple", "pear", "banana")
str_replace(x, "[aeiou]", "-")
[1] "-pple"  "p-ar"   "b-nana"
str_replace_all(x, "[aeiou]", "-")
[1] "-ppl-"  "p--r"   "b-n-n-"

Этим можно воспользоваться, если вы хотите, например, удалить из текста все греческие символы. Для стандартного греческого алфавита хватит [Α-Ωα-ω], но для древнегреческого этого, например, не хватит. Попробуем на отрывке из письма Цицерона Аттику, которое содержит греческий текст.

cicero <- "nihil hāc sōlitūdine iūcundius, nisi paulum interpellāsset Amyntae fīlius. ὢ ἀπεραντολογίας ἀηδοῦς! "

str_replace_all(cicero, "[Α-Ωα-ω]", "")
[1] "nihil hāc sōlitūdine iūcundius, nisi paulum interpellāsset Amyntae fīlius. ὢ ἀί ἀῦ! "

ὢ ἀί ἀῦ! Не все у нас получилось гладко. Попробуем иначе:

str_replace_all(cicero, "[\u0370-\u03FF]", "")
[1] "nihil hāc sōlitūdine iūcundius, nisi paulum interpellāsset Amyntae fīlius. ὢ ἀ ἀῦ! "

Удалилась (буквально была заменена на пустое место) та диакритика, которая есть в новогреческом (ί). Но остались еще буквы со сложной диакритикой, которой современные греки не пользуются.

no_greek <- str_replace_all(cicero, "[[\u0370-\u03FF][\U1F00-\U1FFF]]", "")
no_greek
[1] "nihil hāc sōlitūdine iūcundius, nisi paulum interpellāsset Amyntae fīlius.   ! "

! Мы молодцы. Избавились от этого непонятного греческого.

На самом деле, конечно, str_replace хорош тем, что он позволяет производить осмысленные замены. Например, мы можем в оставшемся латинском текст заменить гласные с макроном (черточка, означающая долготу) на обычные гласные.

str_replace_all(no_greek, c("ā" = "a", "ū" = "u", "ī" = "i", "ō" = "o"))
[1] "nihil hac solitudine iucundius, nisi paulum interpellasset Amyntae filius.   ! "

Красота. О более сложных заменах с перемещением групп можно посмотреть видео здесь и здесь. Это помогает даже в таком скорбном деле, как переоформление библиографии.

Задание

Дана библиографическая запись:

Ast, Friedrich. 1816. Platon’s Leben und Schriften. Leipzig, Weidmann.

Используя регулярные выражения, замените полное имя на инициал. Запятую перед инициалом удалите. Уберите название издательства. Год поставьте в круглые скобки.

7.12 str_split

Функция str_split() помогает разбить текст на предложения, слова или просто на бессмысленные наборы символов. Это важный этап подготовки текста для анализа, и проводится он нередко именно с применением регулярных выражений.

sentences |>
  head(2) |> 
  str_split(" ")
[[1]]
[1] "The"     "birch"   "canoe"   "slid"    "on"      "the"     "smooth" 
[8] "planks."

[[2]]
[1] "Glue"        "the"         "sheet"       "to"          "the"        
[6] "dark"        "blue"        "background."

Но можно обойтись и без регулярных выражений.

x <- "This is a sentence.  This is another sentence."
str_view_all(x, boundary("word"))
[1] │ <This> <is> <a> <sentence>.  <This> <is> <another> <sentence>.
str_view_all(x, boundary("sentence"))
[1] │ <This is a sentence.  ><This is another sentence.>

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

apology <- c("νῦν δ' ἐπειδὴ ἀνθρώπω ἐστόν, τίνα αὐτοῖν ἐν νῷ ἔχεις ἐπιστάτην λαβεῖν; τίς τῆς τοιαύτης ἀρετῆς, τῆς ἀνθρωπίνης τε καὶ πολιτικῆς, ἐπιστήμων ἐστίν; οἶμαι γάρ σε ἐσκέφθαι διὰ τὴν τῶν ὑέων κτῆσιν. ἔστιν τις,” ἔφην ἐγώ, “ἢ οὔ;” “Πάνυ γε,” ἦ δ' ὅς. “Τίς,” ἦν δ' ἐγώ, “καὶ ποδαπός, καὶ πόσου διδάσκει;")

str_view_all(apology, boundary("sentence"))
[1] │ <νῦν δ' ἐπειδὴ ἀνθρώπω ἐστόν, τίνα αὐτοῖν ἐν νῷ ἔχεις ἐπιστάτην λαβεῖν; τίς τῆς τοιαύτης ἀρετῆς, τῆς ἀνθρωπίνης τε καὶ πολιτικῆς, ἐπιστήμων ἐστίν; οἶμαι γάρ σε ἐσκέφθαι διὰ τὴν τῶν ὑέων κτῆσιν. ἔστιν τις,” ἔφην ἐγώ, “ἢ οὔ;” “Πάνυ γε,” ἦ δ' ὅς. ><“Τίς,” ἦν δ' ἐγώ, “καὶ ποδαπός, καὶ πόσου διδάσκει;>

Полный крах 💩


  1. https://r4ds.had.co.nz/strings.html↩︎

  2. https://stringr.tidyverse.org/reference/str_sub.html↩︎

  3. https://r4ds.had.co.nz/strings.html#extract-matches↩︎