Rows are observations, columns are variables, a cell is one value. Almost every data-wrangling question is one of four actions:
Question
Operation
Which rows do I keep?
filter rows
Which columns do I keep or compute?
select / mutate columns
Should I calculate separately for each group?
group by
Do I need information from another table?
join
Today we express each of these three ways and compare them.
data.table syntax: DT[i, j, by]
DT[ i , j , by ]|||`-- group by these columns| | `------- compute, select, or update columns|`------------ keep or match these rows`--------------- the data.table object
Read it as:
Take rows i, do j, separately within each by group.
No grouping? Leave by out. No row filter? Leave i empty but keep the comma: DT[, j].
.N = rows in the current group. .() = shorthand for list().
Filter rows
Task: keep called homozygous-alt rows with adequate depth.
# base Rgenotypes_df[genotypes_df$genotype ==2& genotypes_df$read_depth >=20, ]# tidyversegenotypes_df |>filter(genotype ==2, read_depth >=20)# data.table -- the row condition goes in igenotypes[genotype ==2& read_depth >=20]
Under the hood: all three build a TRUE/FALSE vector over the rows and keep the TRUE ones. All three return a new table – the original is untouched – so you pay memory for the rows you keep. You typically assign the returned table a name.
Select columns
Task: keep only a few of the variant columns.
# select a few columnsvariants_df[, c("variant_id", "gene", "maf")] # base Rvariants_df |>select(variant_id, gene, maf) # tidyversevariants[, .(variant_id, gene, maf)] # data.table
Under the hood: all three return a new table object – but data vectors are not duplicated until necessary.
Create columns
# add a column# base R: adds the column, rebinds the namevariants_df$is_rare <- variants_df$maf <0.01# tidyverse: you must assign/name the resultvariants_df <- variants_df |>mutate(is_rare = maf <0.01) # data.table: modifies in place, no assignmentvariants[, is_rare := maf <0.01]
Style
Adding a column
base R
shallow copy: columns are shared until modified, then only that column is duplicated
tidyverse
same, but returns a new object – you must assign it
data.table :=
no new object at all; every reference sees the change
:= needs no assignment and changes the original in place. Convenient, but every other reference to that table sees the change. Use copy(variants) to avoid.
Grouped summaries
Task: per sample, mean read depth among called genotypes.
# base Rcalled <- genotypes_df[genotypes_df$genotype >=0, ]aggregate(read_depth ~ sample_id, data = called, FUN = mean)# tidyversegenotypes_df |>filter(genotype >=0) |>group_by(sample_id) |>summarise(mean_depth =mean(read_depth))# data.table -- i filters, j computes, by groupsgenotypes[genotype >=0, .(mean_depth =mean(read_depth)), by = sample_id]
Split by group, compute within each group, combine. data.table does this with low overhead, especially when the data are already keyed.
Joins: add columns from another table
# base Rmerge(genotypes_df, variants_df[, c("variant_id","consequence","gene")], by ="variant_id")# tidyversegenotypes_df |>inner_join(select(variants_df, variant_id, consequence, gene), by ="variant_id")# data.table -- x[y, on = "key"]variants[genotypes, on ="variant_id", .(sample_id, variant_id, genotype, consequence, gene)]
Keys: sort once, look up fast
A key is one or more columns a data.table is physically sorted by.
setkey(genotypes, sample_id) # reorders genotypes in place, by referencekey(genotypes) # "sample_id"
Because the rows are sorted, data.table can use binary search and sorted-merge joins instead of scanning every row:
genotypes["S00001"] # fast lookup of one sample's rowssetkey(variants, variant_id) # then a keyed join is a fast sorted merge
A key speeds up lookups, repeated joins, and grouping on the key column. You do not always need one (on = joins ad-hoc), but set a key when you will hit the same column many times, so the sort is paid once.
Benchmarks: time and memory
bench::mark() records elapsed time, memory allocated, and garbage collections.