---
title: "Utiliser .SD pour l’analyse de données"
date: "`r Sys.Date()`"
output:
  markdown::html_format:
    options:
      toc: true
      number_sections: true
vignette: >
  %\VignetteIndexEntry{Utiliser .SD pour l’analyse de données}
  %\VignetteEngine{knitr::knitr}
  \usepackage[utf8]{inputenc}
---

```{r, echo = FALSE, message = FALSE}
require(data.table)
knitr::opts_chunk$set(
  comment = "#",
  error = FALSE,
  tidy = FALSE,
  cache = FALSE,
  collapse = TRUE,
  out.width = '100%',
  dpi = 144
)
.old.th = setDTthreads(1)
```

Cette vignette explique les manières habituelles d'utiliser la variable `.SD` dans vos analyses de `data.table` . C'est une adaptation ce [cette réponse](https://stackoverflow.com/a/47406952/3576984) donnée sur StackOverflow.

# C'est quoi `.SD` ?

Au sens large, `.SD` est simplement un raccourci pour capturer une variable qui apparait fréquemment dans le contexte de l'analyse de données. Il faut comprendre *S* pour *S*ubset, *S*elfsame, ou *S*elf-reference et *D* pour *D*onnée. Ce qui donne, `.SD` qui dans sa forme la plus basique est une *référence réflexive* de la `data.table` elle-même -- comme nous le verrons dans les exemples ci-dessous,  ceci est particulièrement utile pour chaîner ensemble les "requêtes" (extractions/sous-ensembles/etc... en utilisant `[`). E particulier cela signifie aussi que *`.SD` est lui-même une `data.table`* (avec la mise en garde qu'il ne peut être assigné avec `:=`).

