Продвинутый tidyverse

Объединение нескольких датафреймов

Соединение структурно схожих датафреймов: bind_rows(), bind_cols()

Загружаем датасет про супергероев:

library("tidyverse")
## ── Attaching packages ─────────────────────────────────────── tidyverse 1.3.2 ──
## ✔ ggplot2 3.4.0      ✔ purrr   1.0.1 
## ✔ tibble  3.1.8      ✔ dplyr   1.0.10
## ✔ tidyr   1.3.0      ✔ stringr 1.5.0 
## ✔ readr   2.1.3      ✔ forcats 0.5.2 
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag()    masks stats::lag()
heroes <- read_csv("https://raw.githubusercontent.com/Pozdniakov/tidy_stats/master/data/heroes_information.csv",
                   na = c("-", "-99"))
## New names:
## • `` -> `...1`
## Warning: One or more parsing issues, call `problems()` on your data frame for details,
## e.g.:
##   dat <- vroom(...)
##   problems(dat)
## Rows: 734 Columns: 11
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (8): name, Gender, Eye color, Race, Hair color, Publisher, Skin color, A...
## dbl (3): ...1, Height, Weight
## 
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.

Теперь создадим следующие тибблы и сохраним их как dc, marvel и other_publishers:

dc <- heroes %>%
  filter(Publisher == "DC Comics") %>%
  group_by(Gender) %>%
  summarise(weight_mean = mean(Weight, na.rm = TRUE))
dc
## # A tibble: 3 × 2
##   Gender weight_mean
##   <chr>        <dbl>
## 1 Female        76.8
## 2 Male         113. 
## 3 <NA>         NaN
marvel <- heroes %>%
  filter(Publisher == "Marvel Comics") %>%
  group_by(Gender) %>%
  summarise(weight_mean = mean(Weight, na.rm = TRUE))
marvel
## # A tibble: 3 × 2
##   Gender weight_mean
##   <chr>        <dbl>
## 1 Female        80.1
## 2 Male         134. 
## 3 <NA>         129.
other_publishers <- heroes %>%
  filter(!(Publisher %in% c("DC Comics","Marvel Comics"))) %>%
  group_by(Gender) %>%
  summarise(weight_mean = mean(Weight, na.rm = TRUE))
other_publishers
## # A tibble: 3 × 2
##   Gender weight_mean
##   <chr>        <dbl>
## 1 Female        70.8
## 2 Male         111. 
## 3 <NA>         NaN

Вертикально объединяем тибблы с помощью функции bind_rows(). Для корректного объединения тибблы должны иметь одинаковые названия колонок.

bind_rows(dc, marvel)
## # A tibble: 6 × 2
##   Gender weight_mean
##   <chr>        <dbl>
## 1 Female        76.8
## 2 Male         113. 
## 3 <NA>         NaN  
## 4 Female        80.1
## 5 Male         134. 
## 6 <NA>         129.

Горизонтально объединяем тибблы с помощью функции bind_cols().

bind_cols(dc, marvel)
## New names:
## • `Gender` -> `Gender...1`
## • `weight_mean` -> `weight_mean...2`
## • `Gender` -> `Gender...3`
## • `weight_mean` -> `weight_mean...4`
## # A tibble: 3 × 4
##   Gender...1 weight_mean...2 Gender...3 weight_mean...4
##   <chr>                <dbl> <chr>                <dbl>
## 1 Female                76.8 Female                80.1
## 2 Male                 113.  Male                 134. 
## 3 <NA>                 NaN   <NA>                 129.

Функции bind_rows() и bind_cols() могут работать с тремя и более датафреймами.

bind_rows(dc, marvel, other_publishers)
## # A tibble: 9 × 2
##   Gender weight_mean
##   <chr>        <dbl>
## 1 Female        76.8
## 2 Male         113. 
## 3 <NA>         NaN  
## 4 Female        80.1
## 5 Male         134. 
## 6 <NA>         129. 
## 7 Female        70.8
## 8 Male         111. 
## 9 <NA>         NaN

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

