25  함수 (Functions)

25.1 소개

데이터 과학자로서 영역을 넓히는 가장 좋은 방법 중 하나는 함수를 작성하는 것입니다. 함수를 사용하면 복사해서 붙여넣기보다 더 강력하고 일반적인 방식으로 일반적인 작업을 자동화할 수 있습니다. 함수를 작성하는 것은 복사해서 붙여넣기보다 네 가지 큰 장점이 있습니다:

  1. 함수에 기억하기 쉬운 이름을 지정하여 코드를 더 쉽게 이해할 수 있게 할 수 있습니다.

  2. 요구 사항이 변경되면 여러 곳이 아닌 한 곳에서만 코드를 업데이트하면 됩니다.

  3. 복사해서 붙여넣을 때 발생할 수 있는 우발적인 실수(예: 한 곳에서는 변수 이름을 업데이트했지만 다른 곳에서는 업데이트하지 않음)를 없앨 수 있습니다.

  4. 프로젝트 간에 작업을 재사용하기가 쉬워져 시간이 지남에 따라 생산성이 향상됩니다.

좋은 경험 법칙은 코드 블록을 두 번 이상 복사해서 붙여넣었을 때(즉, 동일한 코드의 사본이 세 개 있을 때) 함수 작성을 고려하는 것입니다. 이 장에서는 유용한 세 가지 유형의 함수에 대해 배웁니다:

  • 벡터 함수: 하나 이상의 벡터를 입력으로 받아 벡터를 출력으로 반환합니다.
  • 데이터 프레임 함수: 데이터 프레임을 입력으로 받아 데이터 프레임을 출력으로 반환합니다.
  • 플롯 함수: 데이터 프레임을 입력으로 받아 플롯을 출력으로 반환합니다.

이러한 각 섹션에는 여러분이 보는 패턴을 일반화하는 데 도움이 되는 많은 예제가 포함되어 있습니다. 이 예제들은 트위터 사용자들의 도움 없이는 불가능했을 것이며, 댓글에 있는 링크를 따라가서 원래의 영감을 확인해 보기를 권장합니다. 또한 일반 함수플롯 함수에 대한 원래의 동기 부여 트윗을 읽어보면 더 많은 함수를 볼 수 있습니다.

25.1.1 선수 지식

tidyverse의 다양한 함수들을 정리해 보겠습니다. 또한 함수와 함께 사용할 친숙한 데이터 소스로 nycflights13을 사용할 것입니다.

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

25.2 벡터 함수

벡터 함수로 시작하겠습니다: 하나 이상의 벡터를 받아 벡터 결과를 반환하는 함수입니다. 예를 들어, 이 코드를 보세요. 무엇을 하는 코드일까요?

df <- tibble(
  a = rnorm(5),
  b = rnorm(5),
  c = rnorm(5),
  d = rnorm(5),
)

df |> mutate(
  a = (a - min(a, na.rm = TRUE)) / 
    (max(a, na.rm = TRUE) - min(a, na.rm = TRUE)),
  b = (b - min(a, na.rm = TRUE)) / 
    (max(b, na.rm = TRUE) - min(b, na.rm = TRUE)),
  c = (c - min(c, na.rm = TRUE)) / 
    (max(c, na.rm = TRUE) - min(c, na.rm = TRUE)),
  d = (d - min(d, na.rm = TRUE)) / 
    (max(d, na.rm = TRUE) - min(d, na.rm = TRUE)),
)
#> # A tibble: 5 × 4
#>       a       b     c     d
#>   <dbl>   <dbl> <dbl> <dbl>
#> 1 0.339  0.387  0.291 0    
#> 2 0.880 -0.613  0.611 0.557
#> 3 0     -0.0833 1     0.752
#> 4 0.795 -0.0822 0     1    
#> 5 1     -0.0952 0.580 0.394

이것이 각 열의 범위를 0에서 1로 조정한다는 것을 알아낼 수 있을 것입니다. 하지만 실수를 발견했나요? Hadley가 이 코드를 작성할 때 복사해서 붙여넣는 과정에서 오류를 범했고 ab로 바꾸는 것을 잊었습니다. 이런 유형의 실수를 방지하는 것이 함수 작성법을 배워야 하는 아주 좋은 이유 중 하나입니다.

25.2.1 함수 작성하기

함수를 작성하려면 먼저 반복되는 코드를 분석하여 어떤 부분이 상수이고 어떤 부분이 변하는지 파악해야 합니다. 위의 코드를 가져와 mutate() 밖으로 꺼내면, 각 반복이 이제 한 줄이 되므로 패턴을 보기가 조금 더 쉽습니다:

(a - min(a, na.rm = TRUE)) / (max(a, na.rm = TRUE) - min(a, na.rm = TRUE))
(b - min(b, na.rm = TRUE)) / (max(b, na.rm = TRUE) - min(b, na.rm = TRUE))
(c - min(c, na.rm = TRUE)) / (max(c, na.rm = TRUE) - min(c, na.rm = TRUE))
(d - min(d, na.rm = TRUE)) / (max(d, na.rm = TRUE) - min(d, na.rm = TRUE))  

이것을 좀 더 명확하게 하기 위해 변하는 부분을 로 바꿀 수 있습니다:

(█ - min(█, na.rm = TRUE)) / (max(█, na.rm = TRUE) - min(█, na.rm = TRUE))

