data.table 심화

특수 기호, 조인, 피봇 등 data.table에서 다루는 심화내용을 살펴봅시다.
data.table
R
Published

January 19, 2023

data.table 기초 포스트 에서는 대용량의 데이터를 빠르게 처리할 수 있는 data.table 패키지에 대해 배워봤습니다.

이번 시간에는 data.table 패키지를 조금 더 효율적으로 사용할 수 있는 방법, 그리고 data.table을 이용하여 데이터를 원하는대로 붙이고 변형하는 방법에 대해 알아보겠습니다.

1. 특수 기호

data.table 패키지에는 유용하게 사용할 수 있는 특수 기호들이 있습니다. 이 특수한 기호들은 다른 패키지에서는 사용할 수 없는 것들로, data.table 형태의 데이터를 다룰 때만 사용이 가능합니다.

이번에 배울 data.table의 특수 기호는 크게 3가지입니다. 바로 .SD, .N, .I 입니다. 이 특수기호들은 모두 data.table의 j(column 부분)에서 쓰입니다.

1) lapply + .SD

.SD는 Subset Data의 약자입니다. 말그대로 데이터의 일부분을 선택하기 위한 특수기호(special symbols)입니다.

.SD와 함께 사용되는 것이 있습니다. 바로 .SDcols 입니다. .SDcols를 통해 데이터 중 원하는 column을 선택할 수 있습니다.

만약 .SDcols 없이 .SD만 사용한다면 데이터의 모든 column을 선택한다는 뜻입니다.

require(data.table)
require(NHANES)
dt <- as.data.table(NHANES)
dt[,head(.SD)]

반면 .SDcols와 함께 .SD를 사용한다면 .SDcols에 입력한 column만 선택합니다.

dt[,str(.SD),.SDcols=c('Gender','Age')]
Classes 'data.table' and 'data.frame':  10000 obs. of  2 variables:
 $ Gender: Factor w/ 2 levels "female","male": 2 2 2 2 1 2 2 1 1 1 ...
 $ Age   : int  34 34 34 4 49 9 8 45 45 45 ...
 - attr(*, ".internal.selfref")=<externalptr> 
 - attr(*, ".data.table.locked")= logi TRUE
NULL

.SDcols에 column을 선택하는 방법은 크게 세 가지가 있습니다.

  • column 이름을 갖는 vector 만들어서 사용

    아래의 예시처럼 찾고자 하는 column 이름 c()로 묶은 vector 형태로 넣어줄 수 있습니다.

    dt[,.SD,.SDcols=c('Gender','Age','Race1','Education')]
    # OR
    target <- c('Gender','Age','Race1','Education')
    dt[,.SD,.SDcols=target] |> head()
  • patterns()를 통한 column 규칙 찾기

    patterns() 함수를 이용해 해당 문자열을 갖는 모든 column을 찾을 수 있습니다.

    dt[,.SD,.SDcols=patterns('Alcohol')] |> head()
  • :을 이용해 연속적인 column을 찾기

    .SDcols에 적용할 column이 연이어 붙어있는 경우, :을 이용하여 찾을 수 있습니다.

    dt[,.SD,.SDcols=Gender:Age] |> head()

한편 .SD (.SDcols)와 자주 사용되는 함수는 lapply()입니다.

lapply()는 list + apply의 약자로, list에 동일한 함수를 적용할 때 사용되는 함수입니다.

lapply()는 크게 아래의 구조로 이루어져 있습니다.

lapply(
  X, # 함수를 적용할 부분
  FUN, # 선택된 X에 적용할 함수
  ... # 추가 인자, e.g., na.rm=T
)

X 에는 함수를 적용할 column이름 또는 벡터가 오게 됩니다. 이 챕터에서는 주로 data.table에 lapply()를 적용하기 때문에 column이름이 오게 됩니다.

FUN 에는 X에서 선택된 column들에 동일하게 적용할 함수를 작성합니다. FUN 부분은 각 column이 호출되어야 하는 횟수에 따라 사용하는 방법이 달라집니다.

보통 function(x) 또는 \(x) 를 이용해 적용시킬 함수를 입력해줍니다.

lapply(1:5, function(x) ifelse(is.na(x),mean(x,na.rm=T),x))
[[1]]
[1] 1

[[2]]
[1] 2

[[3]]
[1] 3

[[4]]
[1] 4