heroes_list_of_df <- list(DC = dc, 
                          Marvel = marvel, 
                          Other = other_publishers)
bind_rows(heroes_list_of_df)
## # A tibble: 9 × 2
##   Gender weight_mean
##   <chr>        <dbl>
## 1 Female        76.8
## 2 Male         113. 
## 3 <NA>         NaN  
## 4 Female        80.1
## 5 Male         134. 
## 6 <NA>         129. 
## 7 Female        70.8
## 8 Male         111. 
## 9 <NA>         NaN

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

bind_rows(heroes_list_of_df, .id = "Publisher")
## # A tibble: 9 × 3
##   Publisher Gender weight_mean
##   <chr>     <chr>        <dbl>
## 1 DC        Female        76.8
## 2 DC        Male         113. 
## 3 DC        <NA>         NaN  
## 4 Marvel    Female        80.1
## 5 Marvel    Male         134. 
## 6 Marvel    <NA>         129. 
## 7 Other     Female        70.8
## 8 Other     Male         111. 
## 9 Other     <NA>         NaN

bind_rows() - если данные хранятся в разных файлах с одинаковой структурой. Читаем все таблицы, заводим их список и объединяем в единое целое.

Реляционные данные: *_join() от dplyr

Функционал left_join(), right_join(), full_join() и inner_join() аналогичен pandas в Python и join-функциям в запросах к базам данным. Ключ - одна или несколько колонок в каждой из табличек, по которым мы можем объединить данные из табличек. Ключ должен однозначно идентифицировать наблюдения и содержать уникальные значения. Если ключи неуникальные, то функции *_join() не будут выдавать ошибку. Вместо этого они добавят в итоговую таблицу все возможные пересечения повторяющихся ключей.

Возьмем тибблы band_members и band_instruments, встроенные в dplyr для демонстрации работы функций *_join(). Ключ - колонка name, одноименная в двух тибблах.

band_members
## # A tibble: 3 × 2
##   name  band   
##   <chr> <chr>  
## 1 Mick  Stones 
## 2 John  Beatles
## 3 Paul  Beatles
band_instruments
## # A tibble: 3 × 2
##   name  plays 
##   <chr> <chr> 
## 1 John  guitar
## 2 Paul  bass  
## 3 Keith guitar
  • left_join(): данные из левого тиббла дополняются информацией из правого тиббла. Все уникальные наблюдения в левом тиббле сохраняются, но отбрасываются строки в правом тиббле, не нашедшие соответствия в левой таблице. В ячейках, которым не нашлось соотвествия в правой таблице, ставится значение NA.
band_members %>%
  left_join(band_instruments)
## Joining, by = "name"
## # A tibble: 3 × 3
##   name  band    plays 
##   <chr> <chr>   <chr> 
## 1 Mick  Stones  <NA>  
## 2 John  Beatles guitar
## 3 Paul  Beatles bass

Чтобы явно задать колонки-ключи, используем параметр by =. По умолчанию объединение производится по всем колонкам с одинаковыми названиями в двух тибблах.

band_members %>%
  left_join(band_instruments, by = "name")
## # A tibble: 3 × 3
##   name  band    plays 
##   <chr> <chr>   <chr> 
## 1 Mick  Stones  <NA>  
## 2 John  Beatles guitar
## 3 Paul  Beatles bass

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

band_members %>%
  left_join(band_instruments2, by = c("name" = "artist"))
## # A tibble: 3 × 3
##   name  band    plays 
##   <chr> <chr>   <chr> 
## 1 Mick  Stones  <NA>  
## 2 John  Beatles guitar
## 3 Paul  Beatles bass
  • right_join(): Все с точностью наборот: строки левой таблицы, не нашедшие соответствия в правой таблице, отбрасываются.
band_members %>%
  right_join(band_instruments)
