File: datatable-keys-fast-subset.Rmd

package info (click to toggle)
r-cran-data.table 1.10.0-1~bpo8%2B1
  • links: PTS, VCS
  • area: main
  • in suites: jessie-backports
  • size: 12,816 kB
  • sloc: ansic: 9,751; sh: 10; makefile: 6
file content (527 lines) | stat: -rw-r--r-- 20,096 bytes parent folder | download | duplicates (4)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
---
title: "Keys and fast binary search based subset"
date: "`r Sys.Date()`"
output:
  rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Keys and fast binary search based subset}
  %\VignetteEngine{knitr::rmarkdown}
  \usepackage[utf8]{inputenc}
---

```{r, echo = FALSE, message = FALSE}
require(data.table)
knitr::opts_chunk$set(
  comment = "#",
    error = FALSE,
     tidy = FALSE,
    cache = FALSE,
 collapse = TRUE)
```

This vignette is aimed at those who are already familiar with *data.table* syntax, its general form, how to subset rows in `i`, select and compute on columns, add/modify/delete columns *by reference* in `j` and  group by using `by`. If you're not familiar with these concepts, please read the *"Introduction to data.table"* and *"Reference semantics"* vignettes first.

***

## Data {#data}

We will use the same `flights` data as in the *"Introduction to data.table"* vignette.

```{r echo = FALSE}
options(width = 100L)
```

```{r}
flights <- fread("flights14.csv")
head(flights)
dim(flights)
```

## Introduction

In this vignette, we will

* first introduce the concept of `key` in *data.table*, and set and use keys to perform *fast binary search* based subsets in `i`,

* see that we can combine key based subsets along with `j` and `by` in the exact same way as before,

* look at other additional useful arguments - `mult` and `nomatch`,

* and finally conclude by looking at the advantage of setting keys - perform *fast binary search based subsets* and compare with the traditional vector scan approach.

## 1. Keys

### a) What is a *key*?

In the *"Introduction to data.table"* vignette, we saw how to subset rows in `i` using logical expressions, row numbers and using `order()`. In this section, we will look at another way of subsetting incredibly fast - using *keys*.

But first, let's start by looking at *data.frames*. All *data.frames* have a row names attribute. Consider the *data.frame* `DF` below.

```{r}
set.seed(1L)
DF = data.frame(ID1 = sample(letters[1:2], 10, TRUE),
                ID2 = sample(1:3, 10, TRUE),
                val = sample(10),
                stringsAsFactors = FALSE,
                row.names = sample(LETTERS[1:10]))
DF

rownames(DF)
```

We can *subset* a particular row using its row name as shown below:

```{r}
DF["C", ]
```

i.e., row names are more or less *an index* to rows of a *data.frame*. However,

1. Each row is limited to *exactly one* row name.

    But, a person (for example) has at least two names - a *first* and a *second* name. It is useful to organise a telephone directory by *surname* then *first name*.

2. And row names should be *unique*.

    ```{r eval = FALSE}
    rownames(DF) = sample(LETTERS[1:5], 10, TRUE)
    # Warning: non-unique values when setting 'row.names': 'C', 'D'
    # Error in `row.names<-.data.frame`(`*tmp*`, value = value): duplicate 'row.names' are not allowed
    ```

Now let's convert it to a *data.table*.

```{r}
DT = as.data.table(DF)
DT

rownames(DT)
```

* Note that row names have been reset.

* *data.tables* never uses row names. Since *data.tables* **inherit** from *data.frames*, it still has the row names attribute. But it never uses them. We'll see in a moment as to why.

    If you would like to preserve the row names, use `keep.rownames = TRUE` in `as.data.table()` - this will create a new column called `rn` and assign row names to this column.

Instead, in *data.tables* we set and use `keys`. Think of a `key` as **supercharged rownames**.

#### Keys and their properties {.bs-callout .bs-callout-info #key-properties}