이것을 함수로 만들려면 세 가지가 필요합니다:

  1. 이름(name). 여기서는 이 함수가 벡터를 0과 1 사이로 재조정하므로 rescale01을 사용할 것입니다.

  2. 인수(arguments). 인수는 호출마다 변하는 것이며, 위의 분석에 따르면 하나만 있으면 됩니다. 수치형 벡터의 관례적인 이름이므로 x라고 부르겠습니다.

  3. 본문(body). 본문은 모든 호출에서 반복되는 코드입니다.

그런 다음 템플릿을 따라 함수를 만듭니다:

name <- function(arguments) {
  body
}

이 경우 다음과 같이 됩니다:

rescale01 <- function(x) {
  (x - min(x, na.rm = TRUE)) / (max(x, na.rm = TRUE) - min(x, na.rm = TRUE))
}

이 시점에서 몇 가지 간단한 입력으로 테스트하여 로직을 올바르게 캡처했는지 확인할 수 있습니다:

rescale01(c(-10, 0, 10))
#> [1] 0.0 0.5 1.0
rescale01(c(1, 2, 3, NA, 5))
#> [1] 0.00 0.25 0.50   NA 1.00

그런 다음 mutate() 호출을 다음과 같이 다시 쓸 수 있습니다:

df |> mutate(
  a = rescale01(a),
  b = rescale01(b),
  c = rescale01(c),
  d = rescale01(d),
)
#> # A tibble: 5 × 4
#>       a     b     c     d
#>   <dbl> <dbl> <dbl> <dbl>
#> 1 0.339 1     0.291 0    
#> 2 0.880 0     0.611 0.557
#> 3 0     0.530 1     0.752
#> 4 0.795 0.531 0     1    
#> 5 1     0.518 0.580 0.394

(Chapter 26 에서는 across()를 사용하여 중복을 더욱 줄여 df |> mutate(across(a:d, rescale01))만 있으면 되도록 하는 방법을 배울 것입니다).

25.2.2 함수 개선하기

rescale01() 함수가 불필요한 작업을 수행한다는 것을 알 수 있습니다. min()을 두 번, max()를 한 번 계산하는 대신 range()를 사용하여 한 단계로 최소값과 최대값을 모두 계산할 수 있습니다:

rescale01 <- function(x) {
  rng <- range(x, na.rm = TRUE)
  (x - rng[1]) / (rng[2] - rng[1])
}

또는 무한대 값이 포함된 벡터에서 이 함수를 시도해 볼 수도 있습니다:

x <- c(1:10, Inf)
rescale01(x)
#>  [1]   0   0   0   0   0   0   0   0   0   0 NaN

그 결과는 그리 유용하지 않으므로 range()에 무한대 값을 무시하도록 요청할 수 있습니다:

rescale01 <- function(x) {
  rng <- range(x, na.rm = TRUE, finite = TRUE)
  (x - rng[1]) / (rng[2] - rng[1])
}

rescale01(x)
#>  [1] 0.0000000 0.1111111 0.2222222 0.3333333 0.4444444 0.5555556 0.6666667
#>  [8] 0.7777778 0.8888889 1.0000000       Inf

이러한 변경 사항은 함수의 중요한 이점을 보여줍니다: 반복되는 코드를 함수로 옮겼기 때문에 한 곳에서만 변경하면 됩니다.

25.2.3 Mutate 함수

이제 함수의 기본 개념을 알았으니 다양한 예제를 살펴보겠습니다. “mutate” 함수, 즉 입력과 길이가 같은 출력을 반환하므로 mutate()filter() 내부에서 잘 작동하는 함수부터 살펴보겠습니다.

rescale01()의 간단한 변형부터 시작하겠습니다. 벡터의 평균이 0이고 표준 편차가 1이 되도록 재조정하는 Z-점수를 계산하고 싶을 수 있습니다:

z_score <- function(x) {
  (x - mean(x, na.rm = TRUE)) / sd(x, na.rm = TRUE)
}

또는 간단한 case_when()을 감싸서 유용한 이름을 지정하고 싶을 수도 있습니다. 예를 들어, 이 clamp() 함수는 벡터의 모든 값이 최소값과 최대값 사이에 있도록 합니다:

clamp <- function(x, min, max) {
  case_when(
    x < min ~ min,
    x > max ~ max,
    .default = x
  )
}

clamp(1:10, min = 3, max = 7)
#>  [1] 3 3 3 4 5 6 7 7 7 7

물론 함수가 수치형 변수하고만 작업해야 하는 것은 아닙니다. 반복되는 문자열 조작을 수행하고 싶을 수도 있습니다. 첫 글자를 대문자로 만들고 싶을 수 있습니다:

first_upper <- function(x) {
  str_sub(x, 1, 1) <- str_to_upper(str_sub(x, 1, 1))
  x
}

first_upper("hello")
#> [1] "Hello"

또는 문자열을 숫자로 변환하기 전에 퍼센트 기호, 쉼표, 달러 기호를 제거하고 싶을 수도 있습니다:

# https://twitter.com/NVlabormarket/status/1571939851922198530
clean_number <- function(x) {
  is_pct <- str_detect(x, "%")
  num <- x |> 
    str_remove_all("%") |> 
    str_remove_all(",") |> 
    str_remove_all(fixed("$")) |> 
    as.numeric()
  if_else(is_pct, num / 100, num)
}

clean_number("$12,300")
#> [1] 12300
clean_number("45%")
#> [1] 0.45