## Joining, by = "name"
## # A tibble: 3 × 3
##   name  band    plays 
##   <chr> <chr>   <chr> 
## 1 John  Beatles guitar
## 2 Paul  Beatles bass  
## 3 Keith <NA>    guitar
  • full_join(): Сохраняет все строки из левой и правой таблицы, при несовпадении ключей вставляется значение NA.
band_members %>%
  full_join(band_instruments)
## Joining, by = "name"
## # A tibble: 4 × 3
##   name  band    plays 
##   <chr> <chr>   <chr> 
## 1 Mick  Stones  <NA>  
## 2 John  Beatles guitar
## 3 Paul  Beatles bass  
## 4 Keith <NA>    guitar
  • inner_join(): Сохраняет только строки, общие для левой и правой таблиц.
band_members %>%
  inner_join(band_instruments)
## Joining, by = "name"
## # A tibble: 2 × 3
##   name  band    plays 
##   <chr> <chr>   <chr> 
## 1 John  Beatles guitar
## 2 Paul  Beatles bass
  • semi_join(): Функции semi_join() и anti_join() не присоединяют второй датафрейм/тиббл к первому. Вместо этого они используются как некоторый словарь-фильтр для отделения только тех значений в левой таблице, которые есть в правой (semi_join()) или, наоборот, которых нет в правой (anti_join()).
band_members %>%
  semi_join(band_instruments)
## Joining, by = "name"
## # A tibble: 2 × 2
##   name  band   
##   <chr> <chr>  
## 1 John  Beatles
## 2 Paul  Beatles
  • anti_join():
band_members %>%
  anti_join(band_instruments)
## Joining, by = "name"
## # A tibble: 1 × 2
##   name  band  
##   <chr> <chr> 
## 1 Mick  Stones

Tidy data: tidyr::pivot_longer(), tidyr::pivot_wider()

Принцип tidy data предполагает, что каждая строчка содержит в себе одно наблюдение (измерение), а каждая колонка - одну характеристику. Но как именно хранить повторные измерения?

  • tidyr::pivot_longer(): из широкого в длинный формат

  • tidyr::pivot_wider(): из длинного в широкий формат

new_diet <- tibble(
  student = c("Маша", "Рома", "Антонина"),
  before_r_course = c(70, 80, 86),
  after_r_course = c(63, 74, 71)
)
new_diet
## # A tibble: 3 × 3
##   student  before_r_course after_r_course
##   <chr>              <dbl>          <dbl>
## 1 Маша                  70             63
## 2 Рома                  80             74
## 3 Антонина              86             71

Тиббл new_diet - это пример широкого формата данных.

Превратим тиббл new_diet длинный:

new_diet %>%
  pivot_longer(cols = before_r_course:after_r_course,
               names_to = "measurement_time", 
               values_to = "weight_kg")
## # A tibble: 6 × 3
##   student  measurement_time weight_kg
##   <chr>    <chr>                <dbl>
## 1 Маша     before_r_course         70
## 2 Маша     after_r_course          63
## 3 Рома     before_r_course         80
## 4 Рома     after_r_course          74
## 5 Антонина before_r_course         86
## 6 Антонина after_r_course          71

А теперь обратно в короткий:

new_diet %>%
  pivot_longer(cols = before_r_course:after_r_course,
               names_to = "measurement_time", 
               values_to = "weight_kg") %>%
  pivot_wider(names_from = "measurement_time",
              values_from = "weight_kg")
## # A tibble: 3 × 3
##   student  before_r_course after_r_course
##   <chr>              <dbl>          <dbl>
## 1 Маша                  70             63
## 2 Рома                  80             74
## 3 Антонина              86             71

Трансформация нескольких колонок: dplyr::across()

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

heroes %>%
  group_by(Gender) %>%
  summarise(height = mean(Height, na.rm = TRUE),
            weight = mean(Weight, na.rm = TRUE))
## # A tibble: 3 × 3
##   Gender height weight
##   <chr>   <dbl>  <dbl>
## 1 Female   175.   78.8
## 2 Male     192.  126. 
## 3 <NA>     177.  129.

