管道时代

谢益辉 2017-07-04

前些天 COS 八卦群里有一位曾经跟我出生入死1的战友问了个问题,说如何用 dplyr 去掉如下数据中重复 Team 并且 Mangager = "B" 的行。

Team Manager
a A
a B
b A
b B
c A
d B
e A
e B

伊尝试但失败的方法是:

group_by(Team) %>%
  filter(!(n_distinct(Team)>=2 & Manager == "B"))

我一看,肿么又非得用 deep liar 不可,这两个逻辑条件非常容易表达,于是操起键盘甩了一个非管道方案:

subset(df, !(duplicated(Team) & Manager == "B"))

然后我感觉伊很失望,大约是因为没见着 dplyr 函数和管道,于是嘤嘤嘤。其实硬要管道的话,也许可以这样:

df %>% subset(!(duplicated(Team) & Manager == "B"))

我说现在流行杀鸡用牛刀,伊很不开森,觉得我乱给人贴标签。我的意思只是不必在一棵树上吊死:如果有一棵现成的更方便的树可以吊死,那赶紧上就好了。R 的基础函数是有各种问题,但也不是那么不堪呀。新语法因为是后起之秀,可以充分借鉴前人的失败经验, 而且是有且仅有一个主心骨(不像 R 核心团队成员各有各的脾性),所以通常容易做到一致性更好,语法给人感受更统一,但我感觉新语法发明的概念和名词实在太多了。当然,我自己不做数据分析,对 dplyr 没有什么刻骨铭心的体会,所以谈这个并没有底气。起码这个 n_distinct() 的命名对我来说就很糟糕,它的意思是 length(unique()),何不命名为 len_uniq() 呢。感觉有时候就像叛逆期的少年一样,总是要跟基础 R 函数对着干,你怎么说,我就非得换个相同意思但不同词语的说法(你 unique 我就非得 distinct)。

前面我说“非得用”,这个“又”字的原因是我之前见过几个别的例子,也是一股脑堆管道和靓丽的新动词,但我不太理解使用它们的必要性。比如有人想生成特定宽度的字母组合,这函数源代码可点评之处太多了,但我现在去点评它并不公平,因为我做开发的时间比伊长很多,已经从各种坑里爬过。我只评论一下跟本文主题相关的部分:

  1. dplyr::as_data_frame() 几乎毫无意义。tibble 数据的最大好处在于显示数据列的格式以及默认只打印前 10 行。在这个例子里,tibble 没什么意义,因为这个函数理想返回格式是字符向量,而不是数据框。作者的原始动机是想生成一些不重复的字符串作为某些数据的行名或者列名。行列名哪有用数据框的嘛,必须是字符向量啊。

  2. tidyr::unite() 又是牛刀杀鸡。明明一个基础 R 函数 paste() 函数就可以解决的问题,却得引入两个新概念:word(列名)和 1:nout 的列序号)。

说到底,这函数要干啥呢?给你一个宽度 n 和一些单个字符,让你生成所有的字符组合,每个组合的字符串中的字符数是 n。如果让我来写这个函数,我会这样两行搞定(我把参数 n 换成了意思更确切的 width):

bind_chars = function(width, chars = LETTERS) {
  cols = expand.grid(rep(list(chars), width))
  do.call(paste, c(list(sep = ''), cols))
}

这里的 do.call() 涉及到黑魔法,初学者可能不容易懂(tidyr::unite() 函数的主要贡献便是抽象了这个黑魔法)。换作下面的写法应该更明了:

  apply(cols, 1, paste, collapse = '')

但这样写的问题是牺牲了性能2,运行速度慢一些,究竟慢多少取决于字符串宽度是多少,有可能是 0.05 秒跟 0.01 秒的区别,也可能是 10 分钟跟 1 分钟的区别。apply() 的本质是笨笨的显式循环,它肯定是快不了的,要是有人告诉你说 apply() 快,伊不是笨就是在撒谎3。因为 paste() 有点点点参数,点点点里面可以接收一个个竖着的向量,把它们横着拼接起来(比如 paste(x, y, z)),这个操作是底层向量化过的,所以要是你传一大串向量给它,它可以很快速地拼接字符串。上面 do.call() 所做的工作便是把矩阵 cols 的每一列作为一个单独的参数传递给 paste(),另外再传一个参数 sep = ''paste(),好让向量之间用空字符串连接。记住下面的规则应该就明了了:

do.call(FUN, list(x, y, z)) 等价于 FUN(x, y, z)

do.call(FUN, list(a = x, y, z)) 等价于 FUN(a = x, y, z)

这魔法之所以有点黑,主要在点点点参数上,初学者可能不太容易理解这个特殊的参数,另外还得理解 R 的数据框本质上就是列表。

我这里举的都是个例,各位客官莫要误会,我个人完全相信极乐净土(tidyverse)的革命性意义,它大大解放了数据分析工作者的生产力,但我还是想说,任何工具都有适用条件,用你自己觉得顺手并且高效的工具就好,不必硬套。我是不幸学 R 太早才会沦落成这样,凡事先想想基础 R 函数有没有方便的解决方案,实在没有,才会去寻求别的包。现今这世道的新名词多得我有点消化不了。如果是新手上路,我还是同意 David Robinson 的观点:先上管道,等能把数据玩得飞起了再学基础 R 函数和语法;尽管天下的计算机语言的教学几乎一定是从数据结构和控制流开始,但 R 作为数据分析语言,完全可以摒弃这个方式,从数据的语言开始(tidyverse 是 R 历史上首次出现的一门比较规整的方言),而不是先来选择分支和循环。

最后留一道思考题,我觉得还挺有意义的:洒家的 bind_chars() 函数跟原作者的 n_letter_words() 函数相比,对用户来说有木有什么便利之处?


  1. 好吧,我承认是坐过山车。 ↩︎

  2. 其实要真追求性能,还有一些可改进的地方,比如修改臭名昭著的 stringsAsFactors 参数:用 expand.grid(rep(list(chars), width), stringsAsFactors = FALSE) 大概还可以再快一倍。 ↩︎

  3. 2012 年我在 AT&T 实验室实习的时候,有一天中午吃饭听见旁边一群印度小哥在那儿激动地谈论 apply() 函数比 for 循环快多少,我心里暗自好笑。 ↩︎