때때로 함수는 하나의 데이터 분석 단계에 매우 특화될 수 있습니다. 예를 들어, 결측값을 997, 998, 999로 기록하는 변수가 많은 경우 이를 NA로 바꾸는 함수를 작성하고 싶을 수 있습니다:

fix_na <- function(x) {
  if_else(x %in% c(997, 998, 999), NA, x)
}

단일 벡터를 취하는 예제에 집중했는데, 이것이 가장 일반적이라고 생각하기 때문입니다. 하지만 함수가 여러 벡터 입력을 받지 못할 이유는 없습니다.

25.2.4 요약 함수

벡터 함수의 또 다른 중요한 제품군은 요약 함수, 즉 summarize()에서 사용할 단일 값을 반환하는 함수입니다. 때로는 기본 인수 한두 개를 설정하는 것만으로도 충분할 수 있습니다:

commas <- function(x) {
  str_flatten(x, collapse = ", ", last = " and ")
}

commas(c("cat", "dog", "pigeon"))
#> [1] "cat, dog and pigeon"

또는 표준 편차를 평균으로 나누는 변동 계수와 같은 간단한 계산을 감쌀 수도 있습니다:

cv <- function(x, na.rm = FALSE) {
  sd(x, na.rm = na.rm) / mean(x, na.rm = na.rm)
}

cv(runif(100, min = 0, max = 50))
#> [1] 0.5196276
cv(runif(100, min = 0, max = 500))
#> [1] 0.5652554

또는 기억하기 쉬운 이름을 지정하여 일반적인 패턴을 기억하기 쉽게 만들고 싶을 수도 있습니다:

# https://twitter.com/gbganalyst/status/1571619641390252033
n_missing <- function(x) {
  sum(is.na(x))
} 

여러 벡터 입력을 받는 함수를 작성할 수도 있습니다. 예를 들어, 모델 예측과 실제 값을 비교하는 데 도움이 되는 평균 절대 백분율 오차(MAPE)를 계산하고 싶을 수 있습니다:

# https://twitter.com/neilgcurrie/status/1571607727255834625
mape <- function(actual, predicted) {
  sum(abs((actual - predicted) / actual)) / length(actual)
}
NoteRStudio

함수 작성을 시작하면 매우 유용한 두 가지 RStudio 단축키가 있습니다:

  • 작성한 함수의 정의를 찾으려면 함수 이름에 커서를 놓고 F2를 누르세요.

  • 함수로 빠르게 이동하려면 Ctrl + .을 눌러 퍼지 파일 및 함수 찾기를 열고 함수 이름의 처음 몇 글자를 입력하세요. 파일, Quarto 섹션 등으로 이동할 수도 있어 매우 편리한 탐색 도구입니다.

25.2.5 연습문제

  1. 다음 코드 조각들을 함수로 바꾸는 연습을 해보세요. 각 함수가 무엇을 하는지 생각해 보세요. 이름을 무엇으로 짓겠습니까? 인수가 몇 개 필요합니까?

    mean(is.na(x))
    mean(is.na(y))
    mean(is.na(z))
    
    x / sum(x, na.rm = TRUE)
    y / sum(y, na.rm = TRUE)
    z / sum(z, na.rm = TRUE)
    
    round(x / sum(x, na.rm = TRUE) * 100, 1)
    round(y / sum(y, na.rm = TRUE) * 100, 1)
    round(z / sum(z, na.rm = TRUE) * 100, 1)
  2. rescale01()의 두 번째 변형에서는 무한대 값이 변경되지 않은 상태로 남습니다. -Inf는 0으로, Inf는 1로 매핑되도록 rescale01()을 다시 작성할 수 있습니까?

  3. 생년월일 벡터가 주어지면 나이(년)를 계산하는 함수를 작성하세요.

  4. 수치형 벡터의 분산과 왜도(skewness)를 계산하는 자신만의 함수를 작성하세요. 위키피디아 등에서 정의를 찾아볼 수 있습니다.

  5. 동일한 길이의 두 벡터를 받아 두 벡터 모두에 NA가 있는 위치의 수를 반환하는 요약 함수 both_na()를 작성하세요.

  6. 설명서를 읽고 다음 함수들이 무엇을 하는지 파악하세요. 매우 짧음에도 불구하고 왜 유용할까요?

    is_directory <- function(x) {
      file.info(x)$isdir
    }
    is_readable <- function(x) {
      file.access(x, 4) == 0
    }

25.3 데이터 프레임 함수

벡터 함수는 dplyr 동사 내에서 반복되는 코드를 추출하는 데 유용합니다. 하지만 동사 자체를 반복하는 경우도 많으며, 특히 대규모 파이프라인 내에서 그렇습니다. 여러 동사를 여러 번 복사해서 붙여넣고 있다는 것을 알게 되면 데이터 프레임 함수 작성을 고려해 볼 수 있습니다. 데이터 프레임 함수는 dplyr 동사처럼 작동합니다: 데이터 프레임을 첫 번째 인수로 받고, 무엇을 할지 알려주는 추가 인수를 받고, 데이터 프레임이나 벡터를 반환합니다.

dplyr 동사를 사용하는 함수를 작성할 수 있도록 하기 위해 먼저 간접 참조(indirection)의 어려움과 {{ }}를 사용한 감싸기(embracing)로 이를 극복하는 방법을 소개하겠습니다. 이 이론을 바탕으로, 무엇을 할 수 있는지 보여주는 다양한 예제를 보여드리겠습니다.

