26  반복

26.1 소개

이 장에서는 서로 다른 객체에 대해 동일한 작업을 반복적으로 수행하는 반복(iteration)을 위한 도구들을 배울 것입니다. R에서의 반복은 다른 프로그래밍 언어와는 다소 다르게 보이는 경향이 있는데, 이는 많은 부분이 암시적으로 처리되어 무료로 제공되기 때문입니다. 예를 들어, R에서 수치형 벡터 x를 두 배로 만들고 싶다면 그냥 2 * x라고 쓰면 됩니다. 대부분의 다른 언어에서는 for 루프 같은 것을 사용하여 x의 각 요소를 명시적으로 두 배로 만들어야 합니다.

이 책은 이미 여러 “것”들에 대해 동일한 작업을 수행하는 작지만 강력한 도구들을 몇 가지 소개했습니다:

이제 다른 함수를 입력으로 받는 함수를 중심으로 구축되었기 때문에 종종 함수형 프로그래밍(functional programming) 도구라고 불리는 좀 더 일반적인 도구들을 배울 때입니다. 함수형 프로그래밍을 배우는 것은 자칫 추상적으로 흐를 수 있지만, 이 장에서는 세 가지 일반적인 작업인 여러 열 수정하기, 여러 파일 읽기, 여러 객체 저장하기에 집중하여 구체적으로 다루겠습니다.

26.1.1 선수 지식

이 장에서는 tidyverse의 핵심 멤버인 dplyr과 purrr에서 제공하는 도구들에 집중하겠습니다. dplyr은 이미 보셨겠지만, purrr는 처음입니다. 이 장에서는 몇 가지 purrr 함수만 사용할 예정이지만, 프로그래밍 기술을 향상시키고 싶다면 탐색해 보기에 아주 좋은 패키지입니다.

library(tidyverse)
#> Warning: package 'ggplot2' was built under R version 4.5.2
#> Warning: package 'readr' was built under R version 4.5.2

26.2 여러 열 수정하기

다음과 같은 간단한 티블이 있고, 모든 열의 관측값 개수를 세고 중앙값을 계산하고 싶다고 가정해 봅시다.

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

복사해서 붙여넣기를 사용하여 할 수 있습니다:

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

하지만 이것은 코드 블록을 두 번 이상 복사해서 붙여넣지 말라는 우리의 경험 법칙을 어기는 것이며, 열이 수십 개 또는 수백 개라면 매우 지루해질 것임을 상상할 수 있습니다. 대신 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()에는 특히 중요한 세 가지 인수가 있으며, 다음 섹션들에서 자세히 다루겠습니다. across()를 사용할 때마다 처음 두 개를 사용하게 될 것입니다. 첫 번째 인수 .cols는 반복할 열을 지정하고, 두 번째 인수 .fns는 각 열에 대해 수행할 작업을 지정합니다. 출력 열의 이름을 추가로 제어해야 할 때는 .names 인수를 사용할 수 있으며, 이는 mutate()와 함께 across()를 사용할 때 특히 중요합니다. 또한 filter()와 함께 작동하는 두 가지 중요한 변형인 if_any()if_all()에 대해서도 논의할 것입니다.

26.2.1 .cols로 열 선택하기

across()의 첫 번째 인수 .cols는 변환할 열을 선택합니다. 이것은 select()와 동일한 사양을 사용하므로(Section 3.3.2), starts_with()ends_with()와 같은 함수를 사용하여 이름에 따라 열을 선택할 수 있습니다.

across()에 특히 유용한 두 가지 추가 선택 기술이 있습니다: everything()where()입니다. everything()은 간단합니다. (그룹화되지 않은) 모든 열을 선택합니다:

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

그룹화 열(여기서는 grp)은 summarize()에 의해 자동으로 보존되므로 across()에 포함되지 않습니다.

