R中对大数据集的有用优化是什么?

16
I built a script that works great with small data sets (<1 M rows) and performs very poorly with large datasets. I've heard of data table as being more performant than tibbles. I'm interested to know about other speed optimizations in addition to learn about data tables.
I'll share a couple of commands in the script for examples. In each of the examples, the datasets are 10 to 15 million rows and 10 to 15 columns.
1. 获取由九个变量分组的数据框的最低日期
      dataframe %>% 
      group_by(key_a, key_b, key_c,
               key_d, key_e, key_f,
               key_g, key_h, key_i) %>%
      summarize(min_date = min(date)) %>% 
      ungroup()

在两个数据帧上执行左连接以添加额外列。
      merge(dataframe, 
          dataframe_two, 
          by = c("key_a", "key_b", "key_c",
               "key_d", "key_e", "key_f",
               "key_g", "key_h", "key_i"),
          all.x = T) %>% 
      as_tibble()
  • 最接近的日期上将两个数据框连接起来
  •       dataframe %>%
          left_join(dataframe_two, 
                      by = "key_a") %>%
          group_by(key_a, date.x) %>%
          summarise(key_z = key_z[which.min(abs(date.x - date.y))]) %>%
          arrange(date.x) %>%
          rename(day = date.x)
    

    我可以应用哪些最佳实践,特别是针对大型数据集,我可以做什么来优化这些类型的函数?

    --

    这是一个示例数据集。
    set.seed(1010)
    library("conflicted")
    conflict_prefer("days", "lubridate")
    bigint <- rep(
      sample(1238794320934:19082323109, 1*10^7)
    )
    
    key_a <-
      rep(c("green", "blue", "orange"), 1*10^7/2)
    
    key_b <-
      rep(c("yellow", "purple", "red"), 1*10^7/2)
    
    key_c <-
      rep(c("hazel", "pink", "lilac"), 1*10^7/2)
    
    key_d <-
      rep(c("A", "B", "C"), 1*10^7/2)
    
    key_e <-
      rep(c("D", "E", "F", "G", "H", "I"), 1*10^7/5)
    
    key_f <-
      rep(c("Z", "M", "Q", "T", "X", "B"), 1*10^7/5)
    
    key_g <-
      rep(c("Z", "M", "Q", "T", "X", "B"), 1*10^7/5)
    
    key_h <-
      rep(c("tree", "plant", "animal", "forest"), 1*10^7/3)
    
    key_i <-
      rep(c("up", "up", "left", "left", "right", "right"), 1*10^7/5)
    
    sequence <- 
      seq(ymd("2010-01-01"), ymd("2020-01-01"), by = "1 day")
    
    date_sequence <-
      rep(sequence, 1*10^7/(length(sequence) - 1))
    
    dataframe <-
      data.frame(
        bigint,
        date = date_sequence[1:(1*10^7)],
        key_a = key_a[1:(1*10^7)],
        key_b = key_b[1:(1*10^7)],
        key_c = key_c[1:(1*10^7)],
        key_d = key_d[1:(1*10^7)],
        key_e = key_e[1:(1*10^7)],
        key_f = key_f[1:(1*10^7)],
        key_g = key_g[1:(1*10^7)],
        key_h = key_h[1:(1*10^7)],
        key_i = key_i[1:(1*10^7)]
      )
    
    dataframe_two <-
      dataframe %>%
          mutate(date_sequence = ymd(date_sequence) + days(1))
    
    sequence_sixdays <-
      seq(ymd("2010-01-01"), ymd("2020-01-01"), by = "6 days")
    
    date_sequence <-
      rep(sequence_sixdays, 3*10^6/(length(sequence_sixdays) - 1))
    
    key_z <-
      sample(1:10000000, 3*10^6)
    
    dataframe_three <-
      data.frame(
        key_a = sample(key_a, 3*10^6),
        date = date_sequence[1:(3*10^6)],
        key_z = key_z[1:(3*10^6)]
      )
    

    2
    data.table 可能非常适合您的需求。你可以提供一个创建虚假数据以使用 microbenchmark 进行测试的脚本吗? - Waldi
    1
    请查看 tidyft::parse_fst 函数,该函数用于读取 fst 文件。 - akrun
    2
    是的,但差异似乎并不是很大:https://iyarlin.github.io/2020/05/26/dtplyr_benchmarks/ 正如此链接所解释的那样,您甚至可以通过将“dataframe”强制转换为“data.table”来更快地完成它。 - iago
    3
    Dirk Eddelbuettel整理的这份清单包含了许多用于处理大数据集的工具。链接为https://cran.r-project.org/web/views/HighPerformanceComputing.html - Cauder
    2
    你的示例中应该包括加载 lubridate 包,它使用 ymd 函数。总体上,这个问题可以通过改进使其完全可复制,这对于回答者提供工作代码是有用的。 - jangorecki
    显示剩余3条评论
    3个回答

    11

    我可以采用哪些最佳实践,特别是针对大型数据集,我可以做什么来优化这些类型的函数?

    使用data.table包。

    library(data.table)
    d1 = as.data.table(dataframe)
    d2 = as.data.table(dataframe_two)
    

    1

    按许多列进行分组是data.table非常擅长的事情
    请参见第二幅图底部的条形图,与dplyr spark和其他工具进行比较,以获得这种精确的分组效果
    https://h2oai.github.io/db-benchmark

    by_cols = paste("key", c("a","b","c","d","e","f","g","h","i"), sep="_")
    a1 = d1[, .(min_date = min(date_sequence)), by=by_cols]
    

    注意我将date更改为date_sequence,我认为你的意思是它作为列名

    2

    不清楚您想要合并哪些字段,因为dataframe_two没有指定字段,所以查询无效。
    请澄清

    3

    data.table有一个非常有用的联接类型叫做滚动联接,可以完全满足您的需求

    a3 = d2[d1, on=c("key_a","date_sequence"), roll="nearest"]
    # Error in vecseq(f__, len__, if (allow.cartesian || notjoin || #!anyDuplicated(f__,  : 
    #  Join results in more than 2^31 rows (internal vecseq reached #physical limit). Very likely misspecified join. Check for #duplicate key values in i each of which join to the same group in #x over and over again. If that's ok, try by=.EACHI to run j for #each group to avoid the large allocation. Otherwise, please search #for this error message in the FAQ, Wiki, Stack Overflow and #data.table issue tracker for advice.
    

    结果出现了错误。事实上,错误非常有用。在您的真实数据中,它可能运行得非常完美,因为错误背后的原因(匹配行的基数)可能与生成样本数据的过程有关。要为连接获得良好的虚拟数据非常棘手。 如果您在真实数据上遇到相同的错误,您可能需要审查查询的设计,因为它试图通过执行多对多连接来进行行扩展。即使已经考虑了仅单一的date_sequence身份(考虑到roll)。我认为这种问题对于该数据(严格讲,连接字段的基数)是无效的。您可能需要引入数据质量检查层来确保key_adate_sequence组合中没有重复项。


    2
    这是一篇关于滚动连接如何工作的好文章 https://www.gormanalysis.com/blog/r-data-table-rolling-joins/ - Cauder
    第二点仍需要澄清。您的“merge”调用指定了要连接的列,但这些列在两个表中都不存在,这是无效的用法。如果我知道您想要在哪些列上合并这些表,我可以尝试提供可直接使用的代码。 - jangorecki
    你能否在连接数据表时提及设置键的值? - Cauder
    我可能会使用dput,这样可能更容易。您能否请再试一次,使用library(conflicted)和conflict_prefer(“days”,“lubridate”)?data tables和lubridate都有一个名为'days'的函数,您可以用ymd()包装date_sequence。我将更新描述。 - Cauder
    并没有太大的帮助:dataframe_two <- mutate(dataframe, date_sequence = lubridate::ymd(date_sequence) + lubridate::days(1))给出了相同的错误。 - jangorecki
    显示剩余2条评论

    5

    扩展@jangorecki的答案。

    数据:

    library(lubridate)
    library(dplyr)
    library(conflicted)
    library(data.table)
    
    dataframe = data.frame(bigint,
        date_sequence = date_sequence[1:(1*10^7)],
        key_a = key_a[1:(1*10^7)],
        key_b = key_b[1:(1*10^7)],
        key_c = key_c[1:(1*10^7)],
        key_d = key_d[1:(1*10^7)],
        key_e = key_e[1:(1*10^7)],
        key_f = key_f[1:(1*10^7)],
        key_g = key_g[1:(1*10^7)],
        key_h = key_h[1:(1*10^7)],
        key_i = key_i[1:(1*10^7)])
    
    dataframe_two = dataframe %>% mutate(date_sequence1 = ymd(date_sequence) + days(1))
    
    dataframe_two$date_sequence = NULL
    

    基准测试:

    1.

    st = Sys.time()
    a1 = dataframe %>% 
      group_by(key_a, key_b, key_c,
               key_d, key_e, key_f,
               key_g, key_h, key_i) %>%
      summarize(min_date = min(date_sequence)) %>% ungroup()
    Sys.time() - st
    

    setDT(dataframe)
    by_cols = paste("key", c("a","b","c","d","e","f","g","h","i"), sep="_")
    st = Sys.time()
    a2 = dataframe[, .(min_date = min(date_sequence)), by=by_cols]
    Sys.time() - st
    

    2.

    dplyr

    setDF(dataframe)
    st = Sys.time()
    df3 = merge(dataframe, 
          dataframe_two, 
          by = c("key_a", "key_b", "key_c",
                 "key_d", "key_e", "key_f",
                 "key_g", "key_h", "key_i"),
          all.x = T) %>% as_tibble()
    Sys.time() - st
    # Error in merge.data.frame(dataframe, dataframe_two, by = c("key_a", "key_b",  : 
    #  negative length vectors are not allowed
    

    data.table

    setDT(dataframe)
    setDT(dataframe_two)
    st = Sys.time()
    df3 = merge(dataframe, 
                dataframe_two, 
                by = c("key_a", "key_b", "key_c",
                       "key_d", "key_e", "key_f",
                       "key_g", "key_h", "key_i"),
                all.x = T)
    Sys.time() - st
    # Error in vecseq(f__, len__, if (allow.cartesian || notjoin || !anyDuplicated(f__,  # : 
    #  Join results in more than 2^31 rows (internal vecseq reached physical limit). 
    # Very likely misspecified join. Check for duplicate key values in i each of which 
    # join to the same group in x over and over again. If that's ok, try by=.EACHI to 
    # run j for each group to avoid the large allocation. Otherwise, please search for 
    # this error message in the FAQ, Wiki, Stack Overflow and data.table issue tracker 
    # for advice.
    

    这个错误信息是有帮助的,可以运行以下代码:

    uniqueN(dataframe_two, by = c("key_a", "key_b", "key_c",
                                        "key_d", "key_e", "key_f",
                                        "key_g", "key_h", "key_i"))
    

    提供

    12
    

    当我使用包含大约1000万行和15列的数据集进行工作时,在进行合并之前,我将字符串转换为因子,并从内部连接中看到了性能提升,从约30秒降至10秒。令我惊讶的是,在该特定情况下,setkey()并不像将字符串转换为因子那样有效。

    编辑:有关data.table合并的可重复示例(基于字符列,setkey,字符串转换为因子)

    创建表格:

    x = 1e6
    ids = x:(2*x-1)
    chrs = rep(LETTERS[1:10], x)
    quant_1 = sample(ids, x, replace = T)
    quant_2 = sample(ids, x, replace = T)
    
    ids_c = paste0(chrs, as.character(ids))
    
    dt1 = data.table(unique(ids_c), quant_1)
    dt2 = data.table(unique(ids_c), quant_2)
    

    (i)关于字符列

    system.time({result_chr = merge(dt1, dt2, by = 'V1')})
    #   user  system elapsed 
    #  10.66    5.18   18.64 
    

    (ii) 使用setkey
    system.time(setkey(dt1, V1))
    #   user  system elapsed 
    #   3.37    1.55    5.66 
    system.time(setkey(dt2, V1))
    #   user  system elapsed 
    #   3.42    1.67    5.85  
    system.time({result_setkey = merge(dt1, dt2, by = 'V1')})
    #   user  system elapsed 
    #   0.17    0.00    0.16
    

    (iii) 将字符串转换为因子

    dt3 = data.table(unique(ids_c), quant_1)
    dt4 = data.table(unique(ids_c), quant_2)
    
    system.time({dt3[, V1 := as.factor(V1)]})
    #   user  system elapsed 
    #   8.16    0.00    8.20 
    system.time({dt4[, V1 := as.factor(V1)]})
    #   user  system elapsed 
    #   8.04    0.00    8.06 
    system.time({result_fac = merge(dt3, dt4, by = 'V1')})
    #   user  system elapsed 
    #   0.32    0.01    0.28 
    

    在这种情况下,使用setkey是最快的,总共需要11.67秒。但是,如果数据被摄取时将字符串转换为因子,则无需使用setkey。 示例2:如果您的数据以属性(例如日期)分隔行放在一个文件中,并且您需要首先将它们分开,然后再进行连接操作。
    数据:
    dt5 = data.table(date = '202009', id = unique(ids_c), quant = quant_1)
    dt6 = data.table(date = '202010', id = unique(ids_c), quant = quant_2)
    # Original data comes combined
    dt = rbindlist(list(dt5, dt6))
    

    (i) setkey

    system.time(setkey(dt, id))
    #  user  system elapsed 
    #  5.78    3.39   10.78 
    dt5 = dt[date == '202009']
    dt6 = dt[date == '202010']
    system.time({result_setkey = merge(dt5, dt6, by = 'id')})
    # user  system elapsed 
    # 0.17    0.00    0.17 
    

    (ii) 字符串作为因子。
    dt5 = data.table(date = '202009', id = unique(ids_c), quant = quant_1)
    dt6 = data.table(date = '202010', id = unique(ids_c), quant = quant_2)
    dt = rbindlist(list(dt5, dt6))
    system.time({dt[, id := as.factor(id)]})
    #   user  system elapsed 
    #   8.17    0.00    8.20  
    dt5 = dt[date == '202009']
    dt6 = dt[date == '202010']
    system.time({result_fac = merge(dt5, dt6, by = 'id')})
    #   user  system elapsed 
    #   0.34    0.00    0.33 
    

    在这种情况下,将字符串转换为因子的速度为8.53秒,而不是10.95秒。然而,在创建表之前对键进行洗牌ids_c = sample(ids_c, replace = F)时,setkey执行的速度快2倍。
    此外,请注意,并非data.table中的每个函数都比基本函数组合更快。例如:
    # data.table    
    system.time(uniqueN(ids_c))
    #   user  system elapsed 
    #  10.63    4.21   16.88 
    
    # base R
    system.time(length(unique(ids_c)))
    #   user  system elapsed 
    #   0.78    0.08    0.94 
    

    请注意,uniqueN()消耗的内存少4倍,因此如果RAM大小受限,则最好使用它。我使用了profvis包来制作这个火焰图(与上面不同的运行): flame graph

    如果处理的数据集大于RAM,请查看disk.frame


    3
    默认情况下,R处理的是内存中的数据。当数据变得非常大时,R可能会抛出“内存不足”的错误,或者根据您的设置使用页面文件(参见此处),但页面文件很慢,因为它涉及到读写磁盘。

    1. 分批处理

    从计算角度来看,您可以通过将处理分批来提高效率。例如,您的示例包括对数据集进行汇总,因此您的汇总数据集显然比输入数据集小得多(如果不是这样,值得考虑采用其他方法来生成相同的最终数据集)。这意味着可以按组合变量进行分批处理。
    我通常通过取数值索引的模数来实现这一点:
    num_batches = 50
    output = list()
    
    for(i in 0:(num_batches-1)){
      subset = df %>% filter(numeric_key %% num_batches == i)
    
      this_summary = subset %>%
        group_by(numeric_key, other_keys) %>%
        summarise(result = min(col)
    
      output[[i]] = this_summary
    }
    final_output = bind_rows(output)
    

    您可以开发类似的方法来处理基于文本的键。

    2. 减小数据大小

    存储文本需要比存储数字数据更多的内存。在这里的一个简单选项是使用数字代码替换字符串,或将字符串存储为因子。这将使用较少的内存,因此计算机在进行分组/连接时需要读取的信息也较少。

    请注意,根据您的R版本,stringsAsFactors可能默认为TRUEFALSE。因此最好明确设置它。(在此讨论

    3. 移至磁盘

    超过一定大小后,值得将数据存储在磁盘上,并让R管理从磁盘读取和写入。这是几个现有R包背后的想法之一,包括bigmemoryff 和 ffbase,以及一系列并行化包

    除了依赖R外,您还可以将任务推送到数据库。虽然数据库永远不会像内存数据一样快速,但它们是设计用于处理大量数据的。PostgreSQL是免费和开源的(在此处找到入门指南),并且您可以在与R相同的机器上运行它 - 它不必是专用服务器。R还具有专门针对PostgreSQL的软件包(RPostgreSQL)。还有几个其他用于与数据库交互的软件包,包括dbplyr、DBI、RODBC等,如果您想要其他选项。

    虽然设置数据库有一些开销,但dplyr和dbplyr将为您将R代码转换为SQL,因此您无需学习新语言。缺点是您仅限于核心dplyr命令,因为从R到SQL的翻译仅定义了标准过程。


    我能否在我的R实例或终端中启动PostgreSQL数据库? - Cauder
    R可以将命令传递给cmd提示符,几乎任何您可以通过鼠标和键盘交互式完成的操作都可以从终端完成。因此,如果您非常确定,我相信您会找到一种方法。但是我没有办法做到这一点,我在网上找到的教程涉及到R之外的一些设置。请注意,一旦在计算机上设置了数据库,您就可以从R中访问它并将数据加载到其中。 - Simon.S.A.
    2
    对于您提到的第二点,R使用全局字符串池,因此将字符串存储为因子不应带来任何额外的好处。 - Alexlok
    @Alexlok提出了一个很好的观点,如果在R内部工作,那么无论是读取/写入磁盘还是数据库,这仍然值得考虑。 - Simon.S.A.
    除了@Alexlok所说的,一般来说,在R中使用因子相对于字符向量来说内存效率较低。这也是为什么在R-4.0.0中将stringAsFactors默认值更改为FALSE的主要原因之一。 - Oliver
    如果正确处理因子,它们可能会更有效率。R的变化是出于用户方便而非效率考虑。我同意Alexlok关于全局缓存的观点,因此通过“无重复字符条目”来节省内存并不可行,但因子是int(4位)而char是8位,所以低级别代码可以使用它,在data.table中的许多地方也确实如此。除非它是高基数变量,否则仍会将大部分条目保留在属性中。 - jangorecki

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接