25.3.1 간접 참조와 Tidy Evaluation

dplyr 동사를 사용하는 함수를 작성하기 시작하면 간접 참조 문제에 빠르게 부딪힙니다. 아주 간단한 함수인 grouped_mean()으로 문제를 설명해 보겠습니다. 이 함수의 목표는 group_var로 그룹화된 mean_var의 평균을 계산하는 것입니다:

grouped_mean <- function(df, group_var, mean_var) {
  df |> 
    group_by(group_var) |> 
    summarize(mean(mean_var))
}

이것을 사용하려고 하면 오류가 발생합니다:

diamonds |> grouped_mean(cut, carat)
#> Error in `group_by()`:
#> ! Must group by variables found in `.data`.
#> ✖ Column `group_var` is not found.

문제를 좀 더 명확하게 하기 위해 가상의 데이터 프레임을 사용할 수 있습니다:

df <- tibble(
  mean_var = 1,
  group_var = "g",
  group = 1,
  x = 10,
  y = 100
)

df |> grouped_mean(group, x)
#> # A tibble: 1 × 2
#>   group_var `mean(mean_var)`
#>   <chr>                <dbl>
#> 1 g                        1
df |> grouped_mean(group, y)
#> # A tibble: 1 × 2
#>   group_var `mean(mean_var)`
#>   <chr>                <dbl>
#> 1 g                        1

grouped_mean()을 어떻게 호출하든 df |> group_by(group) |> summarize(mean(x)) 또는 df |> group_by(group) |> summarize(mean(y)) 대신 항상 df |> group_by(group_var) |> summarize(mean(mean_var))를 수행합니다. 이것은 간접 참조의 문제이며, dplyr가 tidy evaluation을 사용하여 특별한 처리 없이 데이터 프레임 내부의 변수 이름을 참조할 수 있도록 하기 때문에 발생합니다.

Tidy evaluation은 변수가 어느 데이터 프레임에서 왔는지 말할 필요가 없으므로 데이터 분석을 매우 간결하게 만들어 주기 때문에 95%의 경우 훌륭합니다. 문맥상 명확하기 때문입니다. Tidy evaluation의 단점은 반복되는 tidyverse 코드를 함수로 감싸려고 할 때 발생합니다. 여기서는 group_by()summarize()에게 group_varmean_var를 변수 이름으로 취급하지 말고 대신 그 안을 들여다보고 우리가 실제로 사용하려는 변수를 찾도록 알려줄 방법이 필요합니다.

Tidy evaluation에는 감싸기(embracing) 🤗라는 이 문제에 대한 해결책이 포함되어 있습니다. 변수를 감싼다는 것은 중괄호로 감싸서(예: var) {{ var }}가 되도록 하는 것을 의미합니다. 변수를 감싸면 dplyr에게 인수를 리터럴 변수 이름이 아니라 인수 안에 저장된 값을 사용하도록 지시합니다. 무슨 일이 일어나고 있는지 기억하는 한 가지 방법은 {{ }}를 터널을 내려다보는 것으로 생각하는 것입니다 — {{ var }}는 dplyr 함수가 var라는 변수를 찾는 대신 var의 내부를 들여다보게 합니다.

따라서 grouped_mean()이 작동하도록 하려면 group_varmean_var{{ }}로 감싸야 합니다:

grouped_mean <- function(df, group_var, mean_var) {
  df |> 
    group_by({{ group_var }}) |> 
    summarize(mean({{ mean_var }}))
}

df |> grouped_mean(group, x)
#> # A tibble: 1 × 2
#>   group `mean(x)`
#>   <dbl>     <dbl>
#> 1     1        10

성공입니다!

25.3.2 언제 감싸야 할까요?

따라서 데이터 프레임 함수 작성의 핵심 과제는 어떤 인수를 감싸야 하는지 파악하는 것입니다. 다행히도 문서에서 찾아볼 수 있기 때문에 쉽습니다 😄. 문서에서 찾아야 할 두 가지 용어가 있으며, 이는 tidy evaluation의 가장 일반적인 두 가지 하위 유형에 해당합니다:

어떤 인수가 tidy evaluation을 사용하는지에 대한 직관은 많은 일반적인 함수에 대해 유효할 것입니다 — 계산할 수 있는지(예: x + 1) 또는 선택할 수 있는지(예: a:x) 생각해보세요.

다음 섹션에서는 감싸기를 이해하고 나면 작성할 수 있는 유용한 함수들을 살펴보겠습니다.

25.3.3 일반적인 사용 사례

초기 데이터 탐색을 수행할 때 동일한 요약 세트를 일반적으로 수행하는 경우 도우미 함수로 감싸는 것을 고려할 수 있습니다:

summary6 <- function(data, var) {
  data |> summarize(
    min = min({{ var }}, na.rm = TRUE),
    mean = mean({{ var }}, na.rm = TRUE),
    median = median({{ var }}, na.rm = TRUE),
    max = max({{ var }}, na.rm = TRUE),
    n = n(),
    n_miss = sum(is.na({{ var }})),
    .groups = "drop"
  )
}

diamonds |> summary6(carat)
#> # A tibble: 1 × 6
#>     min  mean median   max     n n_miss
#>   <dbl> <dbl>  <dbl> <dbl> <int>  <int>
#> 1   0.2 0.798    0.7  5.01 53940      0