[[5]]
[1] 5
lapply(1:5, \(x) mean(x,na.rm=T))
[[1]]
[1] 1

[[2]]
[1] 2

[[3]]
[1] 3

[[4]]
[1] 4

[[5]]
[1] 5

만약 평균을 구하늖 ㅏㅁ수처럼 mean() 처럼 각 column이 한번만 와도 되는 상황이라면 function()을 생략하고 함수의 이름만 사용할 수 있습니다. 결측치가 있는 경우 부분에 추가적으로 na.rm=T 를 사용할 수 있습니다.

lapply(1:5, mean, na.rm=T)
[[1]]
[1] 1

[[2]]
[1] 2

[[3]]
[1] 3

[[4]]
[1] 4

[[5]]
[1] 5

lapply().SD를 사용할 때는 .SD.SDcols를 통해 선택되는 column에 동일한 함수를 적용하게 됩니다. 일일이 column마다 함수를 적용할 필요가 없으니, 입력해야 하는 코드도 줄어들 뿐만 아니라 계산에 필요한 시간도 훨씬 단축됩니다.

dt[,lapply(.SD, # 함수를 적용할 column 
           mean, # functcion 부분
           na.rm=T # 추가 인자 부분
           ),
   .SDcols=c('Age','BMI') # .SD 중 선택되는 column이름
   ]

또한 lapply().SD는 여러 column들의 평균, 표준편차 등의 요약통계량을 계산하는 경우, 동시에 column들의 유형을 numeric에서 character로 변환하는 경우 등에 자주 사용됩니다.

dt[,lapply(.SD, mean, na.rm=T), .SDcols=target]
dt[,lapply(.SD, as.factor), .SDcols=target] |> head()

lapply().SD를 활용하여 여러 column들을 변경한 후, 데이터에 저장하는 것은 chapter 3에서 배웠던 := 를 이용합니다.

여러 column을 동시에 변경하여 저장할 때는 := 왼쪽 부분에 column 이름이 있는 vector()로 감싸주면 됩니다.

예를 들어 나이와 BMI의 NA를 각 column의 중앙값으로 채워넣고자 하는 경우,

target <- c('Age','BMI')

dt[,(target):=lapply(.SD, \(x) ifelse(is.na(x), median(x,na.rm=T),x)),.SDcols=target]

2) .N

.N은 데이터의 수, 다시 말해 row의 개수를 확인하는 특수기호입니다. .N은 주로 by와 함께 됩니다. by를 통해 특정 집단의 분포별로 몇 명이 있는지, 또는 몇 건의 데이터가 있는지 확인합니다.

예를 들면 인종(race)에 따른 데이터의 수를 확인하고 싶을 때는 아래와 같이 사용합니다.

dt[,.N, by=Race1]
Note

사실 그룹별 응답 수는 table() 함수를 이용해도 구할 수 있습니다. 그러나 .N을 이용하게 되면 각 범주별 빈도수를 data.table 문법 내에서 구할 수 있기 때문에 활용성에 있어 table()보다 더 뛰어나다고 할 수 있습니다.

dt[,c(.N, lapply(.SD,mean)),by=.(Race1),.SDcols='Age']

3) .I

.I는 j 부분에서 행을 다루기 특수기호입니다.

보통 .I는 특정 조건을 만족하는 row의 위치를 찾을 때 사용합니다. 특히 by가 있는 경우, 다시 말해 범주별로 row의 위치를 찾을 때 많이 사용합니다.

집단별 row가 필요하지 않다면, j가 아닌 i 부분에서 행을 선택할 수 있습니다. 그러나 i 부분만 입력하게 될 경우, 집단 별 조건을 확인할 수 없습니다. 왜냐하면 by를 사용하기 위해서는 j가 선행되어야 하기 때문입니다.

따라서 i 부분에서 바로 row를 filtering 하는 것이 아니라, j 부분에서 집단별로 조건을 만족시키는 행의 번호를 찾아서 사용합니다.

또한 .I를 사용하는 경우 행의 번호를 확인할 수 있기 때문에, 그 행의 번호에 해당하는 모든 column을 사용할 수 있습니다.

예를 들어, 인종별 첫 번째 행을 선택하는 경우는 다음과 같이 실행할 수 있습니다.

dt[dt[,.I[1L],by=Race1]$V1]

이번에는 인종 별로 첫 번째 행이 아니라 Height가 최대인 (키가 가장 큰) 데이터를 추출해보겠습니다.