where()를 사용하면 유형에 따라 열을 선택할 수 있습니다:

  • where(is.numeric)은 모든 수치형 열을 선택합니다.
  • where(is.character)는 모든 문자열 열을 선택합니다.
  • where(is.Date)는 모든 날짜 열을 선택합니다.
  • where(is.POSIXct)는 모든 날짜-시간 열을 선택합니다.
  • where(is.logical)은 모든 논리형 열을 선택합니다.

다른 선택자들과 마찬가지로 부울 대수와 결합할 수 있습니다. 예를 들어 !where(is.numeric)은 수치형이 아닌 모든 열을 선택하고, starts_with("a") & where(is.logical)은 이름이 “a”로 시작하는 모든 논리형 열을 선택합니다.

26.2.2 단일 함수 호출하기

across()의 두 번째 인수는 각 열이 어떻게 변환될지를 정의합니다. 위의 간단한 경우처럼 단일 기존 함수가 될 수도 있습니다. 이것은 R의 꽤 특별한 기능입니다. 우리는 한 함수(median, mean, str_flatten, …)를 다른 함수(across)에 전달하고 있습니다. 이것이 R을 함수형 프로그래밍 언어로 만드는 기능 중 하나입니다.

여기서 중요한 점은 이 함수를 across()에 전달하여 across()가 호출할 수 있게 하는 것이지, 우리가 직접 호출하는 것이 아니라는 점입니다. 즉, 함수 이름 뒤에 ()가 오면 안 됩니다. 이를 잊어버리면 오류가 발생합니다:

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

이 오류는 입력 없이 함수를 호출하려고 했기 때문에 발생합니다. 예:

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

26.2.3 여러 함수 호출하기

더 복잡한 경우에는 추가 인수를 제공하거나 여러 변환을 수행하고 싶을 수 있습니다. 간단한 예로 이 문제의 동기를 부여해 보겠습니다. 데이터에 결측값이 있으면 어떻게 될까요? median()은 결측값을 전파하여 최적이 아닌 출력을 제공합니다:

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

이 결측값들을 제거하기 위해 na.rm = TRUEmedian()에 전달할 수 있다면 좋을 것입니다. 그렇게 하려면 median()을 직접 호출하는 대신, 원하는 인수로 median()을 호출하는 새 함수를 만들어야 합니다:

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

이것은 약간 장황하므로, R에는 편리한 단축키가 있습니다. 이런 일회용 또는 익명(anonymous)1 함수의 경우 function\2로 바꿀 수 있습니다:

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

어느 경우든 across()는 실질적으로 다음 코드로 확장됩니다:

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()
  )

median()에서 결측값을 제거할 때, 얼마나 많은 값이 제거되었는지도 알 수 있으면 좋을 것입니다. across()에 두 개의 함수를 제공하여 이를 알아낼 수 있습니다. 하나는 중앙값을 계산하고 다른 하나는 결측값의 개수를 세는 것입니다. .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>

자세히 보시면, 열 이름이 {.col}_{.fn}과 같은 glue 사양(Section 14.3.2)을 사용하여 지정되었다는 것을 직감할 수 있을 것입니다. 여기서 .col은 원래 열의 이름이고 .fn은 함수의 이름입니다. 이것은 우연이 아닙니다! 다음 섹션에서 배우게 되겠지만, .names 인수를 사용하여 자신만의 glue 사양을 제공할 수 있습니다.

26.2.4 열 이름

across()의 결과는 .names 인수에 제공된 사양에 따라 이름이 지정됩니다. 함수 이름이 먼저 오게 하고 싶다면 직접 지정할 수 있습니다3:

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>

.names 인수는 mutate()와 함께 across()를 사용할 때 특히 중요합니다. 기본적으로 across()의 출력에는 입력과 동일한 이름이 지정됩니다. 즉, mutate() 내부의 across()는 기존 열을 대체합니다. 예를 들어, 여기서는 coalesce()를 사용하여 NA0으로 바꿉니다:

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

대신 새 열을 만들고 싶다면 .names 인수를 사용하여 출력에 새 이름을 지정할 수 있습니다:

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 필터링

