require(data.table)
knitr::opts_chunk$set(
  comment = "#",
    error = FALSE,
     tidy = FALSE,
    cache = FALSE,
 collapse = TRUE
)

Introduction

data.table, dès ses premières versions, a permis l'utilisation des fonctions subset et with (ou within) en définissant la méthode [.data.table. subset et with sont des fonctions de base de R qui sont utiles pour réduire les répétitions dans le code, améliorer la lisibilité, et réduire le nombre total de caractères que l'utilisateur doit taper. Cette fonctionnalité est possible dans R grâce à une fonction unique appelée évaluation paresseuse ('lazy evaluation'). Cette fonctionnalité permet à une fonction de récupérer ses arguments, avant qu'ils ne soient évalués, et de les évaluer dans un cadre différente de celle dans laquelle ils ont été appelés. Récapitulons l'utilisation de la fonction subset.

registerS3method("print", "data.frame", function(x, ...) {
  base::print.data.frame(head(x, 2L), ...)
  cat("...\n")
  invisible(x)
})
.opts = options(
  datatable.print.topn=2L,
  datatable.print.nrows=20L
)
subset(iris, Species == "setosa")

Ici, subset prend le second argument et l'évalue dans le cadre du data.frame donné comme premier argument. Cela supprime le besoin de répéter les variables, ce qui réduit le risque d'erreurs et rend le code plus lisible.

Description du problème

Le problème de ce type d'interface est qu'il n'est pas facile de paramétrer le code qui l'utilise. En effet, les expressions passées à ces fonctions sont substituées avant d'être évaluées.

Exemple

my_subset = function(data, col, val) {
  subset(data, col == val)
}
my_subset(iris, Species, "setosa")

Approches du problème

Il existe plusieurs façons de contourner ce problème.

Éviter les lazy evaluation

La solution la plus simple est d'éviter les évaluations paresseuses ('lazy evaluation'), et de se rabattre sur des approches moins intuitives et plus sujettes aux erreurs comme df[["variable"]], etc.

my_subset = function(data, col, val) {
  data[data[[col]] == val & !is.na(data[[col]]), ]
}
my_subset(iris, col = "Species", val = "setosa")

Ici, nous calculons un vecteur logique de longueur nrow(iris), puis ce vecteur est fourni à l'argument i de [.data.frame pour effectuer un sous-ensemble ordinaire basé sur un "vecteur logique". Pour s'aligner avec subset(), qui supprime aussi les NA, nous devons inclure une utilisation supplémentaire de data[[col]]. Cela fonctionne assez bien pour cet exemple simple, mais cela manque de flexibilité, introduit des répétitions de variables, et demande à l'utilisateur de changer l'interface de la fonction pour passer le nom de la colonne comme un caractère plutôt qu'un symbole sans guillemet. Plus l'expression à paramétrer est complexe, moins cette approche est pratique.

Utilisation de parse / eval

Cette méthode est généralement préférée par les nouveaux venus dans R, car elle est peut-être la plus simple sur le plan conceptuel. Cette méthode consiste à produire l'expression requise à l'aide de la concaténation de chaînes, à l'analyser, puis à l'évaluer.

my_subset = function(data, col, val) {
  data = deparse(substitute(data))
  col = deparse(substitute(col))
  val = paste0("'", val, "'")
  text = paste0("subset(", data, ", ", col, " == ", val, ")")
  eval(parse(text = text)[[1L]])
}
my_subset(iris, Species, "setosa")

Nous devons utiliser deparse(substitute(...)) pour récupérer les noms réels des objets passés à la fonction, afin de pouvoir construire l'appel à la fonction subset en utilisant ces noms originaux. Bien que cela offre une flexibilité illimitée avec une complexité relativement faible, l'utilisation de eval(parse(...)) devrait être évitée. Les raisons principales sont les suivantes :

Martin Machler, R Project Core Developer, a dit :

Désolé, mais je ne comprends pas pourquoi tant de gens pensent qu'une chaîne de caractères est quelque chose qui peut être évalué. Il faut vraiment changer d'état d'esprit. Oubliez toutes les connexions entre les chaînes d'un côté et les expressions, les appels, l'évaluation de l'autre côté. La (possible) seule connexion est via parse(text = ....) et tous les bons programmeurs R devraient savoir que c'est rarement un moyen efficace ou sûr de construire des expressions (ou des appels). Apprenez plutôt à connaître substitute(), quote(), et peut-être la puissance de l'utilisation de do.call(substitute, ......).

Calculs sur le langage

Les fonctions mentionnées ci-dessus, ainsi que quelques autres (y compris as.call, as.name/as.symbol, bquote, et eval), peuvent être catégorisées comme des fonctions pour calculer sur le langage, puisqu'elles opèrent sur des objets du langage (par exemple call, name/symbol).

