什麼是函式程式設計?
對我們來說,以下是幾個關鍵特點:
不過,R 並不是純函式語言(例如,和 Haskell 相反)。
把 R 視為一門務實的語言,當函式能力能乾淨又輕鬆地完成工作時,它(越來越常)會提供這些能力。一般來說,資料分析才是目標,寫程式只是達成目標的手段。
使用管道|>來連接函式,是幾種語言中越來越常見的技巧。
簡而言之:
有些程式設計師在閱讀程式碼時,覺得把管道唸成「then」很有幫助:做步驟 1,THEN 做步驟 2……
下面的範例示範了這個語法。
library(stringr)
"Monday-25-February" |>
str_split("-") |>
unlist() |> # [1] "Monday" "25" "February"
str_sub(1, 3) |> # [1] "Mon" "25" "Feb"
str_to_upper() |> # [1] "MON" "25" "FEB"
str_flatten(collapse = ", ")
#> [1] "MON, 25, FEB"
這種管道是在 v4.1.0 加入 Base R 的,你可能會看到它被稱為「原生管道」。
RStudio 編輯器(以及其他一些編輯器)使用Ctrl-Shift-M作為鍵盤快速鍵,來插入附有適當空格的管道。
我們先前看過,R 裡的許多函式都是「向量化」的:它們會作用於整個向量,不需要寫出明確的迴圈。
只使用向量化函式的函式,本身也很可能會自動被向量化。舉個簡單的例子:
triple <- function(x) return(3 * x)
triple(1:10)
[1] 3 6 9 12 15 18 21 24 27 30
不過,比較複雜的函式可能不接受向量輸入,例如當它們含有迴圈或 if-else 區塊時。
我們需要一種更通用的方式,把函式套用到向量上(類似其他一些語言裡的map())。
因為 R 的基礎語言中有各式各樣的資料結構,它也有一整家族*apply()函式可以用來操作它們。更技術性地說,程式設計師常稱它們為「高階函式」,數學家則稱之為「泛函」,因為它們接受函式作為引數。
現在,先來看看lapply()和sapply()。
lapply()「List-apply」是設計來處理清單輸入的,但向量在必要時會被默默地強制轉成清單。
輸出會是一個清單,所以可能需要拆解才能得到向量:
h <- function(size) {
switch(size,
"small" = 6,
"medium" = 8,
"large" = 10
)
}
v <- c("small", "large", "medium")
h(v) # bad use of an unvectorized function
#> Error in switch(size, small = 6, medium = 8, large = 10) :
#> EXPR must be a length 1 vector
lapply(v, h) # output is a list
#> [[1]]
#> [1] 6
#> [[2]]
#> [1] 10
#> [[3]]
#> [1] 8
lapply(v, h) |> unlist() # output is a vector
#> [1] 6 10 8
熟悉 Switch 概念的學生可能會注意到,我們也可以改用來自dplyr套件的向量化recode_values()來取代純量switch。向量化是好事,而現代 R 提供了多種方式來達成它。
library(dplyr)
h <- function(size) {
recode_values(size,
"small" ~ 6,
"medium" ~ 8,
"large" ~ 10
)
}
v <- c("small", "large", "medium")
h(v)
#> [1] 6 10 8
sapply()「Simplified-apply」會做和lapply類似的事,但也會試著簡化回傳值:例如,用具名向量取代清單。視情境而定,可能有必要使用unname():
sapply(v, h)
small large medium
6 10 8
sapply(v, h) |> unname()
#> [1] 6 10 8
purrr 套件lapply和sapply很簡單,對大多數 R 使用者來說很熟悉,而且在處理向量和簡單清單時仍然相當受歡迎。
Base R 也有_許多_其他 apply 函式。為這類混亂提供更簡單、更一致的替代方案,是 Tidyverse 的一大目標。
現在有了purrr套件,它提供了幾種額外的選項,例如map(),同時改善了設計上的一致性,以及錯誤訊息的清晰度。
這個套件比 Base R 更不受向後相容性的限制,讓 R 大幅接近其他語言中可見的現代函式程式設計。
purrr裡有許多各式各樣的函式,很難簡單地概括。以下是一些有助於認識這個套件的資源:
map()函式會把函式套用到輸入中的每個元素,並且總是回傳清單。
到目前為止,這聽起來就像lapply(),只是改了名字,或許還加上更好的錯誤訊息。
不過,map()只是_龐大_家族中的一員。
挑選函式時可以思考的幾個問題:
map()總是回傳清單,但如果你想要特定型別的原子向量,就用map_chr()處理字串、map_int()處理整數,依此類推。i,例如imap()。map換成map2,例如用map2_dbl()處理兩個輸入(例如值和權重,用來計算加權平均)pmap,例如pmap_chr()。沒錯,硬體層級的平行處理是可行的。map_if()套用述詞(布林)篩選、用map_at()指定位置索引條件,或用head_while()處理輸入,直到某個元素不符合述詞為止。modify()系列的函式取代map,或用modify_tree()做遞迴套用。這些函式在可能的情況下都會把輸入資料放在第一個引數,以確保它們能和管道搭配良好。
此外,purrr(刻意)比 Base R 更吹毛求疵。如果你要求特定的東西,這些函式會回傳_正好_那樣東西,否則就停下來並附上錯誤訊息。雖然嚴苛,但對於減少難以找到的 bug 非常有效。
map 函式通常回傳值的長度和輸入相同。
其他一些函式會_縮減_資料的維度,例如sum()。
sum(1:100)
#> [1] 5050
在上面的例子中,我們把長度 100 的向量轉換成長度 1 的整數。我們之後會在關於矩陣與陣列的概念中看到,維度縮減的概念其實更為通用。
sum()函式是內建的(許多其他統計函式也是)。不過,我們需要一種方式,能把任意的維度縮減函式套用到某個資料結構上,並使用某個相當於map()的高階函式。
在其他幾種語言中,會使用像fold、foldl和foldr這樣的函式名稱。在 R 裡,對應的函式是reduce(),相當於(對演算法愛好者來說)耳熟能詳的 MapReduce 框架。
第一個引數照例是輸入資料。第二個引數是一個接受兩個引數的函式:通常是匿名函式,但也可能簡單到只是一個像+這樣的算術運算子。
# surround an infix operator with backticks, to use as a function
reduce(1:100, `+`)
#> [1] 5050 # same as sum(1:100)
# anonymous function, alternately add and subtract
reduce(1:10, \(accum, nextval) ifelse(nextval %% 2 == 0, accum + nextval, accum - nextval))
#> [1] 7
我們從輸入的開頭還是結尾開始套用函式,會有差別嗎?
對於像+或*這種具結合性的運算子來說沒有差別。很明顯,(1 + 2) + 3 == 1 + (2 + 3)。
相對地,-和/不具結合性:
(1 - 2) - 3
#> [1] -4
1 - (2 - 3)
#> [1] 2
對於方向有差別的函式,有一個選用的.dir引數。
# default is .dir = "forward"
reduce(1:10, `-`)
#> [1] -53
reduce(1:10, `-`, .dir = "backward")
#> [1] -5
就像map()和map2()一樣,也有reduce2()函式可以平行操作兩個輸入向量。
在 R 裡,我們喜歡用向量來工作。假設你不想要單一的最終值,而是想看到所有中間步驟。
為此,對應的函式是accumulate()和accumulate2()。語法和reduce()類似,並加上一些額外的選用引數來指定輸出型別。
accumulate(1:10, `+`)
#> [1] 1 3 6 10 15 21 28 36 45 55
有些常見的 accumulate 類型有專屬的函式。向量函式概念討論過 Base R 裡的cumsum()和cumprod(),而dplyr套件有cummean()可用於數值平均,cumall()和cumany()則用於邏輯向量。
我們經常會想納入(或排除)輸入中符合某個述詞的元素。
在向量篩選概念中,我們看過一種慣用的做法。
v <- 1:3
v[v >= 2]
#> [1] 2 3
這在傳統 R 中用得很多,但不太適合放進現代的函式化管線中。
因此,purrr提供了一組述詞函式,它們遵循f(input, func, ...)這種慣用的引數慣例,以便和管道搭配使用。
這些名稱和大多數其他語言有點不同(像filter和drop這類名稱在 R 裡已經有完全不同的意思)。
我們正在籌組一個起司俱樂部,將使用機器學習,根據顧客的歷史紀錄與口味,為熱愛起司的顧客挑選新的起司。
新成員必須填寫一份初始問卷,讓我們能先蒐集一些基本資料。 透過這份問卷,我們發現有一小群情感強烈的顧客,他們的評價缺乏細膩度。 由於這可能會無可挽回地讓更細緻的演算法產生偏差,因此我們另外設置了一套演算法來處理他們的需求。 請你提供一些輔助函式,來整理他們的資料。
雖然解決以下任務可能有不同的方法,但每個任務都能用不超過兩個高階函式來解決。
評分系統以五顆星為基準,也就是單純由整數1:5組成。
情感強烈的顧客只會給出1或5的評分,而我們想知道某位顧客是否表現出這種行為。
實作all_15(),它接收一個評分向量,若所有評分都是1或5就回傳true,否則回傳 false。
all_15(c(2, 3, 4, 4, 1))
#> [1] FALSE
all_15(c(1, 5, 5, 1, 5))
#> [1] TRUE
我們需要把顧客的姓名(或 ID)和他們的評分關聯起來。
實作name_customers(),它接收一個姓名向量和一個評分陣列,並將它們「配對」成一個陣列的陣列。
內層陣列元素的名稱應該要是name和rating。
names <- c("c1", "c2")
ratings <- list(c(2, 3, 5), c(1, 1, 5))
name_customers(names, ratings)
#> [[1]]
#> [[1]]$name
#> [1] "c1"
#> [[1]]$rating
#> [1] 2 3 5
#> [[2]]
#> [[2]]$name
#> [1] "c2"
#> [[2]]$rating
#> [1] 1 1 5
我們需要把情感較強烈的顧客和其他人區分開來。
實作emphatics(),它接收顧客與評分。
回傳一個陣列,只保留那些只用1或5星評分的顧客。
# names and ratings as in the task 2 example
emphatics(names, ratings)
#> [[1]]
#> [[1]]$name
#> [1] "c2"
#> [[1]]$rating
#> [1] 1 1 5
由於情感強烈的顧客只使用1和5的評分,若能將這些評分改成0和1,在運算上會更方便。
實作to_binary(),它接收一個情感強烈評分的向量。
回傳二進位評分,其中1已改成0,而5已改成1。
to_binary(c(1, 1, 5, 5, 1))
#> [1] 0 0 1 1 0
隨著你越來越了解顧客的口味,你會想持續改善對所有個別顧客(不只是情感強烈的顧客)的推薦。
實作satisfactions(),它接收一個評分向量。
回傳一個到此為止的累積平均評分向量。
將結果四捨五入到小數點後 2 位。
希望這些數字能持續成長,讓顧客開心!
satisfactions(c(1, 2, 2, 3))
#> [1] 1.00 1.50 1.67 2.00