(summarize()를 도우미로 감쌀 때마다 메시지를 피하고 데이터를 그룹화되지 않은 상태로 유지하기 위해 .groups = "drop"을 설정하는 것이 좋은 관행이라고 생각합니다.)

이 함수의 좋은 점은 summarize()를 감싸기 때문에 그룹화된 데이터에 사용할 수 있다는 것입니다:

diamonds |> 
  group_by(cut) |> 
  summary6(carat)
#> # A tibble: 5 × 7
#>   cut         min  mean median   max     n n_miss
#>   <ord>     <dbl> <dbl>  <dbl> <dbl> <int>  <int>
#> 1 Fair       0.22 1.05    1     5.01  1610      0
#> 2 Good       0.23 0.849   0.82  3.01  4906      0
#> 3 Very Good  0.2  0.806   0.71  4    12082      0
#> 4 Premium    0.2  0.892   0.86  4.01 13791      0
#> 5 Ideal      0.2  0.703   0.54  3.5  21551      0

또한 summarize에 대한 인수는 데이터 마스킹이므로 summary6()에 대한 var 인수도 마찬가지입니다. 즉, 계산된 변수도 요약할 수 있습니다:

diamonds |> 
  group_by(cut) |> 
  summary6(log10(carat))
#> # A tibble: 5 × 7
#>   cut          min    mean  median   max     n n_miss
#>   <ord>      <dbl>   <dbl>   <dbl> <dbl> <int>  <int>
#> 1 Fair      -0.658 -0.0273  0      0.700  1610      0
#> 2 Good      -0.638 -0.133  -0.0862 0.479  4906      0
#> 3 Very Good -0.699 -0.164  -0.149  0.602 12082      0
#> 4 Premium   -0.699 -0.125  -0.0655 0.603 13791      0
#> 5 Ideal     -0.699 -0.225  -0.268  0.544 21551      0

여러 변수를 요약하려면 Section 26.2 까지 기다려야 하며, 거기서 across() 사용법을 배우게 될 것입니다.

또 다른 인기 있는 summarize() 도우미 함수는 비율도 계산하는 count()의 버전입니다:

# https://twitter.com/Diabb6/status/1571635146658402309
count_prop <- function(df, var, sort = FALSE) {
  df |>
    count({{ var }}, sort = sort) |>
    mutate(prop = n / sum(n))
}

diamonds |> count_prop(clarity)
#> # A tibble: 8 × 3
#>   clarity     n   prop
#>   <ord>   <int>  <dbl>
#> 1 I1        741 0.0137
#> 2 SI2      9194 0.170 
#> 3 SI1     13065 0.242 
#> 4 VS2     12258 0.227 
#> 5 VS1      8171 0.151 
#> 6 VVS2     5066 0.0939
#> # ℹ 2 more rows

이 함수에는 df, var, sort의 세 가지 인수가 있으며, 모든 변수에 대해 데이터 마스킹을 사용하는 count()에 전달되므로 var만 감싸면 됩니다. 사용자가 자신의 값을 제공하지 않으면 기본적으로 FALSE가 되도록 sort에 기본값을 사용한다는 점에 유의하세요.

또는 데이터의 하위 집합에 대해 변수의 정렬된 고유 값을 찾고 싶을 수도 있습니다. 필터링을 수행하기 위해 변수와 값을 제공하는 대신 사용자가 조건을 제공하도록 허용합니다:

unique_where <- function(df, condition, var) {
  df |> 
    filter({{ condition }}) |> 
    distinct({{ var }}) |> 
    arrange({{ var }})
}

# 12월의 모든 목적지 찾기
flights |> unique_where(month == 12, dest)
#> # A tibble: 96 × 1
#>   dest 
#>   <chr>
#> 1 ABQ  
#> 2 ALB  
#> 3 ATL  
#> 4 AUS  
#> 5 AVL  
#> 6 BDL  
#> # ℹ 90 more rows

여기서는 filter()에 전달되기 때문에 condition을 감싸고, distinct()arrange()에 전달되기 때문에 var를 감쌉니다.

이 모든 예제는 데이터 프레임을 첫 번째 인수로 받도록 만들었지만, 동일한 데이터로 반복적으로 작업하는 경우 하드코딩하는 것이 합리적일 수 있습니다. 예를 들어, 다음 함수는 항상 flights 데이터셋과 작동하며 time_hour, carrier, flight가 행을 식별할 수 있는 복합 기본 키를 형성하므로 항상 이를 선택합니다.

subset_flights <- function(rows, cols) {
  flights |> 
    filter({{ rows }}) |> 
    select(time_hour, carrier, flight, {{ cols }})
}

25.3.4 데이터 마스킹 대 Tidy 선택

때때로 데이터 마스킹을 사용하는 함수 내부에서 변수를 선택하고 싶을 때가 있습니다. 예를 들어, 행에서 누락된 관측값의 수를 계산하는 count_missing()을 작성하고 싶다고 상상해 보세요. 다음과 같이 작성하려고 할 수 있습니다:

count_missing <- function(df, group_vars, x_var) {
  df |> 
    group_by({{ group_vars }}) |> 
    summarize(
      n_miss = sum(is.na({{ x_var }})),
      .groups = "drop"
    )
}

flights |> 
  count_missing(c(year, month, day), dep_time)