across()summarize()mutate()와 훌륭한 조화를 이루지만 filter()와 함께 사용하기에는 더 어색합니다. 일반적으로 여러 조건을 | 또는 &로 결합하기 때문입니다. across()가 여러 논리형 열을 만드는 데 도움이 될 수 있다는 것은 분명하지만, 그 다음에는 어떻게 해야 할까요? 그래서 dplyr은 if_any()if_all()이라는 across()의 두 가지 변형을 제공합니다:

# 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

# 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()

across()는 여러 열에 대해 작업할 수 있게 해주기 때문에 프로그래밍하기에 특히 유용합니다. 예를 들어, Jacob Scott은 여러 lubridate 함수를 래핑하여 모든 날짜 열을 연, 월, 일 열로 확장하는 이 작은 도우미 함수를 사용합니다:

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()는 또한 첫 번째 인수가 tidy-select를 사용하기 때문에 단일 인수에 여러 열을 쉽게 제공할 수 있습니다. Section 25.3.2 에서 논의한 대로 해당 인수를 감싸야 한다는 점만 기억하면 됩니다. 예를 들어, 이 함수는 기본적으로 수치형 열의 평균을 계산합니다. 하지만 두 번째 인수를 제공하여 선택한 열만 요약하도록 선택할 수 있습니다:

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 pivot_longer()와 비교

계속 진행하기 전에 across()pivot_longer()(Section 5.3) 사이의 흥미로운 연결 고리를 짚고 넘어갈 가치가 있습니다. 많은 경우, 먼저 데이터를 피벗한 다음 열이 아닌 그룹별로 연산을 수행하여 동일한 계산을 수행할 수 있습니다. 예를 들어, 이 다중 함수 요약을 보세요:

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

피벗을 더 길게(longer) 한 다음 요약하여 동일한 값을 계산할 수 있습니다:

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

그리고 across()와 동일한 구조를 원한다면 다시 피벗할 수 있습니다:

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

이것은 가끔 현재 across()로는 해결할 수 없는 문제에 부딪힐 때 유용한 기술입니다. 즉, 동시에 계산하려는 열 그룹이 있는 경우입니다. 예를 들어, 데이터 프레임에 값과 가중치가 모두 포함되어 있고 가중 평균을 계산하고 싶다고 가정해 봅시다:

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)
)

현재로서는 across()를 사용하여 이 작업을 수행할 수 있는 방법이 없지만4, 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

필요한 경우 pivot_wider()를 사용하여 이를 다시 원래 형태로 되돌릴 수 있습니다.

26.2.8 연습문제

  1. 다음을 수행하여 across() 기술을 연습하세요:

    1. palmerpenguins::penguins의 각 열에 있는 고유 값의 개수를 계산합니다.

    2. mtcars의 모든 열의 평균을 계산합니다.

    3. diamondscut, clarity, color로 그룹화한 다음 관측값 개수를 세고 각 수치형 열의 평균을 계산합니다.

  2. across()에 함수 리스트를 사용하면서 이름을 지정하지 않으면 어떻게 됩니까? 출력 이름은 어떻게 지정됩니까?

  3. 날짜 열이 확장된 후 자동으로 제거되도록 expand_dates()를 조정하세요. 인수를 감싸야(embrace) 합니까?

  4. 이 함수 파이프라인의 각 단계가 무엇을 하는지 설명하세요. 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 여러 파일 읽기

이전 섹션에서는 dplyr::across()를 사용하여 여러 열에 대해 변환을 반복하는 방법을 배웠습니다. 이 섹션에서는 purrr::map()을 사용하여 디렉터리의 모든 파일에 대해 작업을 수행하는 방법을 배울 것입니다. 간단한 동기 부여로 시작하겠습니다. 읽고 싶은 엑셀 스프레드시트로 가득 찬 디렉터리가 있다고 가정해 봅시다5. 복사해서 붙여넣기를 사용하여 할 수 있습니다:

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")

그런 다음 dplyr::bind_rows()를 사용하여 모두 결합합니다:

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