1. We can set keys on *multiple columns* and the column can be of *different types* -- *integer*, *numeric*, *character*, *factor*, *integer64* etc. *list* and *complex* types are not supported yet.

2. Uniqueness is not enforced, i.e., duplicate key values are allowed. Since rows are sorted by key, any duplicates in the key columns will appear consecutively.

3. Setting a `key` does *two* things:

    a. physically reorders the rows of the *data.table* by the column(s) provided *by reference*, always in *increasing* order.

    b. marks those columns as *key* columns by setting an attribute called `sorted` to the *data.table*.

    Since the rows are reordered, a *data.table* can have at most one key because it can not be sorted in more than one way.

#

For the rest of the vignette, we will work with `flights` data set.

### b) Set, get and use keys on a *data.table*

#### -- How can we set the column `origin` as key in the *data.table* `flights`?

```{r}
setkey(flights, origin)
head(flights)

## alternatively we can provide character vectors to the function 'setkeyv()'
# setkeyv(flights, "origin") # useful to program with
```

#### {.bs-callout .bs-callout-info}

* You can use the function `setkey()` and provide the column names (without quoting them). This is helpful during interactive use.

* Alternatively you can pass a character vector of column names to the function `setkeyv()`. This is particularly useful while designing functions to pass columns to set key on as function arguments.

* Note that we did not have to assign the result back to a variable. This is because like the `:=` function we saw in the *"Introduction to data.table"* vignette, `setkey()` and `setkeyv()` modify the input *data.table* *by reference*. They return the result invisibly.

* The *data.table* is now reordered (or sorted) by the column we provided - `origin`. Since we reorder by reference, we only require additional memory of one column of length equal to the number of rows in the *data.table*, and is therefore very memory efficient.

* You can also set keys directly when creating *data.tables* using the `data.table()` function using `key` argument. It takes a character vector of column names.

#### set* and `:=`: {.bs-callout .bs-callout-info}

In *data.table*, the `:=` operator and all the `set*` (e.g., `setkey`, `setorder`, `setnames` etc..) functions are the only ones which modify the input object *by reference*.

#

Once you *key* a *data.table* by certain columns, you can subset by querying those key columns using the `.()` notation in `i`. Recall that `.()` is an *alias to* `list()`.

#### -- Use the key column `origin` to subset all rows where the origin airport matches *"JFK"*

```{r}
flights[.("JFK")]

## alternatively
# flights[J("JFK")] (or) 
# flights[list("JFK")]
```

#### {.bs-callout .bs-callout-info}

* The *key* column has already been set to `origin`. So it is sufficient to provide the value, here *"JFK"*, directly. The `.()` syntax helps identify that the task requires looking up the value *"JFK"* in the key column of *data.table* (here column `origin` of `flights` *data.table*).

* The *row indices* corresponding to the value *"JFK"* in `origin` is obtained first. And since there is no expression in `j`, all columns corresponding to those row indices are returned.

* On single column key of *character* type, you can drop the `.()` notation and use the values directly when subsetting, like subset using row names on *data.frames*.

    ```{r eval = FALSE}
    flights["JFK"]              ## same as flights[.("JFK")]
    ```

* We can subset any amount of values as required

    ```{r eval = FALSE}
    flights[c("JFK", "LGA")]    ## same as flights[.(c("JFK", "LGA"))]
    ```

    This returns all columns corresponding to those rows where `origin` column matches either *"JFK"* or *"LGA"*.

#### -- How can we get the column(s) a *data.table* is keyed by?

Using the function `key()`.

```{r}
key(flights)
```

#### {.bs-callout .bs-callout-info}

* It returns a character vector of all the key columns.

* If no key is set, it returns `NULL`.

### c) Keys and multiple columns

To refresh, *keys* are like *supercharged* row names. We can set key on multiple columns and they can be of multiple types.

#### -- How can I set keys on both `origin` *and* `dest` columns?

