Search code examples
rmatrixvctrs

Using vctrs in matrices


I'm experimenting with the vctrs package. My actual use-case is in relevant aspects similar to the rational class implemented in the helpful S3 vectors article on the vctrs homepage, in that it uses rcrd for paired data. I'll use that for my reprex for clarity. (EDIT: I am not, however, specifically interested in rationals.) Let me paste the relevant parts first:

library(vctrs)
library(zeallot)

new_rational <- function(n = integer(), d = integer()) {
  vec_assert(n, ptype = integer())
  vec_assert(d, ptype = integer())

  new_rcrd(list(n = n, d = d), class = "vctrs_rational")
}

rational <- function(n, d) {
  c(n, d) %<-% vec_cast_common(n, d, .to = integer())
  c(n, d) %<-% vec_recycle_common(n, d)

  new_rational(n, d)
}

format.vctrs_rational <- function(x, ...) {
  n <- field(x, "n")
  d <- field(x, "d")

  out <- paste0(n, "/", d)
  out[is.na(n) | is.na(d)] <- NA

  out
}

vec_ptype_abbr.vctrs_rational <- function(x, ...) "rtnl"
vec_ptype_full.vctrs_rational <- function(x, ...) "rational"

An example of using this:

(x <- rational(1, 1:15))
#> <rational[15]>
#>  [1] 1/1  1/2  1/3  1/4  1/5  1/6  1/7  1/8  1/9  1/10 1/11 1/12 1/13 1/14 1/15

My problem arises when trying to use a class like this in a matrix:

matrix(x, ncol = 5, nrow = 3)
#> Warning in matrix(x, ncol = 5, nrow = 3): data length [2] is not a sub-multiple
#> or multiple of the number of rows [3]
#>      [,1]       [,2]       [,3]       [,4]       [,5]      
#> [1,] Integer,15 Integer,15 Integer,15 Integer,15 Integer,15
#> [2,] Integer,15 Integer,15 Integer,15 Integer,15 Integer,15
#> [3,] Integer,15 Integer,15 Integer,15 Integer,15 Integer,15

Created on 2020-06-05 by the reprex package (v0.3.0)

I was hoping to get a 3-by-5 matrix with each cell containing one value from x, as would have happened if x had been a "normal" vector. Instead, I get a 3-by-5 matrix of lists, where vctrs tries to make alternating rows contain n and d values, respectively.

My question, therefore, is is it possible to get vctrs to work with matrices in the "expected" manner for a situation like this, and if so, how? By experimenting, I got the sense that this might have to do with implementing dim.rational and `dim<-.rational`, but I couldn't make it work.

EDIT: If the desired matrix is not clear (as suggested in the comments), I would like a matrix object somewhat akin to the following, which I've edited by hand:

(m <- matrix(x, ncol = 5, nrow = 3))
#> <rational[15]>
#>      [,1] [,2] [,3] [,4]  [,5]   
#> [1,] 1/1  1/4  1/7  1/10  1/13
#> [2,] 1/2  1/5  1/8  1/11  1/14
#> [3,] 1/3  1/6  1/9  1/12  1/15

Such that normal matrix operations would work on m, e.g.

m[1,]
#> <rational[5]>
#> 1/1  1/4  1/7  1/10  1/13