이 작업은 파일이 4개가 아니라 수백 개라면 금방 지루해질 것임을 상상할 수 있습니다. 다음 섹션들에서는 이러한 종류의 작업을 자동화하는 방법을 보여줍니다. 세 가지 기본 단계가 있습니다: list.files()를 사용하여 디렉터리의 모든 파일을 나열한 다음, purrr::map()을 사용하여 각 파일을 리스트로 읽어 들인 다음, purrr::list_rbind()를 사용하여 이를 단일 데이터 프레임으로 결합합니다. 그런 다음 모든 파일에 대해 정확히 동일한 작업을 수행할 수 없는, 이질성이 증가하는 상황을 처리하는 방법에 대해 논의하겠습니다.

26.3.1 디렉터리의 파일 나열하기

이름에서 알 수 있듯이 list.files()는 디렉터리의 파일을 나열합니다. 거의 항상 다음 세 가지 인수를 사용하게 될 것입니다:

  • 첫 번째 인수 path는 살펴볼 디렉터리입니다.

  • pattern은 파일 이름을 필터링하는 데 사용되는 정규 표현식입니다. 가장 일반적인 패턴은 지정된 확장자를 가진 모든 파일을 찾기 위한 [.]xlsx$ 또는 [.]csv$와 같은 것입니다.

  • full.names는 디렉터리 이름을 출력에 포함할지 여부를 결정합니다. 거의 항상 이것을 TRUE로 설정하고 싶을 것입니다.

우리의 동기 부여 예제를 구체화하기 위해, 이 책에는 gapminder 패키지의 데이터가 포함된 12개의 엑셀 스프레드시트가 있는 폴더가 있습니다. 이 폴더는 https://github.com/hadley/r4ds/tree/main/data/gapminder에서 찾을 수 있습니다. 각 파일에는 142개 국가에 대한 1년 치 데이터가 들어 있습니다. 적절한 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 리스트

이제 이 12개의 경로가 있으므로 read_excel()을 12번 호출하여 12개의 데이터 프레임을 얻을 수 있습니다:

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")

하지만 각 시트를 자체 변수에 넣으면 몇 단계 후에 작업하기가 매우 어려워질 것입니다. 대신 단일 객체에 넣는 것이 작업하기 더 쉬울 것입니다. 리스트는 이 작업에 완벽한 도구입니다:

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")
)

이제 리스트에 이 데이터 프레임들이 있으니, 어떻게 하나를 꺼낼까요? files[[i]]를 사용하여 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