L'utilisation la plus simple de `.SD` est pour le sous-ensemble de colonnes (i.e., quand `.SDcols` est spécifié) ; comme cette version est beaucoup plus simple à comprendre, nous allons la couvrir en premier ci-dessous. L'interprétation de `.SD` dans sa seconde utilisation, les scénarios de regroupement (i.e., quand `by = ` ou `keyby = ` est spécifié), est légèrement différente, conceptuellement (bien qu'au fond ce soit la même chose, puisque, après tout, une opération non regroupée est un cas limite de regroupement avec un seul groupe).

## Charger et afficher les données Lahman

Pour rendre cela un peu plus concret, plutôt que de modifier les données, chargeons quelques ensembles de données concernant le baseball à partir de la [base de données Lahman](https://github.com/cdalzell/Lahman). Dans R typiquement, nous aurions simplement chargé ces ensembles de données du package R `Lahman`; dans cette vignette, nous les avons préchargés à la place, directement à partir de la page GitHub du package.

```{r download_lahman}
load('../Teams.RData')
setDT(Teams)
Teams

load('../Pitching.RData')
setDT(Pitching)
Pitching
```

Les lecteurs connaissant le jargon du baseball devraient trouver le contenu des tableaux familier ; `Teams` enregistre certaines statistiques pour une équipe et une année donnée, alors que `Pitching` enregistre les statistiques pour un lanceur et une année donnée. Veuillez lire la [documentation](https://github.com/cdalzell/Lahman) et explorer un peu les données avant  d'aller plus loin afin de vous familiariser avec leur structure.

# `.SD` sur des données non groupées

Pour illustrer ce que l'on entend par nature réflexive de `.SD`, considérons son utilisation la plus banale :

```{r plain_sd}
Pitching[ , .SD]
```

C'est à dire que `Pitching[ , .SD]` a simplement renvoyé la table complète, et c'est une manière exagérément verbeuse d'écrire `Pitching` ou `Pitching[]`:

```{r plain_sd_is_table}
identical(Pitching, Pitching[ , .SD])
```

En terme de sous-groupe, `.SD` est un sous-groupe des données, le plus évident (c'est l'ensemble lui-même).

## Extraction de colonnes : `.SDcols`

La première façon d'impacter ce que représente `.SD` c'est de limiter les *colonnes* contenues dans `.SD` en utilisant l'argument `.SDcols` dans `[` :

```{r simple_sdcols}
# W: Wins; L: Losses; G: Games
Pitching[ , .SD, .SDcols = c('W', 'L', 'G')]
```

Ceci ne sert que d'illustration et était très ennuyeux. En plus d'accepter un vecteur de caractères `.SDcols` accepte également :

1. toute fonction telle que `is.character` pour filtrer les *colonnes*
2. la fonction^*^ `patterns()` pour filtrer les *noms de colonnes* par expression régulière
3. les vecteurs entiers et logiques

*voir `?patterns` pour davantage de détails

Cette simple utilisation permet une large variété d'opérations avantageuses ou équivalentes de manipulation des données :

## Convertir un type de colonne

La conversion du type de colonne est une réalité en gestion des données. Bien que [`fwrite` a récemment gagné la possibilité de déclarer en amont la classe de chaque colonne](https://github.com/Rdatatable/data.table/pull/2545), chaque ensemble de données n'est pas forcément issu d'un `fread` (comme dans cette vignette) et les conversions alternatives parmi les types `character`, `factor`, et `numeric` sont courantes. Nous pouvons utiliser `.SD` et `.SDcols` pour convertir par lots des groupes de colonnes vers un type commun.

Remarquons que les colonnes suivantes sont rangées en tant que `character` dans l'ensemble de données `Teams`, mais qu'elles pourraient avantageusement être rangées comme `factor` :

```{r identify_factors}
# teamIDBR: Team ID utilisé par le site de référence du baseball
# teamIDlahman45: Team ID utilisé dans la base de données Lahman v4.5
# teamIDretro: Team ID utilisé par Retrosheet
fkt = c('teamIDBR', 'teamIDlahman45', 'teamIDretro')
# confirmer que ce sont bien des `character`
str(Teams[ , ..fkt])
```

La syntaxe pour convertir ces colonnes en `factor` est simple :

```{r assign_factors}
Teams[ , names(.SD) := lapply(.SD, factor), .SDcols = patterns('teamID')]
# imprime la première colonne pour montrer que c’est correct
head(unique(Teams[[fkt[1L]]]))
```

Note :

1. Le `:=` est un opérateur d'affectation qui permet de mettre à jour `data.table` sans faire de copie. Voir [reference semantics](https://cran.r-project.org/package=data.table/vignettes/datatable-reference-semantics.html) pour plus d'informations. 
2. Le membre de gauche, `names(.SD)`, indique quelles colonnes nous mettons à jour - dans ce cas, nous mettons à jour l'intégralité de `.SD`.
3. Le membre de droite, `lapply()`, parcourt chaque colonne du `.SD` et convertit la colonne en un facteur.
4. Nous utilisons `.SDcols` pour sélectionner uniquement les colonnes qui ont le motif `teamID`.

A nouveau, l'argument `.SDcols` est très souple ; nous avons fourni ci-dessus `patterns` mais nous aurions pu passer également `fkt` ou tout vecteur `character` de noms de colonnes. Dans d'autres situations, il est plus pratique de fournir un vecteur `integer` de *positions* des colonnes ou un vecteur de `booléens` indiquant pour chaque colonne s'il faut l'inclure ou l'exclure. Finalement nous utilisons une fonction pour filtrer les colonnes ce qui est très pratique.

Par exemple nous pourrions faire ceci pour convertir toutes les colonnes `factor` en `character` :

```{r sd_as_logical}
fct_idx = Teams[, which(sapply(.SD, is.factor))] # numéros de colonnes (changement de classe)
str(Teams[[fct_idx[1L]]])
Teams[ , names(.SD) := lapply(.SD, as.character), .SDcols = is.factor]
str(Teams[[fct_idx[1L]]])
```

Enfin, nous pouvons faire une correspondance basée sur les motifs des colonnes dans `.SDcols` pour sélectionner toutes les colonnes qui contiennent `team` vers `factor` :

```{r sd_patterns}
Teams[ , .SD, .SDcols = patterns('team')]
Teams[ , names(.SD) := lapply(.SD, factor), .SDcols = patterns('team')]
```

** En plus de ce qui a été dit ci-dessus : *utiliser *explicitement* le numéro des colonnes (comme `DT[ , (1) := rnorm(.N)]`) n'est pas recommandé et peut conduire progressivement à obtenir un code corrompu au fil du temps si la position des colonnes change. Même l'utilisation implicite de numéros peut être dangereuse si nous ne gardons pas un contrôle intelligent et strict de l'ordre quand nous créons et utilisons l'index numéroté.

## Contrôler le membre droit d'un modèle

Modifier les spécifications du modèle est une fonctionnalité de base en analyse statistique robuste. Essayons de prédire l'ERA d'un lanceur (Earned Runs Average, moyenne des tournois gagnés, une mesure de performance) en utilisant le petit ensemble des covariables disponible dans la table `Pitching`. Comment varie la relation (linéaire) entre `W` (wins) et `ERA` en fonction des autres covariables que l'on inclut dans la spécification ?

Voici une courte description qui évalue la puissance de `.SD` explorant cette question :

```{r sd_for_lm, cache = FALSE, fig.cap="Ajustement OLS pour le coefficient W, diverses spécifications, représentées par des barres de couleurs distinctes."}
# ceci génère une liste des 2^k variables extra possibles
#   pour les modèles de forme ERA ~ G + (...)
extra_var = c('yearID', 'teamID', 'G', 'L')
models = unlist(
  lapply(0L:length(extra_var), combn, x = extra_var, simplify = FALSE),
  recursive = FALSE
)

# voici 16 couleurs distinctes, choisis dans une liste de 20 ici:
#   https://sashat.me/2017/01/11/list-of-20-simple-distinct-colors/
col16 = c('#e6194b', '#3cb44b', '#ffe119', '#0082c8',
          '#f58231', '#911eb4', '#46f0f0', '#f032e6',
          '#d2f53c', '#fabebe', '#008080', '#e6beff',
          '#aa6e28', '#fffac8', '#800000', '#aaffc3')

par(oma = c(2, 0, 0, 0))
lm_coef = sapply(models, function(rhs) {
  # utilisation de ERA ~ . et data = .SD, puis variation de
  #   quelles colonnes sont incluses dans .SD, ce qui nous permet
  #   de varier les iterations sur les 16 modèles facilement.
  #   coef(.)['W'] extrait le coefficient W de chaque modèle ajusté
  Pitching[ , coef(lm(ERA ~ ., data = .SD))['W'], .SDcols = c('W', rhs)]
})
barplot(lm_coef, names.arg = sapply(models, paste, collapse = '/'),
        main = 'Wins Coefficient\nWith Various Covariates',
        col = col16, las = 2L, cex.names = 0.8)
```

Le coefficient a toujours le signe attendu (les meilleurs lanceurs ont tendance à avoir plus de victoires et moins de tours autorisés), mais l'amplitude peut varier substantiellement en fonction de ce qui est contrôlé par ailleurs.

## Jointures conditionnelles

La syntaxe de `data.table` est belle par sa simplicité et sa robustesse. La syntaxe `x[i]` gère de manière souple trois approches communes du sous-groupement -- si `i` est un vecteur `booléen`, `x[i]` renvoie les lignes de `x` qui correspondent aux indices où `i` vaut `TRUE`; si `i` est une *autre `data.table`* (ou une `list`), une `jointure droite` (join right)  est réalisée (dans la forme à plat, en utilisant les `clés` de `x` et `i`, sinon, si `on = ` est spécifié, en utilisant les colonnes qui correspondent); et si `i` est un caratère, il est interprété comme raccourci pour `x[list(i)]`, c'est à dire comme une jointure.

C'est très bien en général, mais ce n'est pas suffisant lorsque nous souhaitons effectuer une "jointure conditionnelle", dans laquelle la nature exacte de la relation entre les tables dépend de certaines caractéristiques des lignes dans une ou plusieurs colonnes.

Cet exemple est certes un peu artificiel, mais il illustre l'idée ; voir ici ([1](https://stackoverflow.com/questions/31329939/conditional-keyed-join-update-and-update-a-flag-column-for-matches), [2](https://stackoverflow.com/questions/29658627/conditional-binary-join-and-update-by-reference-using-the-data-table-package)) pour plus d'informations.

Le but est d'ajouter une colonne `team_performance` à la table `Pitching` qui enregistre les performances de l'équipe (rang) du meilleur lanceur de chaque équipe (tel que mesuré par le ERA le plus faible, parmi les lanceurs ayant au moins 6 jeux enregistrés).

```{r conditional_join}
# pour exclure les pichers ayant des performances exceptionnelles dans peu de jeux,
#   faire un sous-ensemble ; ensuite définir le rang des pichers dans leur équipe chaque
#   année (en général, nous nous focaliserions sur 'ties.method' de frank)
Pitching[G > 5, rank_in_team := frank(ERA), by = .(teamID, yearID)]
Pitching[rank_in_team == 1, team_performance :=
           Teams[.SD, Rank, on = c('teamID', 'yearID')]]
```

Notez que la syntaxe de `x[y]` renvoie `nrow(y)` values (c'est une jointure droite), c'est pourquoi `.SD` se trouve à droite dans `Teams[.SD]` (parce que le membre de droite de `:=` dans ce cas nécessite les valeurs de `nrow(Pitching[rank_in_team == 1])` ).

# Opérations `.SD` groupées

Nous aimerions souvent réaliser une opération sur nos données *au niveau groupe*. Si nous indiquons `by =` (ou `keyby = `), le modèle que nous imaginons mentalement pour ce qui se passe quand `data.table` traite `j` est de considérer que la `data.table` est constituée de plusieurs composants sous-`data.table`, dont chacun correspond à une seule valeur des variables du `by` :

![Regroupement, illustré](../plots/grouping_illustration.png)

<!-- 'A visual depiction of how grouping works. On the left is a grid. The first column is titled "ID COLUMN" with values the capital letters A through G, and the rest of the data is unlabelled, but is in a darker color and simply has "Data" written to indicate that's arbitrary. A right arrow shows how this data is split into groups. Each capital letter A through G has a grid on the right-hand side; the grid on the left has been subdivided to create that on the right.' -->

En cas de groupement, `.SD` est multiple par nature -- il se réfère à *chaque* sous-`data.table, *une à la fois* (ou plus précisément, la visibilité de `.SD` est une sous-`data.table` unique). Ceci nous permet d'indiquer précisément une opération à réaliser sur *chaque sous-`data.table`* avant de réassembler et renvoyer le résultat.

C'est utile pour diverses initialisations, les plus communes sont présentées ici :

## Sous-groupes

Essayons d'obtenir la saison la plus récente des données pour chaque équipe des données Lahman. Ceci peut être fait simplement avec :

```{r group_sd_last}
# les données sont déjà triées par année ; si ce n’était pas le cas
#   nous pourrions faire Teams[order(yearID), .SD[.N], by = teamID]
Teams[ , .SD[.N], by = teamID]
```

Rappelez-vous que `.SD` est lui-même une `data.table`, et que `.N` se rapporte au nombre total de lignes dans un groupe (c'est égal à `nrow(.SD)` à l'intérieur de chaque groupe), donc `.SD[.N]` renvoie la *totalité de `.SD`* pour la dernière ligne associée à chaque `teamID`.

Une autre version commune de ceci est l'utilisation de `.SD[1L]` à la place, pour obtenir la *première* observation de chaque groupe, ou `.SD[sample(.N, 1L)]` pour renvoyer une ligne *aléatoire* pour chaque groupe.

## Groupe Optima

Supposons que nous voulions renvoyer la *meilleure* année pour chaque équipe, tel que mesuré par leur nombre total de tournois enregistrés (`R`; il est facile d'ajuster cela pour s'adapter à d'autres métriques, bien sûr). Au lieu de prendre un élément *fixe* de chaque sous-`data.table`, nous définissons maintenant *dynamiquement* l'indice souhaité ainsi :

```{r sd_team_best_year}
Teams[ , .SD[which.max(R)], by = teamID]
```

Notez que cette approche peut bien sûr être combinée avec `.SDcols` pour renvoyer uniquement les portions de `data.table` pour chaque `.SD` (avec la mise en garde que `.SDcols` soit initialisé en fonction des différents sous-ensembles)

*NB* : `.SD[1L]` est actuellement optimisé par [*`GForce`*](https://Rdatatable.gitlab.io/data.table/library/data.table/html/datatable-optimize.html) ([voir aussi](https://stackoverflow.com/questions/22137591/about-gforce-in-data-table-1-9-2)), `data.table` interne qui accélère massivement les opérations groupées les plus courantes comme `sum` ou `mean` -- voir ` ?GForce` pour plus de détails et gardez un oeil sur le support pour les demandes d'amélioration des fonctionnalités pour les mises à jour sur ce front : [1](https://github.com/Rdatatable/data.table/issues/735), [2](https://github.com/Rdatatable/data.table/issues/2778), [3](https://github.com/Rdatatable/data.table/issues/523), [4](https://github.com/Rdatatable/data.table/issues/971), [5](https://github.com/Rdatatable/data.table/issues/1197), [6](https://github.com/Rdatatable/data.table/issues/1414)

## Régression groupée

Revenons à la requête ci-dessus à propos des relations entre `ERA` et `W`; supposez que nous espérions que cette relation soit différente en fonction de l'équipe (c'est à dire que la pente soit différente pour chaque équipe). Nous pouvons facilement réexécuter cette régression pour explorer l'hétérogenéité dans cette relation comme ceci (en notant que les erreurs standard de cette approche sont généralement incorrectes -- la spécification `ERA ~ W*teamID` sera meilleurs -- cette approche est plus facile à lire et les *coefficients* sont OK) :

```{r group_lm, results = 'hide', fig.cap="Histogramme de la distribution des coefficients ajustés. Il a plus ou moins une forme en cloche centrée autour de -.2"}
# Coefficients globaux pour comparaison
overall_coef = Pitching[ , coef(lm(ERA ~ W))['W']]
# utilisation du filtre .N > 20 pour exclure les équipes où il y a peu de données
Pitching[ , if (.N > 20L) .(w_coef = coef(lm(ERA ~ W))['W']), by = teamID
          ][ , hist(w_coef, 20L, las = 1L,
                    xlab = 'Fitted Coefficient on W',
                    ylab = 'Number of Teams', col = 'darkgreen',
                    main = 'Team-Level Distribution\nWin Coefficients on ERA')]
abline(v = overall_coef, lty = 2L, col = 'red')
```

Tandis qu'il existe une grande hétérogénéité, la concentration autour de la valeur générale observée reste très distincte.

Tout ceci n'est simplement qu'une brève introduction sur la puissance de `.SD` qui facilite la beauté et l'efficacité du code dans `data.table` !

```{r, echo=FALSE}
setDTthreads(.old.th)
```
