Тема 8 Регулярные выражения

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

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

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

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

8.1 Regex в базовом R

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

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

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

8.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” на пробелы.


8.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.


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

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

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

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

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


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


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

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

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


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


8.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 за пробелом следует ровно три согласных.

8.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”. Необходимо удалить первые скобки с их содержанием.

8.8 Regex в stringr: основы

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

library(stringr)

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

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

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

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

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

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.


8.9 str_detect() и str_count()

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

library(rcorpora)
data("fruit")
head(fruit)
## [1] "apple"       "apricot"     "avocado"     "banana"     
## [5] "bell pepper" "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$"))
## # A tibble: 18 × 2
##    god            i
##    <chr>      <int>
##  1 Ares           3
##  2 Artemis        4
##  3 Dionysus       7
##  4 Hades          8
##  5 Hephaestus     9
##  6 Hermes        11
##  7 Zeus          14
##  8 Chaos         17
##  9 Chronos       18
## 10 Erebus        19
## 11 Eros          20
## 12 Hypnos        21
## 13 Uranus        22
## 14 Phanes        24
## 15 Pontus        25
## 16 Tartarus      26
## 17 Thanatos      28
## 18 Nemesis       31

Вариацией этой функции является 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
## [29] 1 0 0

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

df %>% 
  mutate(
    vowels = str_count(god, "[AEIOYaeiou]"),
    consonants = str_count(god, "[^AEIOYaeiou]")
  )
## # A tibble: 31 × 4
##    god            i vowels consonants
##    <chr>      <int>  <int>      <int>
##  1 Aphrodite      1      4          5
##  2 Apollo         2      3          3
##  3 Ares           3      2          2
##  4 Artemis        4      3          4
##  5 Athena         5      3          3
##  6 Demeter        6      3          4
##  7 Dionysus       7      3          5
##  8 Hades          8      2          3
##  9 Hephaestus     9      4          6
## 10 Hera          10      2          2
## # ℹ 21 more rows

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

8.10 str_extract(), str_subset() и str_match()

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

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

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" 
##  [5] "the parked" "the sun"    "the huge"   "the ball"  
##  [9] "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
  )
## # A tibble: 720 × 3
##    sentence                                    article noun 
##    <chr>                                       <chr>   <chr>
##  1 The birch canoe slid on the smooth planks.  the     smoo…
##  2 Glue the sheet to the dark blue background. the     sheet
##  3 It's easy to tell the depth of a well.      the     depth
##  4 These days a chicken leg is a rare dish.    a       chic…
##  5 Rice is often served in round bowls.        <NA>    <NA> 
##  6 The juice of lemons makes fine punch.       <NA>    <NA> 
##  7 The box was thrown beside the parked truck. the     park…
##  8 The hogs were fed chopped corn and garbage. <NA>    <NA> 
##  9 Four hours of steady work faced us.         <NA>    <NA> 
## 10 A large size in stockings is hard to sell.  <NA>    <NA> 
## # ℹ 710 more rows


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


8.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.

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

8.12 str_split

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

sentences %>%
  head(2) %>% 
  str_split(" ")
## [[1]]
## [1] "The"     "birch"   "canoe"   "slid"    "on"     
## [6] "the"     "smooth"  "planks."
## 
## [[2]]
## [1] "Glue"        "the"         "sheet"       "to"         
## [5] "the"         "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] │ <νῦν δ' ἐπειδὴ ἀνθρώπω ἐστόν, τίνα αὐτοῖν ἐν νῷ ἔχεις ἐπιστάτην λαβεῖν; τίς τῆς τοιαύτης ἀρετῆς, τῆς ἀνθρωπίνης τε καὶ πολιτικῆς, ἐπιστήμων ἐστίν; οἶμαι γάρ σε ἐσκέφθαι διὰ τὴν τῶν ὑέων κτῆσιν. ἔστιν τις,” ἔφην ἐγώ, “ἢ οὔ;” “Πάνυ γε,” ἦ δ' ὅς. ><“Τίς,” ἦν δ' ἐγώ, “καὶ ποδαπός, καὶ πόσου διδάσκει;>

Полный крах 💩