Row relational operations with slice()

data wrangling
dplyr
A love letter to dplyr::slice() and a gallery of usecases
Author
Affiliation

June Choe

Published

June 11, 2023

Intro

In data wrangling, there are a handful of classes of operations on data frames that we think of as theoretically well-defined and tackling distinct problems. To name a few, these include subsetting, joins, split-apply-combine, pairwise operations, nested-column workflows, and so on.

Against this rich backdrop, there’s one aspect of data wrangling that doesn’t receive as much attention: ordering of rows. This isn’t necessarily surprising - we often think of row order as an auxiliary attribute of data frames since they don’t speak to the content of the data, per se. I think we all share the intuition that two dataframe that differ only in row order are practically the same for most analysis purposes.

Except when they aren’t.

In this blog post I want to talk about a few, somewhat esoteric cases of what I like to call row-relational operations. My goal is to try to motivate row-relational operations as a full-blown class of data wrangling operation that includes not only row ordering, but also sampling, shuffling, repeating, interweaving, and so on (I’ll go over all of these later).

Without spoiling too much, I believe that dplyr::slice() offers a powerful context for operations over row indices, even those that at first seem to lack a “tidy” solution. You may already know slice() as an indexing function, but my hope is to convince you that it can do so much more.

Let’s start by first talking about some special properties of dplyr::slice(), and then see how we can use it for various row-relational operations.

Special properties of dplyr::slice()

Basic usage

For the following demonstration, I’ll use a small subset of the dplyr::starwars dataset:

starwars_sm <- dplyr::starwars[1:10, 1:3]
starwars_sm
  # A tibble: 10 × 3
     name               height  mass
     <chr>               <int> <dbl>
   1 Luke Skywalker        172    77
   2 C-3PO                 167    75
   3 R2-D2                  96    32
   4 Darth Vader           202   136
   5 Leia Organa           150    49
   6 Owen Lars             178   120
   7 Beru Whitesun Lars    165    75
   8 R5-D4                  97    32
   9 Biggs Darklighter     183    84
  10 Obi-Wan Kenobi        182    77

1) Row selection

slice() is a row indexing verb - if you pass it a vector of integers, it subsets data frame rows:

starwars_sm |> 
  slice(1:6) # First six rows
  # A tibble: 6 × 3
    name           height  mass
    <chr>           <int> <dbl>
  1 Luke Skywalker    172    77
  2 C-3PO             167    75
  3 R2-D2              96    32
  4 Darth Vader       202   136
  5 Leia Organa       150    49
  6 Owen Lars         178   120

Like other dplyr verbs with mutate-semantics, you can use context-dependent expressions inside slice(). For example, you can use n() to grab the last row (or last couple of rows):

starwars_sm |> 
  slice( n() ) # Last row
  # A tibble: 1 × 3
    name           height  mass
    <chr>           <int> <dbl>
  1 Obi-Wan Kenobi    182    77
starwars_sm |> 
  slice( n() - 2:0 ) # Last three rows
  # A tibble: 3 × 3
    name              height  mass
    <chr>              <int> <dbl>
  1 R5-D4                 97    32
  2 Biggs Darklighter    183    84
  3 Obi-Wan Kenobi       182    77

Another context-dependent expression that comes in handy is row_number(), which returns all row indices. Using it inside slice() essentially performs an identity transformation:

identical(
  starwars_sm,
  starwars_sm |> slice( row_number() )
)
  [1] TRUE

Lastly, similar to in select(), you can use - for negative indexing (to remove rows):

identical(
  starwars_sm |> slice(1:3),      # First three rows
  starwars_sm |> slice(-(4:n()))  # All rows except fourth row to last row
)
  [1] TRUE

2) Dynamic dots

slice() supports dynamic dots. If you pass row indices into multiple argument positions, slice() will concatenate them for you:

identical(
  starwars_sm |> slice(1:6),
  starwars_sm |> slice(1, 2:4, 5, 6)
)
  [1] TRUE

If you have a list() of row indices, you can use the splice operator !!! to spread them out:

starwars_sm |> 
  slice( !!!list(1, 2:4, 5, 6) )
  # A tibble: 6 × 3
    name           height  mass
    <chr>           <int> <dbl>
  1 Luke Skywalker    172    77
  2 C-3PO             167    75
  3 R2-D2              96    32
  4 Darth Vader       202   136
  5 Leia Organa       150    49
  6 Owen Lars         178   120

The above call to slice() evaluates to the following after splicing:

rlang::expr( slice(!!!list(1, 2:4, 5, 6)) )
  slice(1, 2:4, 5, 6)

3) Row ordering

slice() respects the order in which you supplied the row indices:

starwars_sm |> 
  slice(3, 1, 2, 5)
  # A tibble: 4 × 3
    name           height  mass
    <chr>           <int> <dbl>
  1 R2-D2              96    32
  2 Luke Skywalker    172    77
  3 C-3PO             167    75
  4 Leia Organa       150    49

This means you can do stuff like random sampling with sample():

starwars_sm |> 
  slice( sample(n()) )
  # A tibble: 10 × 3
     name               height  mass
     <chr>               <int> <dbl>
   1 Obi-Wan Kenobi        182    77
   2 Owen Lars             178   120
   3 Leia Organa           150    49
   4 Darth Vader           202   136
   5 Luke Skywalker        172    77
   6 R5-D4                  97    32
   7 C-3PO                 167    75
   8 Beru Whitesun Lars    165    75
   9 Biggs Darklighter     183    84
  10 R2-D2                  96    32

You can also shuffle a subset of rows (ex: just the first five):

starwars_sm |> 
  slice( sample(5), 6:n() )
  # A tibble: 10 × 3
     name               height  mass
     <chr>               <int> <dbl>
   1 C-3PO                 167    75
   2 Leia Organa           150    49
   3 R2-D2                  96    32
   4 Darth Vader           202   136
   5 Luke Skywalker        172    77
   6 Owen Lars             178   120
   7 Beru Whitesun Lars    165    75
   8 R5-D4                  97    32
   9 Biggs Darklighter     183    84
  10 Obi-Wan Kenobi        182    77

Or reorder all rows by their indices (ex: in reverse):

starwars_sm |> 
  slice( rev(row_number()) )
  # A tibble: 10 × 3
     name               height  mass
     <chr>               <int> <dbl>
   1 Obi-Wan Kenobi        182    77
   2 Biggs Darklighter     183    84
   3 R5-D4                  97    32
   4 Beru Whitesun Lars    165    75
   5 Owen Lars             178   120
   6 Leia Organa           150    49
   7 Darth Vader           202   136
   8 R2-D2                  96    32
   9 C-3PO                 167    75
  10 Luke Skywalker        172    77

4) Out-of-bounds handling

If you pass a row index that’s out of bounds, slice() returns a 0-row data frame:

starwars_sm |> 
  slice( n() + 1 ) # Select the row after the last row
  # A tibble: 0 × 3
  # ℹ 3 variables: name <chr>, height <int>, mass <dbl>

When mixed with valid row indices, out-of-bounds indices are simply ignored (much 💜 for this behavior):

starwars_sm |> 
  slice(
    0,       # 0th row - ignored
    1:3,     # first three rows
    n() + 1  # 1 after last row - ignored
  )
  # A tibble: 3 × 3
    name           height  mass
    <chr>           <int> <dbl>
  1 Luke Skywalker    172    77
  2 C-3PO             167    75
  3 R2-D2              96    32

This lets you do funky stuff like select all even numbered rows by passing slice() all row indices times 2:

starwars_sm |> 
  slice( row_number() * 2 ) # Add `- 1` at the end for *odd* rows!
  # A tibble: 5 × 3
    name           height  mass
    <chr>           <int> <dbl>
  1 C-3PO             167    75
  2 Darth Vader       202   136
  3 Owen Lars         178   120
  4 R5-D4              97    32
  5 Obi-Wan Kenobi    182    77

Re-imagining slice() with data-masking

slice() is already pretty neat as it is, but that’s just the tip of the iceberg.

The really cool, under-rated feature of slice() is that it’s data-masked, meaning that you can reference column vectors as if they’re variables. Another way of describing this property of slice() is to say that it has mutate-semantics.

At a very basic level, this means that slice() can straightforwardly replicate the behavior of some dplyr verbs like arrange() and filter()!

slice() as arrange()