dplyr::across() - аналог apply() в tydyverse, использует tidyselect для выбора колонок.

Функция across() появилась в пакете dplyr относительно недавно, до этого для работы с множественными колонками в tidyverse использовались многочисленные функции *_at(), *_if(), *_all(), например, summarise_at(), summarise_if(), summarize_all(). Эти функции до сих пор присутствуют в dplyr, но считаются устаревшими. Другая альтернатива - использование пакета purrr (@sec-purrr) или семейства функций apply() (@sec-apply_f).

Таким образом, конструкции с функцией across() можно разбить на три части:

  1. Выбор колонок с помощью tidyselect. Здесь работают все те приемы, которые мы изучили при выборе колонок (@sec-tidyselect).
  2. Собственно применение функции across(). Первый аргумент .col – колонки, выбранные на первом этапе с помощью tidyselect, по умолчанию это everything(), т.е. все колонки. Второй аргумент .fns – это функция или целый список из функций, которые будут применены к выбранным колонкам. Если функции требуют дополнительных аргументов, то они могут быть перечислены внутри across().
  3. Использование summarise() или другой функции dplyr. В этом случае в качестве аргумента для функции используется результат работы функции across().
heroes %>%
  group_by(Gender) %>%
  summarise(across(c(Height,Weight), mean))
## # A tibble: 3 × 3
##   Gender Height Weight
##   <chr>   <dbl>  <dbl>
## 1 Female     NA     NA
## 2 Male       NA     NA
## 3 <NA>       NA     NA

Функция mean() при столкновении хотя бы с одним NA будет возвращать NA, если мы не изменим параметр na.rm =. Дополнительные для функции аргументы можно перечислить через запятую после названия функции:

heroes %>%
  group_by(Gender) %>%
  summarise(across(c(Height, Weight), mean, na.rm = TRUE))
## # A tibble: 3 × 3
##   Gender Height Weight
##   <chr>   <dbl>  <dbl>
## 1 Female   175.   78.8
## 2 Male     192.  126. 
## 3 <NA>     177.  129.

Посчитаем среднее для всех numeric колонок:

heroes %>%
  drop_na(Height, Weight) %>%
  group_by(Gender) %>%
  summarise(across(where(is.numeric), mean, na.rm = TRUE))
## # A tibble: 3 × 4
##   Gender  ...1 Height Weight
##   <chr>  <dbl>  <dbl>  <dbl>
## 1 Female  394.   174.   78.3
## 2 Male    369.   193.  126. 
## 3 <NA>    375.   182   129.

Или длину строк для строковых колонок – с помощью анонимной функции function().

heroes %>%
  group_by(Gender) %>%
  summarise(across(where(is.character), 
                   function(x) mean(nchar(x), na.rm = TRUE)))
## # A tibble: 3 × 8
##   Gender  name `Eye color`  Race `Hair color` Publisher `Skin color` Alignment
##   <chr>  <dbl>       <dbl> <dbl>        <dbl>     <dbl>        <dbl>     <dbl>
## 1 Female  9.04        4.68  6.42         5.05      11.5         4.57      3.88
## 2 Male    9.05        4.53  6.75         5.48      11.4         5.02      3.78
## 3 <NA>    9.48        5.16 10.1          6.44      11.9         4         3.96

Два across() внутри одного summarise():

heroes %>%
  group_by(Gender) %>%
  summarise(across(where(is.numeric), mean, na.rm = TRUE),
            across(where(is.character), 
                   function(x) mean(nchar(x), na.rm = TRUE)))