dt[dt[,.I[max(Height,na.rm=T)],by=.(Race1)]$V1]
dt[dt[,.I[which.max(Height)],by=.(Race1)]$V1]

2. 데이터 병합

R에서 데이터를 묶는 방법은 크게 두 종류가 있습니다. 하나는 bind() 이고, 다른 하나는 merge() 또는 join() 입니다.

1) bind

bind 계열의 함수는 두 개 이상의 데이터를 합치는 함수입니다. merge는 뒤에서 언급하겠지만, bind는 merge와 달리 특정한 기준이 되는 column이 필요하지 않습니다.

bind에는 rbind(), cbind()가 있습니다.

a. rbind()

rbind()는 row-bind의 약자로, 두 가지 이상의 데이터의 행을 묶는 함수입니다. 행을 묶는 것이기 때문에 데이터가 아래로 추가됩니다.

rbind(1:3,c('a','b','c'))
     [,1] [,2] [,3]
[1,] "1"  "2"  "3" 
[2,] "a"  "b"  "c" 

rbind()를 통해 합치고자 하는 데이터의 길이가 맞지 않는 경우, 자동으로 부족한 부분을 채웁니다.

rbind(c(1:3),c(1:4))
     [,1] [,2] [,3] [,4]
[1,]    1    2    3    1
[2,]    1    2    3    4

만약 column 이름을 갖는 matrix나 data.table 형태를 rbind()하고자 하는 경우는 합치고자 하는 데이터의 column이름이 같아야 합니다.

require(data.table)
a <- data.table(num=1:5,
                str = letters[1:5])
b <- data.table(num=6:10,
                str = LETTERS[1:5])
rbind(a,b)

만약 두 데이터의 column 이름이 같지 않은 경우, 에러 메시지가 뜨게 됩니다.

a <- data.table(num=1:5,
                str=letters[1:5])
b <- data.table(num=6:10,
                str2=LETTERS[1:5])
rbind(a,b)

# 에러메시지
# Column 2 ['str2'] of item 2 is missing in item 1. Use fill=TRUE to fill with NA (NULL for list columns), or use.names=FALSE to ignore column names.

이럴 때는 column 이름을 무시하거나, 이름이 같지 않은 column을 NA로 채워줄 수 있습니다.

rbind(a,b, use.names=F)
rbind(a,b, fill=T)

b. cbind()

cbind()는 column-bind의 약자로, 두 가지 이상의 데이터의 열을 묶는 함수입니다. 열을 묶기 때문에 데이터가 옆으로 추가됩니다.

a <- data.table(num=1:5,
                str = letters[1:5])
b <- data.table(num=6:10,
                str2 = LETTERS[1:5])
cbind(a,b)

2) merge

merge()는 두가지의 데이터를 특정 column을 기준으로 합치는 함수입니다. bind 계열의 함수와 다르게 기준이 되는 column이 반드시 필요합니다.

merge()는 두 가지 데이터 중 어떤 방식으로 합치느냐에 따라 크게 3가지로 분류할 수 있습니다.

a. inner join

inner join은 두 가지 데이터의 기준이 되는 column 에서 공통된 값들만 갖는 데이터를 합치는 방법입니다.

data.table에서 inner join 하는 방법은 아래와 같습니다.

merge(dt1, dt2, by="id")

b. left / right join

left/right join은 두 가지 데이터에서 왼(오른)쪽 데이터의 column을 기준으로 데이터를 합치는 방법입니다.

data.table에서 left join과 right join을 하는 방법은 아래와 같습니다.

merge(dt1, dt2, by, all.x=T) # left join
merge(dt1, dt2, by, all.y=T) # right join

all.x 인자에 TRUE를 주면 left join을, all.y는 right join을 의미합니다.

c. outer join

outer join은 두 가지 데이터의 모든 column을 기준으로 데이터를 합치는 방법입니다.

공통되지 않은 값을 갖는 경우는 NA로 채워넣어집니다.

data.table에서 outer join을 하는 방법은 아래와 같습니다.

merge(dt1, dt2, all.x)

merge의 경우 공통된 이름의 column을 기준으로 두 데이터를 합치는 방법이라고 했습니다. 그런데 만약 기준이 되는 column의 이름이 다르다면 어떻게 해야 할까요? 이때는 by.xby.y 인자를 사용합니다.

merge(by.x, by.y)

기존에는 by인자에 공통된 column명을 넣으면 됐지만, 두 가지 데이터의 column이 다르기 때문에 각각 by.x와 by.y에 기준이 되는 column 이름을 넣어주는 것입니다.

Tip

두 가지 column을 기준으로 합치고자 할 때, 이름이 다른 경우 아래와 같이 사용합니다.

merge(dt1, dt2, by.x=c('a','b'), by.y=c('A','B'))

.EACHI: X와 Y를 합칠 때, 요소별 합쳐진 개수 확인

X = data.table(x = c(1,1,1,2,2,5,6), y = 1:7, key = "x")
Y = data.table(x = c(2,6), z = letters[2:1], key = "x")
X[Y,.N, by=x]; #X[Y,.N, by=y];
X[Y, .N, by=.EACHI]

3. Pivoting

pivoting이란 column이 여러 개 좌우로 붙어있던 것을 위아래로 길게 늘이거나, 데이터를 좌우로 넓게 펼치는 것을 의미합니다.

pivot: 축을 기준으로 데이터를 회전시키는 것을 의미합니다. 데이터 측면에서 축은 기준이 되는 column을 의미합니다. 예를 들면 환자의 식별자, 일자 등이 있습니다.

pivoting 개념에서 사용되는 데이터 유형 두 가지에 대해 살펴보겠습니다.

  • Wide data

    넓은(wide) 데이터는 이름 그대로 양 옆으로 넓은 데이터를 의미합니다. 양 옆으로 넓다는 것은 column의 개수가 많다는 것을 의미합니다. 지금까지 교재에서 다루었던 거의 대부분의 데이터가 column들이 옆으로 붙어있는 형태는 wide 데이터입니다.

  • Long data

    긴 (long) 데이터는 넓은 데이터와 다르게, 반복되는 column을 기준으로 여러 변수(variable)와 값(value)으로 구성된 데이터입니다.

    이 때 반복되는 column이란 반복되어 기록된 값들입니다. 예를 들면 환자가 입원해있는 동안의 기록이나 일자별 날씨 같이 반복적으로 기록되어 있는 데이터 등이 있습니다.

    airquality는 MonthDay 별 오존량, 풍량, 기온 등이 기록된 데이터입니다. 여기서 MonthDay가 바로 반복되는 column입니다.

    airquality |> head()

    long 데이터는 이렇게 반복되는 column들을 기준으로 데이터를 variable과 value로 길게 확장시켜 놓은 것입니다.

데이터 분석을 수행하는 우리 입장에서는 wide한 데이터가 보기에 더 편리합니다. column마다 어떤 데이터가 있는지 확인할 수 있고, 각 column 별로 계산을 수행할 수 있기 때문입니다.

그러나 컴퓨터 입장에서는, 즉 계산하는 측면에서는 long data가 더 효율적입니다. 그렇기 때문에 동일한 column에 대해 연산을 할 때, long data가 훨씬 빠르게 계산됩니다.

1) melt

melt는 column들이 옆으로 나열되어 있는 옆으로 넓은(wide)한 데이터를 위아래로 길게(long) 바꾸는 것을 의미합니다.

Tip

melt는 어떤 것을 녹이는 의미를 갖고 있습니다. 넓게 퍼진 데이터를 녹여 길게 만든다고 이해하시면 됩니다.

이렇게 옆으로 나열된 column들을 특정한 축을 기준으로 데이터를 variable과 value라는 column으로 녹입니다. 이렇게 되면 long data의 축에 오는 데이터들은 계속 반복되고, variable과 value만 변경되는 데이터로 변환됩니다.

melt 함수에서는 기준이 되는 column의 이름과 길게 변환할 column을 지정해줄 수 있습니다.

air_dt <- as.data.table(airquality)
melt(air_dt,
     id.vars=c('Month','Day'),
     measure.vars=c('Ozone','Temp'),
     na.rm = T # TRUE일 경우 NA인 값은 제외
     ) |> head()

만약 measure.vars에 아무런 인자를 주지 않는다면, melt를 수행했을 때, 기준 column을 제외한 모든 column들이 long 형태로 변경됩니다.

air_melt <- melt(air_dt,
     id.vars=c('Month','Day'),
     na.rm = T # TRUE일 경우 NA인 값은 제외
     )
air_melt |> head()

melt의 measure.vars에는 앞서 배웠던 patterns을 통해 비슷한 패턴을 갖는 column명을 선택할 수도 있습니다.

