tidyverse : les fonctionnalités/bugs de tibble

Vous avez peut être entendu parler de tidyverse, un ensemble de packages pour le langage R, qui redéfinissent la manière de faire de nombreuses choses par rapport au R de base. Cela comprend notamment les data frames, qui sont remplacés par une alternative, prétendue meilleure, les tibbles.

Créer des variantes incompatibles de structures de données de base est un choix qui doit être mûrement réfléchi car les problèmes que l’on souhaite résoudre risquent de s’empirer. Dans le scenario idéal, la nouvelle structure est tellement supérieure à l’ancienne que la communauté dans son ensemble réécrit tous les packages pour utiliser la nouvelle structure, toutes les ressources pédagogiques sont réécrites et on oublie le fonctionnement de l’ancienne structure avec ses défauts parce qu’on ne l’utilise plus. Mais, ce scenario est improbable. La plupart du temps, comme pour les tibbles, cela divise juste la communauté en deux, un pourcentage important des packages utilisant chacune des structures de données et obligeant l’utilisateur final à rencontrer les deux structures de données (les tibbles et data frames). Lorsqu’on rencontre les deux structures, on doit faire un choix : soit apprendre les différences de fonctionnement des deux structures, soit fuir le package dès qu’on s’aperçoit qu’il produit la structure de données qu’on ne veut pas voir. Dans une certaine mesure, il est possible d’avoir une approche intermédiaire consistant à convertir toutes les entrées-sorties du package complémentaire entre les formats, mais même là, on est obligé de connaître certaines divergences de fonctionnalités, telle que la propension des tibbles à détruire les noms de ligne (rownames) même lorsqu’on lui dit de les conserver. Le mélange arrive très vite, il suffit de faire un appel à merge() sur deux tibbles pour générer un data frame.

Par ailleurs, si certains des changements de comportement entre les data frames et tibbles présentent un intérêt, d’autres s’apparentent plus à un bug qu’à une fonctionnalité. Nous allons voir quelques différences, de manière non exhaustive.

Identification

On peut distinguer les data frames des tibbles grâce à leur classe:

> class(data.frame(x=1))
[1] "data.frame"
> class(tibble(x=1))
[1] "tbl_df" "tbl" "data.frame"
> inherits(tibble(x=1), "data.frame")
[1] TRUE
> is.data.frame(tibble(x=1))
[1] TRUE

Cependant, bien que les tibbles soient incompatibles avec les data frames, elles héritent de la classe data.frame, ce qui a pour conséquence de faire croire aux packages qui ne connaissent pas l’existence des tibbles qu’il s’agit de data.frames normaux parce qu’ils se contentent de tester is.data.frame() pour identifier les data frames. Plutôt que de faire un joli message d’erreur, l’usage de tels packages conduira à un comportement imprévisible, pouvant aller du comportement correct à l’erreur silencieuse en passant par l’erreur bruyante si l’utilisateur ne convertit pas préalablement le tableau de données dans le format correct.

On n’a pas besoin d’aller très loin pour trouver un package qui fournit un résultat faux silencieux avec les tibbles plutôt que les data frames:

> psy::ckappa(tibble(x=c(1,1,2), y=c(1,2,2)))$kappa # buggé
[1] 0
> psy::ckappa(data.frame(x=c(1,1,2), y=c(1,2,2)))$kappa # correct
[1] 0.4

Cela s’explique par la divergence de comportement d’une instruction aussi simple que data[i,j] où i et j sont deux nombres entiers. Pour la défense des tibbles, ce bug n’est pas dû au fait que les tibbles mentent sur leur compatibilité avec les data frames mais à l’absence de vérification du type d’objet d’entrée par la fonction psy::ckappa.

Indexation

La différence la plus pertinente et la plus évidente entre les data frames et les tibbles concerne le comportement d’indexation. Alors que l’option par défaut des data frames est drop=TRUE, elle est à drop=FALSE pour les tibbles. C’est une différence qui saute aux yeux sur le plus simple des codes:

> tibble(x=1:2, id=1:2)[,1]
# A tibble: 2 x 1
      x
  <int>
1     1
2     2
> data.frame(x=1:2, id=1:2)[,1]
[1] 1 2

