Quick Start - replicating dplyr’s tutorial on nycflight13

The disk.frame package aims to be the answer to the question: how do I manipulate structured tabular data that doesn’t fit into Random Access Memory (RAM)?

In a nutshell, disk.frame makes use of two simple ideas

  1. split up a larger-than-RAM dataset into chunks and store each chunk in a separate file inside a folder and
  2. provide a convenient API to manipulate these chunks

disk.frame performs a similar role to distributed systems such as Apache Spark, Python’s Dask, and Julia’s JuliaDB.jl for medium data which are datasets that are too large for RAM but not quite large enough to qualify as big data.

In this tutorial, we introduce disk.frame, address some common questions, and replicate the sparklyr data manipulation tutorial using disk.frame constructs.

Installation

Simply run

install.packages("disk.frame") # when CRAN ready

or

devtools::install_github("xiaodaigh/disk.frame")

Set-up disk.frame

disk.frame works best if it can process multiple data chunks in parallel. The best way to set-up disk.frame so that each CPU core runs a background worker is by using

setup_disk.frame()

# this will allow unlimited amount of data to be passed from worker to worker
options(future.globals.maxSize = Inf)

The setup_disk.frame() sets up background workers equal to the number of CPU cores; please note that, by default, hyper-threaded cores are counted as one not two.

Alternatively, one may specify the number of workers using setup_disk.frame(workers = n).

Basic Data Operations with disk.frame

The disk.frame package provides convenient functions to convert data.frames and CSVs to disk.frames.

Creating a disk.frame from data.frame

We convert a data.frame to disk.frame using the as.data.frame function.

library(nycflights13)
library(dplyr)
library(disk.frame)
library(data.table)

# convert the flights data to a disk.frame and store the disk.frame in the folder
# "tmp_flights" and overwrite any content if needed
flights.df <- as.disk.frame(
  flights,
  outdir = file.path(tempdir(), "tmp_flights.df"),
  overwrite = TRUE)
flights.df

You should now see a folder called tmp_flights with some files in it, namely 1.fst, 2.fst…. where each fst files is one chunk of the disk.frame.

Creating a disk.frame from CSV

library(nycflights13)
# write a csv
csv_path = file.path(tempdir(), "tmp_flights.csv")
data.table::fwrite(flights, csv_path)

# load the csv into a disk.frame
df_path = file.path(tempdir(), "tmp_flights.df")
flights.df <- csv_to_disk.frame(
  csv_path,
  outdir = df_path,
  overwrite = T)

flights.df

If the CSV is too large to read in, then we can also use the in_chunk_size option to control how many rows to read in at once. For example to read in the data 100,000 rows at a time.

library(nycflights13)
library(disk.frame)

# write a csv
csv_path = file.path(tempdir(), "tmp_flights.csv")

data.table::fwrite(flights, csv_path)

df_path = file.path(tempdir(), "tmp_flights.df")

flights.df <- csv_to_disk.frame(
  csv_path,
  outdir = df_path,
  in_chunk_size = 100000)

flights.df

disk.frame also has a function zip_to_disk.frame that can convert every CSV in a zip file to disk.frames.

Simple dplyr verbs and lazy evaluation

flights.df1 <- select(flights.df, year:day, arr_delay, dep_delay)
flights.df1
class(flights.df1)
#> [1] "disk.frame"        "disk.frame.folder"

The class of flights.df1 is also a disk.frame after the dplyr::select transformation. Also, disk.frame operations are by default (and where possible) lazy, meaning it doesn’t perform the operations right away. Instead, it waits until you call collect. Exceptions to this rule are the *_join operations which evaluated eagerly under certain conditions see Joins for disk.frame in-depth for details.

For lazily constructed disk.frames (e.g. flights.df1). The function collect can be used to bring the results from disk into R, e.g.

collect(flights.df1) %>% head(2)
#>    year month day arr_delay dep_delay
#> 1: 2013     1   1        11         2
#> 2: 2013     1   1        20         4

Of course, for larger-than-RAM datasets, one wouldn’t call collect on the whole disk.frame (because why would you need disk.frame otherwise). More likely, one would call collect on a filtered dataset or one summarized with group_by.

Some examples of other dplyr verbs applied:

