更快的读取固定宽度文件的方法

54

我使用许多固定宽度的文件(即没有分隔符)需要在 R 中读取。因此,通常需要定义列宽以将字符串解析为变量。我可以使用 read.fwf 无问题地读取数据。但是对于大型文件,这可能需要很长时间。对于最近的一个数据集,读取包含约 500,000 行和 143 个变量的数据集花费了 800 秒钟。

seer9 <- read.fwf("~/data/rawdata.txt", 
  widths = cols,
  header = FALSE,
  buffersize = 250000,
  colClasses = "character",
  stringsAsFactors = FALSE))

data.table包中的fread在解决大多数数据读取问题时非常棒,但是它无法解析固定宽度文件。但是,我可以将每行作为单个字符字符串(约500,000行,1列)读入。这只需要3-5秒钟。(我喜欢data.table。)

seer9 <- fread("~/data/rawdata.txt", colClasses = "character",
               sep = "\n", header = FALSE, verbose = TRUE)

在SO上有很多关于如何解析文本文件的好帖子。请参见JHoward的建议here,创建一个起始和结束列的矩阵,并使用substr解析数据。请参见GSee的建议here,使用strsplit。我无法弄清楚如何将其与此数据配合使用。(此外,Michael Smith在data.table邮件列表中提出了一些涉及sed的建议,这超出了我的能力implement)。现在,使用freadsubstr(),我可以在大约25-30秒内完成整个过程。请注意,强制转换为data.table需要一定的时间(5秒?)。

end_col <- cumsum(cols)
start_col <- end_col - cols + 1
start_end <- cbind(start_col, end_col) # matrix of start and end positions
text <- lapply(seer9, function(x) {
        apply(start_end, 1, function(y) substr(x, y[1], y[2])) 
        })
dt <- data.table(text$V1)
setnames(dt, old = 1:ncol(dt), new = seervars)

我想知道这个能不能进一步改善?我知道不仅仅是我需要读取固定宽度的文件,如果能让它更快,就可以容忍读入更大的文件(有数百万行)。我尝试使用parallelmclapply以及data.table代替lapply,但并没有改变什么。(可能是由于我在R上的经验不足。)我想象中,可以编写一个Rcpp函数来实现真正快速的操作,但这超出了我的技能范围。而且,我可能没有正确地使用lapply和apply。

我的data.table实现(使用magrittr管道)时间相同:

text <- seer9[ , apply(start_end, 1, function(y) substr(V1, y[1], y[2]))] %>% 
  data.table(.)

有人能提出改进这个代码速度的建议吗?或者说,这已经是最好的了吗?

以下是在R中创建类似数据表的代码(而不是链接到实际数据)。它应该有331个字符和500,000行。其中包含一些空格以模拟数据中的缺失字段,但这不是基于空格分隔的数据。(我正在读取原始SEER数据,如果有人感兴趣的话)同时包含列宽(cols)和变量名(seervars),以便帮助其他人。这些是SEER数据的实际列和变量定义。

seer9 <-
  data.table(rep((paste0(paste0(letters, 1000:1054, " ", collapse = ""), " ")),
                 500000))

