2

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
MSR
  • 2,731
  • 1
  • 14
  • 24
  • Just a thought, but I wonder if your question could be improved by more clearly defining what "expected" means. Perhaps, "how can I modify my object `x` or write a custom method for `as.matrix` such that the values of `x` become `i,j` entries of a matrix?" Or whatever it is that your "expected" behavior is. – Ian Campbell Jun 05 '20 at 12:51
  • Sure, I've added an edit with an example. Hope it helps. Feel free to edit further if you feel anything could be made clearer still. – MSR Jun 05 '20 at 13:02
  • `MASS::fractions(matrix(1/1:15,5,3))`??? – Onyambu Jun 05 '20 at 23:45

1 Answers1

3

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.

Allan Cameron
  • 147,086
  • 7
  • 49
  • 87
  • 2
    instead of defining function for each operator, you should consider using the groupings eg `Math` group for all the `"+", "-", "*", "/", "^", "%%", "%/%",&", "|", "!","==", "!=", "<", "<=", ">=", ">"`. You define one function that takes all the rest into account. check `?Math` for more information – Onyambu Jun 05 '20 at 14:18
  • @Onyambu yes, good point. I was trying to keep this simple, but actually it's still just as clear with the generic operations all defined within an `Ops` definition, so I have updated this. I appreciate your feedback and your time. – Allan Cameron Jun 05 '20 at 22:51
  • After looking at the data, it seems that these are just fractions. Why not use the fractions in `MASS` package? for example `MASS::fractions(matrix(1/1:15,5,3))`??. – Onyambu Jun 05 '20 at 23:42
  • @Onyambu `MASS::fractions` is certainly an option. The point of this answer was not to show how to get fractions into a matrix, but rather how to get the class the OP had designed into a matrix, and to point out all the difficulties that would be involved in doing it that way. Plus, it's a useful exploration of some aspects of S3 classes and methods. I do say at the bottom of my post that this is not the best way to tackle the problem. – Allan Cameron Jun 05 '20 at 23:58
  • 1
    @Allan Cameron: I'd missed the point about `dims` (and related methods) explicitly not being allowed in `new_vctrs`. That's a shame, since it would feel like a natural extension, but I'm sure there are good reasons for this. Anyway, thank you for providing a framework for how to think about this. – MSR Jun 06 '20 at 12:55
  • 1
    @Onyambu Thanks for the suggestion about `MASS::fractions`. I'm not actually interested in fractions, however, I just have a data type with paired entries that causes the same problems for matrices, so I thought I'd go with an example from the docs for clarity. More generally, I was interested in the interface between `vctrs` and matrices. Thanks for the helpful pointer to `?Math` also. – MSR Jun 06 '20 at 12:57
  • @Allan Cameron: Just thinking a little more about this, I wonder how to square the fact that `dims` are not permitted in `new_vctrs`, yet it is explicitly mentioned e.g. in `?vec_data` that `names`, `dims` and `dimnames` are kept, unlike all other attributes. – MSR Jun 06 '20 at 13:03
  • 1
    @MSR it seems like the underlying data can be in matrix format, but not the resulting object. It's the difference between implementation and interface, i.e. back-end versus front-end – Allan Cameron Jun 06 '20 at 13:08