filter(flights.df, dep_delay > 1000) %>% collect %>% head(2)
#>   year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#> 1 2013     1   9      641            900      1301     1242           1530
#> 2 2013     1  10     1121           1635      1126     1239           1810
#>   arr_delay carrier flight tailnum origin dest air_time distance hour minute
#> 1      1272      HA     51  N384HA    JFK  HNL      640     4983    9      0
#> 2      1109      MQ   3695  N517MQ    EWR  ORD      111      719   16     35
#>              time_hour
#> 1 2013-01-09T14:00:00Z
#> 2 2013-01-10T21:00:00Z
mutate(flights.df, speed = distance / air_time * 60) %>% collect %>% head(2)
#>   year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#> 1 2013     1   1      517            515         2      830            819
#> 2 2013     1   1      533            529         4      850            830
#>   arr_delay carrier flight tailnum origin dest air_time distance hour minute
#> 1        11      UA   1545  N14228    EWR  IAH      227     1400    5     15
#> 2        20      UA   1714  N24211    LGA  IAH      227     1416    5     29
#>              time_hour    speed
#> 1 2013-01-01T10:00:00Z 370.0441
#> 2 2013-01-01T10:00:00Z 374.2731

Examples of NOT fully supported dplyr verbs

The chunk_arrange function arranges (sorts) each chunk but not the whole dataset. So use with caution. Similarly chunk_summarise creates summary variables within each chunk and hence also needs to be used with caution. In the Group By section, we demonstrate how to use summarise in the disk.frame context correctly with hard_group_bys.

# this only sorts within each chunk
chunk_arrange(flights.df, dplyr::desc(dep_delay)) %>% collect %>% head(2)
#>   year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#> 1 2013     1   9      641            900      1301     1242           1530
#> 2 2013     1  10     1121           1635      1126     1239           1810
#>   arr_delay carrier flight tailnum origin dest air_time distance hour minute
#> 1      1272      HA     51  N384HA    JFK  HNL      640     4983    9      0
#> 2      1109      MQ   3695  N517MQ    EWR  ORD      111      719   16     35
#>              time_hour
#> 1 2013-01-09T14:00:00Z
#> 2 2013-01-10T21:00:00Z
chunk_summarize(flights.df, mean_dep_delay = mean(dep_delay, na.rm =T)) %>% collect
#>   mean_dep_delay
#> 1       12.32700
#> 2       12.01291
#> 3       13.81315
#> 4       12.94445
#> 5       12.67813
#> 6       12.05854

Piping

One can chain dplyr verbs together like with a data.frame

c4 <- flights %>%
  filter(month == 5, day == 17, carrier %in% c('UA', 'WN', 'AA', 'DL')) %>%
  select(carrier, dep_delay, air_time, distance) %>%
  mutate(air_time_hours = air_time / 60) %>%
  collect %>%
  arrange(carrier)# arrange should occur after `collect`

c4  %>% head
#>    carrier dep_delay air_time distance air_time_hours
#> 1:      AA        -7      142     1089       2.366667
#> 2:      AA        -9      186     1389       3.100000
#> 3:      AA        -6      143     1096       2.383333
#> 4:      AA        -4      114      733       1.900000
#> 5:      AA        -2      146     1085       2.433333
#> 6:      AA        -7      119      733       1.983333

List of supported dplyr verbs

select
rename
filter
chunk_arrange # within each chunk
chunk_group_by # within each chunk
chunk_summarize # within each chunk
group_by # limited functions
summarize # limited functions
mutate
transmute
left_join
inner_join
full_join # careful. Performance!
semi_join
anit_join

Sharding and distribution of chunks

Like other distributed data manipulation frameworks disk.frame utilizes the sharding concept to distribute the data into chunks. For example “to shard by cust_id” means that all rows with the same cust_id will be stored in the same chunk. This enables chunk_group_by by cust_id to produce the same results as non-chunked data.

The by variables that were used to shard the dataset are called the shardkeys. The sharding is performed by computing a deterministic hash on the shard keys (the by variables) for each row. The hash function produces an integer between 1 and n, where n is the number of chunks.

Group-by

{disk.frame} implements the group_by operation some caveats. In the {disk.frame} framework, only a set functions are supported in summarize. However, the user can create more custom group-by functions can be defined.

flights.df %>%
  group_by(carrier) %>% # notice that hard_group_by needs to be set
  summarize(count = n(), mean_dep_delay = mean(dep_delay, na.rm=T)) %>%  # mean follows normal R rules
  collect %>%
  arrange(carrier)