#> Error in `group_by()`:
#> ℹ In argument: `c(year, month, day)`.
#> Caused by error:
#> ! `c(year, month, day)` must be size 336776 or 1, not 1010328.

group_by()는 tidy 선택이 아니라 데이터 마스킹을 사용하기 때문에 이것은 작동하지 않습니다. 데이터 마스킹 함수 내부에서 tidy 선택을 사용할 수 있게 해주는 편리한 pick() 함수를 사용하여 이 문제를 해결할 수 있습니다:

count_missing <- function(df, group_vars, x_var) {
  df |> 
    group_by(pick({{ group_vars }})) |> 
    summarize(
      n_miss = sum(is.na({{ x_var }})),
      .groups = "drop"
  )
}

flights |> 
  count_missing(c(year, month, day), dep_time)
#> # A tibble: 365 × 4
#>    year month   day n_miss
#>   <int> <int> <int>  <int>
#> 1  2013     1     1      4
#> 2  2013     1     2      8
#> 3  2013     1     3     10
#> 4  2013     1     4      6
#> 5  2013     1     5      3
#> 6  2013     1     6      1
#> # ℹ 359 more rows

pick()의 또 다른 편리한 사용법은 2차원 카운트 테이블을 만드는 것입니다. 여기서는 rowscols의 모든 변수를 사용하여 카운트한 다음 pivot_wider()를 사용하여 카운트를 그리드로 재배열합니다:

# https://twitter.com/pollicipes/status/1571606508944719876
count_wide <- function(data, rows, cols) {
  data |> 
    count(pick(c({{ rows }}, {{ cols }}))) |> 
    pivot_wider(
      names_from = {{ cols }}, 
      values_from = n,
      names_sort = TRUE,
      values_fill = 0
    )
}

diamonds |> count_wide(c(clarity, color), cut)
#> # A tibble: 56 × 7
#>   clarity color  Fair  Good `Very Good` Premium Ideal
#>   <ord>   <ord> <int> <int>       <int>   <int> <int>
#> 1 I1      D         4     8           5      12    13
#> 2 I1      E         9    23          22      30    18
#> 3 I1      F        35    19          13      34    42
#> 4 I1      G        53    19          16      46    16
#> 5 I1      H        52    14          12      46    38
#> 6 I1      I        34     9           8      24    17
#> # ℹ 50 more rows

예제는 주로 dplyr에 집중했지만 tidy evaluation은 tidyr의 기반이기도 하며, pivot_wider() 문서를 보면 names_from이 tidy 선택을 사용한다는 것을 알 수 있습니다.

25.3.5 연습문제

  1. nycflights13의 데이터셋을 사용하여 다음을 수행하는 함수를 작성하세요:

    1. 취소되었거나(is.na(arr_time)) 1시간 이상 지연된 모든 항공편을 찾습니다.

      flights |> filter_severe()
    2. 취소된 항공편 수와 1시간 이상 지연된 항공편 수를 계산합니다.

      flights |> group_by(dest) |> summarize_severe()
    3. 취소되었거나 사용자가 제공한 시간 이상 지연된 모든 항공편을 찾습니다:

      flights |> filter_severe(hours = 2)
    4. 날씨를 요약하여 사용자가 제공한 변수의 최소, 평균, 최대를 계산합니다:

      weather |> summarize_weather(temp)
    5. 시계 시간(예: dep_time, arr_time 등)을 사용하는 사용자 제공 변수를 십진수 시간(즉, 시간 + (분 / 60))으로 변환합니다.

      flights |> standardize_time(sched_dep_time)
  2. 다음 각 함수에 대해 tidy evaluation을 사용하는 모든 인수를 나열하고 데이터 마스킹을 사용하는지 tidy 선택을 사용하는지 설명하세요: distinct(), count(), group_by(), rename_with(), slice_min(), slice_sample().

  3. 다음 함수를 일반화하여 카운트할 변수를 원하는 수만큼 제공할 수 있도록 하세요.

    count_prop <- function(df, var, sort = FALSE) {
      df |>
        count({{ var }}, sort = sort) |>
        mutate(prop = n / sum(n))
    }

25.4 플롯 함수

데이터 프레임을 반환하는 대신 플롯을 반환하고 싶을 수도 있습니다. 다행히도 aes()가 데이터 마스킹 함수이기 때문에 ggplot2에서도 동일한 기술을 사용할 수 있습니다. 예를 들어, 많은 히스토그램을 만들고 있다고 상상해 보세요:

diamonds |> 
  ggplot(aes(x = carat)) +
  geom_histogram(binwidth = 0.1)

diamonds |> 
  ggplot(aes(x = carat)) +
  geom_histogram(binwidth = 0.05)

이것을 히스토그램 함수로 감쌀 수 있다면 좋지 않을까요? aes()가 데이터 마스킹 함수이고 감싸야 한다는 것을 알면 아주 쉽습니다:

histogram <- function(df, var, binwidth = NULL) {
  df |> 
    ggplot(aes(x = {{ var }})) + 
    geom_histogram(binwidth = binwidth)
}

diamonds |> histogram(carat, 0.1)

0에서 5 사이의 다이아몬드 캐럿 히스토그램으로, 0에서 1 캐럿 사이에  피크가 있는 단봉형의 오른쪽으로 치우쳐진 분포를 보여줍니다.