Solution

  • The whole design of the rational class seems built on preserving its type safety, and hiding implementation from users, which I can see would be necessary to get it to work consistently, but this means that you can't expect it to play nicely with R's default S3 methods.

    The help file for vctrs specifically says

    • dims(), dims<-, dimnames(), dimnames<-, levels(), and levels<- methods throw errors.

    This suggests that the authors of vctrs didn't think it was a great base on which to build matrix methods.

    In any case, I wouldn't be in such a hurry to try to get it into a matrix, since you can't do anything with it once it's there: there are no arithmetic methods available to you:

    x + 2
    #> Error: <rational> + <double> is not permitted
    #> Run `rlang::last_error()` to see where the error occurred.
    x * 2
    #> Error: <rational> * <double> is not permitted
    #> Run `rlang::last_error()` to see where the error occurred.
    x + x
    #> Error: <rational> + <rational> is not permitted
    #> Run `rlang::last_error()` to see where the error occurred.
    

    So you would need to define the arithmetic methods first. Before you even do that, you need $ accessors for the numerators and denominators, an is.rational function to check the type before attempting arithmetic, a function to find the greatest common denominator, and a function to simplify your rationals based on it.

    `$.vctrs_rational` <- function(vec, symb) unclass(vec)[[as.character(symb)]]
    
    is.rational <- function(num) class(num)[1] == "vctrs_rational"
    
    gcd <- function(x, y) ifelse(x %% y, gcd(y, x %% y), y)
    
    simplify <- function(num) {
      common <- gcd(num$n, num$d)
      rational(num$n / common, num$d/common)
    }
    

    So now you can do

    x$n
    #>  [1] 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
    x$d
    #>  [1]  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
    is.rational(x)
    #> [1] TRUE
    

    And now write the arithmetic functions. For example, here is an implementation of basic arithmetic to cover numeric and rational types:

    Ops.vctrs_rational <- function(vec, num)
    {
      if(!is.rational(vec)) {tmp <- vec; vec <- num; num <- tmp; }
      if(.Generic == '*'){
        if(is.rational(num)) return(simplify(rational(vec$n * num$n, vec$d * num$d)))
        else return(simplify(rational(vec$n * 2, vec$d)))
      }
    
      else if (.Generic == '/'){
        if(is.rational(num)) return(vec * rational(num$d, num$n))
        else return(vec * rational(1, num))
      }
    
      else if (.Generic == '+'){
        if(is.rational(num)){ 
          new_n <- vec$n * (vec$d * num$d)/vec$d + num$n * (vec$d * num$d)/num$d
          return(simplify(rational(new_n, vec$d * num$d)))
        }
        else return(simplify(rational(num * vec$d + vec$n, vec$d)))
      }
    
      else if (.Generic == '-'){
        if(is.rational(num)) return(vec + rational(-num$n, num$d)) 
        else return(vec + (-num)) 
      }
    
      else if (.Generic == '^'){
        if(is.rational(num) | num < 0) stop("fractional and negative powers not supported")
        return(simplify(rational(vec$n ^ num, vec$d ^ num)))
      }
    }
    

    This now allows you to do, for example:

    x * 3
    #> <rational[15]>
    #>  [1] 3/1  3/2  1/1  3/4  3/5  1/2  3/7  3/8  1/3  3/10 3/11 1/4  3/13 3/14 1/5
    
    x + x
    #> <rational[15]>
    #> [1] 2/1  1/1  2/3  1/2  2/5  1/3  2/7  1/4  2/9  1/5  2/11 1/6  2/13 1/7  2/15
    
    (2 + x)^2 / (3 * x + 1)
    #> <rational[15]>
    #>  [1] 3/1     25/8    49/15   27/8    121/35  169/48  25/7    289/80 
    #>  [9] 361/99  147/40  529/143 625/168 243/65  841/224 961/255
    

    Trying to use matrix() itself directly is probably not going to work, since matrix works by converting to a base vector and then calling C code. This strips out class information.

    That means you need to define a separate rational_matrix class, which in turn would benefit from a supporting rational_vector class. We can then define specific format and print methods:

    as.vector.vctrs_rational <- function(x, ...) {
      n <- x$n/x$d
      attr(n, "denom") <- x$d
      attr(n, "numerator") <- x$n
      class(n) <- "rational_attr"
      n
    }
    
    rational_matrix <- function(data, nrow = 1, ncol = 1, 
                                byrow = FALSE, dimnames = NULL){
      d <- as.vector(data)
      m <- .Internal(matrix(d, nrow, ncol, byrow, dimnames, missing(nrow), 
                            missing(ncol)))
      m_dim <- dim(m)
      attributes(m) <- attributes(d)
      dim(m) <- rev(m_dim)
      class(m) <- c("rational_matrix", "matrix")
      m
    }
    
    format.rational_matrix <- function(x) {
     return(paste0(attr(x, "numerator"), "/", attr(x, "denom")))
    }
    
    print.rational_matrix <- function(x)
    {
      print(matrix(format(x), nrow = dim(x)[2]), quote = FALSE)
    }
    

    Finally, you need to overwrite matrix() to make it an S3 method, being sure you first copy the function as matrix.default

    matrix.default <- matrix
    matrix <- function(data = NA, ...) UseMethod("matrix")
    matrix.vctrs_rational <- function(data, ...) rational_matrix(data, ...)
    

    So now you can do:

    matrix(x, nrow = 5)
    #>      [,1] [,2] [,3] [,4] [,5]
    #> [1,] 1/1  1/4  1/7  1/10 1/13
    #> [2,] 1/2  1/5  1/8  1/11 1/14
    #> [3,] 1/3  1/6  1/9  1/12 1/15
    
    rational_matrix(x + 5, nrow = 3)
    #>      [,1] [,2] [,3] [,4]  [,5] 
    #> [1,] 6/1  21/4 36/7 51/10 66/13
    #> [2,] 11/2 26/5 41/8 56/11 71/14
    #> [3,] 16/3 31/6 46/9 61/12 76/15
    
    rational_matrix(x + x, nrow = 5)
    #>      [,1] [,2] [,3]
    #> [1,] 2/1  1/3  2/11
    #> [2,] 1/1  2/7  1/6 
    #> [3,] 2/3  1/4  2/13
    #> [4,] 1/2  2/9  1/7 
    #> [5,] 2/5  1/5  2/15
    

    However, to get this to work we had to add extra classes with attributes anyway, so my feeling is that if you want a rational class that works with matrices etc, you should do it in native S3 or one of the other object-oriented approaches available in R rather than using the vctrs package.

    It's also worth saying that the above class is far from production-ready, since you would need to add in methods to test equality / inequality, methods to describe the matrix operations, an ability to convert to decimal, plotting methods, etc.