Il faut dire que l’opérateur d’indexation [] a une double fonction avec les data frames : créer de nouveaux data frames dérivés (p.e. data frame contenant un sous-ensemble de colonnes) et extraire des données (donnée d’une seule cellule ou de toute une colonne). Ce mélange de fonctions est à l’origine de bugs insidieux si on n’est pas suffisamment attentif car on peut vouloir créer un data frame ne contenant qu’un sous-ensemble de colonnes sans penser au cas où ce sous-ensemble ne comprend qu’une seule colonne. Dans ce cas limite, la fonction peut être buggée car le data frame extrait la colonne sous forme de vecteur là où le programme attendait un data frame. Par exemple, la fonction suivante calcule la moyenne des colonnes numériques d’un data frame, mais ne fonctionne pas s’il y a une seule colonne numérique :

> moy_num <- function(data) {colMeans(data[,sapply(data, is.numeric)])}
> moy_num(data.frame(x=1:3, y="a", z=4:6))
x z 
2 5 
> moy_num(data.frame(x=1:3, y="a"))
Error in colMeans(data[, sapply(data, is.numeric)]) : 
  'x' must be an array of at least two dimensions

Une fois qu’on connaît ce problème, on devient très attentif, spécifiant toujours drop=FALSE dès qu’on veut créer un data frame dérivé. Cependant, les débutants tombent facilement dans le panneau, là où les tibbles devrait les protégera en utilisant l’option drop=FALSE par défaut.

Cependant, les débutants peuvent toujours tomber dans le panneau sur les matrices (qui ne sont pas remplacées dans le tidyverse) ou les data frames qu’ils peuvent utiliser accidentellement, en utilisant merge() sur deux tibbles par exemple. On peut argumenter que c’est toujours ça diminue quand même leur risque de confrontation au problème.

Noms de lignes

Alors que la motivation à la modification du comportement d’indexation était évidente, elle est beaucoup moins évidente pour les noms de lignes (rownames). Avant d’en donner la motivation, je vais vous montrer les différences de comportement par quelques exemples :

> mtcars[c("Fiat 128","Valiant"),]
          mpg cyl  disp  hp drat   wt  qsec vs am gear carb
Fiat 128 32.4   4  78.7  66 4.08 2.20 19.47  1  1    4    1
Valiant  18.1   6 225.0 105 2.76 3.46 20.22  1  0    3    1
> mtcars[c("Fiat 128","Valiant"),]["Valiant",]
         mpg cyl disp  hp drat   wt  qsec vs am gear carb
Valiant 18.1   6  225 105 2.76 3.46 20.22  1  0    3    1
> as_tibble(mtcars)[c("Fiat 128","Valiant"),]
# A tibble: 2 x 11
    mpg   cyl  disp    hp  drat    wt  qsec    vs    am  gear  carb
  <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
1    NA    NA    NA    NA    NA    NA    NA    NA    NA    NA    NA
2    NA    NA    NA    NA    NA    NA    NA    NA    NA    NA    NA
> as_tibble(mtcars,rownames=NA)[c("Fiat 128","Valiant"),]
# A tibble: 2 x 11
    mpg   cyl  disp    hp  drat    wt  qsec    vs    am  gear  carb
  <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
1  32.4     4  78.7    66  4.08  2.2   19.5     1     1     4     1
2  18.1     6 225     105  2.76  3.46  20.2     1     0     3     1
> as_tibble(mtcars,rownames=NA)[c("Fiat 128","Valiant"),]["Valiant",]
# A tibble: 1 x 11
    mpg   cyl  disp    hp  drat    wt  qsec    vs    am  gear  carb
  <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
1    NA    NA    NA    NA    NA    NA    NA    NA    NA    NA    NA
> attr(data.frame(x=1:3),"row.names")
[1] 1 2 3
> attr(tibble(x=1:3),"row.names")
[1] 1 2 3
> rownames(data.frame(x=1:3))
[1] "1" "2" "3"
> rownames(tibble(x=1:3))
[1] "1" "2" "3"
> rownames(tibble(x=1:3)[c(1,3),,drop=FALSE])
[1] "1" "2"
> rownames(data.frame(x=1:3)[c(1,3),,drop=FALSE])
[1] "1" "3"