```{r}
setkey(flights, origin, dest)
head(flights)

## or alternatively
# setkeyv(flights, c("origin", "dest")) # provide a character vector of column names

key(flights)
```

#### {.bs-callout .bs-callout-info}

* It sorts the *data.table* first by the column `origin` and then by `dest` *by reference*.

#### -- Subset all rows using key columns where first key column `origin` matches *"JFK"* and second key column `dest` matches *"MIA"*

```{r}
flights[.("JFK", "MIA")]
```

#### How does the subset work here? {.bs-callout .bs-callout-info #multiple-key-point}

* It is important to undertand how this works internally. *"JFK"* is first matched against the first key column `origin`. And *within those matching rows*, *"MIA"* is matched against the second key column `dest` to obtain *row indices* where both `origin` and `dest` match the given values.

* Since no `j` is provided, we simply return *all columns* corresponding to those row indices.

#

#### -- Subset all rows where just the first key column `origin` matches *"JFK"*

```{r}
key(flights)

flights[.("JFK")] ## or in this case simply flights["JFK"], for convenience
```

#### {.bs-callout .bs-callout-info}

* Since we did not provide any values for the second key column `dest`, it just matches *"JFK"* against the first key column `origin` and returns all the matched rows.

#### -- Subset all rows where just the second key column `dest` matches *"MIA"*

```{r}
flights[.(unique(origin), "MIA")]
```

#### What's happening here? {.bs-callout .bs-callout-info}

* Read [this](#multiple-key-point) again. The value provided for the second key column *"MIA"* has to find the matching values in `dest` key column *on the matching rows provided by the first key column `origin`*. We can not skip the values of key columns *before*. Therefore we provide *all* unique values from key column `origin`.

* *"MIA"* is automatically recycled to fit the length of `unique(origin)` which is *3*.

## 2) Combining keys with `j` and `by`

All we have seen so far is the same concept -- obtaining *row indices* in `i`, but just using a different method -- using `keys`. It shouldn't be surprising that we can do exactly the same things in `j` and `by` as seen from the previous vignettes. We will highlight this with a few examples.

### a) Select in `j`

#### -- Return `arr_delay` column as a *data.table* corresponding to `origin = "LGA"` and `dest = "TPA"`.

```{r}
key(flights)
flights[.("LGA", "TPA"), .(arr_delay)]
```

#### {.bs-callout .bs-callout-info}

* The *row indices* corresponding to `origin == "LGA"` and `dest == "TPA"` are obtained using *key based subset*.

* Once we have the row indices, we look at `j` which requires only the `arr_delay` column. So we simply select the column `arr_delay` for those *row indices* in the exact same way as we have seen in *Introduction to data.table* vignette.

* We could have returned the result by using `with = FALSE` as well.

    ```{r eval = FALSE}
    flights[.("LGA", "TPA"), "arr_delay", with = FALSE]
    ```

### b) Chaining

#### -- On the result obtained above, use chaining to order the column in decreasing order.

```{r}
flights[.("LGA", "TPA"), .(arr_delay)][order(-arr_delay)]
```

### c) Compute or *do* in `j`

#### -- Find the maximum arrival delay correspondong to `origin = "LGA"` and `dest = "TPA"`.

```{r}
flights[.("LGA", "TPA"), max(arr_delay)]
```

#### {.bs-callout .bs-callout-info}

* We can verify that the result is identical to first value (486) from the previous example.

### d) *sub-assign* by reference using `:=` in `j`

We have seen this example already in the *Reference semantics* vignette. Let's take a look at all the `hours` available in the `flights` *data.table*:

```{r}
# get all 'hours' in flights
flights[, sort(unique(hour))]
```

We see that there are totally `25` unique values in the data. Both *0* and *24* hours seem to be present. Let's go ahead and replace *24* with *0*, but this time using *key*.

```{r}
setkey(flights, hour)
key(flights)
flights[.(24), hour := 0L]
key(flights)
```

#### {.bs-callout .bs-callout-info}

* We first set `key` to `hour`. This reorders `flights` by the column `hour` and marks that column as the `key` column.

* Now we can subset on `hour` by using the `.()` notation. We subset for the value *24* and obtain the corresponding *row indices*.

* And on those row indices, we replace the `key` column with the value `0`.

* Since we have replaced values on the *key* column, the *data.table* `flights` isn't sorted by `hour` anymore. Therefore, the key has been automatically removed by setting to NULL.

#
Now, there shouldn't be any *24* in the `hour` column.

```{r}
flights[, sort(unique(hour))]
```

### e) Aggregation using `by`

Let's set the key back to `origin, dest` first.

```{r}
setkey(flights, origin, dest)
key(flights)
```

#### -- Get the maximum departure delay for each `month` corresponding to `origin = "JFK"`. Order the result by `month`

```{r}
ans <- flights["JFK", max(dep_delay), keyby = month]
head(ans)
key(ans)
```

#### {.bs-callout .bs-callout-info}

* We subset on the `key` column *origin* to obtain the *row indices* corresponding to *"JFK"*.

* Once we obtain the row indices, we only need two columns - `month` to group by and `dep_delay` to obtain `max()` for each group. *data.table's* query optimisation therefore subsets just those two columns corresponding to the *row indices* obtained in `i`, for speed and memory efficiency.

* And on that subset, we group by *month* and compute `max(dep_delay)`.

* We use `keyby` to automatically key that result by *month*. Now we understand what that means. In addition to ordering, it also sets *month* as the `key` column.

## 3) Additional arguments - `mult` and `nomatch`

### a) The *mult* argument

We can choose, for each query, if *"all"* the matching rows should be returned, or just the *"first"* or *"last"* using the `mult` argument. The default value is *"all"* - what we've seen so far.

#### -- Subset only the first matching row from all rows where `origin` matches *"JFK"* and `dest` matches *"MIA"*

```{r}
flights[.("JFK", "MIA"), mult = "first"]
```

#### -- Subset only the last matching row of all the rows where `origin` matches *"LGA", "JFK", "EWR"* and `dest` matches *"XNA"*

```{r}
flights[.(c("LGA", "JFK", "EWR"), "XNA"), mult = "last"]
```

#### {.bs-callout .bs-callout-info}

* The query *"JFK", "XNA"* doesn't match any rows in `flights` and therefore returns `NA`.

* Once again, the query for second key column `dest`,  *"XNA"*, is recycled to fit the length of the query for first key column `origin`, which is of length 3.

### b) The *nomatch* argument

We can choose if queries that do not match should return `NA` or be skipped altogether using the `nomatch` argument.

#### -- From the previous example, Subset all rows only if there's a match

```{r}
flights[.(c("LGA", "JFK", "EWR"), "XNA"), mult = "last", nomatch = 0L]
```

#### {.bs-callout .bs-callout-info}

* Default value for `nomatch` is `NA`. Setting `nomatch = 0L` skips queries with no matches.

* The query “JFK”, “XNA” doesn’t match any rows in flights and therefore is skipped.

## 4) binary search vs vector scans

We have seen so far how we can set and use keys to subset. But what's the advantage? For example, instead of doing:

```{r eval = FALSE}
# key by origin,dest columns
flights[.("JFK", "MIA")]
```

we could have done:

```{r eval = FALSE}
flights[origin == "JFK" & dest == "MIA"]
```

One advantage very likely is shorter syntax. But even more than that, *binary search based subsets* are **incredibly fast**.

### a) Performance of binary search approach

To illustrate, let's create a sample *data.table* with 20 million rows and three columns and key it by columns `x` and `y`.

```{r}
set.seed(2L)
N = 2e7L
DT = data.table(x = sample(letters, N, TRUE),
                y = sample(1000L, N, TRUE),
              val = runif(N), key = c("x", "y"))
print(object.size(DT), units = "Mb")

key(DT)
```

`DT` is ~380MB. It is not really huge, but this will do to illustrate the point.

From what we have seen in the Introduction to data.table section, we can subset those rows where columns `x = "g"` and `y = 877` as follows:

```{r}
## (1) Usual way of subsetting - vector scan approach
t1 <- system.time(ans1 <- DT[x == "g" & y == 877L])
t1
head(ans1)
dim(ans1)
```

Now let's try to subset by using keys.

```{r}
## (2) Subsetting using keys
t2 <- system.time(ans2 <- DT[.("g", 877L)])
t2
head(ans2)
dim(ans2)

identical(ans1$val, ans2$val)
```

* The speedup is **~`r round(t1[3]/max(t2[3], .001))`x**!

### b) Why does keying a *data.table* result in blazing fast susbets?

To understand that, let's first look at what *vector scan approach* (method 1) does.

#### Vector scan approach: {.bs-callout .bs-callout-info}

* The column `x` is searched for the value *"g"* row by row, on all 20 million of them. This results in a *logical vector* of size 20 million, with values `TRUE, FALSE or NA` corresponding to `x`'s value.

* Similarly, the column `y` is searched for `877` on all 20 million rows one by one, and stored in another logical vector.

* Element wise `&` operations are performed on the intermediate logical vectors and all the rows where the expression evaluates to `TRUE` are returned.

This is what we call a *vector scan approach*. And this is quite inefficient, especially on larger tables and when one needs repeated subsetting, because it has to scan through all the rows each time.

#

Now let us look at binary search approach (method 2). Recall from [Properties of key](#key-properties) - *setting keys reorders the data.table by key columns*. Since the data is sorted, we don't have to *scan through the entire length of the column*! We can instead use *binary search* to search a value in `O(log n)` as opposed to `O(n)` in case of *vector scan approach*, where `n` is the number of rows in the *data.table*.

#### Binary search approach: {.bs-callout .bs-callout-info}

Here's a very simple illustration. Let's consider the (sorted) numbers shown below:

```{r eval = FALSE}
1, 5, 10, 19, 22, 23, 30
```

Suppose we'd like to find the matching position of the value *1*, using binary search, this is how we would proceed - because we know that the data is *sorted*.

* Start with the middle value = 19. Is 1 == 19? No. 1 < 19.

* Since the value we're looking for is smaller than 19, it should be somewhere before 19. So we can discard the rest of the half that are >= 19.

* Our set is now reduced to *1, 5, 10*. Grab the middle value once again = 5. Is 1 == 5? No. 1 < 5.

* Our set is reduced to *1*. Is 1 == 1? Yes. The corresponding index is also 1. And that's the only match.

A vector scan approach on the other hand would have to scan through all the values (here, 7).

#

It can be seen that with every search we reduce the number of searches by half. This is why *binary search* based subsets are **incredibly fast**. Since rows of each column of *data.tables* have contiguous locations in memory, the operations are performed in a very cache efficient manner (also contributes to *speed*).

In addition, since we obtain the matching row indices directly without having to create those huge logical vectors (equal to the number of rows in a *data.table*), it is quite **memory efficient** as well.

## Summary

In this vignette, we have learnt another method to subset rows in `i` by keying a *data.table*. Setting keys allows us to perform blazing fast subsets by using *binary search*. In particular, we have seen how to

#### {.bs-callout .bs-callout-info}

* set key and subset using the key on a *data.table*.

* subset using keys which fetches *row indices* in `i`, but much faster.

* combine key based subsets with `j` and `by`. Note that the `j` and `by` operations are exactly the same as before.

#

Key based subsets are **incredibly fast** and are particularly useful when the task involves *repeated subsetting*. But it may not be always desirable to set key and physically reorder the *data.table*. In the next vignette, we will address this using a *new* feature -- *secondary indexes*.

***