## # A tibble: 3 × 11
##   Gender  ...1 Height Weight  name Eye c…¹  Race Hair …² Publi…³ Skin …⁴ Align…⁵
##   <chr>  <dbl>  <dbl>  <dbl> <dbl>   <dbl> <dbl>   <dbl>   <dbl>   <dbl>   <dbl>
## 1 Female  395.   175.   78.8  9.04    4.68  6.42    5.05    11.5    4.57    3.88
## 2 Male    357.   192.  126.   9.05    4.53  6.75    5.48    11.4    5.02    3.78
## 3 <NA>    329    177.  129.   9.48    5.16 10.1     6.44    11.9    4       3.96
## # … with abbreviated variable names ¹​`Eye color`, ²​`Hair color`, ³​Publisher,
## #   ⁴​`Skin color`, ⁵​Alignment

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

heroes %>%
  group_by(Gender) %>%
  summarise(across(c(Height, Weight), 
                   list(minimum = min,
                        average = mean,
                        maximum = max), 
                   na.rm = TRUE))
## # A tibble: 3 × 7
##   Gender Height_minimum Height_average Height_maximum Weight_m…¹ Weigh…² Weigh…³
##   <chr>           <dbl>          <dbl>          <dbl>      <dbl>   <dbl>   <dbl>
## 1 Female           62.5           175.            366         41    78.8     630
## 2 Male             15.2           192.            975          2   126.      900
## 3 <NA>            108             177.            198         39   129.      383
## # … with abbreviated variable names ¹​Weight_minimum, ²​Weight_average,
## #   ³​Weight_maximum

Cписок функций:

heroes %>%
  group_by(Gender) %>%
  summarise(across(c(Height, Weight),
                   list(min = function(x) min(x, na.rm = TRUE),
                        mean = function(x) mean(x, na.rm = TRUE),
                        max = function(x) max(x, na.rm = TRUE),
                        na_n = function(x, ...) sum(is.na(x)))
                   )
            )
## # A tibble: 3 × 9
##   Gender Height_min Height_mean Height…¹ Heigh…² Weigh…³ Weigh…⁴ Weigh…⁵ Weigh…⁶
##   <chr>       <dbl>       <dbl>    <dbl>   <int>   <dbl>   <dbl>   <dbl>   <int>
## 1 Female       62.5        175.      366      56      41    78.8     630      58
## 2 Male         15.2        192.      975     147       2   126.      900     166
## 3 <NA>        108          177.      198      14      39   129.      383      15
## # … with abbreviated variable names ¹​Height_max, ²​Height_na_n, ³​Weight_min,
## #   ⁴​Weight_mean, ⁵​Weight_max, ⁶​Weight_na_n

Хотя основное применение функции across() – это массовое подытоживание с помощью summarise(), across() можно использовать и с другими функциями dplyr. Массовые операции с колонками с помощью mutate():

heroes %>%
  mutate(across(where(is.character), as.factor))
## # A tibble: 734 × 11
##     ...1 name        Gender Eye c…¹ Race  Hair …² Height Publi…³ Skin …⁴ Align…⁵
##    <dbl> <fct>       <fct>  <fct>   <fct> <fct>    <dbl> <fct>   <fct>   <fct>  
##  1     0 A-Bomb      Male   yellow  Human No Hair    203 Marvel… <NA>    good   
##  2     1 Abe Sapien  Male   blue    Icth… No Hair    191 Dark H… blue    good   
##  3     2 Abin Sur    Male   blue    Unga… No Hair    185 DC Com… red     good   
##  4     3 Abomination Male   green   Huma… No Hair    203 Marvel… <NA>    bad    
##  5     4 Abraxas     Male   blue    Cosm… Black       NA Marvel… <NA>    bad    
##  6     5 Absorbing … Male   blue    Human No Hair    193 Marvel… <NA>    bad    
##  7     6 Adam Monroe Male   blue    <NA>  Blond       NA NBC - … <NA>    good   
##  8     7 Adam Stran… Male   blue    Human Blond      185 DC Com… <NA>    good   
##  9     8 Agent 13    Female blue    <NA>  Blond      173 Marvel… <NA>    good   
## 10     9 Agent Bob   Male   brown   Human Brown      178 Marvel… <NA>    good   
## # … with 724 more rows, 1 more variable: Weight <dbl>, and abbreviated variable
## #   names ¹​`Eye color`, ²​`Hair color`, ³​Publisher, ⁴​`Skin color`, ⁵​Alignment

