Programmation avec data.table
2025-04-18
Source:vignettes/fr/datatable-programming.Rmd
datatable-programming.Rmd
Translations of this document are available in: en | fr
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
.
subset(iris, Species == "setosa")
# Sepal.Length Sepal.Width Petal.Length Petal.Width Species
# 1 5.1 3.5 1.4 0.2 setosa
# 2 4.9 3.0 1.4 0.2 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")
# Error: object 'Species' not found
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")
# Sepal.Length Sepal.Width Petal.Length Petal.Width Species
# 1 5.1 3.5 1.4 0.2 setosa
# 2 4.9 3.0 1.4 0.2 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")
# Sepal.Length Sepal.Width Petal.Length Petal.Width Species
# 1 5.1 3.5 1.4 0.2 setosa
# 2 4.9 3.0 1.4 0.2 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 :
- absence de validation syntaxique
- vulnérabilité à l’injection de code
- existence de meilleures alternatives
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îtresubstitute()
,quote()
, et peut-être la puissance de l’utilisation dedo.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")
# Sepal.Length Sepal.Width Petal.Length Petal.Width Species
# 1 5.1 3.5 1.4 0.2 setosa
# 2 4.9 3.0 1.4 0.2 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.
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.
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"
)
)
# sqrt(square(a) + square(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"
)]
)
# num [1:150] 6.19 5.75 5.69 5.55 6.16 ...
# 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"
)]
# Species Sepal.Length Sepal.Width Sepal.Hypotenuse
# <fctr> <num> <num> <num>
# 1: setosa 5.1 3.5 6.185467
# 2: setosa 4.9 3.0 5.745433
# ---
# 149: virginica 6.2 3.4 7.071068
# 150: virginica 5.9 3.0 6.618912
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"
)]
# Species Sepal.Length Sepal.Width Sepal.Hypotenuse
# <fctr> <num> <num> <num>
# 1: versicolor 7.0 3.2 7.696753
# 2: versicolor 6.4 3.2 7.155418
# ---
# 99: virginica 6.2 3.4 7.071068
# 100: virginica 5.9 3.0 6.618912
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")
)
# rank(Sepal.Width, ties.method = "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"))
)
# rank(Sepal.Width, ties.method = "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"))
)
# rank(Sepal.Width, ties.method = "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")))
)
# f(a, 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"))))
)
# f("a", list(b, list(c, "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]
# Sepal.Length Sepal.Width
# <num> <num>
# 1: 5.1 3.5
# 2: 4.9 3.0
# ---
# 149: 6.2 3.4
# 150: 5.9 3.0
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)]
# Sepal.Length Sepal.Width
# <num> <num>
# 1: 5.1 3.5
# 2: 4.9 3.0
# ---
# 149: 6.2 3.4
# 150: 5.9 3.0
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]
# Argument 'j' after substitute: list(Sepal.Length, Sepal.Width)
# Detected that j uses these columns: [Sepal.Length, Sepal.Width]
# Sepal.Length Sepal.Width
# <num> <num>
# 1: 5.1 3.5
# 2: 4.9 3.0
# ---
# 149: 6.2 3.4
# 150: 5.9 3.0
# 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]
# Argument 'j' after substitute: list(Sepal.Length, Sepal.Width)
# Detected that j uses these columns: [Sepal.Length, Sepal.Width]
# Sepal.Length Sepal.Width
# <num> <num>
# 1: 5.1 3.5
# 2: 4.9 3.0
# ---
# 149: 6.2 3.4
# 150: 5.9 3.0
DT[, j, # transformer la liste 'j' ci-dessus en un appel de liste
env = list(j = quote(list(Sepal.Length, Sepal.Width))),
verbose = TRUE]
# Argument 'j' after substitute: list(Sepal.Length, Sepal.Width)
# Detected that j uses these columns: [Sepal.Length, Sepal.Width]
# Sepal.Length Sepal.Width
# <num> <num>
# 1: 5.1 3.5
# 2: 4.9 3.0
# ---
# 149: 6.2 3.4
# 150: 5.9 3.0
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]
# Argument 'j' after substitute: list(Sepal.Length, Sepal.Width)
# Detected that j uses these columns: [Sepal.Length, Sepal.Width]
# Sepal.Length Sepal.Width
# <num> <num>
# 1: 5.1 3.5
# 2: 4.9 3.0
# ---
# 149: 6.2 3.4
# 150: 5.9 3.0
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]
# Error: object 'VRAI' not found
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]
# Argument 'j' after substitute: list(Sepal.Length, Sepal.Width)
# Detected that j uses these columns: [Sepal.Length, Sepal.Width]
# Sepal.Length Sepal.Width
# <num> <num>
# 1: 5.1 3.5
# 2: 4.9 3.0
# ---
# 149: 6.2 3.4
# 150: 5.9 3.0
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)))))
# List of 2
# $ : symbol Sepal.Length
# $ : symbol Sepal.Width
str(substitute2(j, env = list(j = as.list(cols))))
# language list(Sepal.Length, Sepal.Width)
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.
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)
# [[1]]
# square(Sepal.Length)
#
# [[2]]
# square(Sepal.Width)
#
# [[3]]
# square(Petal.Length)
#
# [[4]]
# square(Petal.Width)
to_add_call = function(x, y) call("+", x, y)
add_calls = Reduce(to_add_call, inner_calls)
print(add_calls)
# square(Sepal.Length) + square(Sepal.Width) + square(Petal.Length) +
# square(Petal.Width)
rms = substitute2(
expr = outer((add_calls) / len),
env = list(
outer = outer,
add_calls = add_calls,
len = length(vars)
)
)
print(rms)
# sqrt((square(Sepal.Length) + square(Sepal.Width) + square(Petal.Length) +
# square(Petal.Width))/4L)
str(
DT[, j, env = list(j = rms)]
)
# num [1:150] 3.17 2.96 2.92 2.87 3.16 ...
# 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)
)]
)
# num [1:150] 3.17 2.96 2.92 2.87 3.16 ...
# 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)
# list(Sepal.Length = Sepal.Length, Sepal.Width = Sepal.Width,
# Petal.Length = Petal.Length, Petal.Width = Petal.Width, Species = Species,
# rms = sqrt((square(Sepal.Length) + square(Sepal.Width) +
# square(Petal.Length) + square(Petal.Width))/4L))
DT[, j, env = list(j = j)]
# Sepal.Length Sepal.Width Petal.Length Petal.Width Species rms
# <num> <num> <num> <num> <fctr> <num>
# 1: 5.1 3.5 1.4 0.2 setosa 3.172538
# 2: 4.9 3.0 1.4 0.2 setosa 2.958462
# ---
# 149: 6.2 3.4 5.4 2.3 virginica 4.594834
# 150: 5.9 3.0 5.1 1.8 virginica 4.273757
# ou alors :
j = as.call(c(
quote(list),
lapply(setNames(nm = vars), as.name),
list(Species = as.name("Species")),
list(rms = rms)
))
print(j)
# list(Sepal.Length = Sepal.Length, Sepal.Width = Sepal.Width,
# Petal.Length = Petal.Length, Petal.Width = Petal.Width, Species = Species,
# rms = sqrt((square(Sepal.Length) + square(Sepal.Width) +
# square(Petal.Length) + square(Petal.Width))/4L))
DT[, j, env = list(j = j)]
# Sepal.Length Sepal.Width Petal.Length Petal.Width Species rms
# <num> <num> <num> <num> <fctr> <num>
# 1: 5.1 3.5 1.4 0.2 setosa 3.172538
# 2: 4.9 3.0 1.4 0.2 setosa 2.958462
# ---
# 149: 6.2 3.4 5.4 2.3 virginica 4.594834
# 150: 5.9 3.0 5.1 1.8 virginica 4.273757
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.
mget
v = c("Petal.Width", "Sepal.Width")
DT[, lapply(mget(v), mean)]
# Petal.Width Sepal.Width
# <num> <num>
# 1: 1.199333 3.057333
DT[, lapply(v, mean),
env = list(v = as.list(v))]
# V1 V2
# <num> <num>
# 1: 1.199333 3.057333
DT[, lapply(v, mean),
env = list(v = as.list(setNames(nm = v)))]
# Petal.Width Sepal.Width
# <num> <num>
# 1: 1.199333 3.057333