相关文章推荐
重情义的毛巾  ·  module 'win32gui' has ...·  1 月前    · 
听话的煎饼  ·  【Windows】Windows ...·  5 月前    · 
玩足球的斑马  ·  LdapConnection Class ...·  10 月前    · 
文武双全的罐头  ·  C++ static、const 和 ...·  1 年前    · 
酒量小的骆驼  ·  mysql ...·  1 年前    · 
首发于 Data Analysis
R数据处理|data.table篇(三)

R数据处理|data.table篇(三)

本文为data.table包介绍最后一篇,前两篇链接如下

R数据处理|data.table篇(一) - 知乎专栏

R数据处理|data.table篇(二) - 知乎专栏

本文主要讲解data.table包中一些比较不常用的函数,还有data.table包高效的深层原理。下面是本文目录

  • 其他函数
  • 改进了的函数
  • options设置
  • 性能之Secondary indices and auto indexing
  • 性能之fast binary search
  • 浅复制和深复制(shallow vs deep copy)
  • by reference

其他函数

具体举例子讲述以下函数

copy
setnames
setDT  setDF
rleid rowid
tables
tstrsplit

copy 复制一个数据框

name1 <- c("Bob","Mary","Jane","Kim")
name2 <- c("Bob","Mary","Kim","Jane")
weight <- c(60,65,45,55)
height <- c(170,165,140,135)
birth <- c("1990-1","1980-2","1995-5","1996-4")
accept <- c("no","ok","ok","no")
library(data.table)
dft <- data.table(name1,weight,height,accept)
dtt <- copy(dft)

这种复制不同于直接用 <- 赋值,在本专题的后面我会专门讲一下R语言的深复制

setnames 修改列名

setnames(dtt,letters[1:4])
colnames(dtt)<-letters[2:5] # 也可以实现列名的修改
setnames(dtt,"c","C") # 修改特定列名
setnames(dtt,1:2,c("m","n"))

setDF 将data.table转化为data.frame

setDF(dtt) 
class(dtt) # "data.frame"
# setDT 将data.frame转化为data.table
setDT(dtt)
class(dtt) # "data.table" "data.frame"

rleid

# 可以接在by后面,每次连续作为一组
dft = data.table(x=rep(c("b","a","c"),each=3), v=c(1,1,1,2,2,1,1,2,2), y=c(1,3,6), a=1:9, b=9:1)
rleid(dft$v) # 返回一个和原向量等长的向量,值与其一一对应。值从1开始,原向量从头往后看,值不变则仍为1,变一次加1
dft[, .N, by=rleid(v)] # 根据上面形成的向量分组(每次连续相同的值为一组)

rowid

一个组合出现第几次就显示为几

DT = data.table(x=c(20,10,10,30,30,20), y=c("a", "a", "a", "b", "b", "b"), z=1:6)
rowid(DT$x) # 1,1,2,1,2,2
rowidv(DT, cols="x") # 同上
rowid(DT$x, prefix="group") # 数字前面加"group"
# 返回  "group1" "group1" "group2" "group1" "group2" "group2"
rowid(DT$x, DT$y) # 多列组合看重复
# 返回1,1,2,1,2,1
rowidv(DT, cols=c("x","y")) # 同上
DT[, .(N=seq_len(.N)), by=.(x,y)]$N # 上面相当于做了这样的事
dcast(DT, x ~ rowid(x, prefix="group"), value.var="z") # 将x为10的两个z值放在同一行,x为20的放在同一行....

tables

tables() # 返回当前所有的datatable,并展示数据集行列数、大小、列名、key等信息

tstrsplit
看过本专题前面讲dplyr和tidyr包的读者可能还记得tidyr包中的那个将日期拆分成年月日的函数,在data.table包中,我们可以使用一个有趣的字符串处理函数来实现相同的功能

name <- 1:3
dates <- c("2016-3-4","2016-3-14","2016-3-24")
nd <- data.table(name,dates)
strsplit(dates,"-")
tstrsplit(dates,"-") # 好像把strsplit得到的结果转置了一样
nd[,c("year","month","day"):=tstrsplit(dates,"-")] # 实现拆分

改进了的函数