#> # A tibble: 16 x 3
#>    carrier count mean_dep_delay
#>    <chr>   <int>          <dbl>
#>  1 9E      18460          16.7 
#>  2 AA      32729           8.59
#>  3 AS        714           5.80
#>  4 B6      54635          13.0 
#>  5 DL      48110           9.26
#>  6 EV      54173          20.0 
#>  7 F9        685          20.2 
#>  8 FL       3260          18.7 
#>  9 HA        342           4.90
#> 10 MQ      26397          10.6 
#> 11 OO         32          12.6 
#> 12 UA      58665          12.1 
#> 13 US      20536           3.78
#> 14 VX       5162          12.9 
#> 15 WN      12275          17.7 
#> 16 YV        601          19.0

Restrict input columns for faster processing

One can restrict which input columns to load into memory for each chunk; this can significantly increase the speed of data processing. To restrict the input columns, use the srckeep function which only accepts column names as a string vector.

flights.df %>%
  srckeep(c("carrier","dep_delay")) %>%
  group_by(carrier) %>%
  summarize(count = n(), mean_dep_delay = mean(dep_delay, na.rm=T)) %>%  # mean follows normal R rules
  collect
#> # A tibble: 16 x 3
#>    carrier count mean_dep_delay
#>    <chr>   <int>          <dbl>
#>  1 9E      18460          16.7 
#>  2 AA      32729           8.59
#>  3 AS        714           5.80
#>  4 B6      54635          13.0 
#>  5 DL      48110           9.26
#>  6 EV      54173          20.0 
#>  7 F9        685          20.2 
#>  8 FL       3260          18.7 
#>  9 HA        342           4.90
#> 10 MQ      26397          10.6 
#> 11 OO         32          12.6 
#> 12 UA      58665          12.1 
#> 13 US      20536           3.78
#> 14 VX       5162          12.9 
#> 15 WN      12275          17.7 
#> 16 YV        601          19.0

Input column restriction is one of the most critical efficiencies provided by disk.frame. Because the underlying format allows random access to columns (i.e. retrieve only the columns used for processing), hence one can drastically reduce the amount of data loaded into RAM for processing by keeping only those columns that are directly used to produce the results.

Joins

disk.frame supports many dplyr joins including:

left_join
inner_join
semi_join
inner_join
full_join # requires hard_group_by on both left and right

In all cases, the left dataset (x) must be a disk.frame, and the right dataset (y) can be either a disk.frame or a data.frame. If the right dataset is a disk.frame and the shardkeys are different between the two disk.frames then two expensive hard group_by operations are performed eagerly, one on the left disk.frame and one on the right disk.frame to perform the joins correctly.

However, if the right dataset is a data.frame then hard_group_bys are only performed in the case of full_join.

Note disk.frame does not support right_join the user should use left_join instead.

The below joins are performed lazily because airlines.dt is a data.table not a disk.frame:

# make airlines a data.table
airlines.dt <- data.table(airlines)
# flights %>% left_join(airlines, by = "carrier") #
flights.df %>%
  left_join(airlines.dt, by ="carrier") %>%
  collect %>%
  head
#>    year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#> 1: 2013     1   1      517            515         2      830            819
#> 2: 2013     1   1      533            529         4      850            830
#> 3: 2013     1   1      542            540         2      923            850
#> 4: 2013     1   1      544            545        -1     1004           1022
#> 5: 2013     1   1      554            600        -6      812            837
#> 6: 2013     1   1      554            558        -4      740            728
#>    arr_delay carrier flight tailnum origin dest air_time distance hour minute
#> 1:        11      UA   1545  N14228    EWR  IAH      227     1400    5     15
#> 2:        20      UA   1714  N24211    LGA  IAH      227     1416    5     29
#> 3:        33      AA   1141  N619AA    JFK  MIA      160     1089    5     40
#> 4:       -18      B6    725  N804JB    JFK  BQN      183     1576    5     45
#> 5:       -25      DL    461  N668DN    LGA  ATL      116      762    6      0
#> 6:        12      UA   1696  N39463    EWR  ORD      150      719    5     58
#>               time_hour                   name
#> 1: 2013-01-01T10:00:00Z  United Air Lines Inc.
#> 2: 2013-01-01T10:00:00Z  United Air Lines Inc.
#> 3: 2013-01-01T10:00:00Z American Airlines Inc.
#> 4: 2013-01-01T10:00:00Z        JetBlue Airways
#> 5: 2013-01-01T11:00:00Z   Delta Air Lines Inc.
#> 6: 2013-01-01T10:00:00Z  United Air Lines Inc.
flights.df %>%
  left_join(airlines.dt, by = c("carrier")) %>%
  collect %>%
  tail