cols = c(8,10,1,2,1,1,1,3,4,3,2,2,4,4,1,4,1,4,1,1,1,1,3,2,2,1,2,2,13,2,4,1,1,1,1,3,3,3,2,3,3,3,3,3,3,3,2,2,2,2,1,1,1,1,1,6,6,6,2,1,1,2,1,1,1,1,1,2,2,1,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,7,5,4,10,3,3,2,2,2,3,1,1,1,1,2,2,1,1,2,1,9,5,5,1,1,1,2,2,1,1,1,1,1,1,1,1,2,3,3,3,3,3,3,1,4,1,4,1,1,3,3,3,3,2,2,2,2)
seervars <- c("CASENUM", "REG", "MAR_STAT", "RACE", "ORIGIN", "NHIA", "SEX", "AGE_DX", "YR_BRTH", "PLC_BRTH", "SEQ_NUM", "DATE_mo", "DATE_yr", "SITEO2V", "LATERAL", "HISTO2V", "BEHO2V", "HISTO3V", "BEHO3V", "GRADE", "DX_CONF", "REPT_SRC", "EOD10_SZ", "EOD10_EX", "EOD10_PE", "EOD10_ND", "EOD10_PN", "EOD10_NE", "EOD13", "EOD2", "EOD4", "EODCODE", "TUMOR_1V", "TUMOR_2V", "TUMOR_3V", "CS_SIZE", "CS_EXT", "CS_NODE", "CS_METS", "CS_SSF1", "CS_SSF2", "CS_SSF3", "CS_SSF4", "CS_SSF5", "CS_SSF6", "CS_SSF25", "D_AJCC_T", "D_AJCC_N", "D_AJCC_M", "D_AJCC_S", "D_SSG77", "D_SSG00", "D_AJCC_F", "D_SSG77F", "D_SSG00F", "CSV_ORG", "CSV_DER", "CSV_CUR", "SURGPRIM", "SCOPE", "SURGOTH", "SURGNODE", "RECONST", "NO_SURG", "RADIATN", "RAD_BRN", "RAD_SURG", "SS_SURG", "SRPRIM02", "SCOPE02", "SRGOTH02", "REC_NO", "O_SITAGE", "O_SEQCON", "O_SEQLAT", "O_SURCON", "O_SITTYP", "H_BENIGN", "O_RPTSRC", "O_DFSITE", "O_LEUKDX", "O_SITBEH", "O_EODDT", "O_SITEOD", "O_SITMOR", "TYPEFUP", "AGE_REC", "SITERWHO", "ICDOTO9V", "ICDOT10V", "ICCC3WHO", "ICCC3XWHO", "BEHANAL", "HISTREC", "BRAINREC", "CS0204SCHEMA", "RAC_RECA", "RAC_RECY", "NHIAREC", "HST_STGA", "AJCC_STG", "AJ_3SEER", "SSG77", "SSG2000", "NUMPRIMS", "FIRSTPRM", "STCOUNTY", "ICD_5DIG", "CODKM", "STAT_REC", "IHS", "HIST_SSG_2000", "AYA_RECODE", "LYMPHOMA_RECODE", "DTH_CLASS", "O_DTH_CLASS", "EXTEVAL", "NODEEVAL", "METSEVAL", "INTPRIM", "ERSTATUS", "PRSTATUS", "CSSCHEMA", "CS_SSF8", "CS_SSF10", "CS_SSF11", "CS_SSF13", "CS_SSF15", "CS_SSF16", "VASINV", "SRV_TIME_MON", "SRV_TIME_MON_FLAG", "SRV_TIME_MON_PA", "SRV_TIME_MON_FLAG_PA", "INSREC_PUB", "DAJCC7T", "DAJCC7N", "DAJCC7M", "DAJCC7STG", "ADJTM_6VALUE", "ADJNM_6VALUE", "ADJM_6VALUE", "ADJAJCCSTG")

更新: LaF从原始的.txt文件中读取所有内容只用了不到7秒钟。也许还有更快的方法,但我怀疑任何东西都不能做得更好了。这个软件包真是太神奇了。

2015年7月27日更新 只想提供一个小小的更新。我使用了新的readr包,并且使用readr::read_fwf在5秒钟内读入了整个文件。

seer9_readr <- read_fwf("path_to_data/COLRECT.TXT",
  col_positions = fwf_widths(cols))

此外,更新后的stringi::stri_sub函数至少比base::substr()快两倍。因此,在上面的代码中,使用fread读取文件(约4秒),然后使用apply解析每一行,使用stringi::stri_sub提取143个变量只需要大约8秒,而对于base::substr则需要19秒。因此,fread加上stri_sub仍然只需要大约12秒才能运行。不错。

seer9 <-  fread("path_to_data/COLRECT.TXT",     
  colClasses = "character", 
  sep = "\n", 
  header = FALSE)
text <- seer9[ , apply(start_end, 1, function(y) substr(V1, y[1], y[2]))] %>% 
  data.table(.)

2015年12月10日更新:

请查看下面由@MichaelChirico提供的答案,他添加了一些很棒的基准测试和iotools软件包。


并行读取文件不会有所帮助。瓶颈在于文件IO。(当然,除非数据分布在多台机器/硬盘上。) - Jan van der Laan
@JanvanderLaan,他可以使用fread()在5秒内将所有数据读入RAM中。我认为问题在于如何并行解析这500k个字符串。 - bdemarest
@bdemarest 是的,你说得对。对于使用 freadsubstr 的代码,子字符串的解析确实是瓶颈,这可以并行处理。 - Jan van der Laan
4个回答