my_subset = function(data, col, val) {
  eval(substitute(subset(data, col == val)))
}
my_subset(iris, Species, "setosa")

Ici, nous avons utilisé la fonction de base R substitute pour transformer l'appel subset(data, col = val) en subset(iris, Species == "setosa") en remplaçant data, col, et val par leurs noms (ou valeurs) d'origine dans leur environnement parent. Les avantages de cette approche par rapport aux précédentes devraient être clairs. Notez que parce que nous opérons au niveau des objets du langage, et que nous n'avons pas à recourir à la manipulation de chaînes de caractères, nous nous référons à cela comme calcul sur le langage ('computing on the language'). Il existe un chapitre dédié au calcul sur le langage dans le Manuel du langage R. Bien qu'il ne soit pas nécessaire pour programmer sur data.table, nous encourageons les lecteurs à lire ce chapitre afin de mieux comprendre cette fonctionnalité puissante et unique du langage R.

Utiliser des packages tiers

Il existe des packages tiers qui peuvent réaliser ce que les routines de calcul du R de base sur le langage font (pryr, lazyeval et rlang, pour n'en citer que quelques-uns).

Bien qu'ils puissent être utiles, nous discuterons ici d'une approche propre à data.table.

Programmation sur data.table

Maintenant que nous avons établi la bonne façon de paramétrer le code qui utilise l'évaluation paresseuse ('lazy evaluation'), nous pouvons passer au sujet principal de cette vignette, la programmation sur data.table.

A partir de la version 1.15.0, data.table fournit un mécanisme robuste pour paramétrer les expressions passées aux arguments i, j, et by (ou keyby) de [.data.table. Il est construit sur la fonction de base R substitute, et imite son interface. Nous présentons ici substitute2 comme une version plus robuste et plus conviviale de la fonction substitute de R de base. Pour une liste complète des différences entre base::substitute et data.table::substitute2, veuillez lire le manuel substitute2.

Substitution de variables et de noms

Disons que nous voulons une fonction générale qui applique une fonction à la somme de deux arguments auxquels une autre fonction a été appliquée. Comme exemple concret, nous avons ci-dessous une fonction qui calcule la longueur de l'hypoténuse dans un triangle droit, connaissant la longueur de ses côtés.

${\displaystyle c = \sqrt{a^2 + b^2}}$

square = function(x) x^2
quote(
  sqrt(square(a) + square(b))
)

L'objectif est de faire en sorte que chaque nom dans l'appel ci-dessus puisse être passé en tant que paramètre.

substitute2(
  outer(inner(var1) + inner(var2)),
  env = list(
    outer = "sqrt",
    inner = "square",
    var1 = "a",
    var2 = "b"
  )
)

Nous pouvons voir dans la sortie que les noms des fonctions, ainsi que les noms des variables passées à ces fonctions, ont été remplacés. Nous avons utilisé substitute2 par commodité. Dans ce cas simple, le substitute de base R aurait pu être utilisé aussi, bien qu'il aurait fallu utiliser lapply(env, as.name).

Maintenant, pour utiliser la substitution à l'intérieur de [.data.table, nous n'avons pas besoin d'appeler la fonction substitute2. Comme elle est maintenant utilisée en interne, tout ce que nous avons à faire est de fournir l'argument env, de la même manière que nous l'avons fourni à la fonction substitute2 dans l'exemple ci-dessus. La substitution peut être appliquée aux arguments i, j et by (ou keyby) de la méthode [.data.table. Notez que le fait de mettre l'argument verbose à TRUE peut être utilisé pour afficher les expressions après que la substitution ait été appliquée. Ceci est très utile pour le débogage.

Utilisons le jeu de données iris comme démonstration. A titre d'exemple, imaginons que nous voulions calculer la Sepal.Hypotenuse, en traitant la largeur et la longueur du sépale comme s'il s'agissait des côtés d'un triangle rectangle.

DT = as.data.table(iris)

str(
  DT[, outer(inner(var1) + inner(var2)),
     env = list(
       outer = "sqrt",
       inner = "square",
       var1 = "Sepal.Length",
       var2 = "Sepal.Width"
    )]
)

# retourner le résultat sous forme de data.table
DT[, .(Species, var1, var2, out = outer(inner(var1) + inner(var2))),
   env = list(
     outer = "sqrt",
     inner = "square",
     var1 = "Sepal.Length",
     var2 = "Sepal.Width",
     out = "Sepal.Hypotenuse"
  )]

Dans le dernier appel, nous avons ajouté un autre paramètre, out = "Sepal.Hypotenuse", qui transmet le nom prévu de la colonne de sortie. Contrairement à substitute de base R, substitute2 gérera également la substitution des noms des arguments d'appel.

La substitution fonctionne également pour i et by (ou keyby).

DT[filter_col %in% filter_val,
   .(var1, var2, out = outer(inner(var1) + inner(var2))),
   by = by_col,
   env = list(
     outer = "sqrt",
     inner = "square",
     var1 = "Sepal.Length",
     var2 = "Sepal.Width",
     out = "Sepal.Hypotenuse",
     filter_col = "Species",
     filter_val = I(c("versicolor", "virginica")),
     by_col = "Species"
  )]

Remplacer des variables et des valeurs de caractères

Dans l'exemple ci-dessus, nous avons vu une fonctionnalité pratique de substitute2 : la conversion automatique de chaînes de caractères en noms/symboles. Une question évidente se pose : que se passe-t-il si nous voulons substituer un paramètre par une valeur caractère, afin d'avoir le comportement substitute de R de base. Nous fournissons un mécanisme pour échapper à la conversion automatique en enveloppant les éléments dans l'appel de base R I(). La fonction I marque un objet comme AsIs, empêchant ses arguments d'être convertis automatiquement de caractère à symbole. (Lisez la documentation ?AsIs pour plus de détails.) Si le comportement de R de base est souhaité pour l'ensemble de l'argument env, alors il est préférable d'envelopper l'ensemble de l'argument dans I(). Alternativement, chaque élément de la liste peut être enveloppé dans I() individuellement. Explorons les deux cas ci-dessous.

substitute( # comportement de base de R
  rank(input, ties.method = ties),
  env = list(input = as.name("Sepal.Width"), ties = "first")
)

substitute2( # imite le comportement "substitute" de base R en utilisant "I"
  rank(input, ties.method = ties),
  env = I(list(input = as.name("Sepal.Width"), ties = "first"))
)

substitute2( # seuls certains éléments de env sont utilisés "AsIs"
  rank(input, ties.method = ties),
  env = list(input = "Sepal.Width", ties = I("first"))
)

Notez que la conversion s'effectue de manière récursive sur chaque élément de la liste, y compris le mécanisme d'échappement bien sûr.

substitute2( # tous sont des symboles
  f(v1, v2),
  list(v1 = "a", v2 = list("b", list("c", "d")))
)
substitute2( # 'a' et 'd' doivent rester des chaines de caractères
  f(v1, v2),
  list(v1 = I("a"), v2 = list("b", list("c", I("d"))))
)

Substituer des listes de longueur arbitraire

L'exemple présenté ci-dessus illustre un moyen propre et puissant de rendre votre code plus dynamique. Cependant, il existe de nombreux autres cas beaucoup plus complexes auxquels un développeur peut être confronté. Un problème courant consiste à gérer une liste d'arguments de longueur arbitraire.

Un cas d'utilisation évident pourrait être d'imiter la fonctionnalité .SD en injectant un appel list dans l'argument j.

cols = c("Sepal.Length", "Sepal.Width")
DT[, .SD, .SDcols = cols]

Avec le paramètre cols, nous voudrions l'intégrer dans un appel list, en faisant ressembler l'argument j au code ci-dessous.

DT[, list(Sepal.Length, Sepal.Width)]

Le 'splicing' est une opération où une liste d'objets doit être intégrée dans une expression comme une séquence d'arguments à appeler. Dans R de base, le 'splicing' de cols dans une liste peut être réalisé en utilisant as.call(c(quote(list), lapply(cols, as.name))). De plus, à partir de R 4.0.0, il y a une nouvelle interface pour une telle opération dans la fonction bquote.

Dans data.table, nous facilitons les choses en transformant automatiquement en liste une liste d'objets en un appel de liste avec ces objets. Cela signifie que tout objet list à l'intérieur de l'argument env list sera transformé en call list, rendant l'API pour ce cas d'utilisation aussi simple que présenté ci-dessous.

# cela fonctionne
DT[, j,
   env = list(j = as.list(cols)),
   verbose = TRUE]

# cela ne fonctionnera pas
#DT[, list(cols),
# env = list(cols = cols)]

Il est important de fournir un appel à as.list, plutôt qu'une simple liste, à l'intérieur de l'argument list de env, comme le montre l'exemple ci-dessus.

Examinons plus en détail la question de l'ajout à la liste ('enlist-ing').

DT[, j, # data.table met automatiquement en liste les listes imbriquées dans des appels de liste
   env = list(j = as.list(cols)),
   verbose = TRUE]

DT[, j, # transformer la liste 'j' ci-dessus en un appel de liste
   env = list(j = quote(list(Sepal.Length, Sepal.Width))),
   verbose = TRUE]

DT[, j, # la même chose que ci-dessus mais accepte un vecteur de caractères
   env = list(j = as.call(c(quote(list), lapply(cols, as.name)))),
   verbose = TRUE]

Essayons maintenant de passer une liste de symboles, plutôt qu'un appel de liste à ces symboles. Nous utiliserons I() pour échapper à la mise en liste (enlist-ing) automatique, mais comme cela désactivera aussi la conversion des caractères en symboles, nous devrons aussi utiliser as.name.

DT[, j, # liste de symboles
   env = I(list(j = lapply(cols, as.name))),
   verbose = VRAI]

DT[, j, # encore une fois de la meilleure façon, ajout automatique de la liste à l'appel de liste
   env = list(j = as.list(cols)),
   verbose = TRUE]

Notez que les deux expressions, bien qu'elles semblent visuellement identiques, ne le sont pas.

str(substitute2(j, env = I(list(j = lapply(cols, as.name)))))

str(substitute2(j, env = list(j = as.list(cols))))

Pour une explication plus détaillée à ce sujet, veuillez consulter les exemples dans la documentation substitute2.

Substitution d'une requête complexe

Prenons l'exemple d'une fonction plus complexe, le calcul de la moyenne quadratique.

${\displaystyle x_{\text{RMS}}={\sqrt{{\frac{1}{n}}\left(x_{1}^{2}+x_{2}^{2}+\cdots +x_{n}^{2}\right)}}}$

Il prend un nombre arbitraire de variables en entrée, mais maintenant nous ne pouvons pas simplement ajouter (splice) une liste d'arguments dans un appel de liste parce que chacun de ces arguments doit être enveloppé dans un appel square. Dans ce cas, nous devons faire l'opération à la main plutôt que de compter sur la transformation automatique en liste ('enlist') de data.table.

Tout d'abord, nous devons construire des appels à la fonction square pour chacune des variables (voir inner_calls). Ensuite, nous devons réduire la liste des appels en un seul appel, avec une séquence imbriquée d'appels + (voir add_calls). Enfin, nous devons substituer l'appel construit dans l'expression environnante (voir rms).

outer = "sqrt"
inner = "square"
vars = c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width")

syms = lapply(vars, as.name)
to_inner_call = function(var, fun) call(fun, var)
inner_calls = lapply(syms, to_inner_call, inner)
print(inner_calls)

to_add_call = function(x, y) call("+", x, y)
add_calls = Reduce(to_add_call, inner_calls)
print(add_calls)

rms = substitute2(
  expr = outer((add_calls) / len),
  env = list(
    outer = outer,
    add_calls = add_calls,
    len = length(vars)
  )
)
print(rms)

str(
  DT[, j, env = list(j = rms)]
)

# idem, mais en sautant le dernier appel à substitute2 et en utilisant directement add_calls
str(
  DT[, outer((add_calls) / len),
     env = list(
       outer = outer,
       add_calls = add_calls,
       len = length(vars)
    )]
)

# retourner le résultat en tant que data.table
j = substitute2(j, list(j = as.list(setNames(nm = c(vars, "Species", "rms")))))
j[["rms"]] = rms
print(j)
DT[, j, env = list(j = j)]

# ou alors :
j = as.call(c(
  quote(list),
  lapply(setNames(nm = vars), as.name),
  list(Species = as.name("Species")),
  list(rms = rms)
))
print(j)
DT[, j, env = list(j = j)]

Interfaces supprimées

Dans [.data.table, il est aussi possible d'utiliser d'autres mécanismes pour la substitution de variables ou pour passer des expressions entre guillemets. Ceux-ci incluent get et mget pour l'injection en ligne de variables en fournissant leurs noms sous forme de chaînes, et eval qui indique à [.data.table que l'expression passée en argument est une expression entre guillemets et qu'elle doit être traitée différemment. Ces interfaces doivent maintenant être considérées comme retirées et nous recommandons d'utiliser le nouvel argument env à la place.

get

v1 = "Petal.Width"
v2 = "Sepal.Width"

DT[, .(total = sum(get(v1), get(v2)))]

DT[, .(total = sum(v1, v2)),
   env = list(v1 = v1, v2 = v2)]

mget

v = c("Petal.Width", "Sepal.Width")

DT[, lapply(mget(v), mean)]

DT[, lapply(v, mean),
   env = list(v = as.list(v))]

DT[, lapply(v, mean),
   env = list(v = as.list(setNames(nm = v)))]

eval

Au lieu d'utiliser la fonction eval, nous pouvons fournir une expression citée dans l'élément de l'argument env, aucun appel supplémentaire à eval n'est alors nécessaire.

cl = quote(
  .(Petal.Width = mean(Petal.Width), Sepal.Width = mean(Sepal.Width))
)

DT[, eval(cl)]

DT[, cl, env = list(cl = cl)]
options(.opts)
registerS3method("print", "data.frame", base::print.data.frame)


Rdatatable/data.table documentation built on Nov. 20, 2024, 6:44 p.m.