%chin%替代了%in%
fsort替代了sort
chmatch替代了match,两个参数返回和前者等长的向量,是前者每一个元素在后者中的索引
chorder或者chgroup代替order,返回一个向量,排列顺序为:最小值在向量中的索引,第二小的...
duplicated替代duplicated
unique替代unique,另有uniqueN直接计算去重之后的个数

上面改进是功能相同,只是运行速度有所提高。下面列举的函数是不仅在运行速度上,而且在功能上也根据data.table包的特性做了一些增强

集合操作函数

增加了all参数,控制重复值。基础函数只能返回去重之后的结果

函数变化:union intersect setdiff setequal 前面都加了一个f

基础函数作用于两个向量,data.table中函数作用于两个data.table数据框,而且列名需要相同

x <- data.table(a=c(1,2,2,2,3,4,4))
y <- data.table(a=c(2,3,4,4,4,5))
fintersect(x, y)            # 返回相交部分并去重
fintersect(x, y, all=TRUE)  # 相交,保留重复值
fsetdiff(x, y)              # x中有y中没有的,去重
fsetdiff(x, y, all=TRUE)    # 保留重复值
funion(x, y)                # 并集,去重
funion(x, y, all=TRUE)      # 保留重复值
fsetequal(x, y)             # 返回一个F,二者不完全相等

rank

frank比rank函数速度更快,而且增加参数ties.method参数的一种取值”dense”,即当有两个值相等并列第二时,让二者都为2,之后的数排名不是第4,而是3,这样结果数值不会发生跳跃

x = c(2, 1, 4, 5, 3, NA, 4)
frank(x) # 自动将NA当成最大的了
frank(x, na.last=F) # 自动将NA当成最小的
frank(x, na.last="keep") # NA仍然是NA
frank(x, ties.method = "min")
frank(x, ties.method = "dense")
DT = data.table(x, y=c(1, 1, 1, 0, NA, 0, 2))
frank(DT, cols="x")

滞后

shift函数,参数如下

  • n控制变换阶数
  • fill控制填充内容
  • type取"lag"或者"lead",看去除后面的值向后靠(前面添NA),还是去除前面的值向前靠(后面添NA)
y <- x <- 1:5
xy <- data.table(x,y)
shift(x, n=1, fill=NA, type="lag")
shift(x, n=1:2, fill=0, type="lag")
xy[,(c("a","b")):=shift(.SD,1,0,"lead")][] # 添加两列
xy[,shift(.SD,1,0,"lead",give.names = T)][] # 自动生成名字
shift(xy, n=1, fill=0, type="lag", give.names=T) # 生成list

上下合并数据框

使用rbindlist函数,先将数据框转化为list再进行合并

DT1 = data.table(A=1:3,B=letters[1:3])
DT2 = data.table(A=4:5,B=letters[4:5])
DT3 = data.table(B=letters[4:5],A=4:5)
DT4 = data.table(B=letters[4:5],C=factor(1:2))
l1 = list(DT1,DT2)
l2 = list(DT1,DT3)
l3 = list(DT1,DT4)
rbindlist(l1)
rbindlist(l1,idcol=T) # 多出一列,对数据框分组(来自不同数据框)
rbindlist(l2) # 不同列名直接合并
rbindlist(l2,use.names=T) # 将相同列名的合并在一起
rbindlist(l3) # 不同列名直接合并
rbindlist(l3,fill=T) # 选择相同列名合并,不匹配的填入NA

options设置

在控制台中输入options()会打印出一个list,这是当前的options设置值,比如显示保留几位小数等。加载data.table包之后,这里新增了一些data.table专用的参数,可以用下面的命令查看

ops <- options() # ops就是一个list,参数和值的一一对应
# ops$  这样输入在rstudio中就会自动提示后面的参数
# 由于data.table专用参数都是以datatable为前缀,使用我们输入时可以这样
# ops$datatable.  这样输入提示的会都是以datatable为前缀的参数,当然当你打出da的时候就已经差不多全是data.table的参数了
ops$datatable.print.nrows # 查看这个参数,返回100
getOption("datatable.print.topn") # 也可以这样查看,返回5

我们拿打印行数来举例子,看这样两个参数datatable.print.topn和datatable.print.nrows

  • datatable.print.topn 当省略输出时输出几行,默认为5
  • datatable.print.nrows 行数达到多少时开始省略输出