42
现在,针对读取固定宽度文件的问题(介于此和关于有效读取固定宽度文件的其他主要问题之间),已经有相当多的选项可供选择,我认为进行一些基准测试是合适的。 我将使用以下大文件(400 MB)进行比较。 它只是一堆随机字符,具有随机定义的字段和宽度:
set.seed(21394)
wwidth = 400L
rrows = 1000000
    
#creating the contents at random
contents = write.table(
  replicate(
    rrows,
    paste0(sample(letters, wwidth, replace = TRUE), collapse = "")
  ),
  file = "testfwf.txt",
  quote = FALSE, row.names = FALSE, col.names = FALSE
)
    
#defining the fields & writing a dictionary
n_fields = 40L
endpoints = unique(
  c(1L, sort(sample(wwidth, n_fields - 1L)), wwidth + 1L)
)
cols = list(
  beg = endpoints[-(n_fields + 1L)], 
  end = endpoints[-1L] - 1L
)
    
dict = data.frame(
  column = paste0("V", seq_len(length(endpoints)) - 1L)),
  start = endpoints[-length(endpoints)] - 1,
  length = diff(endpoints)
)
    
write.csv(dict, file = "testdic.csv", quote = FALSE, row.names = FALSE)
我会比较这两个主题中提到的五种方法(如果作者愿意,我会添加一些其他的):基本版本(read.fwf)、将 in2csv 的结果导入 fread(@AnandaMahto的建议)、Hadley's新的 readrread_fwf)、使用 LaF/ffbase(@jwijffls的建议),以及该问题作者提出的改进(简化)版,结合了来自 stringistri_subfread。(@MarkDanese的建议)。 这是基准测试代码:
library(data.table)
library(stringi)
library(readr)
library(LaF)
library(ffbase)
library(microbenchmark)
    
microbenchmark(
  times = 5L,
  utils = read.fwf("testfwf.txt", diff(endpoints), header = FALSE),
  in2csv = fread(cmd = sprintf(
    "in2csv -f fixed -s %s %s",
    "testdic.csv", "testfwf.txt"
  )),
  readr = read_fwf("testfwf.txt", fwf_widths(diff(endpoints))),
  LaF = {
    my.data.laf = laf_open_fwf(
      'testfwf.txt', 
      column_widths = diff(endpoints),
      column_types = rep("character", length(endpoints) - 1L)
    )
    my.data = laf_to_ffdf(my.data.laf, nrows = rrows)
    as.data.frame(my.data)
  },
  fread = {
    DT = fread("testfwf.txt", header = FALSE, sep = "\n")
    DT[ , lapply(seq_len(length(cols$beg)), function(ii) {
      stri_sub(V1, cols$beg[ii], cols$end[ii])
    })]
  }
)

输出结果为:

# Unit: seconds
#    expr       min        lq      mean    median        uq       max neval cld
#   utils 423.76786 465.39212 499.00109 501.87568 543.12382 560.84598     5   c
#  in2csv  67.74065  68.56549  69.60069  70.11774  70.18746  71.39210     5 a  
#   readr  10.57945  11.32205  15.70224  14.89057  19.54617  22.17298     5 a  
#     LaF 207.56267 236.39389 239.45985 237.96155 238.28316 277.09798     5  b 
#   fread  14.42617  15.44693  26.09877  15.76016  20.45481  64.40581     5 a  

看起来readrfread+ stri_sub在速度方面相当竞争力,而内置的read.fwf则是明显的输家。

请注意,readr真正的优势在于您可以预先指定列类型;而对于fread,您将不得不在之后进行类型转换。

编辑:添加一些替代方案

根据@AnandaMahto的建议,我包括了一些更多的选项,其中一个似乎是新的赢家!为了节省时间,在新的比较中排除了最慢的选项。以下是新的代码:

library(iotools)
    