Внутри count() вместе с функцией n_distinct(), которая считает количество уникальных значений в векторе:

heroes %>%
  count(across(where(function(x) n_distinct(x) <= 6)))
## # A tibble: 11 × 3
##    Gender Alignment     n
##    <chr>  <chr>     <int>
##  1 Female bad          35
##  2 Female good        161
##  3 Female neutral       4
##  4 Male   bad         165
##  5 Male   good        316
##  6 Male   neutral      18
##  7 Male   <NA>          6
##  8 <NA>   bad           7
##  9 <NA>   good         19
## 10 <NA>   neutral       2
## 11 <NA>   <NA>          1

Функциональное программирование: purrr

purrr – пакет для функционального программирования в tidyverse. Здесь речь пойдет об аналогах функций семейства apply() из базового R.

  • lapply() в качестве первого аргумента функция lapply() принимает список (или то, что может быть в него превращено, например, датафрейм), в качестве второго - функцию, которая будет применена к каждому элементу списка. На выходе получается список такой же длины.
lapply(heroes, class)
## $...1
## [1] "numeric"
## 
## $name
## [1] "character"
## 
## $Gender
## [1] "character"
## 
## $`Eye color`
## [1] "character"
## 
## $Race
## [1] "character"
## 
## $`Hair color`
## [1] "character"
## 
## $Height
## [1] "numeric"
## 
## $Publisher
## [1] "character"
## 
## $`Skin color`
## [1] "character"
## 
## $Alignment
## [1] "character"
## 
## $Weight
## [1] "numeric"
  • purrr::map() работает по тому же принципу:
map(heroes, class)
## $...1
## [1] "numeric"
## 
## $name
## [1] "character"
## 
## $Gender
## [1] "character"
## 
## $`Eye color`
## [1] "character"
## 
## $Race
## [1] "character"
## 
## $`Hair color`
## [1] "character"
## 
## $Height
## [1] "numeric"
## 
## $Publisher
## [1] "character"
## 
## $`Skin color`
## [1] "character"
## 
## $Alignment
## [1] "character"
## 
## $Weight
## [1] "numeric"

map() можно встроить в канал с пайпом (впрочем, как и lapply()):

heroes %>%
  map(class)
## $...1
## [1] "numeric"
## 
## $name
## [1] "character"
## 
## $Gender
## [1] "character"
## 
## $`Eye color`
## [1] "character"
## 
## $Race
## [1] "character"
## 
## $`Hair color`
## [1] "character"
## 
## $Height
## [1] "numeric"
## 
## $Publisher
## [1] "character"
## 
## $`Skin color`
## [1] "character"
## 
## $Alignment
## [1] "character"
## 
## $Weight
## [1] "numeric"
  • sapply() из базового R упрощает результат до вектора, если это возможно.

  • vapply() из базового R добавляет управление типом данных на выходе, но она не очень удобная.

  • map_*() множество функций из purrr, где вместо звездочки - нужный формат на выходе.

    • map_chr():
heroes %>%
  map_chr(class)
##        ...1        name      Gender   Eye color        Race  Hair color 
##   "numeric" "character" "character" "character" "character" "character" 
##      Height   Publisher  Skin color   Alignment      Weight 
##   "numeric" "character" "character" "character"   "numeric"
  • map_df() возвращает результат как датафрейм:
heroes %>%
  map_df(class)
## # A tibble: 1 × 11
##   ...1  name  Gender Eye c…¹ Race  Hair …² Height Publi…³ Skin …⁴ Align…⁵ Weight
##   <chr> <chr> <chr>  <chr>   <chr> <chr>   <chr>  <chr>   <chr>   <chr>   <chr> 
## 1 nume… char… chara… charac… char… charac… numer… charac… charac… charac… numer…
## # … with abbreviated variable names ¹​`Eye color`, ²​`Hair color`, ³​Publisher,
## #   ⁴​`Skin color`, ⁵​Alignment

Так же как и функции семейства apply(), функции map_*() сочетаются с анонимными функциями:

heroes %>%
  map_int(function(x) sum(is.na(x)))
##       ...1       name     Gender  Eye color       Race Hair color     Height 
##          0          0         29        172        304        172        217 
##  Publisher Skin color  Alignment     Weight 
##          0        662          7        239

Более короткий способ записи анонимных функций: function(arg) заменяется на ~, а arg на ..

heroes %>%
  map_int(~sum(is.na(.)))
##       ...1       name     Gender  Eye color       Race Hair color     Height 
##          0          0         29        172        304        172        217 
##  Publisher Skin color  Alignment     Weight 
##          0        662          7        239

Если нужно итерироваться сразу по нескольким спискам, то есть функции map2_*() (для двух списков) и pmap_*() (для нескольких списков).

Колонки-списки и нестинг: nest()

Ранее мы говорили о том, что датафрейм – это по своей сути список из векторов разной длины. На самом деле, это не совсем так: колонки обычного датафрейма вполне могут быть списками. Однако делать так обычно не рекомендуется, пусть R это и не запрещает создавать такие колонки: многие функции предполагают, что все колонки датафрейма являются векторами.

tidyverse лучше заточен на использование списков в качестве колонок (колонок-списков (list columns)). - tidyr::nest() С помощью tidyselect нужно выбрать сжимаемые колонки, которые будут агрегированы по невыбранным колонками - это и есть нестинг.

heroes %>%
  nest(!Gender)
## Warning: Supplying `...` without names was deprecated in tidyr 1.0.0.
## ℹ Please specify a name for each selection.
## ℹ Did you want `data = !Gender`?
## # A tibble: 3 × 2
##   Gender data               
##   <chr>  <list>             
## 1 Male   <tibble [505 × 10]>
## 2 Female <tibble [200 × 10]>
## 3 <NA>   <tibble [29 × 10]>

Заметьте, у нас появилась колонка data, в которой содержатся тибблы. Туда и спрятались все наши данные.

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

heroes %>%
  group_by(Gender) %>%
  nest()
## # A tibble: 3 × 2
## # Groups:   Gender [3]
##   Gender data               
##   <chr>  <list>             
## 1 Male   <tibble [505 × 10]>
## 2 Female <tibble [200 × 10]>
## 3 <NA>   <tibble [29 × 10]>

Теперь можно работать с колонкой-списком как с обычной колонкой. Например, применять функцию для каждой строчки (то есть для каждого тиббла) с помощью map() и записывать результат в новую колонку с помощью mutate().

heroes %>%
  group_by(Gender) %>%
  nest() %>%
  mutate(dim = map(data, dim))
## # A tibble: 3 × 3
## # Groups:   Gender [3]
##   Gender data                dim      
##   <chr>  <list>              <list>   
## 1 Male   <tibble [505 × 10]> <int [2]>
## 2 Female <tibble [200 × 10]> <int [2]>
## 3 <NA>   <tibble [29 × 10]>  <int [2]>

В конце концов нам нужно “разжать” сжатую колонку-список. Сделать это можно с помощью unnest(), выбрав с помощью tidyselect нужные колонки.

heroes %>%
  group_by(Gender) %>%
  nest() %>%
  mutate(dim = map(data, dim)) %>%
  unnest(dim)
## # A tibble: 6 × 3
## # Groups:   Gender [3]
##   Gender data                  dim
##   <chr>  <list>              <int>
## 1 Male   <tibble [505 × 10]>   505
## 2 Male   <tibble [505 × 10]>    10
## 3 Female <tibble [200 × 10]>   200
## 4 Female <tibble [200 × 10]>    10
## 5 <NA>   <tibble [29 × 10]>     29
## 6 <NA>   <tibble [29 × 10]>     10

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

