學習軌道
/
R
R
/
練習
/
起司俱樂部
起司俱樂部

起司俱樂部

學習練習

簡介

什麼是函式程式設計?

對我們來說,以下是幾個關鍵特點:

  • 純函式,會一致地把輸入對應到輸出,且沒有副作用。
  • 不可變的資料。
  • 一級函式,可以像其他型別一樣當作值來處理。
  • 高階函式,接受其他函式作為引數,或將其作為回傳值。

不過,R 並不是純函式語言(例如,和 Haskell 相反)。

把 R 視為一門務實的語言,當函式能力能乾淨又輕鬆地完成工作時,它(越來越常)會提供這些能力。一般來說,資料分析才是目標,寫程式只是達成目標的手段。

管道

使用管道|>來連接函式,是幾種語言中越來越常見的技巧。

簡而言之:

  • 管道左邊的結果會成為右邊函式的第一個引數(這裡的「右邊」會忽略選用的換行)。
  • 其餘的引數可以一併放在函式呼叫中。
  • 在 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 函式

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 裡已經有完全不同的意思)。

對應的purrr函式包括keep()和discard(),以及其他幾個。

此外,我們有every()、some()和none(),用來取代all()和any(),以回傳單一的邏輯值。

說明

我們正在籌組一個起司俱樂部,將使用機器學習,根據顧客的歷史紀錄與口味,為熱愛起司的顧客挑選新的起司。

新成員必須填寫一份初始問卷,讓我們能先蒐集一些基本資料。 透過這份問卷,我們發現有一小群情感強烈的顧客,他們的評價缺乏細膩度。 由於這可能會無可挽回地讓更細緻的演算法產生偏差,因此我們另外設置了一套演算法來處理他們的需求。 請你提供一些輔助函式,來整理他們的資料。

Note

雖然解決以下任務可能有不同的方法,但每個任務都能用不超過兩個高階函式來解決。

1. 分類顧客

評分系統以五顆星為基準,也就是單純由整數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

2. 為顧客命名

我們需要把顧客的姓名(或 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

3. 區分情感強烈的顧客

我們需要把情感較強烈的顧客和其他人區分開來。

實作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

4. 把評分改成二進位

由於情感強烈的顧客只使用1和5的評分,若能將這些評分改成0和1,在運算上會更方便。

實作to_binary(),它接收一個情感強烈評分的向量。 回傳二進位評分,其中1已改成0,而5已改成1。

to_binary(c(1, 1, 5, 5, 1))
#> [1] 0 0 1 1 0

5. 計算一段時間以來的平均評分

隨著你越來越了解顧客的口味,你會想持續改善對所有個別顧客(不只是情感強烈的顧客)的推薦。

實作satisfactions(),它接收一個評分向量。 回傳一個到此為止的累積平均評分向量。 將結果四捨五入到小數點後 2 位。

希望這些數字能持續成長,讓顧客開心!

satisfactions(c(1, 2, 2, 3))
#> [1] 1.00 1.50 1.67 2.00
透過 GitHub 編輯 連結會在新視窗或分頁中開啟
R Exercism

準備好開始 起司俱樂部 了嗎?

註冊 Exercism,透過 21 個概念111 個練習 和真人引導來學習並精通 R,全部免費。