microbenchmark(
  times = 5L,
  readr = read_fwf("testfwf.txt", fwf_widths(diff(endpoints))),
  fread = {
    DT = fread("testfwf.txt", header = FALSE, sep = "\n")
    DT[ , lapply(seq_len(length(cols$beg)), function(ii) {
      stri_sub(V1, cols$beg[ii], cols$end[ii])
    })]
  },
  iotools = input.file(
    "testfwf.txt", formatter = dstrfw, 
    col_types = rep("character", length(endpoints) - 1L), 
    widths = diff(endpoints)
  ),
  awk = fread(header = FALSE, cmd = sprintf(
    "awk -v FIELDWIDTHS='%s' -v OFS=', ' '{$1=$1 \"\"; print}' < testfwf.txt",
    paste(diff(endpoints), collapse = " ")
  ))
)

并且新的输出:

# Unit: seconds
#     expr       min        lq      mean    median        uq       max neval cld
#    readr  7.892527  8.016857 10.293371  9.527409  9.807145 16.222916     5  a 
#    fread  9.652377  9.696135  9.796438  9.712686  9.807830 10.113160     5  a 
#  iotools  5.900362  7.591847  7.438049  7.799729  7.845727  8.052579     5  a 
#      awk 14.440489 14.457329 14.637879 14.472836 14.666587 15.152156     5   b

看起来 iotools 既非常快又非常一致。


2
基准测试非常有用。在另一个问题的评论中,我建议尝试“iotools”包。您能否在基准测试中包括它,以及“awk”解决方案?我猜“awk”方法比“in2csv”更快,但比“fread”/“readr”慢,并且根据我的“iotools”经验,如果迄今为止可用的选项比那更快,我不会感到惊讶。尚未测试,但方法应该类似于:library(iotools); input.file("testfwf.txt", formatter = dstrfw, col_types = rep("character", length(col_ends)-1), widths = diff(col_ends))。 (+1) - A5C1D2H2I1M1N2O1R2T1
哦,针对“sqldf”出现的错误(我不会费力测试其速度比较),很可能是因为我们需要指定相当于“header = FALSE”的等价物。此刻真的没有时间去探索... - A5C1D2H2I1M1N2O1R2T1
您IP地址为143.198.54.68,由于运营成本限制,当前对于免费用户的使用频率限制为每个IP每72小时10次对话,如需解除限制,请点击左下角设置图标按钮(手机用户先点击左上角菜单按钮)。 - Mark Danese
但是让我困扰的是,在“input.file”中没有设置输入文件的编码选项。 - Martin Schmelzer
我一直回到这个帖子,试图将data.table代码调整为根据导入字典(varnemes,start,end)也分配变量名。 我在此SO问题中介绍了这一点。 如果您的情况是如此,请检查那里的解决方案。 - LucasMation

30

您可以使用LaF包,该包是为处理大型固定宽度文件(也太大而无法放入内存)而编写的。要使用它,您首先需要使用laf_open_fwf打开文件。然后您可以像读取普通数据帧一样对结果对象进行索引以读取所需的数据。在下面的示例中,我读取了整个文件,但您也可以读取特定的列和/或行:

library(LaF)
laf <- laf_open_fwf("foo.dat", column_widths = cols, 
  column_types=rep("character", length(cols)),
  column_names = seervars)
seer9 <- laf[,]

你的示例使用5000行(而不是50万行),使用read.fwf花费了28秒,而使用LaF只花费了1.6秒。

附加内容 在我的电脑上,你的示例使用50000行(而不是50万行),使用read.fwf花费了258秒,而使用LaF只花费了7秒。


1
我之前不知道这个包。哇,只用了6秒钟,非常出色。速度几乎和读取CSV文件的fread一样快,这非常令人印象深刻。由于我们有一些大型数据集,所以会进一步研究这个包。谢谢。 - Mark Danese

3
我不确定你使用的是什么操作系统,但在Linux中这对我来说非常简单: 步骤1:创建一个命令用于将文件转换为csv格式,可以将其存储到实际的csv文件中,如果您计划在其他软件中使用数据。
myCommand <- paste(
  "awk -v FIELDWIDTHS='", 
  paste(cols, collapse = " "), 
  "' -v OFS=',' '{$1=$1 \"\"; print}' < ~/rawdata.txt", 
  collapse = " ")

步骤2: 直接在刚刚创建的命令上使用fread

seer9 <- fread(myCommand)