From our starwars_sm data, if we want to sort by height we can use arrange():

starwars_sm |> 
  arrange(height)
  # A tibble: 10 × 3
     name               height  mass
     <chr>               <int> <dbl>
   1 R2-D2                  96    32
   2 R5-D4                  97    32
   3 Leia Organa           150    49
   4 Beru Whitesun Lars    165    75
   5 C-3PO                 167    75
   6 Luke Skywalker        172    77
   7 Owen Lars             178   120
   8 Obi-Wan Kenobi        182    77
   9 Biggs Darklighter     183    84
  10 Darth Vader           202   136

But we can also do this with slice() to the same effect, using order():

starwars_sm |> 
  slice( order(height) )
  # A tibble: 10 × 3
     name               height  mass
     <chr>               <int> <dbl>
   1 R2-D2                  96    32
   2 R5-D4                  97    32
   3 Leia Organa           150    49
   4 Beru Whitesun Lars    165    75
   5 C-3PO                 167    75
   6 Luke Skywalker        172    77
   7 Owen Lars             178   120
   8 Obi-Wan Kenobi        182    77
   9 Biggs Darklighter     183    84
  10 Darth Vader           202   136

This is conceptually equivalent to combining the following 2-step process:

  1. ordered_val_ind <- order(starwars_sm$height)
      ordered_val_ind
       [1]  3  8  5  7  2  1  6 10  9  4
  2. starwars_sm |> 
        slice( ordered_val_ind )
      # A tibble: 10 × 3
         name               height  mass
         <chr>               <int> <dbl>
       1 R2-D2                  96    32
       2 R5-D4                  97    32
       3 Leia Organa           150    49
       4 Beru Whitesun Lars    165    75
       5 C-3PO                 167    75
       6 Luke Skywalker        172    77
       7 Owen Lars             178   120
       8 Obi-Wan Kenobi        182    77
       9 Biggs Darklighter     183    84
      10 Darth Vader           202   136

slice() as filter()

We can also use slice() to filter(), using which():

identical(
  starwars_sm |> filter( height > 150 ),
  starwars_sm |> slice( which(height > 150) )
)
  [1] TRUE

Thus, we can think of filter() and slice() as two sides of the same coin:

  • filter() takes a logical vector that’s the same length as the number of rows in the data frame

  • slice() takes an integer vector that’s a (sub)set of a data frame’s row indices.

To put it more concretely, this logical vector was being passed to the above filter() call:

starwars_sm$height > 150
   [1]  TRUE  TRUE FALSE  TRUE FALSE  TRUE  TRUE FALSE  TRUE  TRUE

While this integer vector was being passed to the above slice() call, where which() returns the position of TRUE values, given a logical vector:

which( starwars_sm$height > 150 )
  [1]  1  2  4  6  7  9 10

Special properties of slice()

This re-imagined slice() that heavily exploits data-masking gives us two interesting properties:

  1. We can work with sets of row indices that need not to be the same length as the data frame (vs. filter()).

  2. We can work with row indices as integers, which are legible to arithmetic operations (ex: + and *)

To grok the significance of working with rows as integer sets, let’s work through some examples where slice() comes in very handy.

Conclusion

When I started drafting this blog post, I thought I’d come with a principled taxonomy of row-relational operations. Ha. This was a lot trickier to think through than I thought.

But I hope that this gallery of esoteric use-cases for slice() inspires you to use it more, and to think about “tidy” solutions to seemingly “untidy” problems.

Footnotes

  1. The .by_group = TRUE is not strictly necessary here, but it’s good for visually inspecting the within-group ordering.↩︎

  2. Although row insertion is a generally tricky problem for column-major data frame structures, which is partly why dplyr’s row manipulation verbs have stayed experimental for quite some time.↩︎

Citation

BibTeX citation:
@online{choe2023,
  author = {Choe, June},
  title = {Row Relational Operations with Slice()},
  date = {2023-06-11},
  url = {https://yjunechoe.github.io/posts/2023-06-11-row-relational-operations/},
  langid = {en}
}
For attribution, please cite this work as:
Choe, June. 2023. “Row Relational Operations with Slice().” June 11. https://yjunechoe.github.io/posts/2023-06-11-row-relational-operations/.