#>    year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#> 1: 2013     9  30       NA           1842        NA       NA           2019
#> 2: 2013     9  30       NA           1455        NA       NA           1634
#> 3: 2013     9  30       NA           2200        NA       NA           2312
#> 4: 2013     9  30       NA           1210        NA       NA           1330
#> 5: 2013     9  30       NA           1159        NA       NA           1344
#> 6: 2013     9  30       NA            840        NA       NA           1020
#>    arr_delay carrier flight tailnum origin dest air_time distance hour minute
#> 1:        NA      EV   5274  N740EV    LGA  BNA       NA      764   18     42
#> 2:        NA      9E   3393            JFK  DCA       NA      213   14     55
#> 3:        NA      9E   3525            LGA  SYR       NA      198   22      0
#> 4:        NA      MQ   3461  N535MQ    LGA  BNA       NA      764   12     10
#> 5:        NA      MQ   3572  N511MQ    LGA  CLE       NA      419   11     59
#> 6:        NA      MQ   3531  N839MQ    LGA  RDU       NA      431    8     40
#>               time_hour                     name
#> 1: 2013-09-30T22:00:00Z ExpressJet Airlines Inc.
#> 2: 2013-09-30T18:00:00Z        Endeavor Air Inc.
#> 3: 2013-10-01T02:00:00Z        Endeavor Air Inc.
#> 4: 2013-09-30T16:00:00Z                Envoy Air
#> 5: 2013-09-30T15:00:00Z                Envoy Air
#> 6: 2013-09-30T12:00:00Z                Envoy Air

Window functions and arbitrary functions

{disk.frame} supports all data.frame operations, unlike Spark which can only perform those operations that Spark has implemented. Hence windowing functions like min_rank and rank are supported out of the box.

For the following example, we will use the hard_group_by which performs a group-by and also reorganises the chunks so that all records with the same year, month, and day end up in the same chunk. This is typically not advised, as hard_group_by can be slow for large datasets.

# Find the most and least delayed flight each day
bestworst <- flights.df %>%
   srckeep(c("year","month","day", "dep_delay")) %>%
   hard_group_by(c("year", "month", "day")) %>%
   filter(dep_delay == min(dep_delay, na.rm = T) || dep_delay == max(dep_delay, na.rm = T)) %>%
   collect

bestworst %>% head
#> # A tibble: 6 x 4
#> # Groups:   year, month, day [1]
#>    year month   day dep_delay
#>   <int> <int> <int>     <int>
#> 1  2013     2    21       301
#> 2  2013     2    21        -9
#> 3  2013     2    21        -1
#> 4  2013     2    21         2
#> 5  2013     2    21        -4
#> 6  2013     2    21        10

another example

ranked <- flights.df %>%
  srckeep(c("year","month","day", "dep_delay")) %>%
  hard_group_by(c("year", "month", "day")) %>%
  filter(min_rank(desc(dep_delay)) <= 2 & dep_delay > 0) %>%
  collect

ranked %>% head
#> # A tibble: 6 x 4
#> # Groups:   year, month, day [3]
#>    year month   day dep_delay
#>   <int> <int> <int>     <int>
#> 1  2013     1     9      1301
#> 2  2013     1     9       253
#> 3  2013     1    10      1126
#> 4  2013     1    10       385
#> 5  2013     1    17       259
#> 6  2013     1    17       255

one more example

# Rank each flight within a daily
ranked <- flights.df %>%
  srckeep(c("year","month","day", "dep_delay")) %>%
  chunk_group_by(year, month, day) %>%
  select(dep_delay) %>%
  mutate(rank = rank(desc(dep_delay))) %>%
  collect

ranked %>% head
#> # A tibble: 6 x 5
#> # Groups:   year, month, day [1]
#>    year month   day dep_delay  rank
#>   <int> <int> <int>     <int> <dbl>
#> 1  2013     1     1         2   313
#> 2  2013     1     1         4   276
#> 3  2013     1     1         2   313
#> 4  2013     1     1        -1   440
#> 5  2013     1     1        -6   742
#> 6  2013     1     1        -4   633

Arbitrary by-chunk processing

One can apply arbitrary transformations to each chunk of the disk.frame by using the delayed function which evaluates lazily or the map.disk.frame(lazy = F) function which evaluates eagerly. For example to return the number of rows in each chunk