Ainsi, tout data frame et tout tibble a des noms de lignes sous forme de l’attribut « row.names ». Par défaut, lors de la création d’un data frame ou d’un tibble, ces noms de ligne correspondent seulement aux numéros de lignes. L’indexation des lignes par chaînes de caractères est possible aussi bien avec les data frames que les tibbles et ne génère aucun message d’avertissement ou d’erreur. Une indexation par un nom inexistant ne fait ni erreur ni message d’avertissement mais génère une ligne de NAs. Mais là où les data frames conservent les noms de lignes lors des indexations, les tibbles les remplacent par les numéros de lignes, détruisant donc tout leur intérêt de traçabilité. Cette réinitialisation des noms de lignes par les tibbles survient aussi lors d’un appel à as.data.frame(), sans option pour l’empêcher. La fonction as_tibble, peut conserver les noms de lignes si l’option rownames=NA est utilisée. Enfin, on peut changer les noms de lignes avec rownames(data)<-names, mais cela fait un message d’avertissement sur les tibbles « Setting row names on a tibble is deprecated ».

Au total, les noms de lignes sont tellement buggés et incohérents avec les tibbles qu’ils sont inutilisables.Venons en maintenant au rationnel. Il y est fait mention dans la documentation de tibble::tbl_df-class :

The tbl_df class is a subclass of data.frame, created in order to have different default behaviour[…]Objects of class tbl_df have:[…]A row.names attribute, included for compatibility with data.frame. This attribute is only consulted to query the number of rows, any row names that might be stored there are ignored by most tibble methods.[…]Row names are not added and are strongly discouraged, in favor of storing that info as a column. Read about in rownames.

Ensuite, la documentation de rownames fournit des précisions:

While a tibble can have row names (e.g., when converting from a regular data frame), they are removed when subsetting with the [ operator. A warning will be raised when attempting to assign non-NULL row names to a tibble. Generally, it is best to avoid row names, because they are basically a character column with different semantics than every other column.

L’objectif de découragement de l’usage des noms de lignes est clairement affiché. Le seul rationnel à ce découragement est le fait que les noms de lignes sont équivalents à une colonne caractères, avec des sémantiques différentes de toutes les autres colonnes… Implicitement, l’argument se résume à « c’est conceptuellement inesthétique ». Aucun problème des noms de lignes des data frames n’est mentionné ; il faut dire qu’on peut les ignorer la plupart du temps sans que ça pose problème ; si on ne les aime pas, on ne les utilise pas. L’argument puriste de l’esthétique conceptuelle contraste avec la solution retenue : plutôt que de ne pas gérer du tout les noms de lignes, de faire une erreur en cas d’indexation des lignes par chaînes de caractères et de supprimer l’attribut row.names, les noms de lignes sont gérés de manière tellement buggée qu’on est immédiatement découragé de les utiliser. La non suppression des noms de lignes est justifiée par la compatibilité avec les data frames, qui est pourtant catastrophique. In fine, en jouant au purisme, on a le pire de tout:

  1. Le code source des tibbles est alourdi par la gestion des noms de lignes
  2. Tous les tibbles sont alourdis par un attribut « row.names » exactement comme les data frames
  3. Mais le fonctionnement est incompatible avec les data frames, susceptible de casser silencieusement ou bruyamment un programme écrit pour un data frame mais utilisé avec un tibble
  4. Et c’est inutilisable de toute façon

Construction d’un tibble

La fonction tibble() construit un tibble de manière semblable à data.frame(), avec cependant une différence majeure. Alors que les arguments de data.frame() sont exécutés dans l’environnement de l’appel, avec tibble(), il existe un masquage de cet environnement par celui du tibble en cours de construction. Cela conduit à la divergence de comportement qui suit:

> x=1:3; y=2:4; data.frame(y=x, x=y)
  y x
1 1 2
2 2 3
3 3 4
> x=1:3; y=2:4; tibble(y=x, x=y)
# A tibble: 3 x 2
      y     x
  <int> <int>
1     1     1
2     2     2
3     3     3

Là où le x et le y font référence aux deux variables de l’environnement global avec data.frame(), les deux variables ne font pas référence au même environnement avec tibble().

Cette fonctionnalité des tibbles peut être utile pour alléger la syntaxe lorsqu’on souhaite créer une colonne calculée à partir des autres colonnes:

> tibble(x=1:3, x_sq=x^2)
# A tibble: 3 x 2
      x  x_sq
  <int> <dbl>
1     1     1
2     2     4
3     3     9

Il reste possible de faire référence à l’environnement supérieur avec la syntaxe spéciale d’échappement de l’évaluation non standard du tidyverse : le double point d’exclamation (double négation pour R de base).

> x=1:3; y=2:4; tibble(y=x, x=!!y)
# A tibble: 3 x 2
      y     x
  <int> <int>
1     1     2
2     2     3
3     3     4
> x=1:3; y=2:4; data.frame(y=x, x=!!y)
  y    x
1 1 TRUE
2 2 TRUE
3 3 TRUE

Évaluation non standard

L’évaluation non standard est un point de divergence philosophique important entre tidyverse et le reste du monde, c’est-à-dire, le R de base et la grande majorité des langages de programmation. D’une manière générale, on s’attend à ce que les arguments d’une fonction soient évalués dans l’environnement de l’appel. Ainsi, dans la plupart des langages de programmation, le comportement de la fonction f est identique dans les deux cas de figure ci-dessous :

f(g(42)) # appel combiné
x=g(42); f(x) # appel successif

Dans la plupart des langages de programmation, la fonction f est incapable ou presque incapable de faire la différence entre l’appel combiné et l’appel successif parce que l’argument est évalué avant d’appeler la fonction f. Mais R est un langage avec évaluation paresseuse de telle sorte que les arguments d’une fonction sont passés sous forme de promesses, c’est-à-dire des expressions non évaluées associées à un environnement d’évaluation candidat. Normalement, au premier usage du paramètre de la fonction, l’argument est évalué dans l’environnement de l’appelant, mais une fonction qui souhaite faire une évaluation non standard (non standard evaluation) peut accéder à l’expression et faire joujou avec. C’est ainsi qu’on peut réaliser des fonctions qui interprètent de manière symbolique les expressions utilisées comme arguments. Ci-dessous un exemple :

> derivative=function(expr) {D(substitute(expr), "x")}
> x=42; y=log(x)
> derivative(y)
[1] 0
> derivative(log(x))
1/x

Cette fonctionnalité est jolie sur le papier, jusqu’au moment où on veut calculer la « dérivée seconde ».

> derivative(derivative(log(x)))
Error in D(substitute(expr), "x") : 
  Function 'derivative' is not in the derivatives table
> y=derivative(log(x)); derivative(y)
[1] 0

Le problème vient du fait que l’argument n’est plus évalué du tout et donc ne peut plus être variable. On peut s’en sortir en générant une expression puis en l’évaluant.

> y=derivative(log(x))
> y
1/x
> call("derivative", y)
derivative(1/x)
> eval(call("derivative", y))
-(1/x^2)

L’évaluation non standard est donc souvent une fausse bonne idée, car on perd le dynamisme du langage. Cela peut rester acceptable si c’est bien fait. Par exemple le libellés des axes de plot() est obtenu à partir des expressions non évaluées en entrée, mais dès qu’on veut faire un plot() dynamique, on utilise les paramètres xlab et ylab qui suppriment ce comportement.

Tidyverse fait un usage intensif de l’évaluation non standard, principalement pour évaluer les expressions dans l’environnement d’un tibble plutôt que celui de l’appelant. Pour limiter les effets secondaires, on peut toujours s’échapper de l’environnement d’exécution avec le double point d’exclamation pour un paramètre à la fois, ou le triple point d’exclamation pour un jeu de paramètres à la fois.

C’est ainsi qu’un appel aussi simple que :

> apply(df, 2, mean)
   x    y 
 5.5 10.5 

Devient, dans le style tidyverse, le code suivant :

> df %>% summarize(!!!lapply(colnames(df), function(x) {call("mean", as.name(x))}))
  mean(x) mean(y)
1     5.5    10.5

L’argument selon lequel tidyverse facilite l’apprentissage s’effondre dès qu’on sort des exemples triviaux et qu’on veut écrire ses propres fonctions paramétrables, parce que cela oblige à maîtriser les concepts d’évaluation paresseuse et la manipulation des expressions.

Conclusion

Tidyverse réinvente la roue avec les tibbles, mais en créant une roue incompatible avec la précédente, et qui même si elle présente certains avantages, compense ces avantages par des défauts et des bugs qui sont présentés comme des fonctionnalités : décourager l’utilisateur de faire ce qu’il veut.

Pour l’évaluation non standard, c’est une question d’école. Je trouve personnellement que ça apporte peu mais que ça met un frein à la liberté de paramétrisation des fonctions.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *