26  Lặp

26.1 Giới thiệu

Trong chương này, bạn sẽ học các công cụ để iterate (iteration), tức là thực hiện iterate đi iterate lại cùng một hành động trên các đối tượng khác nhau. Lặp trong R nhìn chung có xu hướng khá khác biệt so với các ngôn ngữ lập trình khác vì phần lớn nó được thực hiện ngầm và chúng ta có được nó miễn phí. Ví dụ, nếu bạn muốn nhân đôi một vector số x trong R, bạn chỉ cần viết 2 * x. Trong hầu hết các ngôn ngữ khác, bạn cần phải nhân đôi từng phần tử của x một cách tường minh bằng một loop for nào đó.

Cuốn sách này đã cung cấp cho bạn một số ít nhưng mạnh mẽ các công cụ thực hiện cùng một hành động cho nhiều “thứ”:

Bây giờ là lúc học thêm một số công cụ tổng quát hơn, thường được gọi là các công cụ lập trình hàm (functional programming) vì chúng được xây dựng xung quanh các function nhận các function khác làm đầu vào. Học lập trình function có thể dễ dàng đi vào hướng trừu tượng, nhưng trong chương này chúng ta sẽ giữ mọi thứ cụ thể bằng cách tập trung vào ba tác vụ phổ biến: chỉnh sửa nhiều column, đọc nhiều tệp và lưu nhiều đối tượng.

26.1.1 Điều kiện tiên quyết

Trong chương này, chúng ta sẽ tập trung vào các công cụ được cung cấp bởi dplyr và purrr, cả hai đều là thành viên cốt lõi của tidyverse. Bạn đã thấy dplyr trước đó, nhưng purrr thì mới. Chúng ta chỉ sử dụng một vài function của purrr trong chương này, nhưng đây là một package tuyệt vời để khám phá khi bạn cải thiện kỹ năng lập trình của mình.

26.2 Chỉnh sửa nhiều column

Hãy tưởng tượng bạn có tibble đơn giản này và muốn đếm số quan sát cũng như tính trung vị của mỗi column.

set.seed(1014)
df <- tibble(
  a = rnorm(10),
  b = rnorm(10),
  c = rnorm(10),
  d = rnorm(10)
)

Bạn có thể làm điều đó bằng cách sao chép và dán:

df |> summarize(
  n = n(),
  a = median(a),
  b = median(b),
  c = median(c),
  d = median(d),
)
#> # A tibble: 1 × 5
#>       n      a      b       c     d
#>   <int>  <dbl>  <dbl>   <dbl> <dbl>
#> 1    10 -0.246 -0.287 -0.0567 0.144

Điều đó vi phạm quy tắc ngón tay cái của chúng ta là không bao giờ sao chép và dán quá hai lần, và bạn có thể tưởng tượng rằng điều này sẽ trở nên rất nhàm chán nếu bạn có row chục hoặc thậm chí row trăm column. Thay vào đó, bạn có thể sử dụng across():

df |> summarize(
  n = n(),
  across(a:d, median),
)
#> # A tibble: 1 × 5
#>       n      a      b       c     d
#>   <int>  <dbl>  <dbl>   <dbl> <dbl>
#> 1    10 -0.246 -0.287 -0.0567 0.144

across() có ba argument đặc biệt quan trọng, mà chúng ta sẽ thảo luận chi tiết trong các phần sau. Bạn sẽ sử dụng hai argument đầu tiên mỗi khi dùng across(): argument đầu tiên, .cols, chỉ định những column nào bạn muốn iterate qua, và argument thứ hai, .fns, chỉ định việc cần làm với mỗi column. Bạn có thể sử dụng argument .names khi cần kiểm soát thêm tên của các column đầu ra, điều này đặc biệt quan trọng khi bạn sử dụng across() với mutate(). Chúng ta cũng sẽ thảo luận về hai biến thể quan trọng, if_any()if_all(), hoạt động với filter().

26.2.1 Chọn column với .cols

Đối số đầu tiên của across(), .cols, chọn các column để biến đổi. Nó sử dụng cùng các đặc tả như select(), Phần 3.3.2, nên bạn có thể sử dụng các function như starts_with()ends_with() để chọn column dựa trên tên của chúng.

Có hai kỹ thuật chọn bổ sung đặc biệt hữu ích cho across(): everything()where(). everything() thì đơn giản: nó chọn mọi column (không phải column nhóm):

set.seed(1014)
df <- tibble(
  grp = sample(2, 10, replace = TRUE),
  a = rnorm(10),
  b = rnorm(10),
  c = rnorm(10),
  d = rnorm(10)
)

df |>
  group_by(grp) |>
  summarize(across(everything(), median))
#> # A tibble: 2 × 5
#>     grp      a      b       c       d
#>   <int>  <dbl>  <dbl>   <dbl>   <dbl>
#> 1     1 -0.244 -0.522 -0.0974 -0.251 
#> 2     2 -0.247  0.468  0.112   0.0700

Lưu ý các column nhóm (grp ở đây) không được bao gồm trong across(), vì chúng được tự động giữ lại bởi summarize().

where() cho phép bạn chọn column dựa trên kiểu dữ liệu của chúng:

  • where(is.numeric) chọn tất cả các column số.
  • where(is.character) chọn tất cả các column string.
  • where(is.Date) chọn tất cả các column ngày.
  • where(is.POSIXct) chọn tất cả các column ngày-giờ.
  • where(is.logical) chọn tất cả các column logic.

Giống như các bộ chọn khác, bạn có thể kết hợp chúng với đại số Boole. Ví dụ, !where(is.numeric) chọn tất cả các column không phải số, và starts_with("a") & where(is.logical) chọn tất cả các column logic có tên bắt đầu bằng “a”.

26.2.2 Gọi một function đơn

Đối số thứ hai của across() định nghĩa cách mỗi column sẽ được biến đổi. Trong các trường hợp đơn giản, như ở trên, đây sẽ là một function có sẵn. Đây là một tính năng khá đặc biệt của R: chúng ta đang truyền một function (median, mean, str_flatten, …) cho một function khác (across). Đây là một trong những tính năng khiến R trở thành một ngôn ngữ lập trình function.

Điều quan trọng cần lưu ý là chúng ta đang truyền function này cho across(), để across() có thể gọi nó; chúng ta không tự gọi nó. Điều đó có nghĩa là tên function không bao giờ được theo sau bởi (). Nếu bạn quên, bạn sẽ nhận được lỗi:

df |>
  group_by(grp) |>
  summarize(across(everything(), median()))
#> Error in `summarize()`:
#> ℹ In argument: `across(everything(), median())`.
#> Caused by error in `median.default()`:
#> ! argument "x" is missing, with no default

Lỗi này phát sinh vì bạn đang gọi function mà không có đầu vào, ví dụ:

median()
#> Error in `median.default()`:
#> ! argument "x" is missing, with no default

26.2.3 Gọi nhiều hàm

Trong các trường hợp phức tạp hơn, bạn có thể muốn cung cấp thêm argument hoặc thực hiện nhiều phép biến đổi. Hãy tạo động lực cho vấn đề này bằng một ví dụ đơn giản: điều gì xảy ra nếu chúng ta có một số missing value (missing value) trong dữ liệu? median() lan truyền những missing value đó, cho chúng ta một đầu ra không tối ưu:

set.seed(1014)
rnorm_na <- function(n, n_na, mean = 0, sd = 1) {
  sample(c(rnorm(n - n_na, mean = mean, sd = sd), rep(NA, n_na)))
}

df_miss <- tibble(
  a = rnorm_na(5, 1),
  b = rnorm_na(5, 1),
  c = rnorm_na(5, 2),
  d = rnorm(5)
)
df_miss |>
  summarize(
    across(a:d, median),
    n = n()
  )
#> # A tibble: 1 × 5
#>       a     b     c     d     n
#>   <dbl> <dbl> <dbl> <dbl> <int>
#> 1    NA    NA    NA 0.413     5

Sẽ tốt nếu chúng ta có thể truyền na.rm = TRUE cho median() để loại bỏ các missing value này. Để làm vậy, thay vì gọi median() trực tiếp, chúng ta cần tạo một function mới gọi median() với các argument mong muốn:

df_miss |>
  summarize(
    across(a:d, function(x) median(x, na.rm = TRUE)),
    n = n()
  )
#> # A tibble: 1 × 5
#>        a      b      c     d     n
#>    <dbl>  <dbl>  <dbl> <dbl> <int>
#> 1 -0.703 -0.265 -0.522 0.413     5

Điều này hơi dài dòng, nên R đi kèm một lối tắt tiện lợi: đối với loại function dùng một lần, hay còn gọi là function vô danh (anonymous)1, bạn có thể thay function bằng \2:

df_miss |>
  summarize(
    across(a:d, \(x) median(x, na.rm = TRUE)),
    n = n()
  )

Trong cả hai trường hợp, across() thực tế mở rộng thành đoạn code sau:

df_miss |>
  summarize(
    a = median(a, na.rm = TRUE),
    b = median(b, na.rm = TRUE),
    c = median(c, na.rm = TRUE),
    d = median(d, na.rm = TRUE),
    n = n()
  )

Khi chúng ta loại bỏ các missing value khỏi median(), sẽ tốt nếu biết chính xác bao nhiêu giá trị đã bị loại bỏ. Chúng ta có thể tìm ra điều đó bằng cách cung cấp hai function cho across(): một function tính trung vị và function kia đếm các missing value. Bạn cung cấp nhiều function bằng cách sử dụng một list có tên cho .fns:

df_miss |>
  summarize(
    across(a:d, list(
      median = \(x) median(x, na.rm = TRUE),
      n_miss = \(x) sum(is.na(x))
    )),
    n = n()
  )
#> # A tibble: 1 × 9
#>   a_median a_n_miss b_median b_n_miss c_median c_n_miss d_median d_n_miss
#>      <dbl>    <int>    <dbl>    <int>    <dbl>    <int>    <dbl>    <int>
#> 1   -0.703        1   -0.265        1   -0.522        2    0.413        0
#> # ℹ 1 more variable: n <int>

Nếu quan sát kỹ, bạn có thể suy ra rằng các column được đặt tên bằng một đặc tả glue (Phần 14.3.2) như {.col}_{.fn} trong đó .col là tên của column gốc và .fn là tên của function. Đó không phải là sự trùng hợp! Như bạn sẽ học trong phần tiếp theo, bạn có thể sử dụng argument .names để cung cấp đặc tả glue của riêng mình.

26.2.4 Tên cột

Kết quả của across() được đặt tên theo đặc tả được cung cấp trong argument .names. Chúng ta có thể chỉ định tên riêng nếu muốn tên function xuất hiện trước3:

df_miss |>
  summarize(
    across(
      a:d,
      list(
        median = \(x) median(x, na.rm = TRUE),
        n_miss = \(x) sum(is.na(x))
      ),
      .names = "{.fn}_{.col}"
    ),
    n = n(),
  )
#> # A tibble: 1 × 9
#>   median_a n_miss_a median_b n_miss_b median_c n_miss_c median_d n_miss_d
#>      <dbl>    <int>    <dbl>    <int>    <dbl>    <int>    <dbl>    <int>
#> 1   -0.703        1   -0.265        1   -0.522        2    0.413        0
#> # ℹ 1 more variable: n <int>

Đối số .names đặc biệt quan trọng khi bạn sử dụng across() với mutate(). Mặc định, đầu ra của across() được đặt cùng tên với đầu vào. Điều này có nghĩa là across() bên trong mutate() sẽ thay thế các column hiện có. Ví dụ, ở đây chúng ta sử dụng coalesce() để thay thế các NA bằng 0:

df_miss |>
  mutate(
    across(a:d, \(x) coalesce(x, 0))
  )
#> # A tibble: 5 × 4
#>          a      b      c        d
#>      <dbl>  <dbl>  <dbl>    <dbl>
#> 1 -0.00557 -0.283 -1.86  -0.783  
#> 2  0.255   -0.247 -0.522 -0.00289
#> 3 -1.40    -0.554  0.512  0.413  
#> 4 -2.44    -0.244  0      0.724  
#> 5  0        0      0      2.35

Nếu bạn muốn tạo các column mới thay vào đó, bạn có thể sử dụng argument .names để đặt tên mới cho đầu ra:

df_miss |>
  mutate(
    across(a:d, \(x) coalesce(x, 0), .names = "{.col}_na_zero")
  )
#> # A tibble: 5 × 8
#>          a      b      c        d a_na_zero b_na_zero c_na_zero d_na_zero
#>      <dbl>  <dbl>  <dbl>    <dbl>     <dbl>     <dbl>     <dbl>     <dbl>
#> 1 -0.00557 -0.283 -1.86  -0.783    -0.00557    -0.283    -1.86   -0.783  
#> 2  0.255   -0.247 -0.522 -0.00289   0.255      -0.247    -0.522  -0.00289
#> 3 -1.40    -0.554  0.512  0.413    -1.40       -0.554     0.512   0.413  
#> 4 -2.44    -0.244 NA      0.724    -2.44       -0.244     0       0.724  
#> 5 NA       NA     NA      2.35      0           0         0       2.35

26.2.5 Lọc

across() là một sự kết hợp tuyệt vời cho summarize()mutate() nhưng nó hơi khó sử dụng với filter(), vì bạn thường kết hợp nhiều điều kiện với | hoặc &. Rõ ràng là across() có thể giúp tạo nhiều column logic, nhưng rồi sao? Vì vậy dplyr cung cấp hai biến thể của across() gọi là if_any()if_all():

# same as df_miss |> filter(is.na(a) | is.na(b) | is.na(c) | is.na(d))
df_miss |> filter(if_any(a:d, is.na))
#> # A tibble: 2 × 4
#>       a      b     c     d
#>   <dbl>  <dbl> <dbl> <dbl>
#> 1 -2.44 -0.244    NA 0.724
#> 2 NA    NA        NA 2.35

# same as df_miss |> filter(is.na(a) & is.na(b) & is.na(c) & is.na(d))
df_miss |> filter(if_all(a:d, is.na))
#> # A tibble: 0 × 4
#> # ℹ 4 variables: a <dbl>, b <dbl>, c <dbl>, d <dbl>

26.2.6 across() trong các function