d <- data.table(a=1:200, b=2:201)
d # 200行数据自动只输出前5行和后5行
op <- options(datatable.print.topn=10) # 设置打出前10行和后10行
d # 打出前10行和后10行
options(op) # 恢复默认值5
f <- data.table(a=1:50, b=2:51)
f # 50行全打了出来
op <- options(datatable.print.nrows = 30) # 设置行数超过30行时就省略打出
f # 只打出前5行和后5行
options(op) # 恢复默认值100

下面我们再深入一点讲解options设置的内部运行机制

上面打印的参数设置其实调用了print函数,options里面设置的参数被print函数自动调用

?print.data.table # 可以查看打印data.table的函数的帮助文档,发现函数参数设置如下
print(x,
    topn=getOption("datatable.print.topn"),          # default: 5
    nrows=getOption("datatable.print.nrows"),        # default: 100
    class=getOption("datatable.print.class"),  # default: FALSE
    row.names=getOption("datatable.print.rownames"), # default: TRUE
    quote=FALSE,...)
# 所以我们之前在options里面设置的参数都在这里被调用
# 所以我们也可以直接使用print函数来实现和options设置相同的功能
print(d)
print(d,topn=10)
print(f)
print(f,nrows=30)

性能之Secondary indices and auto indexing

上面我们提到setkey设置键值方便以后提取,但是它会自动按照键将整个数据框排序,这是是非常耗费时间的。我们可以选择用setindex函数省去这部分时间,同时不损失提取效率。

下面我们首先来介绍一下index的创建和查询,以及index和判断提取的关系。

dft <- data.table(name1,weight,height,accept)
setindex(dft, name1) # 设置按照name1列来索引,但不进行排序
names(attributes(dft)) # 多出了属性index
indices(dft) # 查看现有的index,"name1"
setindex(dft,accept) # 增加一个index
indices(dft) # "name1"  "accept"
setindex(dft,NULL) # 去掉index
dft[name1=="Bob"] # 用==判断提取
indices(dft) # 自动生成index为name1
dft[weight==45] # 这样之后就有两个index了
setindex(dft,NULL) # 去掉index
dft[.(60),on="weight"] # 使用on判断提取
indices(dft) # 不会创建index

我们会发现使用==进行提取时就已经自动创建了index,所以一般没有必要提前用setindex去设置

那么创建index有什么好处呢?主要是运行速度上的问题,我们来看一下实例

set.seed(1L)
dt = data.table(x = sample(1e5L, 1e7L, TRUE), y = runif(100L))
print(object.size(dt), units = "Mb") # 114.4 Mb
system.time(ans <- dt[.(988L),on="x"]) # 有一定的时间消耗,多次运行这条命令,实现消耗几乎没有区别
system.time(ans <- dt[x == 989L]) # 时间消耗与使用on基本相同
system.time(ans <- dt[x == 1L]) # 几乎没有时间消耗
system.time(ans <- dt[.(988L),on="x"]) # 这时使用on也不耗费了
system.time(ans <- dt[y == 989L]) # 有较大时间消耗
system.time(ans <- dt[y == 9]) # 几乎没有时间消耗
setindex(dt,NULL)
system.time(ans <- dt[x == 1L]) # 仍有一定的时间消耗
# 看普通数据框
df = data.frame(x = sample(1e5L, 1e7L, TRUE), y = runif(100L))
system.time(ans <- df[df$x == 1L,]) # 时间消耗比较小,但是每次运行时间相同

我们可以看到,使用==提取创建了index耗费了一些时间后,第二次提取就几乎不耗费时间了,而用on提取每次都要创建index。

下面我们来看一下设置index的耗时,和index与key的对比