histogram()은 ggplot2 플롯을 반환하므로 원하는 경우 추가 구성 요소를 계속 추가할 수 있습니다. |>에서 +로 전환해야 한다는 점만 기억하세요:

diamonds |> 
  histogram(carat, 0.1) +
  labs(x = "Size (in carats)", y = "Number of diamonds")

25.4.1 더 많은 변수

더 많은 변수를 혼합하는 것은 간단합니다. 예를 들어, 매끄러운 선과 직선을 겹쳐서 데이터셋이 선형인지 여부를 눈으로 확인하는 쉬운 방법을 원할 수 있습니다:

# https://twitter.com/tyler_js_smith/status/1574377116988104704
linearity_check <- function(df, x, y) {
  df |>
    ggplot(aes(x = {{ x }}, y = {{ y }})) +
    geom_point() +
    geom_smooth(method = "loess", formula = y ~ x, color = "red", se = FALSE) +
    geom_smooth(method = "lm", formula = y ~ x, color = "blue", se = FALSE) 
}

starwars |> 
  filter(mass < 1000) |> 
  linearity_check(mass, height)

스타워즈 캐릭터의 키 대 질량 산점도로 양의 관계를 보여줍니다. 관계의  매끄러운 곡선은 빨간색으로, 최적 적합선은 파란색으로 플롯됩니다.

또는 오버플로팅이 문제가 되는 매우 큰 데이터셋의 경우 색상이 지정된 산점도에 대한 대안을 원할 수도 있습니다:

# https://twitter.com/ppaxisa/status/1574398423175921665
hex_plot <- function(df, x, y, z, bins = 20, fun = "mean") {
  df |> 
    ggplot(aes(x = {{ x }}, y = {{ y }}, z = {{ z }})) + 
    stat_summary_hex(
      aes(color = after_scale(fill)), # make border same color as fill
      bins = bins, 
      fun = fun,
    )
}

diamonds |> hex_plot(carat, price, depth)

다이아몬드의 가격 대 캐럿의 육각형(Hex) 플롯으로 양의 관계를 보여줍니다.  2캐럿 미만의 다이아몬드가 2캐럿 이상의 다이아몬드보다 더 많습니다.

25.4.2 다른 tidyverse와 결합하기

가장 유용한 도우미 중 일부는 데이터 조작과 ggplot2를 약간 결합합니다. 예를 들어, fct_infreq()를 사용하여 막대를 빈도 순서대로 자동으로 정렬하는 수직 막대 차트를 만들고 싶을 수 있습니다. 막대 차트가 수직이므로 가장 높은 값이 맨 위에 오도록 일반적인 순서를 반전시켜야 합니다:

sorted_bars <- function(df, var) {
  df |> 
    mutate({{ var }} := fct_rev(fct_infreq({{ var }})))  |>
    ggplot(aes(y = {{ var }})) +
    geom_bar()
}

diamonds |> sorted_bars(clarity)

다이아몬드 투명도의 막대 플롯. y축에 투명도가 있고 x축에 카운트가  있으며 막대는 빈도 순으로 정렬됩니다: SI1, VS2, SI2, VS1, VVS2,  VVS1, IF, I1.

여기서는 사용자 제공 데이터를 기반으로 변수 이름을 생성하기 때문에 :=(흔히 “바다코끼리 연산자”라고 함)라는 새로운 연산자를 사용해야 합니다. 변수 이름은 =의 왼쪽에 오지만 R의 구문은 단일 리터럴 이름을 제외하고는 = 왼쪽에 어떤 것도 허용하지 않습니다. 이 문제를 해결하기 위해 tidy evaluation이 =와 정확히 동일한 방식으로 취급하는 특수 연산자 :=를 사용합니다.

또는 데이터의 일부에 대해서만 막대 플롯을 쉽게 그리고 싶을 수도 있습니다:

conditional_bars <- function(df, condition, var) {
  df |> 
    filter({{ condition }}) |> 
    ggplot(aes(x = {{ var }})) + 
    geom_bar()
}

diamonds |> conditional_bars(cut == "Good", clarity)

다이아몬드 투명도의 막대 플롯. 가장 흔한 것은 SI1, 그 다음 SI2,  VS2, VS1, VVS2, VVS1, I1, 마지막으로 IF입니다.

창의력을 발휘하여 다른 방식으로 데이터 요약을 표시할 수도 있습니다. https://gist.github.com/GShotwell/b19ef520b6d56f61a830fabb3454965b에서 멋진 응용 프로그램을 찾을 수 있습니다; 축 레이블을 사용하여 가장 높은 값을 표시합니다. ggplot2에 대해 더 많이 알게 될수록 함수의 힘은 계속 커질 것입니다.

더 복잡한 경우인 생성한 플롯에 레이블을 지정하는 것으로 마무리하겠습니다.

25.4.3 레이블 지정

앞서 보여드린 히스토그램 함수를 기억하시나요?

histogram <- function(df, var, binwidth = NULL) {
  df |> 
    ggplot(aes(x = {{ var }})) + 
    geom_histogram(binwidth = binwidth)
}

출력에 사용된 변수와 빈 너비로 레이블을 지정할 수 있다면 좋지 않을까요? 그렇게 하려면 tidy evaluation의 이면으로 들어가서 아직 이야기하지 않은 패키지인 rlang의 함수를 사용해야 합니다. rlang은 tidy evaluation(및 기타 많은 유용한 도구)을 구현하기 때문에 tidyverse의 거의 모든 다른 패키지에서 사용되는 저수준 패키지입니다.

