本文來源於 2018 年學習《R for Data Science》寫的筆記。一起複習一下吧~
函數有3個好處:
除了函數,減少重複代碼的另一種工具是迭代,它的作用在於可以對多個輸入執行同一種處理,比如對多個列或多個數據集進行同樣的操作。
迭代方式主要有兩種:
準備工作purrr是tidyverse的核心r包之一,提供了一些更加強大的編程工具。(讀者可以點擊原文獲取小抄)
library(tidyverse)#> ─ Attaching packages ─────────────────────────────────────────────────── tidyverse 1.2.1 ─#> ✔ ggplot2 3.0.0 ✔ purrr 0.2.5#> ✔ tibble 1.4.2 ✔ dplyr 0.7.6#> ✔ tidyr 0.8.1 ✔ stringr 1.3.1#> ✔ readr 1.1.1 ✔ forcats 0.3.0#> ─ Conflicts ──────────────────────────────────────────────────── tidyverse_conflicts() ─#> ✖ dplyr::filter() masks stats::filter()#> ✖ dplyr::lag() masks stats::lag()for循環與函數式編程因為R是一門函數式程式語言,我們可以先將for循環包裝在函數中,然後再調用函數,而不是使用for循環,因此for循環在R中不像在其他程式語言中那麼重要。
為了說明函數式編程,我們先利用下面簡單的數據框進行一些思考:
df = tibble( a = rnorm(10), b = rnorm(10), c = rnorm(10), d = rnorm(10))如果想要計算每列的均值,我們使用for循環完成任務:
output = vector("double", length(df))
for (i in seq_along(df)) { output[[i]] = mean(df[[i]])}
output#> [1] 0.45635 -0.17938 0.32879 0.00263然後我們可能意識到需要頻繁地計算每列的均值,因此將代碼提取出來,轉換為一個函數:
col_mean = function(df) { output = vector("double", length(df)) for ( i in seq_along(df)) { output[i] = mean(df[[i]]) } output}然後我們覺得可能還需要這樣計算每列的中位數和標準差,因此複製粘貼了col_mean(),並使用相應的median()和sd()函數替換了mean()函數:
col_median = function(df) { output = vector("double", length(df)) for ( i in seq_along(df)) { output[i] = median(df[[i]]) } output}
col_sd = function(df) { output = vector("double", length(df)) for ( i in seq_along(df)) { output[i] = sd(df[[i]]) } output}(有時候我還真這麼幹的。)
哎呀,我們又複製粘貼了2次代碼,因此是不是該思考下如何擴展一個代碼讓它同時發揮幾個函數的功能呢?這段代碼的大部分是一個for循環,而且如果不仔細很難看出3個函數有什麼差別。
通過添加支持函數到每列的參數,我們可以使用同一個函數解決3個問題:
col_summary = function(df, fun){ out = vector("double", length(df)) for (i in seq_along(df)) { out[i] = fun(df[[i]]) } out}
col_summary(df, median)#> [1] 0.4666 0.0269 0.6161 0.0573col_summary(df, mean)#> [1] 0.45635 -0.17938 0.32879 0.00263將函數作為參數傳入另一個函數的做法是一種非常強大的功能,我們需要花些時間理解這種思想,但絕對是值得的。接下來我們將學習和使用purrr包,它提供的函數可以替代很多常見的for循環應用。R基礎包中的apply應用函數族也可以完成類似的任務,但purrr包的函數更一致,也更容易學習。
使用purrr函數替代for循環的目的是將常見的列表問題分解為獨立的幾部分:
對於列表的單個元素,我們能找到解決辦法嗎?如果可以,我們就能使用purrr將該方法擴展到列表的所有元素。如果我們面臨的是一個複雜的問題,那麼將其分解為可行的子問題,然後依次解決。使用purrr,我們可以解決子問題,然後用管道將其組合起來。映射函數先對向量進行循環,然後對其每一個元素進行一番處理,最後保存結果。這種模式太普遍了,因而purrr包提供了一個函數族替我們完成這種操作。每種類型的輸出都有一個相應的函數:
每個函數都使用一個向量(注意列表可以作為遞歸向量看待)作為輸入,並對向量的每個元素應用一個函數,然後返回和輸入向量同樣長度的一個新向量。向量的類型由映射函數的後綴決定。
使用map()函數族的優勢不是速度,而是簡潔:它可以讓我們的代碼更易編寫,也更易閱讀。
下面是進行上一節一樣的操作:
library(purrr)
map_dbl(df, mean)#> a b c d #> 0.45635 -0.17938 0.32879 0.00263map_dbl(df, median)#> a b c d #> 0.4666 0.0269 0.6161 0.0573map_dbl(df, sd)#> a b c d #> 0.608 1.086 0.797 0.873**與for循環相比,映射函數的重點在於需要執行的操作(即mean()、median()和sd()),而不是在所有元素中循環所需的跟蹤記錄以及保存結果。使用管道時這一點尤為突出:
df %>% map_dbl(mean)#> a b c d #> 0.45635 -0.17938 0.32879 0.00263df %>% map_dbl(median)#> a b c d #> 0.4666 0.0269 0.6161 0.0573df %>% map_dbl(sd)#> a b c d #> 0.608 1.086 0.797 0.873map_*()和col_summary()具有以下幾點區別:
所有的purrr函數都是用C實現的,這讓它們的速度非常快,但犧牲了一些可讀性。第二個參數可以是一個公式、一個字符向量或一個整型向量。map_*()使用...向.f傳遞一些附加參數,供每次調用時使用快捷方式對於第二個參數.f,我們可以使用幾種快捷方式來減少輸入量。比如我們現在想對某個數據集中的每一個分組都擬合一個線性模型,下面示例將mtcars數據集拆分為3個部分(按照氣缸值分類),並對每個部分擬合一個線性模型:
models = mtcars %>% split(.$cyl) %>% map(function(df) lm(mpg ~ wt, data = df))因為在R中創建匿名函數的語法比較複雜,所以purrr提供了一種更方便的快捷方式——單側公式:
models = mtcars %>% split(.$cyl) %>% map(~lm(mpg ~ wt, data = .))上面.作為一個代詞:它表示當前列表元素(與for循環中用i表示當前索引是一樣的)。
當檢查多個模型時,有時候我們需要提取像R方這樣的摘要統計量,要想完成這個任務,我們需要先運行summary()函數,然後提取結果中的r.squared:
models %>% map(summary) %>% map_dbl(~.$r.squared)#> 4 6 8 #> 0.509 0.465 0.423因為提取命名成分操作非常普遍,所以purrr提供了一種更簡單的快捷方式:使用字符串。
models %>% map(summary) %>% map_dbl("r.squared")#> 4 6 8 #> 0.509 0.465 0.423對操作失敗的處理當使用映射函數重複多次操作時,某次操作失敗的概率大大增加。這個時候我們會收到一條錯誤信息,但得不到任何結果。這讓人很惱火!我們怎麼保證不會出現一條魚腥了一鍋湯?
safely()是一個修飾函數(副詞),它接收一個函數(動詞),對其進行修改並返回修改後的函數。這樣,修改後的函數就不會拋出錯誤,相反,它總是返回由下面兩個元素組成的列表:
result - 原始結果。如果出現錯誤,那麼它就是NULLerror - 錯誤對象。如果操作成功,那麼它就是NULL下面用log()函數進行說明:
safe_log = safely(log)str(safe_log(10))#> List of 2#> $ result: num 2.3#> $ error : NULL
str(safe_log("a"))#> List of 2#> $ result: NULL#> $ error :List of 2#> ..$ message: chr "數學函數中用了非數值參數"#> ..$ call : language log(x = x, base = base)#> ..- attr(*, "class")= chr [1:3] "simpleError" "error" "condition"safely()函數也可以與map()共同使用:
x = list(1, 10, "a")y = x %>% map(safely(log))str(y)#> List of 3#> $ :List of 2#> ..$ result: num 0#> ..$ error : NULL#> $ :List of 2#> ..$ result: num 2.3#> ..$ error : NULL#> $ :List of 2#> ..$ result: NULL#> ..$ error :List of 2#> .. ..$ message: chr "數學函數中用了非數值參數"#> .. ..$ call : language log(x = x, base = base)#> .. ..- attr(*, "class")= chr [1:3] "simpleError" "error" "condition"如果將以上結果轉換為2個列表,一個列表包含所有錯誤對象,另一個列表包含所有原始結果,那麼處理起來就會更容易。我們可以使用purrr::transpose()函數輕鬆完成該任務:
y = y %>% transpose()str(y)#> List of 2#> $ result:List of 3#> ..$ : num 0#> ..$ : num 2.3#> ..$ : NULL#> $ error :List of 3#> ..$ : NULL#> ..$ : NULL#> ..$ :List of 2#> .. ..$ message: chr "數學函數中用了非數值參數"#> .. ..$ call : language log(x = x, base = base)#> .. ..- attr(*, "class")= chr [1:3] "simpleError" "error" "condition"我們可以自行決定如何處理錯誤對象,一般來說,我們應該檢查一下y中錯誤對象所對應的x值,或者使用y中的正常結果進行一些處理:
is_ok = y$error %>% map_lgl(is_null)x[!is_ok]#> [[1]]#> [1] "a"
y$result[is_ok] %>% flatten_dbl()#> [1] 0.0 2.3purrr還提供了兩個有用的修飾函數:
與safely()類似,possibly()函數總是會成功返回。它比safely()還要簡單一些,因為可以設定出現錯誤時返回一個默認值:x = list(1, 10, "a")x %>% map_dbl(possibly(log, NA_real_))#> [1] 0.0 2.3 NAquietly()函數與safely()的作用基本相同,但前者結果不包含錯誤對象,而是包含輸出、消息和警告:x = list(1, -1)x %>% map(quietly(log)) %>% str()#> List of 2#> $ :List of 4#> ..$ result : num 0#> ..$ output : chr ""#> ..$ warnings: chr(0) #> ..$ messages: chr(0) #> $ :List of 4#> ..$ result : num NaN#> ..$ output : chr ""#> ..$ warnings: chr "產生了NaNs"#> ..$ messages: chr(0)
x %>% map(safely(log)) %>% str()#> Warning in .f(...): 產生了NaNs#> List of 2#> $ :List of 2#> ..$ result: num 0#> ..$ error : NULL#> $ :List of 2#> ..$ result: num NaN#> ..$ error : NULL多參數映射前面我們提到的映射函數都是對單個輸入進行映射,但有時候我們需要多個相關輸入同步迭代,這就是map2()和pmap()函數的用武之地。
例如我們想模擬幾個均值不同的隨機正態分布,我們可以使用map完成這個任務:
mu = list(5, 10, -3)mu %>% map(rnorm, n = 5) %>% str()#> List of 3#> $ : num [1:5] 5.65 6.48 6.35 4.61 4.74#> $ : num [1:5] 8.93 8.93 10.67 10.98 8.72#> $ : num [1:5] -4.04 -3.25 -2.16 -3.02 -2.53如果我們想讓標準差也不同,一種方法是使用均值向量和標準差向量的索引進行迭代:
sigma = list(1, 5, 10)seq_along(mu) %>% map(~rnorm(5, mu[[.]], sigma[[.]])) %>% str()#> List of 3#> $ : num [1:5] 4.5 4.73 4.43 6.19 5.47#> $ : num [1:5] 8.71 8.59 18.26 7.93 4.93#> $ : num [1:5] -21.46 -7.94 -21.41 5.66 2.38但這種方式比較難理解,我們使用map2()進行同步迭代:
map2(mu, sigma, rnorm, n = 5) %>% str()#> List of 3#> $ : num [1:5] 6.08 6.72 7.59 5.21 3.99#> $ : num [1:5] 13.44 6.81 3.61 22.29 14.29#> $ : num [1:5] 4.05 -1.77 -2.77 0.69 -23.91注意這裡每次調用時值發生變換的參數要放在映射函數前面,值不變的參數要放在映射函數後面。
和map()函數一樣,map2()函數也是對for循環的包裝:
map2 = function(x, y, f, ...){ out = vector("list", length(x)) for (i in seq_along(x)) { out[[i]] = f(x[[i]], y[[i]], ...) } out}(實際的map2()並不是這樣的,此處是給出R實現的一種思想)
根據這個函數,我們可以涉及map3()、map4()等等,但這樣實在無聊。purrr提供了pmap()函數,它可以將列表作為參數。如果我們想要生成均值、標準差和樣本數都不同的正態分布,可以使用:
n = list(1, 3, 5)args1 = list(n, mu, sigma)
args1 %>% pmap(rnorm) %>% str()#> List of 3#> $ : num 3.55#> $ : num [1:3] 8.4 10.9 -3.3#> $ : num [1:5] 3.9 -11.61 2.06 7.14 -16.25如果沒有為列表元素命名,那麼pmap()在調用函數時會按照位置匹配。這樣做容易出錯而且可讀性差,因此最後使用命名參數:
args2 = list(mean = mu, sd = sigma, n = n)args2 %>% pmap(rnorm) %>% str()#> List of 3#> $ : num 6.18#> $ : num [1:3] 11.2 18 14.8#> $ : num [1:5] -5.27 6.57 1.88 6.53 -8.35這樣更加安全。
因為長度都相同,所以將各個參數保存在一個數據框中:
params = tibble::tribble( ~mean, ~sd, ~n, 5, 1, 1, 10, 5, 3, -3, 10, 5)
params %>% pmap(rnorm)#> [[1]]#> [1] 5.41#> #> [[2]]#> [1] 5.4 10.2 14.4#> #> [[3]]#> [1] -8.653 -4.457 9.747 -4.916 -0.436調用不同的函數還有一種更複雜的情況:不但傳給函數的參數不同,甚至函數本身也是不同的。
f = c("runif", "rnorm", "rpois")param = list( list(min = -1, max = 1), list(sd = 5), list(lambda = 10))為了處理這種情況,我們使用invoke_map()函數:
invoke_map(f, param, n = 5) %>% str()#> List of 3#> $ : num [1:5] 0.167 -0.235 -0.366 -0.933 0.304#> $ : num [1:5] 6.961 3.642 13.405 0.536 -2.078#> $ : int [1:5] 8 8 8 6 11第1個參數是一個函數列表或包含函數名稱的字符串向量。第2個參數是列表的一個列表,給出了要傳給各個函數的不同參數。隨後的參數要傳給每個函數。
我們使用tribble()讓參數配對更容易:
sim = tibble::tribble( ~f, ~params, "runif", list(min = -1, max = 1), "rnorm", list(sd = 5), "rpois", list(lambda = 10))
sim %>% dplyr::mutate(sim = invoke_map(f, params, n = 10))#> # A tibble: 3 x 3#> f params sim #> <chr> <list> <list> #> 1 runif <list [2]> <dbl [10]>#> 2 rnorm <list [1]> <dbl [10]>#> 3 rpois <list [1]> <int [10]>遊走函數當使用函數的目的是向屏幕提供輸出或將文件保存到磁碟——重要的是操作過程而不是返回值,我們應該使用遊走函數,而不是映射函數。
下面是一個示例:
x = list(1, "a", 3)
x %>% walk(print)#> [1] 1#> [1] "a"#> [1] 3一般來說,walk()函數不如walk2()和pwalk()實用。例如有一個圖形列表和一個文件名向量,那麼我們就可以使用pwalk()將每個文件保存到相應的磁碟位置:
library(ggplot2)
plots = mtcars %>% split(.$cyl) %>% map(~ggplot(., aes(mpg, wt)) + geom_point())paths = stringr::str_c(names(plots), ".pdf")
pwalk(list(paths, plots), ggsave, path = tempdir())#> Saving 7 x 5 in image#> Saving 7 x 5 in image#> Saving 7 x 5 in image我們來查看一下是不是建立好了:
dir(tempdir())#> [1] "4.pdf" "6.pdf" "8.pdf"for循環的其他模式purrr還提供了其他一些函數,雖然這些函數的使用率低,但了解還是有必要的。本節就是對它們進行簡單介紹
預測函數一些函數可以與返回TRUE或FALSE的預測函數一同使用。
keep()和discard()函數可以分別保留輸入中預測值為TRUE和FALSE的元素(在數據框中就是指列):
iris %>% keep(is.factor) %>% str()#> 'data.frame': 150 obs. of 1 variable:#> $ Species: Factor w/ 3 levels "setosa","versicolor",..: 1 1 1 1 1 1 1 1 1 1 ...
iris %>% discard(is.factor) %>% str()#> 'data.frame': 150 obs. of 4 variables:#> $ Sepal.Length: num 5.1 4.9 4.7 4.6 5 5.4 4.6 5 4.4 4.9 ...#> $ Sepal.Width : num 3.5 3 3.2 3.1 3.6 3.9 3.4 3.4 2.9 3.1 ...#> $ Petal.Length: num 1.4 1.4 1.3 1.5 1.4 1.7 1.4 1.5 1.4 1.5 ...#> $ Petal.Width : num 0.2 0.2 0.2 0.2 0.2 0.4 0.3 0.2 0.2 0.1 ...some()和every()函數分別用來確定預測值是否對某個元素為真以及是否對所有元素為真:
x = list(1:5, letters, list(10))
x %>% some(is_character)#> [1] TRUE
x %>% every(is_vector)#> [1] TRUEdetect()可以找出預測值為真的第一個元素,detect_index()可以返回該元素的索引。
x = sample(10)x#> [1] 10 8 5 7 4 1 2 9 3 6
x %>% detect(~ . >5)#> [1] 10
x %>% detect_index(~ . >5)#> [1] 1head_while()和tail_while()分別從向量的開頭和結尾找出預測值為真的元素:
x %>% head_while(~ . > 5)#> [1] 10 8
x %>% tail_while(~ . > 5)#> [1] 6歸約和累計操作一個複雜的列表,有時候我們想要不斷合併兩個預算兩個元素(基礎函數Reduce幹的事情)。
dfs = list( age = tibble(name = "John", age = 30), sex = tibble(name = c("John", "Mary"), sex = c("M", "F")), trt = tibble(name = "Mary", treatment = "A"))
dfs %>% reduce(full_join)#> Joining, by = "name"#> Joining, by = "name"#> # A tibble: 2 x 4#> name age sex treatment#> <chr> <dbl> <chr> <chr> #> 1 John 30 M <NA> #> 2 Mary NA F A這裡我們使用reduce結合dplyr中的full_join()將它們輕鬆合併為一個數據框。
reduce()函數使用一個「二元函數」(即兩個基本輸入),將其不斷應用於一個列表,直到最後只剩下一個元素。
累計函數與歸約函數類似,但會保留中間結果,比如下面求取累計和:
x = sample(10)
x#> [1] 9 10 8 5 6 2 3 4 7 1x %>% accumulate(`+`)#> [1] 9 19 27 32 38 40 43 47 54 55