dt = data.table(x = sample(1e5L, 1e7L, TRUE), y = runif(100L))
head(dt)
system.time(setindex(dt,x)) # 0.28 
setindex(dt,NULL) # 这样删除之后再重新加,时间不变
system.time(setindex(dt,x)) # 0.28 
# setkey
system.time(setkey(dt,x)) # setkey多了排序,时间要长一些,0.72
setkey(dt,NULL)
head(dt) # 即使删除后,依然按照x排序
system.time(setkey(dt,x)) # 因为排序仍然保留,所以再重新加时间缩短了非常多,0.03
system.time(setkey(dt,y)) # 时间还是很多
system.time(setkey(dt,x)) # 因为按y排序,x被打乱了,所以这一次时间也延长了
dt = data.table(x = sample(1e5L, 1e7L, TRUE), y = runif(100L))
system.time(dt[x==2]) # 有一定的时间消耗
setkey(dt,x)
system.time(dt[x==2]) # 几乎不耗费时间
system.time(dt[.(1),on="x"]) # 几乎不耗费时间

总结一下

  • 设置index之后提取速度明显加快的原理是,它将设置的这一列进行了排序,并把结果存储到了index属性之中,日后根据这个新的索引来寻找会快很多。
  • 而设置key则不止将这一列排序,而且把整个数据框都排了个序,因此耗时较长。
  • 无论是设置了index还是key,都可以一次设定,日后提取无忧

我们也可以通过设置options参数来禁止index的使用,主要有两个参数

  • datatable.auto.index 为F时,使用==不会自动创建index
  • datatable.use.index 为F时,即使创建了index,也无法提高提取速度
dt = data.table(x = sample(1e5L, 1e7L, TRUE), y = runif(100L))
op <- options(datatable.auto.index = F) # 使用==时不会自动创建index
system.time(ans <- dt[x == 989L]) # 多次运行,每次消耗时间相同
indices(dt) # NULL
setindex(dt,x)
system.time(ans <- dt[x == 989L]) # 特意设置index还是可以不消耗时间
options(op)op <- options(datatable.use.index = F) # 使用==时不会自动创建index
setindex(dt,x)
system.time(ans <- dt[x == 989L]) # 特意设置index也要消耗时间
indices(dt) # 虽然有index:”x”
options(op)

性能之fast binary search

dt = data.table(x = sample(1e5L, 1e7L, TRUE), y = runif(100L))
system.time(dt[x==1&y==0.5])
indices(dt) # NULL,说明使用&连接两个选择无法创建index
setindex(dt,x,y)
indices(dt) "x__y"
system.time(dt[x==1&y==0.5]) # 速度没有改善,所以说Index应该是只能处理单种选择
# 多种同时选择还是要用key
setkey(dt,x,y)
system.time(dt[.(1,0.5)]) # 几乎不耗时

这里解释一下排序之后提取速度变快的原因

  • 在没有排序的时候,匹配x==1,需要生成nrow个逻辑值,从中挑选出为T的打印出来
  • 排序之后,就可以使用二分法来减少匹配次数,大大提高运算速度
  • 计算复杂度从O(n)变成了O(log n)

浅复制和深复制(shallow vs deep copy)

使用R语言基础函数进行数据处理时,常常默认使用的是深复制的方法,当处理数据集较大时,运行速度就会很慢,data.table在一些地方使用了浅复制,极大提高了运行效率。不过浅复制也会有一些副作用,本节后面会进行介绍。

浅复制和深复制的区别

比如我们要修改一个数据框中某一列的值,用R基础函数的[]处理,其实处理之后得到的数据框已经完全不是最初的数据框本身,它是把原有数据框复制出一个完整的备份,再在这个备份上进行修改,修改的过程中,还可能多次复制,这样的复制不仅极大增加了运行时间,同时也非常消耗内存。这就是所谓的深复制。

而data.table在处理的时候,会使用改变后的新值,而其他没改变的内容还是用原来那些,没有重新复制出来使用。虽然也是一个新的数据框,但是只是新创建了一个指针,指向原有的内容。这样不需要把大量数据全部复制一遍,会大大缩短运行时间,这就是浅复制。

而浅复制有一个弊端,就是新数据框合旧数据框都指向同一个内容,只要在一个数据框中把这个内容改变,另外的数据框也会受到影响。这就是copy函数存在的意义,这样深复制一下可以让两个数据框之间互不影响。下面我们用具体的例子来解释

使用函数来判断数据框的复制

R语言中可以用tracemem函数来跟踪一个变量名指向的地址。地址是变量名指向的内容的存放位置,如果改变数据框时地址发生变化,说明在其他位置复制出了一个一模一样的数据框,新的数据框则使用新产生的那个。因为每次复制数据框,都要分配给它一个新的地址来储存,所以我们可以通过地址变化的次数来反映数据框被复制的次数。