flights.df1 <- delayed(flights.df, ~nrow(.x))
collect_list(flights.df1) %>% head # returns number of rows for each data.frame in a list
#> [[1]]
#> [1] 56131
#> 
#> [[2]]
#> [1] 56131
#> 
#> [[3]]
#> [1] 56131
#> 
#> [[4]]
#> [1] 56131
#> 
#> [[5]]
#> [1] 56131
#> 
#> [[6]]
#> [1] 56121

and to do the same with map.disk.frame

map(flights.df, ~nrow(.x), lazy = F) %>% head
#> Warning in map.disk.frame(flights.df, ~nrow(.x), lazy = F): map(df, ...) where
#> df is a disk.frame has been deprecated. Please use cmap(df,...) instead
#> [[1]]
#> [1] 56131
#> 
#> [[2]]
#> [1] 56131
#> 
#> [[3]]
#> [1] 56131
#> 
#> [[4]]
#> [1] 56131
#> 
#> [[5]]
#> [1] 56131
#> 
#> [[6]]
#> [1] 56121

The map function can also output the results to another disk.frame folder, e.g.

# return the first 10 rows of each chunk
flights.df2 <- map(flights.df, ~.x[1:10,], lazy = F, outdir = file.path(tempdir(), "tmp2"), overwrite = T)
#> Warning in map.disk.frame(flights.df, ~.x[1:10, ], lazy = F, outdir =
#> file.path(tempdir(), : map(df, ...) where df is a disk.frame has been
#> deprecated. Please use cmap(df,...) instead

flights.df2 %>% head
#>    year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#> 1: 2013     1   1      517            515         2      830            819
#> 2: 2013     1   1      533            529         4      850            830
#> 3: 2013     1   1      542            540         2      923            850
#> 4: 2013     1   1      544            545        -1     1004           1022
#> 5: 2013     1   1      554            600        -6      812            837
#> 6: 2013     1   1      554            558        -4      740            728
#>    arr_delay carrier flight tailnum origin dest air_time distance hour minute
#> 1:        11      UA   1545  N14228    EWR  IAH      227     1400    5     15
#> 2:        20      UA   1714  N24211    LGA  IAH      227     1416    5     29
#> 3:        33      AA   1141  N619AA    JFK  MIA      160     1089    5     40
#> 4:       -18      B6    725  N804JB    JFK  BQN      183     1576    5     45
#> 5:       -25      DL    461  N668DN    LGA  ATL      116      762    6      0
#> 6:        12      UA   1696  N39463    EWR  ORD      150      719    5     58
#>               time_hour
#> 1: 2013-01-01T10:00:00Z
#> 2: 2013-01-01T10:00:00Z
#> 3: 2013-01-01T10:00:00Z
#> 4: 2013-01-01T10:00:00Z
#> 5: 2013-01-01T11:00:00Z
#> 6: 2013-01-01T10:00:00Z

Notice {disk.frame} supports the purrr syntax for defining a function using ~.

Sampling

In the disk.frame framework, sampling a proportion of rows within each chunk can be performed using sample_frac.

flights.df %>% sample_frac(0.01) %>% collect %>% head
#>   year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#> 1 2013     5  20      933            935        -2     1152           1208
#> 2 2013     5  17     1624           1622         2     1726           1735
#> 3 2013     5  21      743            745        -2      955            956
#> 4 2013     2   1      553            600        -7      821            825
#> 5 2013     5  22      757            759        -2     1015           1028
#> 6 2013     5  16     1726           1725         1     2027           2020
#>   arr_delay carrier flight tailnum origin dest air_time distance hour minute
#> 1       -16      EV   4140  N14543    EWR  ATL      107      746    9     35
#> 2        -9      EV   4299  N13994    EWR  DCA       38      199   16     22
#> 3        -1      EV   4237  N15985    EWR  CHS       99      628    7     45
#> 4        -4      MQ   4650  N1EAMQ    LGA  ATL      125      762    6      0
#> 5       -13      DL   2047  N662DN    LGA  ATL      104      762    7     59
#> 6         7      AA   1901  N3FAAA    JFK  IAH      204     1417   17     25
#>              time_hour
#> 1 2013-05-20T13:00:00Z
#> 2 2013-05-17T20:00:00Z
#> 3 2013-05-21T11:00:00Z
#> 4 2013-02-01T11:00:00Z
#> 5 2013-05-22T11:00:00Z
#> 6 2013-05-16T21:00:00Z

Writing Data

One can output a disk.frame by using the write_disk.frame function. E.g.

write_disk.frame(flights.df, outdir="out")

this will output a disk.frame to the folder “out”