across() đặc biệt hữu ích khi lập trình vì nó cho phép bạn thao tác trên nhiều column. Ví dụ, Jacob Scott sử dụng function trợ giúp nhỏ này để bọc một loạt các function lubridate nhằm mở rộng tất cả các column ngày thành các column năm, tháng và ngày:

expand_dates <- function(df) {
  df |>
    mutate(
      across(where(is.Date), list(year = year, month = month, day = mday))
    )
}

df_date <- tibble(
  name = c("Amy", "Bob"),
  date = ymd(c("2009-08-03", "2010-01-16"))
)

df_date |>
  expand_dates()
#> # A tibble: 2 × 5
#>   name  date       date_year date_month date_day
#>   <chr> <date>         <dbl>      <dbl>    <int>
#> 1 Amy   2009-08-03      2009          8        3
#> 2 Bob   2010-01-16      2010          1       16

across() cũng giúp dễ dàng cung cấp nhiều column trong một argument đơn vì argument đầu tiên sử dụng tidy-select; bạn chỉ cần nhớ embrace argument đó, như chúng ta đã thảo luận trong Phần 25.3.2. Ví dụ, function này sẽ tính trung bình của các column số theo mặc định. Nhưng bằng cách cung cấp argument thứ hai, bạn có thể chọn chỉ tóm tắt các column được chọn:

summarize_means <- function(df, summary_vars = where(is.numeric)) {
  df |>
    summarize(
      across({{ summary_vars }}, \(x) mean(x, na.rm = TRUE)),
      n = n(),
      .groups = "drop"
    )
}
diamonds |>
  group_by(cut) |>
  summarize_means()
#> # A tibble: 5 × 9
#>   cut       carat depth table price     x     y     z     n
#>   <ord>     <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <int>
#> 1 Fair      1.05   64.0  59.1 4359.  6.25  6.18  3.98  1610
#> 2 Good      0.849  62.4  58.7 3929.  5.84  5.85  3.64  4906
#> 3 Very Good 0.806  61.8  58.0 3982.  5.74  5.77  3.56 12082
#> 4 Premium   0.892  61.3  58.7 4584.  5.97  5.94  3.65 13791
#> 5 Ideal     0.703  61.7  56.0 3458.  5.51  5.52  3.40 21551

diamonds |>
  group_by(cut) |>
  summarize_means(c(carat, x:z))
#> # A tibble: 5 × 6
#>   cut       carat     x     y     z     n
#>   <ord>     <dbl> <dbl> <dbl> <dbl> <int>
#> 1 Fair      1.05   6.25  6.18  3.98  1610
#> 2 Good      0.849  5.84  5.85  3.64  4906
#> 3 Very Good 0.806  5.74  5.77  3.56 12082
#> 4 Premium   0.892  5.97  5.94  3.65 13791
#> 5 Ideal     0.703  5.51  5.52  3.40 21551

26.2.7 So sánh với pivot_longer()

Trước khi tiếp tục, cần chỉ ra một mối liên hệ thú vị giữa across()pivot_longer() (Phần 5.3). Trong nhiều trường hợp, bạn thực hiện cùng các phép tính bằng cách trước tiên xoay dữ liệu rồi thực hiện các thao tác theo nhóm thay vì theo column. Ví dụ, hãy xem phép tóm tắt đa function này:

df |>
  summarize(across(a:d, list(median = median, mean = mean)))
#> # A tibble: 1 × 8
#>   a_median  a_mean b_median  b_mean c_median  c_mean d_median d_mean
#>      <dbl>   <dbl>    <dbl>   <dbl>    <dbl>   <dbl>    <dbl>  <dbl>
#> 1   -0.246 -0.0426    0.155 -0.0656   0.0480 -0.0297   -0.193 -0.200

Chúng ta có thể tính cùng các giá trị bằng cách xoay dài hơn rồi tóm tắt:

long <- df |>
  pivot_longer(a:d) |>
  group_by(name) |>
  summarize(
    median = median(value),
    mean = mean(value)
  )
long
#> # A tibble: 4 × 3
#>   name   median    mean
#>   <chr>   <dbl>   <dbl>
#> 1 a     -0.246  -0.0426
#> 2 b      0.155  -0.0656
#> 3 c      0.0480 -0.0297
#> 4 d     -0.193  -0.200

Và nếu bạn muốn cùng cấu trúc như across(), bạn có thể xoay lại:

long |>
  pivot_wider(
    names_from = name,
    values_from = c(median, mean),
    names_vary = "slowest",
    names_glue = "{name}_{.value}"
  )
#> # A tibble: 1 × 8
#>   a_median  a_mean b_median  b_mean c_median  c_mean d_median d_mean
#>      <dbl>   <dbl>    <dbl>   <dbl>    <dbl>   <dbl>    <dbl>  <dbl>
#> 1   -0.246 -0.0426    0.155 -0.0656   0.0480 -0.0297   -0.193 -0.200

Đây là một kỹ thuật hữu ích cần biết vì đôi khi bạn sẽ gặp vấn đề hiện không thể giải quyết được với across(): khi bạn có các nhóm column mà bạn muốn tính toán đồng thời. Ví dụ, hãy tưởng tượng rằng data frame của chúng ta chứa cả giá trị và trọng số và chúng ta muốn tính trung bình có trọng số:

set.seed(1014)
df_paired <- tibble(
  a_val = rnorm(10),
  a_wts = runif(10),
  b_val = rnorm(10),
  b_wts = runif(10),
  c_val = rnorm(10),
  c_wts = runif(10),
  d_val = rnorm(10),
  d_wts = runif(10)
)

Hiện tại không có cách nào để làm điều này với across()4, nhưng nó tương đối đơn giản với pivot_longer():

df_long <- df_paired |>
  pivot_longer(
    everything(),
    names_to = c("group", ".value"),
    names_sep = "_"
  )
df_long
#> # A tibble: 40 × 3
#>   group    val   wts
#>   <chr>  <dbl> <dbl>
#> 1 a     -1.40  0.290
#> 2 b     -1.86  0.461
#> 3 c      0.935 0.528
#> 4 d      2.76  0.709
#> 5 a      0.255 0.678
#> 6 b     -0.522 0.315
#> # ℹ 34 more rows

df_long |>
  group_by(group) |>
  summarize(mean = weighted.mean(val, wts))
#> # A tibble: 4 × 2
#>   group    mean
#>   <chr>   <dbl>
#> 1 a     -0.207 
#> 2 b     -0.237 
#> 3 c      0.0208
#> 4 d      0.0655

Nếu cần, bạn có thể pivot_wider() kết quả này trở lại dạng ban đầu.

26.2.8 Bài tập

  1. Thực hành kỹ năng across() của bạn bằng cách:

    1. Tính số giá trị duy nhất trong mỗi column của palmerpenguins::penguins.

    2. Tính trung bình của mỗi column trong mtcars.

    3. Nhóm diamonds theo cut, claritycolor rồi đếm số quan sát và tính trung bình của mỗi column số.

  2. Điều gì xảy ra nếu bạn sử dụng một list các function trong across(), nhưng không đặt tên cho chúng? Đầu ra được đặt tên như thế nào?

  3. Điều chỉnh expand_dates() để tự động loại bỏ các column ngày sau khi chúng đã được mở rộng. Bạn có cần embrace bất kỳ argument nào không?

  4. Giải thích mỗi bước trong pipeline của function này làm gì. Chúng ta đang tận dụng tính năng đặc biệt nào của where()?

    show_missing <- function(df, group_vars, summary_vars = everything()) {
      df |>
        group_by(pick({{ group_vars }})) |>
        summarize(
          across({{ summary_vars }}, \(x) sum(is.na(x))),
          .groups = "drop"
        ) |>
        select(where(\(x) any(x > 0)))
    }
    nycflights13::flights |> show_missing(c(year, month, day))

26.3 Đọc nhiều tệp

Trong phần trước, bạn đã học cách sử dụng dplyr::across() để iterate lại một phép biến đổi trên nhiều column. Trong phần này, bạn sẽ học cách sử dụng purrr::map() để làm gì đó với mỗi tệp trong một thư mục. Hãy bắt đầu với một chút động lực: hãy tưởng tượng bạn có một thư mục đầy các spreadsheet excel5 mà bạn muốn đọc. Bạn có thể làm điều đó bằng cách sao chép và dán:

data2019 <- readxl::read_excel("data/y2019.xlsx")
data2020 <- readxl::read_excel("data/y2020.xlsx")
data2021 <- readxl::read_excel("data/y2021.xlsx")
data2022 <- readxl::read_excel("data/y2022.xlsx")

Và sau đó sử dụng dplyr::bind_rows() để kết hợp tất cả lại với nhau:

data <- bind_rows(data2019, data2020, data2021, data2022)

Bạn có thể tưởng tượng rằng điều này sẽ nhanh chóng trở nên nhàm chán, đặc biệt nếu bạn có row trăm tệp, không chỉ bốn. Các phần sau đây cho bạn thấy cách tự động hóa loại tác vụ này. Có ba bước cơ bản: sử dụng list.files() để liệt kê tất cả các tệp trong một thư mục, sau đó sử dụng purrr::map() để đọc từng tệp vào một list, rồi sử dụng purrr::list_rbind() để kết hợp chúng thành một data frame duy nhất. Sau đó chúng ta sẽ thảo luận cách xử lý các tình huống ngày càng không đồng nhất, khi bạn không thể làm chính xác cùng một việc cho mỗi tệp.

26.3.1 Liệt kê các tệp trong một thư mục

Đúng như tên gọi, list.files() liệt kê các tệp trong một thư mục. Bạn hầu như luôn sử dụng ba argument:

  • Đối số đầu tiên, path, là thư mục cần tìm.

  • pattern là một regular expression (regular expression) dùng để lọc tên tệp. Mẫu phổ biến nhất là thứ gì đó như [.]xlsx$ hoặc [.]csv$ để tìm tất cả các tệp có phần mở rộng được chỉ định.

  • full.names xác định liệu tên thư mục có nên được bao gồm trong đầu ra hay không. Bạn hầu như luôn muốn giá trị này là TRUE.

Để làm cho ví dụ động lực của chúng ta trở nên cụ thể, cuốn sách này chứa một thư mục với 12 spreadsheet excel chứa dữ liệu từ package gapminder. Thư mục này có thể được tìm thấy tại https://github.com/hadley/r4ds/tree/main/data/gapminder. Mỗi tệp chứa dữ liệu một năm cho 142 quốc gia. Chúng ta có thể liệt kê tất cả chúng với lệnh gọi thích hợp đến list.files():

paths <- list.files("data/gapminder", pattern = "[.]xlsx$", full.names = TRUE)
paths
#>  [1] "data/gapminder/1952.xlsx" "data/gapminder/1957.xlsx"
#>  [3] "data/gapminder/1962.xlsx" "data/gapminder/1967.xlsx"
#>  [5] "data/gapminder/1972.xlsx" "data/gapminder/1977.xlsx"
#>  [7] "data/gapminder/1982.xlsx" "data/gapminder/1987.xlsx"
#>  [9] "data/gapminder/1992.xlsx" "data/gapminder/1997.xlsx"
#> [11] "data/gapminder/2002.xlsx" "data/gapminder/2007.xlsx"

26.3.2 Danh sách

Bây giờ chúng ta có 12 đường dẫn này, chúng ta có thể gọi read_excel() 12 lần để có 12 data frame:

gapminder_1952 <- readxl::read_excel("data/gapminder/1952.xlsx")
gapminder_1957 <- readxl::read_excel("data/gapminder/1957.xlsx")
gapminder_1962 <- readxl::read_excel("data/gapminder/1962.xlsx")
 ...,
gapminder_2007 <- readxl::read_excel("data/gapminder/2007.xlsx")

Nhưng việc đặt mỗi spreadsheet vào biến riêng sẽ khiến việc làm việc với chúng trở nên khó khăn ở một vài bước sau. Thay vào đó, sẽ dễ dàng hơn khi đặt chúng vào một đối tượng duy nhất. Một list (list) là công cụ hoàn hảo cho công việc này:

files <- list(
  readxl::read_excel("data/gapminder/1952.xlsx"),
  readxl::read_excel("data/gapminder/1957.xlsx"),
  readxl::read_excel("data/gapminder/1962.xlsx"),
  ...,
  readxl::read_excel("data/gapminder/2007.xlsx")
)

Bây giờ khi bạn có các data frame này trong một list, làm thế nào để lấy một cái ra? Bạn có thể sử dụng files[[i]] để trích xuất phần tử thứ i:

files[[3]]
#> # A tibble: 142 × 5
#>   country     continent lifeExp      pop gdpPercap
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 Afghanistan Asia         32.0 10267083      853.
#> 2 Albania     Europe       64.8  1728137     2313.
#> 3 Algeria     Africa       48.3 11000948     2551.
#> 4 Angola      Africa       34    4826015     4269.
#> 5 Argentina   Americas     65.1 21283783     7133.
#> 6 Australia   Oceania      70.9 10794968    12217.
#> # ℹ 136 more rows