我没有计时,因为我显然使用比您和Jan慢的系统 :-)

非常感谢。我希望有人能建议类似的东西。我尝试了一下,但返回了一个错误。Error in fread(myCommand) : ' ends field 14 on line 26 when detecting types: 428135680000001527 . . . 我无法粘贴整个331个字符的字符串。不确定问题出在哪里。这是OSX(Mavericks)。现在我应该强制所有内容为char类型。 - Mark Danese
我尝试将所有内容强制转换为字符。但问题是freed只检测到了15列,而不是143列。这是我编辑过的命令版本,删除了许多列值以适应此评论:"awk -v FIELDWIDTHS=' 8 10 1 2 1 1 1 3 4 3 2 2 4 4 1 4 1 4 1 1 1 1 3 2 2 1 2 2 13 2 4 1 1 ' -v OFS=',' '{$1=$1 \"\"; print}' < ~/file.TXT" - Mark Danese

2
我昨天写了一个解析器来处理这种东西,但它只适用于头文件的特定输入类型,所以我将向您展示如何格式化您的列宽以便使用它。
将您的平面文件转换为csv
首先下载相关工具
如果您使用的是OS X Mavericks(我在此编译),则可以从bin目录下载二进制文件,或者通过转到src并使用clang++ csv_iterator.cpp parse.cpp main.cpp -o flatfileparser进行编译。
平面文件解析器需要两个文件,一个CSV头文件,其中每五个元素指定变量宽度(再次提醒,这是由于我的极其特定的应用程序),您可以使用以下方法生成:
cols = c(8,10,1,2,1,1,1,3,4,3,2,2,4,4,1,4,1,4,1,1,1,1,3,2,2,1,2,2,13,2,4,1,1,1,1,3,3,3,2,3,3,3,3,3,3,3,2,2,2,2,1,1,1,1,1,6,6,6,2,1,1,2,1,1,1,1,1,2,2,1,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,7,5,4,10,3,3,2,2,2,3,1,1,1,1,2,2,1,1,2,1,9,5,5,1,1,1,2,2,1,1,1,1,1,1,1,1,2,3,3,3,3,3,3,1,4,1,4,1,1,3,3,3,3,2,2,2,2)
writeLines(sapply(c(-1, cols), function(x) paste0(',,,,', x)), '~/tmp/header.csv')

将生成的~/tmp/header.csv复制到与您的flatfileparser相同的目录中。同时将平面文件移动到同一目录中,然后您就可以在该文件上运行它了。
./flatfileparser header.csv yourflatfile

这将生成yourflatfile.csv。使用管道(从Bash中的>>)手动添加你上面的标题。

快速读取CSV文件

通过将文件名传递给fastread::read_csv,使用Hadley的实验性fastread包快速读取CSV文件,它会产生一个data.frame。我认为他还不支持fwf文件,但这也在路上了。


我似乎无法让它工作。我不是一个命令行的人,所以可能只是我做错了什么。在Mavericks上,mark-mbp-osx:bin mark$ flatfileparser header.csv COLRECT.TXT 给了我 -bash: flatfileparser: command not found。这是目录的列表:mark-mbp-osx:bin mark$ ls COLRECT.TXT flatfileparser header.csv - Mark Danese
尝试运行 chmod +x flatfileparser; ./flatfileparser header.csv COLRECT.TXT - Robert Krzyzanowski
尽管出现错误,但似乎已经起作用了:mark-mbp-osx:bin mark$ chmod +x flatfileparserchmod +x flatfileparser; ./flatfileparser header.csv COLRECT.TXT chmod: flatfileparserchmod: 没有那个文件或目录 chmod: +x: 没有那个文件或目录 mark-mbp-osx:bin mark$ - Mark Danese
我认为您将字符串“chmod +x flatfileparser”复制了两次。请尝试两个单独的命令:首先是chmod +x flatfileparser,然后是./flatfileparser header.csv COLRECT.TXT - Robert Krzyzanowski
我的错,我在SO上粘贴了两次。结果我得到了144列而不是143列。看起来它运行良好,所以谢谢。我不确定我能否经常使用它或在我们的Windows服务器上使用它。如果能够轻松从R中访问它就太好了。我只是不是一个真正的程序员。 - Mark Danese

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