heroes %>%
  group_by(Gender) %>%
  nest() %>%
  mutate(dim = map(data, dim)) %>%
  unnest_wider(dim, names_sep = "_") 
## # A tibble: 3 × 4
## # Groups:   Gender [3]
##   Gender data                dim_1 dim_2
##   <chr>  <list>              <int> <int>
## 1 Male   <tibble [505 × 10]>   505    10
## 2 Female <tibble [200 × 10]>   200    10
## 3 <NA>   <tibble [29 × 10]>     29    10

Пример применения нестинга – решение проблемы с несколькими значениями в одной ячейки, которые записаны через запятую или какой-либо другой разделитель.

films <- tribble(
  ~film, ~genres,
  "Ирония Судьбы", "comedy, drama",
  "Большой Лебовски", "comedy, criminal",
  "Аватар", "fantasy, drama"
)

films
## # A tibble: 3 × 2
##   film             genres          
##   <chr>            <chr>           
## 1 Ирония Судьбы    comedy, drama   
## 2 Большой Лебовски comedy, criminal
## 3 Аватар           fantasy, drama
  • strsplit() разбивает значения вектора по выбранному разделителю. Поскольку результат – список, перезаписанная колонка genres станет колонкой-списком.
films %>%
  mutate(genres = strsplit(genres, ", "))
## # A tibble: 3 × 2
##   film             genres   
##   <chr>            <list>   
## 1 Ирония Судьбы    <chr [2]>
## 2 Большой Лебовски <chr [2]>
## 3 Аватар           <chr [2]>

Теперь нам нужно сделать unnest()

films %>%
  mutate(genres = strsplit(genres, ", ")) %>%
  unnest()
## Warning: `cols` is now required when using `unnest()`.
## ℹ Please use `cols = c(genres)`.
## # A tibble: 6 × 2
##   film             genres  
##   <chr>            <chr>   
## 1 Ирония Судьбы    comedy  
## 2 Ирония Судьбы    drama   
## 3 Большой Лебовски comedy  
## 4 Большой Лебовски criminal
## 5 Аватар           fantasy 
## 6 Аватар           drama

Теперь у нас данные в длинном виде! Результат можно расширить с помощью уже знакомого pivot_wider() и дополнительной колонки со значениями TRUE. Если соответствующей пары нет в тиббле, то в итоговой широкой таблице будет NA, мы можем поменять их на FALSE с помощью параметра values_fill =.

films %>%
  mutate(genres = strsplit(genres, ", ")) %>%
  unnest() %>%
  mutate(value = TRUE) %>%
  pivot_wider(names_from = "genres",
              values_from = "value", values_fill = FALSE)
## Warning: `cols` is now required when using `unnest()`.
## ℹ Please use `cols = c(genres)`.
## # A tibble: 3 × 5
##   film             comedy drama criminal fantasy
##   <chr>            <lgl>  <lgl> <lgl>    <lgl>  
## 1 Ирония Судьбы    TRUE   TRUE  FALSE    FALSE  
## 2 Большой Лебовски TRUE   FALSE TRUE     FALSE  
## 3 Аватар           FALSE  TRUE  FALSE    TRUE
  • tidyr::separate_rows(): заменяет связку strsplit() с unnest():
films %>%
  separate_rows(genres, sep = ", ") %>%
  mutate(value = TRUE) %>%
  pivot_wider(names_from = "genres",
              values_from = "value", values_fill = FALSE)
## # A tibble: 3 × 5
##   film             comedy drama criminal fantasy
##   <chr>            <lgl>  <lgl> <lgl>    <lgl>  
## 1 Ирония Судьбы    TRUE   TRUE  FALSE    FALSE  
## 2 Большой Лебовски TRUE   FALSE TRUE     FALSE  
## 3 Аватар           FALSE  TRUE  FALSE    TRUE

Наибольшее распространение нестинг получил в смычке с пакетом broom для расчета множественных статистических моделей.