轨道
/
R
R
/
练习
/
奶酪俱乐部
奶酪俱乐部

奶酪俱乐部

学习练习

简介

什么是函数式编程?

对我们来说,下面是几个关键特性:

  • 纯函数,它们以一致的方式把输入映射到输出,而且没有副作用。
  • 不可变数据。
  • 一等函数,可以像任何其他类型的值一样被当作值来对待。
  • 高阶函数,它们接收其他函数作为实参,或者把其他函数作为返回值。

不过,R 并不是一门纯函数式语言(这一点与例如 Haskell 相反)。

把 R 看作一门务实的语言:在这些能力是干净、简单地完成任务的方式时,R(越来越多地)会提供函数式能力。一般来说,数据分析才是目标,编程只是达成这个目标的手段。

管道

用管道 |> 把函数连接起来,在好几种语言里都是越来越常见的技术。

简单来说:

  • 管道左侧的结果会成为右侧函数的第一个实参(这里的“右侧”会忽略可选的换行)。
  • 其余实参可以写进这次函数调用里。
  • 在 R 中,我们必须把管道接到函数_调用_上,而不只是_名字_上,所以括号永远不能省(这一点和其他一些语言不同)。

有些程序员在查看代码时,习惯把管道读作“然后”:先做第 1 步,然后做第 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"

这种管道在 Base R 的 v4.1.0 版本中被加入,你可能还会看到它被称为“原生管道”。

RStudio 编辑器(以及其他一些编辑器)使用 Ctrl-Shift-M 作为键盘快捷键,插入一个带合适空格的管道。

apply 函数

我们之前看到,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 概念的学生可能会注意到,我们也可以把标量 switch 换成 dplyr 库中向量化的 recode_values()。向量化是好事,而现代 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 框架。

第一个实参照例是输入数据。第二个实参是一个接收 2 个实参的函数:通常是匿名函数,但也可能简单到像 + 这样的算术运算符。

# 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,全部免费。