[[에 대해서는 Section 27.3 에서 더 자세히 다루겠습니다.

26.3.3 purrr::map()list_rbind()

해당 데이터 프레임들을 리스트에 “수동으로” 모으는 코드는 파일을 하나씩 읽는 코드만큼이나 입력하기 지루합니다. 다행히도 purrr::map()을 사용하여 paths 벡터를 훨씬 더 잘 활용할 수 있습니다. map()across()와 비슷하지만, 데이터 프레임의 각 열에 대해 작업을 수행하는 대신 벡터의 각 요소에 대해 작업을 수행합니다. map(x, f)는 다음의 단축 표현입니다:

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

따라서 map()을 사용하여 12개의 데이터 프레임 리스트를 얻을 수 있습니다:

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

(이것은 str()로 특히 간결하게 표시되지 않는 또 다른 데이터 구조이므로, RStudio에 로드하고 View()로 검사하고 싶을 수 있습니다.)

이제 purrr::list_rbind()를 사용하여 해당 데이터 프레임 리스트를 단일 데이터 프레임으로 결합할 수 있습니다:

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

또는 파이프라인에서 두 단계를 한 번에 수행할 수도 있습니다:

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

read_excel()에 추가 인수를 전달하고 싶다면 어떻게 할까요? across()와 동일한 기술을 사용합니다. 예를 들어, 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

이것은 무언가 빠져 있다는 것을 명확히 해줍니다. year 열이 없는데, 해당 값은 개별 파일이 아니라 경로에 기록되어 있기 때문입니다. 다음으로 이 문제를 해결해 보겠습니다.

26.3.4 경로에 있는 데이터

때로는 파일 이름 자체가 데이터이기도 합니다. 이 예제에서 파일 이름에는 연도가 포함되어 있는데, 이는 개별 파일 내부에는 기록되지 않습니다. 해당 열을 최종 데이터 프레임에 넣으려면 두 가지 작업을 수행해야 합니다:

먼저, 경로 벡터의 이름을 지정합니다. 가장 쉬운 방법은 함수를 인수로 받을 수 있는 set_names() 함수를 사용하는 것입니다. 여기서는 basename()을 사용하여 전체 경로에서 파일 이름만 추출합니다:

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"

이 이름들은 모든 map 함수에 의해 자동으로 전달되므로, 데이터 프레임 리스트도 동일한 이름을 갖게 됩니다:

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

이렇게 하면 map() 호출은 다음의 단축 표현이 됩니다:

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")
)

또한 [[를 사용하여 이름으로 요소를 추출할 수 있습니다:

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

그런 다음 list_rbind()names_to 인수를 사용하여 이름을 year라는 새 열에 저장하도록 지시한 다음, readr::parse_number()를 사용하여 문자열에서 숫자를 추출합니다.

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

더 복잡한 경우에는 디렉터리 이름에 다른 변수가 저장되어 있거나, 파일 이름에 여러 데이터 비트가 포함되어 있을 수 있습니다. 이 경우 set_names()(인수 없이)를 사용하여 전체 경로를 기록한 다음, tidyr::separate_wider_delim()과 그 친구들을 사용하여 유용한 열로 변환하세요.

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 작업 저장

이제 멋지고 깔끔한 데이터 프레임을 얻기 위해 이 모든 노력을 기울였으니, 작업을 저장하기에 아주 좋은 시간입니다:

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

write_csv(gapminder, "gapminder.csv")

이제 나중에 이 문제로 돌아올 때 단일 csv 파일을 읽어 들일 수 있습니다. 대규모의 풍부한 데이터셋의 경우 Section 22.4 에서 논의한 대로 .csv보다는 parquet를 사용하는 것이 더 나은 선택일 수 있습니다.

프로젝트에서 작업하는 경우, 이런 종류의 데이터 준비 작업을 수행하는 파일을 0-cleanup.R과 같은 이름으로 부르는 것이 좋습니다. 파일 이름의 0은 이 파일이 다른 무엇보다 먼저 실행되어야 함을 암시합니다.

입력 데이터 파일이 시간이 지남에 따라 변경된다면, 이런 종류의 데이터 정리 코드가 입력 파일 중 하나가 수정될 때마다 자동으로 다시 실행되도록 설정하는 targets와 같은 도구를 배워서 사용하는 것을 고려해 보세요.

26.3.6 여러 번의 단순한 반복

여기서는 데이터를 디스크에서 직접 로드했고, 운 좋게도 깔끔한 데이터셋을 얻었습니다. 대부분의 경우 추가적인 정리가 필요하며, 두 가지 기본 옵션이 있습니다. 복잡한 함수로 한 번의 반복을 수행하거나, 단순한 함수로 여러 번의 반복을 수행하는 것입니다. 우리의 경험상 대부분의 사람들은 먼저 한 번의 복잡한 반복을 시도하지만, 여러 번의 단순한 반복을 수행하는 것이 종종 더 좋습니다.

예를 들어 여러 파일을 읽어 들이고, 결측값을 필터링하고, 피벗한 다음, 결합하고 싶다고 가정해 봅시다. 문제에 접근하는 한 가지 방법은 파일을 받아서 그 모든 단계를 수행하는 함수를 작성한 다음 map()을 한 번 호출하는 것입니다:

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()

또는 process_file()의 각 단계를 모든 파일에 대해 수행할 수도 있습니다:

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()

이 접근 방식을 권장하는 이유는 첫 번째 파일을 올바르게 만드는 데 집착하지 않고 나머지로 넘어갈 수 있기 때문입니다. 정리 및 청소 작업을 할 때 모든 데이터를 고려함으로써 전체적으로 생각하고 더 높은 품질의 결과를 얻을 가능성이 큽니다.

이 특정 예제에서는 데이터 프레임들을 더 일찍 결합하여 또 다른 최적화를 할 수 있습니다. 그러면 일반적인 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 이질적인 데이터

불행히도 때로는 map()에서 list_rbind()로 바로 넘어갈 수 없는 경우가 있습니다. 데이터 프레임들이 너무 이질적이어서 list_rbind()가 실패하거나 별로 유용하지 않은 데이터 프레임을 생성하기 때문입니다. 이럴 때는 여전히 모든 파일을 로드하는 것부터 시작하는 것이 유용합니다:

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

그런 다음 데이터 프레임의 구조를 캡처하여 데이터 과학 기술로 탐색할 수 있게 하는 전략이 매우 유용합니다. 그렇게 하는 한 가지 방법은 각 열에 대해 한 행씩 있는 티블을 반환하는 이 편리한 df_types 함수6를 사용하는 것입니다:

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

그런 다음 이 함수를 모든 파일에 적용하고, 차이점이 어디에 있는지 더 쉽게 볼 수 있도록 피벗을 수행할 수 있습니다. 예를 들어, 이를 통해 우리가 작업해 온 gapminder 스프레드시트가 모두 상당히 동질적임을 쉽게 확인할 수 있습니다:

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

파일 형식이 이질적이라면 성공적으로 병합하기 전에 추가 처리를 수행해야 할 수도 있습니다. 불행히도 이 부분은 여러분이 스스로 알아내도록 남겨두겠지만, map_if()map_at()에 대해 읽어보는 것이 좋습니다. map_if()를 사용하면 값에 따라 리스트의 요소를 선택적으로 수정할 수 있고, map_at()을 사용하면 이름에 따라 요소를 선택적으로 수정할 수 있습니다.

26.3.8 실패 처리

때로는 데이터의 구조가 너무 엉망이라 단일 명령으로 모든 파일을 읽을 수조차 없을 때가 있습니다. 그러면 map()의 단점 중 하나를 만나게 됩니다. 즉, 전체가 성공하거나 실패한다는 것입니다. map()은 디렉터리의 모든 파일을 성공적으로 읽거나, 아니면 오류와 함께 실패하여 파일을 하나도 읽지 못합니다. 이것은 짜증 나는 일입니다. 왜 단 하나의 실패가 다른 모든 성공에 접근하는 것을 막아야 할까요?

다행히 purrr에는 이 문제를 해결하기 위한 도우미인 possibly()가 있습니다. possibly()는 함수 연산자(function operator)로 알려져 있습니다. 함수를 인수로 받아 수정된 동작을 가진 함수를 반환합니다. 특히 possibly()는 함수가 오류를 발생시키는 대신 지정된 값을 반환하도록 변경합니다:

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

data <- files |> list_rbind()

이것은 여기서 특히 잘 작동하는데, list_rbind()가 많은 tidyverse 함수와 마찬가지로 NULL을 자동으로 무시하기 때문입니다.

이제 쉽게 읽을 수 있는 모든 데이터를 확보했으니, 일부 파일 로드에 실패한 이유와 그에 대해 무엇을 할지 알아내는 어려운 부분을 해결할 차례입니다. 먼저 실패한 경로들을 가져옵니다:

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

그런 다음 실패한 각 파일에 대해 가져오기 함수를 다시 호출하여 무엇이 잘못되었는지 확인합니다.

26.4 여러 출력 저장하기

지난 섹션에서는 여러 파일을 단일 객체로 읽어 들이는 데 유용한 map()에 대해 배웠습니다. 이 섹션에서는 이제 그 반대 문제, 즉 하나 이상의 R 객체를 가져와서 하나 이상의 파일에 저장하는 방법을 살펴보겠습니다. 다음 세 가지 예제를 사용하여 이 과제를 탐구하겠습니다:

  • 여러 데이터 프레임을 하나의 데이터베이스에 저장하기.
  • 여러 데이터 프레임을 여러 .csv 파일에 저장하기.
  • 여러 플롯을 여러 .png 파일에 저장하기.

26.4.1 데이터베이스에 쓰기

때로는 한꺼번에 많은 파일로 작업할 때 모든 데이터를 메모리에 한 번에 올릴 수 없어서 map(files, read_csv)를 수행할 수 없는 경우가 있습니다. 이 문제를 처리하는 한 가지 접근 방식은 dbplyr을 사용하여 필요한 부분만 액세스할 수 있도록 데이터를 데이터베이스에 로드하는 것입니다.

운이 좋다면 사용 중인 데이터베이스 패키지가 경로 벡터를 받아서 모두 데이터베이스에 로드하는 편리한 함수를 제공할 것입니다. duckdb의 duckdb_read_csv()가 그런 사례입니다:

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

이것은 여기서 잘 작동하겠지만, 우리는 csv 파일이 아니라 엑셀 스프레드시트를 가지고 있습니다. 그래서 “수동으로” 해야 할 것입니다. 수동으로 하는 법을 배우면 csv 파일 뭉치가 있고, 작업 중인 데이터베이스에 이를 모두 로드하는 단일 함수가 없는 경우에도 도움이 될 것입니다.

먼저 데이터를 채울 테이블을 만드는 것부터 시작해야 합니다. 가장 쉬운 방법은 원하는 모든 열을 포함하지만 데이터는 샘플링된 정도만 포함하는 더미 데이터 프레임인 템플릿을 만드는 것입니다. gapminder 데이터의 경우, 단일 파일을 읽고 연도를 추가하여 해당 템플릿을 만들 수 있습니다:

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

이제 데이터베이스에 연결하고 DBI::dbCreateTable()을 사용하여 템플릿을 데이터베이스 테이블로 바꿀 수 있습니다:

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

dbCreateTable()template의 데이터를 사용하지 않고 변수 이름과 유형만 사용합니다. 따라서 지금 gapminder 테이블을 검사해 보면 비어 있지만, 우리가 예상하는 유형을 가진 필요한 변수들이 있는 것을 볼 수 있습니다:

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

다음으로, 단일 파일 경로를 받아서 R로 읽어 들이고 그 결과를 gapminder 테이블에 추가하는 함수가 필요합니다. read_excel()DBI::dbAppendTable()과 결합하여 이를 수행할 수 있습니다:

append_file <- function(path) {
  df <- readxl::read_excel(path)
  df$year <- parse_number(basename(path))
  
  DBI::dbAppendTable(con, "gapminder", df)
}

이제 paths의 각 요소에 대해 append_file()을 한 번씩 호출해야 합니다. map()으로도 가능합니다:

paths |> map(append_file)

하지만 우리는 append_file()의 출력 결과에는 관심이 없으므로, map() 대신 walk()를 사용하는 것이 약간 더 좋습니다. walk()map()과 정확히 동일한 작업을 수행하지만 출력 결과는 버립니다:

paths |> walk(append_file)

이제 테이블에 모든 데이터가 들어 있는지 확인할 수 있습니다:

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

26.4.2 csv 파일 쓰기

각 그룹에 대해 하나씩 여러 csv 파일을 쓰려는 경우에도 동일한 기본 원칙이 적용됩니다. ggplot2::diamonds 데이터를 가져와서 각 clarity(투명도)별로 하나의 csv 파일을 저장하고 싶다고 가정해 봅시다. 먼저 그 개별 데이터셋들을 만들어야 합니다. 여러 가지 방법으로 할 수 있지만, 우리가 특히 좋아하는 방법은 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

이것은 8개의 행과 2개의 열이 있는 새로운 티블을 제공합니다. clarity는 그룹화 변수이고, data는 각 고유한 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

여기서 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

따라서 이 데이터 프레임들을 수동으로 저장한다면 다음과 같이 쓸 수 있을 것입니다:

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$by_clarity[[8]], by_clarity$path[[8]])

이것은 변하는 인수가 하나가 아니라 두 개이므로 이전의 map() 사용과는 조금 다릅니다. 즉, 첫 번째 인수와 두 번째 인수를 모두 변경하는 map2()라는 새로운 함수가 필요합니다. 그리고 이번에도 출력 결과에는 관심이 없으므로 map2() 대신 walk2()를 원합니다. 그러면 다음과 같습니다:

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

26.4.3 플롯 저장하기

동일한 기본 접근 방식을 사용하여 많은 플롯을 만들 수 있습니다. 먼저 원하는 플롯을 그리는 함수를 만들어 보겠습니다:

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

carat_histogram(by_clarity$data[[1]])

0에서 5캐럿 사이인 by_clarity 데이터셋의 다이아몬드 캐럿 히스토그램.  분포는 단봉형이며 오른쪽으로 치우쳐 있고 1캐럿 부근에서 피크를 보입니다.

이제 map()을 사용하여 많은 플롯7과 최종 파일 경로의 리스트를 만들 수 있습니다:

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

그런 다음 walk2()ggsave()와 함께 사용하여 각 플롯을 저장합니다:

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

이것은 다음의 단축 표현입니다:

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 요약

이 장에서는 데이터 과학을 할 때 자주 발생하는 세 가지 문제, 즉 여러 열 조작하기, 여러 파일 읽기, 여러 출력 저장하기를 해결하기 위해 명시적 반복을 사용하는 방법을 살펴보았습니다. 하지만 일반적으로 반복은 슈퍼 파워입니다. 올바른 반복 기술을 알고 있다면 하나의 문제를 해결하는 것에서 모든 문제를 해결하는 것으로 쉽게 나아갈 수 있습니다. 이 장의 기술들을 마스터했다면, Advanced R함수형(Functionals) 장을 읽고 purrr 웹사이트를 참고하여 더 많은 내용을 배워보시기를 강력히 추천합니다.

다른 언어의 반복문에 대해 많이 알고 있다면, 우리가 for 루프에 대해 논의하지 않은 것에 놀랐을 수도 있습니다. 그 이유는 R의 데이터 분석 지향적 특성이 반복 방식을 바꾸기 때문입니다. 대부분의 경우 기존의 관용구에 의존하여 각 열이나 각 그룹에 작업을 수행할 수 있습니다. 그럴 수 없는 경우에는 리스트의 각 요소에 작업을 수행하는 map()과 같은 함수형 프로그래밍 도구를 종종 사용할 수 있습니다. 하지만 야생에서 마주치는 코드에서는 for 루프를 보게 될 것이므로, 몇 가지 중요한 기본(base) R 도구를 논의하는 다음 장에서 이에 대해 배울 것입니다.


  1. 익명인 이유는 <-를 사용하여 명시적으로 이름을 지정하지 않았기 때문입니다. 프로그래머들은 이를 “람다 함수”라고도 부릅니다.↩︎

  2. 오래된 코드에서는 ~ .x + 1과 같은 구문을 볼 수 있습니다. 이것은 익명 함수를 작성하는 또 다른 방법이지만 tidyverse 함수 내에서만 작동하며 항상 변수 이름 .x를 사용합니다. 우리는 이제 기본 구문인 \(x) x + 1을 권장합니다.↩︎

  3. 현재로서는 열의 순서를 변경할 수는 없지만, relocate() 등을 사용하여 나중에 재정렬할 수 있습니다.↩︎

  4. 언젠가는 가능할 수도 있겠지만, 현재로서는 방법이 보이지 않습니다.↩︎

  5. 대신 동일한 형식의 csv 파일 디렉터리가 있다면 Section 7.4 의 기술을 사용할 수 있습니다.↩︎

  6. 이 함수의 작동 방식은 설명하지 않겠지만, 사용된 함수들의 문서를 찾아보면 알아낼 수 있을 것입니다.↩︎

  7. by_clarity$plot을 인쇄하여 조잡한 애니메이션을 얻을 수 있습니다. plots의 각 요소에 대해 하나의 플롯을 얻게 됩니다. 참고: 저에게는 이런 일이 일어나지 않았습니다.↩︎