Chúng ta sẽ quay lại [[ chi tiết hơn trong Phần 27.3.

26.3.3 purrr::map()list_rbind()

Đoạn code để thu thập các data frame đó vào một list “bằng tay” về cơ bản cũng nhàm chán để gõ như code đọc từng tệp một. May mắn thay, chúng ta có thể sử dụng purrr::map() để tận dụng tốt hơn vector paths của mình. map() tương tự như across(), nhưng thay vì làm gì đó với mỗi column trong một data frame, nó làm gì đó với mỗi phần tử của một vector. map(x, f) là viết tắt của:

list(
  f(x[[1]]),
  f(x[[2]]),
  ...,
  f(x[[n]])
)

Vì vậy chúng ta có thể sử dụng map() để lấy một list gồm 12 data frame:

files <- map(paths, readxl::read_excel)
length(files)
#> [1] 12

files[[1]]
#> # A tibble: 142 × 5
#>   country     continent lifeExp      pop gdpPercap
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 Afghanistan Asia         28.8  8425333      779.
#> 2 Albania     Europe       55.2  1282697     1601.
#> 3 Algeria     Africa       43.1  9279525     2449.
#> 4 Angola      Africa       30.0  4232095     3521.
#> 5 Argentina   Americas     62.5 17876956     5911.
#> 6 Australia   Oceania      69.1  8691212    10040.
#> # ℹ 136 more rows

(Đây là một cấu trúc dữ liệu khác không hiển thị đặc biệt gọn gàng với str() nên bạn có thể muốn tải nó vào RStudio và kiểm tra bằng View()).

Bây giờ chúng ta có thể sử dụng purrr::list_rbind() để kết hợp list các data frame đó thành một data frame duy nhất:

list_rbind(files)
#> # A tibble: 1,704 × 5
#>   country     continent lifeExp      pop gdpPercap
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 Afghanistan Asia         28.8  8425333      779.
#> 2 Albania     Europe       55.2  1282697     1601.
#> 3 Algeria     Africa       43.1  9279525     2449.
#> 4 Angola      Africa       30.0  4232095     3521.
#> 5 Argentina   Americas     62.5 17876956     5911.
#> 6 Australia   Oceania      69.1  8691212    10040.
#> # ℹ 1,698 more rows

Hoặc chúng ta có thể thực hiện cả hai bước cùng lúc trong một pipeline:

paths |>
  map(readxl::read_excel) |>
  list_rbind()

Điều gì nếu chúng ta muốn truyền thêm argument cho read_excel()? Chúng ta sử dụng cùng kỹ thuật đã dùng với across(). Ví dụ, thường hữu ích khi xem qua vài row đầu tiên của dữ liệu với n_max = 1:

paths |>
  map(\(path) readxl::read_excel(path, n_max = 1)) |>
  list_rbind()
#> # A tibble: 12 × 5
#>   country     continent lifeExp      pop gdpPercap
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 Afghanistan Asia         28.8  8425333      779.
#> 2 Afghanistan Asia         30.3  9240934      821.
#> 3 Afghanistan Asia         32.0 10267083      853.
#> 4 Afghanistan Asia         34.0 11537966      836.
#> 5 Afghanistan Asia         36.1 13079460      740.
#> 6 Afghanistan Asia         38.4 14880372      786.
#> # ℹ 6 more rows

Điều này cho thấy rõ rằng có gì đó đang thiếu: không có column year vì giá trị đó được ghi trong đường dẫn, không phải trong các tệp riêng lẻ. Chúng ta sẽ giải quyết vấn đề đó tiếp theo.

26.3.4 Dữ liệu trong đường dẫn

Đôi khi tên tệp chính là dữ liệu. Trong ví dụ này, tên tệp chứa năm, thông tin không được ghi ở nơi khác trong các tệp riêng lẻ. Để đưa column đó vào data frame cuối cùng, chúng ta cần làm hai việc:

Đầu tiên, chúng ta đặt tên cho vector các đường dẫn. Cách dễ nhất để làm điều này là với function set_names(), có thể nhận một function. Ở đây chúng ta sử dụng basename() để trích xuất chỉ tên tệp từ đường dẫn đầy đủ:

paths |> set_names(basename)
#>                  1952.xlsx                  1957.xlsx 
#> "data/gapminder/1952.xlsx" "data/gapminder/1957.xlsx" 
#>                  1962.xlsx                  1967.xlsx 
#> "data/gapminder/1962.xlsx" "data/gapminder/1967.xlsx" 
#>                  1972.xlsx                  1977.xlsx 
#> "data/gapminder/1972.xlsx" "data/gapminder/1977.xlsx" 
#>                  1982.xlsx                  1987.xlsx 
#> "data/gapminder/1982.xlsx" "data/gapminder/1987.xlsx" 
#>                  1992.xlsx                  1997.xlsx 
#> "data/gapminder/1992.xlsx" "data/gapminder/1997.xlsx" 
#>                  2002.xlsx                  2007.xlsx 
#> "data/gapminder/2002.xlsx" "data/gapminder/2007.xlsx"

Những tên đó được tự động mang theo bởi tất cả các function map, nên list các data frame sẽ có cùng những tên đó:

files <- paths |>
  set_names(basename) |>
  map(readxl::read_excel)

Điều đó khiến lệnh gọi map() này là viết tắt của:

files <- list(
  "1952.xlsx" = readxl::read_excel("data/gapminder/1952.xlsx"),
  "1957.xlsx" = readxl::read_excel("data/gapminder/1957.xlsx"),
  "1962.xlsx" = readxl::read_excel("data/gapminder/1962.xlsx"),
  ...,
  "2007.xlsx" = readxl::read_excel("data/gapminder/2007.xlsx")
)

Bạn cũng có thể sử dụng [[ để trích xuất phần tử theo tên:

files[["1962.xlsx"]]
#> # A tibble: 142 × 5
#>   country     continent lifeExp      pop gdpPercap
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 Afghanistan Asia         32.0 10267083      853.
#> 2 Albania     Europe       64.8  1728137     2313.
#> 3 Algeria     Africa       48.3 11000948     2551.
#> 4 Angola      Africa       34    4826015     4269.
#> 5 Argentina   Americas     65.1 21283783     7133.
#> 6 Australia   Oceania      70.9 10794968    12217.
#> # ℹ 136 more rows

Sau đó chúng ta sử dụng argument names_to của list_rbind() để yêu cầu nó lưu các tên vào một column mới gọi là year rồi sử dụng readr::parse_number() để trích xuất số từ string.

paths |>
  set_names(basename) |>
  map(readxl::read_excel) |>
  list_rbind(names_to = "year") |>
  mutate(year = parse_number(year))
#> # A tibble: 1,704 × 6
#>    year country     continent lifeExp      pop gdpPercap
#>   <dbl> <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1  1952 Afghanistan Asia         28.8  8425333      779.
#> 2  1952 Albania     Europe       55.2  1282697     1601.
#> 3  1952 Algeria     Africa       43.1  9279525     2449.
#> 4  1952 Angola      Africa       30.0  4232095     3521.
#> 5  1952 Argentina   Americas     62.5 17876956     5911.
#> 6  1952 Australia   Oceania      69.1  8691212    10040.
#> # ℹ 1,698 more rows

Trong các trường hợp phức tạp hơn, có thể có các biến khác được lưu trong tên thư mục, hoặc có thể tên tệp chứa nhiều mẩu dữ liệu. Trong trường hợp đó, hãy sử dụng set_names() (không có argument nào) để ghi lại đường dẫn đầy đủ, và sau đó sử dụng tidyr::separate_wider_delim() và các function liên quan để biến chúng thành các column hữu ích.

paths |>
  set_names() |>
  map(readxl::read_excel) |>
  list_rbind(names_to = "year") |>
  separate_wider_delim(year, delim = "/", names = c(NA, "dir", "file")) |>
  separate_wider_delim(file, delim = ".", names = c("file", "ext"))
#> # A tibble: 1,704 × 8
#>   dir       file  ext   country     continent lifeExp      pop gdpPercap
#>   <chr>     <chr> <chr> <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 gapminder 1952  xlsx  Afghanistan Asia         28.8  8425333      779.
#> 2 gapminder 1952  xlsx  Albania     Europe       55.2  1282697     1601.
#> 3 gapminder 1952  xlsx  Algeria     Africa       43.1  9279525     2449.
#> 4 gapminder 1952  xlsx  Angola      Africa       30.0  4232095     3521.
#> 5 gapminder 1952  xlsx  Argentina   Americas     62.5 17876956     5911.
#> 6 gapminder 1952  xlsx  Australia   Oceania      69.1  8691212    10040.
#> # ℹ 1,698 more rows

26.3.5 Lưu công việc của bạn

Bây giờ khi bạn đã làm tất cả công việc khó khăn này để có được một data frame gọn gàng (tidy), đây là thời điểm tuyệt vời để lưu công việc của bạn:

gapminder <- paths |>
  set_names(basename) |>
  map(readxl::read_excel) |>
  list_rbind(names_to = "year") |>
  mutate(year = parse_number(year))

write_csv(gapminder, "gapminder.csv")

Bây giờ khi bạn quay lại vấn đề này trong tương lai, bạn có thể đọc vào một tệp csv duy nhất. Đối với các tập dữ liệu lớn và phong phú hơn, sử dụng parquet có thể là lựa chọn tốt hơn .csv, như đã thảo luận trong Phần 22.4.

Nếu bạn đang làm việc trong một dự án, chúng tôi gợi ý đặt tên tệp thực hiện công việc chuẩn bị dữ liệu kiểu này là 0-cleanup.R. Số 0 trong tên tệp gợi ý rằng nó nên được chạy trước bất kỳ thứ gì khác.

Nếu các tệp dữ liệu đầu vào của bạn thay đổi theo thời gian, bạn có thể cân nhắc học một công cụ như targets để thiết lập code dọn dẹp dữ liệu tự động chạy lại mỗi khi một trong các tệp đầu vào bị thay đổi.

26.3.6 Nhiều loop đơn giản

Ở đây chúng ta chỉ tải dữ liệu trực tiếp từ đĩa, và may mắn có được một tập tidy data. Trong hầu hết các trường hợp, bạn sẽ cần thực hiện thêm một số thao tác dọn dẹp, và bạn có hai lựa chọn cơ bản: bạn có thể thực hiện một loop với một function phức tạp, hoặc thực hiện nhiều loop với các function đơn giản. Theo kinh nghiệm của chúng tôi, hầu hết mọi người thường chọn một loop phức tạp trước, nhưng bạn thường tốt hơn khi thực hiện nhiều loop đơn giản.

Ví dụ, hãy tưởng tượng rằng bạn muốn đọc một loạt tệp, lọc bỏ các missing value, xoay dữ liệu, rồi kết hợp chúng. Một cách tiếp cận vấn đề là viết một function nhận một tệp và thực hiện tất cả các bước đó rồi gọi map() một lần:

process_file <- function(path) {
  df <- read_csv(path)

  df |>
    filter(!is.na(id)) |>
    mutate(id = tolower(id)) |>
    pivot_longer(jan:dec, names_to = "month")
}

paths |>
  map(process_file) |>
  list_rbind()

Ngoài ra, bạn có thể thực hiện từng bước của process_file() cho mỗi tệp:

paths |>
  map(read_csv) |>
  map(\(df) df |> filter(!is.na(id))) |>
  map(\(df) df |> mutate(id = tolower(id))) |>
  map(\(df) df |> pivot_longer(jan:dec, names_to = "month")) |>
  list_rbind()

Chúng tôi khuyến nghị cách tiếp cận này vì nó ngăn bạn bị ám ảnh với việc làm đúng tệp đầu tiên trước khi chuyển sang các tệp còn lại. Bằng cách xem xét tất cả dữ liệu khi thực hiện dọn dẹp, bạn có nhiều khả năng suy nghĩ toàn diện hơn và đạt được kết quả chất lượng cao hơn.

Trong ví dụ cụ thể này, có một cách tối ưu hóa khác bạn có thể thực hiện, bằng cách kết nối tất cả các data frame lại sớm hơn. Sau đó bạn có thể dựa vào hành vi thông thường của dplyr:

paths |>
  map(read_csv) |>
  list_rbind() |>
  filter(!is.na(id)) |>
  mutate(id = tolower(id)) |>
  pivot_longer(jan:dec, names_to = "month")

26.3.7 Dữ liệu không đồng nhất

Thật không may, đôi khi không thể đi từ map() thẳng đến list_rbind() vì các data frame quá không đồng nhất khiến list_rbind() hoặc thất bại hoặc cho ra một data frame không hữu ích lắm. Trong trường hợp đó, vẫn hữu ích khi bắt đầu bằng việc tải tất cả các tệp:

files <- paths |>
  map(readxl::read_excel)

Sau đó một chiến lược rất hữu ích là nắm bắt cấu trúc của các data frame để bạn có thể khám phá nó bằng các kỹ năng khoa học dữ liệu của mình. Một cách để làm điều đó là với function df_types tiện lợi này6 trả về một tibble với một row cho mỗi column:

df_types <- function(df) {
  tibble(
    col_name = names(df),
    col_type = map_chr(df, vctrs::vec_ptype_full),
    n_miss = map_int(df, \(x) sum(is.na(x)))
  )
}

df_types(gapminder)
#> # A tibble: 6 × 3
#>   col_name  col_type  n_miss
#>   <chr>     <chr>      <int>
#> 1 year      double         0
#> 2 country   character      0
#> 3 continent character      0
#> 4 lifeExp   double         0
#> 5 pop       double         0
#> 6 gdpPercap double         0

Sau đó bạn có thể áp dụng function này cho tất cả các tệp, và có thể thực hiện một số thao tác xoay để dễ dàng thấy sự khác biệt ở đâu. Ví dụ, điều này giúp dễ dàng xác minh rằng các spreadsheet gapminder mà chúng ta đã làm việc đều khá đồng nhất:

files |>
  map(df_types) |>
  list_rbind(names_to = "file_name") |>
  select(-n_miss) |>
  pivot_wider(names_from = col_name, values_from = col_type)
#> # A tibble: 12 × 6
#>   file_name country   continent lifeExp pop    gdpPercap
#>   <chr>     <chr>     <chr>     <chr>   <chr>  <chr>    
#> 1 1952.xlsx character character double  double double   
#> 2 1957.xlsx character character double  double double   
#> 3 1962.xlsx character character double  double double   
#> 4 1967.xlsx character character double  double double   
#> 5 1972.xlsx character character double  double double   
#> 6 1977.xlsx character character double  double double   
#> # ℹ 6 more rows

Nếu các tệp có định dạng không đồng nhất, bạn có thể cần xử lý thêm trước khi có thể hợp nhất chúng thành công. Thật không may, chúng tôi sẽ để bạn tự tìm hiểu điều đó, nhưng bạn có thể muốn đọc về map_if()map_at(). map_if() cho phép bạn chọn lọc sửa đổi các phần tử của một list dựa trên giá trị của chúng; map_at() cho phép bạn chọn lọc sửa đổi các phần tử dựa trên tên của chúng.

26.3.8 Xử lý lỗi

Đôi khi cấu trúc dữ liệu của bạn có thể đủ lộn xộn đến mức bạn thậm chí không thể đọc tất cả các tệp bằng một lệnh duy nhất. Và rồi bạn sẽ gặp một trong những nhược điểm của map(): nó thành công hoặc thất bại toàn bộ. map() sẽ hoặc đọc thành công tất cả các tệp trong một thư mục hoặc thất bại với một lỗi, đọc được không tệp nào. Điều này thật phiền: tại sao một lỗi lại ngăn bạn truy cập tất cả các thành công khác?

May mắn thay, purrr đi kèm với một function trợ giúp để giải quyết vấn đề này: possibly(). possibly() là cái được gọi là toán tử function (function operator): nó nhận một function và trả về một function với hành vi đã được sửa đổi. Cụ thể, possibly() thay đổi một function từ việc báo lỗi thành trả về một giá trị mà bạn chỉ định:

files <- paths |>
  map(possibly(\(path) readxl::read_excel(path), NULL))

data <- files |> list_rbind()

Điều này hoạt động đặc biệt tốt ở đây vì list_rbind(), giống như nhiều function tidyverse, tự động bỏ qua các NULL.

Bây giờ bạn có tất cả dữ liệu có thể đọc được dễ dàng, và đây là lúc giải quyết phần khó là tìm ra tại sao một số tệp không tải được và phải làm gì với chúng. Bắt đầu bằng cách lấy các đường dẫn bị lỗi:

failed <- map_vec(files, is.null)
paths[failed]
#> character(0)

Sau đó gọi lại function nhập cho từng lỗi và tìm hiểu xem có vấn đề gì.

26.4 Lưu nhiều đầu ra

Trong phần trước, bạn đã học về map(), hữu ích cho việc đọc nhiều tệp vào một đối tượng duy nhất. Trong phần này, chúng ta sẽ khám phá vấn đề ngược lại: làm thế nào bạn có thể lấy một hoặc nhiều đối tượng R và lưu chúng vào một hoặc nhiều tệp? Chúng ta sẽ khám phá thách thức này bằng ba ví dụ:

  • Lưu nhiều data frame vào một database.
  • Lưu nhiều data frame vào nhiều tệp .csv.
  • Lưu nhiều biểu đồ vào nhiều tệp .png.

26.4.1 Ghi vào database

Đôi khi khi làm việc với nhiều tệp cùng lúc, không thể đưa tất cả dữ liệu của bạn vào bộ nhớ cùng lúc, và bạn không thể dùng map(files, read_csv). Một cách tiếp cận để giải quyết vấn đề này là tải dữ liệu vào một database để bạn có thể truy cập chỉ những phần bạn cần với dbplyr.

Nếu may mắn, package database bạn đang sử dụng sẽ cung cấp một function tiện lợi nhận một vector các đường dẫn và tải tất cả chúng vào database. Đây là trường hợp với duckdb_read_csv() của duckdb:

con <- DBI::dbConnect(duckdb::duckdb())
duckdb::duckdb_read_csv(con, "gapminder", paths)

Điều này sẽ hoạt động tốt ở đây, nhưng chúng ta không có các tệp csv, thay vào đó là các spreadsheet excel. Vì vậy chúng ta sẽ phải làm “thủ công”. Học cách làm thủ công cũng sẽ giúp bạn khi bạn có một loạt csv và database mà bạn đang làm việc không có một function nào tải tất cả chúng vào.

Chúng ta cần bắt đầu bằng cách tạo một bảng mà chúng ta sẽ điền dữ liệu vào. Cách dễ nhất để làm điều này là tạo một mẫu (template), một data frame giả chứa tất cả các column chúng ta muốn, nhưng chỉ một phần nhỏ dữ liệu. Đối với dữ liệu gapminder, chúng ta có thể tạo mẫu đó bằng cách đọc một tệp duy nhất và thêm năm vào:

template <- readxl::read_excel(paths[[1]])
template$year <- 1952
template
#> # A tibble: 142 × 6
#>   country     continent lifeExp      pop gdpPercap  year
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl> <dbl>
#> 1 Afghanistan Asia         28.8  8425333      779.  1952
#> 2 Albania     Europe       55.2  1282697     1601.  1952
#> 3 Algeria     Africa       43.1  9279525     2449.  1952
#> 4 Angola      Africa       30.0  4232095     3521.  1952
#> 5 Argentina   Americas     62.5 17876956     5911.  1952
#> 6 Australia   Oceania      69.1  8691212    10040.  1952
#> # ℹ 136 more rows

Bây giờ chúng ta có thể kết nối đến database, và sử dụng DBI::dbCreateTable() để biến mẫu thành một bảng database:

con <- DBI::dbConnect(duckdb::duckdb())
DBI::dbCreateTable(con, "gapminder", template)

dbCreateTable() không sử dụng dữ liệu trong template, chỉ tên biến và kiểu dữ liệu. Vì vậy nếu chúng ta kiểm tra bảng gapminder bây giờ, bạn sẽ thấy nó trống nhưng có các biến cần thiết với các kiểu mong đợi:

con |> tbl("gapminder")
#> # Source:   table<gapminder> [?? x 6]
#> # Database: DuckDB 1.5.1 [root@Darwin 25.3.0:R 4.5.3/:memory:]
#> # ℹ 6 variables: country <chr>, continent <chr>, lifeExp <dbl>, pop <dbl>,
#> #   gdpPercap <dbl>, year <dbl>

Tiếp theo, chúng ta cần một function nhận một đường dẫn tệp đơn, đọc nó vào R, và thêm kết quả vào bảng gapminder. Chúng ta có thể làm điều đó bằng cách kết hợp read_excel() với DBI::dbAppendTable():

append_file <- function(path) {
  df <- readxl::read_excel(path)
  df$year <- parse_number(basename(path))

  DBI::dbAppendTable(con, "gapminder", df)
}

Bây giờ chúng ta cần gọi append_file() một lần cho mỗi phần tử của paths. Điều đó chắc chắn có thể làm được với map():

paths |> map(append_file)

Nhưng chúng ta không quan tâm đến đầu ra của append_file(), nên thay vì map(), sử dụng walk() sẽ gọn gàng hơn một chút. walk() làm chính xác như map() nhưng bỏ đi đầu ra:

paths |> walk(append_file)

Bây giờ chúng ta có thể xem liệu chúng ta có tất cả dữ liệu trong bảng không:

con |>
  tbl("gapminder") |>
  count(year)
#> # Source:   SQL [?? x 2]
#> # Database: DuckDB 1.5.1 [root@Darwin 25.3.0:R 4.5.3/:memory:]
#>    year     n
#>   <dbl> <dbl>
#> 1  1967   142
#> 2  1977   142
#> 3  1972   142
#> 4  1992   142
#> 5  1987   142
#> 6  2007   142
#> # ℹ more rows

26.4.2 Ghi tệp csv

Cùng nguyên tắc cơ bản áp dụng nếu chúng ta muốn ghi nhiều tệp csv, mỗi tệp cho một nhóm. Hãy tưởng tượng rằng chúng ta muốn lấy dữ liệu ggplot2::diamonds và lưu một tệp csv cho mỗi clarity. Đầu tiên chúng ta cần tạo các tập dữ liệu riêng lẻ đó. Có nhiều cách để làm điều đó, nhưng có một cách chúng tôi đặc biệt thích: group_nest().

by_clarity <- diamonds |>
  group_nest(clarity)

by_clarity
#> # A tibble: 8 × 2
#>   clarity               data
#>   <ord>   <list<tibble[,9]>>
#> 1 I1               [741 × 9]
#> 2 SI2            [9,194 × 9]
#> 3 SI1           [13,065 × 9]
#> 4 VS2           [12,258 × 9]
#> 5 VS1            [8,171 × 9]
#> 6 VVS2           [5,066 × 9]
#> # ℹ 2 more rows

Điều này cho chúng ta một tibble mới với tám row và hai column. clarity là biến nhóm của chúng ta và data là một column list chứa một tibble cho mỗi giá trị duy nhất của clarity:

by_clarity$data[[1]]
#> # A tibble: 741 × 9
#>   carat cut       color depth table price     x     y     z
#>   <dbl> <ord>     <ord> <dbl> <dbl> <int> <dbl> <dbl> <dbl>
#> 1  0.32 Premium   E      60.9    58   345  4.38  4.42  2.68
#> 2  1.17 Very Good J      60.2    61  2774  6.83  6.9   4.13
#> 3  1.01 Premium   F      61.8    60  2781  6.39  6.36  3.94
#> 4  1.01 Fair      E      64.5    58  2788  6.29  6.21  4.03
#> 5  0.96 Ideal     F      60.7    55  2801  6.37  6.41  3.88
#> 6  1.04 Premium   G      62.2    58  2801  6.46  6.41  4   
#> # ℹ 735 more rows

Trong khi đang ở đây, hãy tạo một column chứa tên tệp đầu ra, sử dụng mutate()str_glue():

by_clarity <- by_clarity |>
  mutate(path = str_glue("diamonds-{clarity}.csv"))

by_clarity
#> # A tibble: 8 × 3
#>   clarity               data path             
#>   <ord>   <list<tibble[,9]>> <glue>           
#> 1 I1               [741 × 9] diamonds-I1.csv  
#> 2 SI2            [9,194 × 9] diamonds-SI2.csv 
#> 3 SI1           [13,065 × 9] diamonds-SI1.csv 
#> 4 VS2           [12,258 × 9] diamonds-VS2.csv 
#> 5 VS1            [8,171 × 9] diamonds-VS1.csv 
#> 6 VVS2           [5,066 × 9] diamonds-VVS2.csv
#> # ℹ 2 more rows

Vì vậy nếu chúng ta lưu các data frame này bằng tay, chúng ta có thể viết thứ gì đó như:

write_csv(by_clarity$data[[1]], by_clarity$path[[1]])
write_csv(by_clarity$data[[2]], by_clarity$path[[2]])
write_csv(by_clarity$data[[3]], by_clarity$path[[3]])
...
write_csv(by_clarity$data[[8]], by_clarity$path[[8]])

Điều này hơi khác so với các lần sử dụng map() trước đây vì có hai argument đang thay đổi, không chỉ một. Điều đó có nghĩa là chúng ta cần một function mới: map2(), thay đổi cả argument thứ nhất và thứ hai. Và vì chúng ta lại không quan tâm đến đầu ra, chúng ta muốn walk2() thay vì map2(). Điều đó cho chúng ta:

walk2(by_clarity$data, by_clarity$path, write_csv)

26.4.3 Lưu biểu đồ

Chúng ta có thể áp dụng cùng cách tiếp cận cơ bản để tạo nhiều biểu đồ. Hãy bắt đầu bằng cách tạo một function vẽ biểu đồ mà chúng ta muốn:

carat_histogram <- function(df) {
  ggplot(df, aes(x = carat)) + geom_histogram(binwidth = 0.1)
}

carat_histogram(by_clarity$data[[1]])

Biểu đồ tần suất của carat từ tập dữ liệu by_clarity, dao động từ 0 đến 5 carat. Phân phối đơn đỉnh và lệch phải với đỉnh khoảng 1 carat.

Bây giờ chúng ta có thể sử dụng map() để tạo một list nhiều biểu đồ7 và các đường dẫn tệp cuối cùng của chúng:

by_clarity <- by_clarity |>
  mutate(
    plot = map(data, carat_histogram),
    path = str_glue("clarity-{clarity}.png")
  )

Sau đó sử dụng walk2() với ggsave() để lưu mỗi biểu đồ:

walk2(
  by_clarity$path,
  by_clarity$plot,
  \(path, plot) ggsave(path, plot, width = 6, height = 6)
)

Đây là viết tắt của:

ggsave(by_clarity$path[[1]], by_clarity$plot[[1]], width = 6, height = 6)
ggsave(by_clarity$path[[2]], by_clarity$plot[[2]], width = 6, height = 6)
ggsave(by_clarity$path[[3]], by_clarity$plot[[3]], width = 6, height = 6)
...
ggsave(by_clarity$path[[8]], by_clarity$plot[[8]], width = 6, height = 6)

26.5 Tóm tắt

Trong chương này, bạn đã thấy cách sử dụng iterate tường minh để giải quyết ba vấn đề thường gặp khi làm khoa học dữ liệu: thao tác nhiều column, đọc nhiều tệp và lưu nhiều đầu ra. Nhưng nói chung, iterate là một siêu năng lực: nếu bạn biết đúng kỹ thuật iterate, bạn có thể dễ dàng đi từ việc sửa một vấn đề sang sửa tất cả các vấn đề. Khi bạn đã thành thạo các kỹ thuật trong chương này, chúng tôi rất khuyến khích bạn tìm hiểu thêm bằng cách đọc chương Functionals của Advanced R và tham khảo trang web purrr.

Nếu bạn biết nhiều về iterate trong các ngôn ngữ khác, bạn có thể ngạc nhiên rằng chúng tôi không thảo luận về loop for. Đó là vì hướng phân tích dữ liệu của R thay đổi cách chúng ta lặp: trong hầu hết các trường hợp bạn có thể dựa vào một thành ngữ có sẵn để làm gì đó cho mỗi column hoặc mỗi nhóm. Và khi bạn không thể, bạn thường có thể sử dụng một công cụ lập trình function như map() để làm gì đó với mỗi phần tử của một list. Tuy nhiên, bạn sẽ thấy loop for trong code ngoài tự nhiên, nên bạn sẽ học về chúng trong chương tiếp theo nơi chúng ta thảo luận một số công cụ base R quan trọng.


  1. Vô danh, vì chúng ta chưa bao giờ đặt tên cho nó một cách tường minh bằng <-. Một thuật ngữ khác mà lập trình viên sử dụng cho điều này là “hàm lambda”.↩︎

  2. Trong code cũ hơn, bạn có thể thấy cú pháp trông giống ~ .x + 1. Đây là một cách khác để viết function vô danh nhưng nó chỉ hoạt động bên trong các function tidyverse và luôn sử dụng tên biến .x. Chúng tôi hiện khuyến nghị sử dụng cú pháp cơ bản, \(x) x + 1.↩︎

  3. Hiện tại bạn không thể thay đổi thứ tự các column, nhưng bạn có thể sắp xếp lại chúng sau đó bằng relocate() hoặc tương tự.↩︎

  4. Có thể một ngày nào đó sẽ có, nhưng hiện tại chúng tôi chưa thấy cách nào.↩︎

  5. Nếu thay vào đó bạn có một thư mục các tệp csv với cùng định dạng, bạn có thể sử dụng kỹ thuật từ Phần 7.4.↩︎

  6. Chúng tôi sẽ không giải thích cách nó hoạt động, nhưng nếu bạn xem tài liệu của các function được sử dụng, bạn sẽ có thể tự suy ra.↩︎

  7. Bạn có thể in by_clarity$plot để có một hoạt ảnh thô — bạn sẽ nhận được một biểu đồ cho mỗi phần tử của plots. LƯU Ý: điều này không xảy ra với tôi.↩︎