melt(dt,
     id.vars = c('Gender','Age','Race1'),
     measure.vars = patterns('HH') # HH가 들어가는 column 선택
     ) |> head()

여러 개의 패턴을 지정해 두 가지 이상으로 정리할 수도 있습니다.

house_dt <- data.table(
  family = 1:5,
  dob_child1 = c('1998-11-26','1996-06-22','2002-07-11','2004-10-10','2000-12-05'),
  dob_child2 = c('2000-01-29',NA,'2004-04-05','2009-08-27','2005-02-28'),
  name_child1 = c('Susan','Mark','Sam','Craig','Parker'),
  name_child2 = c('Jose',NA,'Seth','Khai','Gracie')
)

house_dt |> 
    melt(
        id.vars = 'family',
        measure.vars = patterns("^dob","^name"),
        value.name = c("dob",'name')) |> head()

2) dcast

이렇게 옆으로 나열된 column들을 특정한 축을 기준으로 데이터를 variable과 value라는 column으로 녹입니다.

Tip

cast는 어떤 형태로 데이터를 굳히는 것을 의미합니다. 기존에 melt로 녹아있던 데이터를 casting 하는 data+casting (dcast)라고 이해하시면 됩니다.

dcast는 long data에서 축 column에서 특정 값들을 기준으로 넓게 펼칠 수 있습니다. dcast에서는 formula 형태로 기준이 되는 축과 wide하게 변경해줄 column을 선택해주면 됩니다. 이 때 formula 형태라는 것은 ~ 을 기준으로 기준 축과 column

dcast(data=air_melt,
      formula = Month + Day ~ variable, # 기준 column ~ column 이름으로 들어갈 column
      fill = 0, # NA인 경우 채워 넣어줄 값을 선택할 수 있습니다.
      value.var = 'value' # 데이터로 들어갈 값들.
        ) |> head()

dcast에는 fun.aggregate라는 인자가 존재합니다. 이 인자의 경우 만약 기준이 되는 column의 하나의 값에 여러 데이터(row)가 존재하는 경우에 사용합니다.

airquality 데이터를 예로 들어보겠습니다. 앞서 보여드린 air_melt의 경우 Month와 Day의 두 column을 기준으로 melt했기 때문에 기준 column에 공통되는 값들이 없었습니다 (5/1~ 9/27 까지 일일 데이터이므로).

이번에는 Day만 기준 column으로 놓고 melt를 한 데이터를 다시 dcast 해보겠습니다.

air_melt2 <- melt(air_dt, id.vars = 'Day',measure.vars = c('Ozone','Solar.R','Wind','Temp'))

air_melt2 |> head()

air_melt와 달리 air_melt2에서는 Day별로 여러 개의 데이터가 존재하게 됩니다 (Day가 1인 경우, 5월 1일, 6월 1일 ~9월 1일 등의 데이터가 있기 때문입니다).

이 데이터를 dcast하게 된다면 Aggregate function missing, defaulting to 'length' 아래와 같은 메시지가 뜹니다.

dcast(air_melt2, Day ~ variable, value.var='value') |> head()

이 메시지가 뜬 이유는 다음과 같습니다. Day라는 고유한 값은 1~31까지 밖에 없는데, air_melt2에서는 Day별로 여러 건의 데이터가 있었습니다. 그렇기 때문에 dcast에서는 Day별 요약 또는 집계를 수행합니다. 이 때 fun.aggregate라는 인자에 어떻게 요약할 것인지 지정해주지 않았기 때문에 초기값인 length()가 적용된 것입니다.

따라서 평균이나 중앙값, 최소, 최대 등 요약함수를 넣어주면 Day별로 여러 건의 데이터를 요약한 값으로 데이터가 펼쳐지게 됩니다.

dcast(air_melt2, Day ~ variable, 
      value.var='value',
      fun.aggregate = mean) |> head()

만약 여러 건의 데이터 중 첫 번째를 사용하고 싶은 경우에는 아래와 같이 사용할 수 있습니다.

dcast(air_melt2, Day ~ variable, 
      value.var='value',
      fun.aggregate = function(x)x[1]) |> head()
Note

가장 일반적으로 사용하는 데이터는 wide 형태의 데이터입니다.

그러나 분석하고자 하는 유형, 필요한 데이터의 모양에 따라 데이터의 형태를 변환해줘야 할 때가 있습니다.

함께 보면 좋은 자료

Back to top