레이블 지정 문제를 해결하기 위해 rlang::englue()를 사용할 수 있습니다. 이것은 str_glue()와 유사하게 작동하므로 { }로 감싸진 모든 값은 문자열에 삽입됩니다. 하지만 적절한 변수 이름을 자동으로 삽입하는 {{ }}도 이해합니다:

histogram <- function(df, var, binwidth) {
  label <- rlang::englue("A histogram of {{var}} with binwidth {binwidth}")
  
  df |> 
    ggplot(aes(x = {{ var }})) + 
    geom_histogram(binwidth = binwidth) + 
    labs(title = label)
}

diamonds |> histogram(carat, 0.1)

0에서 5 사이의 다이아몬드 캐럿 히스토그램. 분포는 단봉형이며 오른쪽으로  치우쳐 있고 0에서 1 캐럿 사이에 피크가 있습니다.

ggplot2 플롯에서 문자열을 제공하려는 다른 모든 곳에서 동일한 접근 방식을 사용할 수 있습니다.

25.4.4 연습문제

아래 단계를 점진적으로 구현하여 풍부한 플롯 함수를 만드세요:

  1. 데이터셋과 x, y 변수가 주어지면 산점도를 그립니다.

  2. 최적 적합선(즉, 표준 오차가 없는 선형 모델)을 추가합니다.

  3. 제목을 추가합니다.

25.5 스타일

R은 함수나 인수의 이름이 무엇이든 상관하지 않지만 이름은 사람에게 큰 차이를 만듭니다. 이상적으로는 함수 이름이 짧으면서도 함수가 하는 일을 명확하게 떠올리게 해야 합니다. 어렵습니다! 하지만 RStudio의 자동 완성을 사용하면 긴 이름을 쉽게 입력할 수 있으므로 짧은 것보다는 명확한 것이 낫습니다.

일반적으로 함수 이름은 동사여야 하고 인수는 명사여야 합니다. 몇 가지 예외가 있습니다: 함수가 매우 잘 알려진 명사를 계산하거나(즉, mean()compute_mean()보다 낫습니다) 객체의 일부 속성에 액세스하는 경우(즉, coef()get_coefficients()보다 낫습니다) 명사가 괜찮습니다. 최선의 판단을 사용하고 나중에 더 나은 이름을 알아내면 함수 이름을 바꾸는 것을 두려워하지 마세요.

# 너무 짧음
f()

# 동사가 아니거나 설명적이지 않음
my_awesome_function()

# 길지만 명확함
impute_missing()
collapse_years()

R은 함수에서 공백을 사용하는 방식에 대해서도 상관하지 않지만 미래의 독자는 상관할 것입니다. Chapter 4 의 규칙을 계속 따르세요. 또한 function() 뒤에는 항상 중괄호({})가 와야 하며 내용은 두 칸 더 들여써야 합니다. 이렇게 하면 왼쪽 여백을 훑어보면서 코드의 계층 구조를 더 쉽게 볼 수 있습니다.

# 추가적인 두 칸 공백 누락
density <- function(color, facets, binwidth = 0.1) {
diamonds |> 
  ggplot(aes(x = carat, y = after_stat(density), color = {{ color }})) +
  geom_freqpoly(binwidth = binwidth) +
  facet_wrap(vars({{ facets }}))
}

# 파이프가 잘못 들여쓰기됨
density <- function(color, facets, binwidth = 0.1) {
  diamonds |> 
  ggplot(aes(x = carat, y = after_stat(density), color = {{ color }})) +
  geom_freqpoly(binwidth = binwidth) +
  facet_wrap(vars({{ facets }}))
}

보시다시피 {{ }} 안에는 공백을 추가하는 것을 권장합니다. 이렇게 하면 뭔가 특이한 일이 일어나고 있다는 것이 매우 분명해집니다.

25.5.1 연습문제

  1. 다음 두 함수의 소스 코드를 읽고 어떤 역할을 하는지 파악한 다음 더 나은 이름을 브레인스토밍하세요.

    f1 <- function(string, prefix) {
      str_sub(string, 1, str_length(prefix)) == prefix
    }
    
    f3 <- function(x, y) {
      rep(y, length.out = length(x))
    }
  2. 최근에 작성한 함수를 가져와서 5분 동안 더 나은 이름과 인수에 대해 브레인스토밍하세요.

  3. rnorm(), dnorm() 등보다 norm_r(), norm_d() 등이 더 나은 이유를 설명하세요. 반대의 경우도 설명하세요. 이름을 어떻게 더 명확하게 만들 수 있습니까?

25.6 요약

이 장에서는 벡터 생성, 데이터 프레임 생성 또는 플롯 생성이라는 세 가지 유용한 시나리오에 대한 함수를 작성하는 방법을 배웠습니다. 그 과정에서 많은 예제를 보았는데, 이를 통해 창의력이 발휘되고 함수가 분석 코드에 도움이 될 수 있는 위치에 대한 아이디어를 얻었기를 바랍니다.

우리는 함수를 시작하기 위한 최소한의 것만 보여주었으며 배워야 할 것이 훨씬 더 많습니다. 더 배울 수 있는 몇 가지 장소는 다음과 같습니다:

다음 장에서는 코드 중복을 줄이는 추가 도구를 제공하는 반복(iteration)에 대해 알아보겠습니다.