tracemem函数作用在一个变量名上,如果这个变量名指向的地址发生改变,就会print出一条信息。

DF <- data.frame(ID = c("b","b","b","a","a","c"), a = 1:6, b = 7:12, c = 13:18)
# 先测试基础函数的复制情况
tracemem(DF) # 打印出此时地址 "<0000000002F25938>"
DF$c <- 18:13 # 修改数据框,打印出三条更改信息,说明这个过程中,数据框被复制了三次
DF$c[DF$ID == "b"] <- 15:13 # 这样改变则复制了四次
untracemem(DF) # 结束检测

接下来我们测试一下data.table

DT <- as.data.table(DF)
tracemem(DT)
DT[,c:=18:13]
DT["b",c:=15:13,on="ID"]
untracemem(DT)

修改的过程中一次信息都没有print出来,说明没有进行过一次深复制,这是data.table处理高效的原因之一。

浅复制的副作用

上面我们已经说明了data.table的处理方式是浅复制,下面我们用例子说明浅复制中相互影响带来的负面影响。

DT <- data.table(ID = c("b","b","b","a","a","c"), a = 1:6, b = 7:12, c = 13:18)
DD <- DT[,c:=18:13][]
DT;DD # 二者相同
DT["b",c:=15:13,on="ID"]
DT;DD # 二者仍相同,说明改变DT的同时也改变了DD
rm(DT,DD) # 删除变量重新试验

使用copy函数实现复制,不影响原来数据框

DT = data.table(ID = c("b","b","b","a","a","c"), a = 1:6, b = 7:12, c = 13:18)
assign_DT <- DT 
copy_DT  <- copy(DT)
DT;assign_DT;copy_DT # 此时三者一样
DT[,c:=18:13] # 改变其中一个
DT;assign_DT # 通过普通赋值符号产生的数据框也跟着改变了
copy_DT # 通过copy深复制才没有被影响
rm(DT,assign_DT,copy_DT)

也可以用address函数检查地址,而不用试验(通过地址来检查各个对象是否改变)

DT = data.table(ID = c("b","b","b","a","a","c"), a = 1:6, b = 7:12, c = 13:18)
assign_DT <- DT 
copy_DT  <- copy(DT)
sapply(c("DT","assign_DT","copy_DT"),
       function(x) address(get(x))) # 我们可以直接看出,前两者的地址是相同的,copy复制后是不同的

我们可以用同样的方法来检查一下data.frame

DF <- data.frame(ID = c("b","b","b","a","a","c"), a = 1:6, b = 7:12, c = 13:18)
address(DF)
DF1 <- DF
sapply(c("DF","DF1"),function(x) address(get(x))) # 相同
DF[1,2] <- 3
DF;DF1 # DF1没有因此而改变
sapply(c("DF","DF1"),function(x) address(get(x))) # DF改变,DF1未变

我们可以看到,data.frame中使用 <- 时,也没有进行深复制,而是共用的同一个内容。不过当其中一个发生变化时,另一个却不受影响,因为那个改变的会进行一次深复制,将它的内容存在了另一个地方。

by reference

我们上文提到的 := 来改变数据框称为 add/update/delete columns by reference。by reference 的含义在于,除了工作记忆以外,没有任何副本,处理时只占一列这么大的空间而不是整个数据框,这会让处理数据更加高效。

data.table包中所有set*函数都是by reference的,除此之外就是:=函数了。下面举几个例子

setorder

dft <- data.table(name1,weight,height,accept)
setorder(dft,weight,-height) # 按照weight从小到大排列,如果weight相同,则按照height从大到小
dft# 我们会发现使用这个函数是在原有数据框中进行的更改
setorderv(dft,c("weight","height"),c(1,-1)) # 和上面等价

setDT和setDF

dat <- data.frame(name1,weight,height,accept)
tracemem(dat)
setDF(dat) # dat 本身变成了data.frame,没有复制
untracemem(dat)

setDF同理,与此做对比的as.data.table函数,这个函数是通过转化的(as.data.frame同理)

daf <- data.frame(name1,weight,height,accept)
tracemem(daf)