232 votes

Pourquoi utiliser purrr::map au lieu de lapply ?

Y a-t-il une raison pour laquelle je devrais utiliser

map(, function(x) )

au lieu de

lapply(, function(x) )

la sortie devrait être la même et les benchmarks que j'ai réalisés semblent montrer que lapply est légèrement plus rapide (ce qui devrait être le cas car map doit évaluer toutes les entrées d'évaluation non standard).

Alors y a-t-il une raison pour laquelle, pour de tels cas simples, je devrais réellement envisager de passer à purrr::map? Je ne demande pas ici les préférences de chacun concernant la syntaxe, les autres fonctionnalités fournies par purrr, etc., mais strictement la comparaison de purrr::map avec lapply en supposant l'utilisation de l'évaluation standard, c'est-à-dire map(, function(x) ). Y a-t-il un avantage que purrr::map a en termes de performance, de gestion des exceptions, etc.? Les commentaires ci-dessous suggèrent que ce n'est pas le cas, mais quelqu'un pourrait peut-être élaborer un peu plus?

15 votes

Pour les cas d'utilisation simples en effet, il vaut mieux rester avec R de base et éviter les dépendances. Si vous avez déjà chargé le tidyverse, vous pouvez néanmoins bénéficier de l'opérateur pipe %>% et de la syntaxe des fonctions anonymes ~ .x + 1

0 votes

De plus, purrr::map fournit une gamme de fonctions, telles que map_int, map_dbl, map_lgl et map2, etc., qui étendent la fonctionnalité au-delà de lapply tout en conservant une syntaxe cohérente.

1 votes

Je suis d'accord avec vous deux, toutes les fonctionnalités mentionnées par vous sont super et sont la raison pour laquelle j'utilise purrr, mais je suis intéressé par le cas simple et je me demande s'il y a un avantage (peut-être par exemple une meilleure gestion des exceptions?).

297voto

hadley Points 33766

Si la seule fonction que vous utilisez de purrr est map(), alors non, les avantages ne sont pas substantiels. Comme le souligne Rich Pauloo, le principal avantage de map() réside dans les helpers qui vous permettent d'écrire un code compact pour des cas spéciaux communs :

  • ~ . + 1 est équivalent à function(x) x + 1 (et \(x) x + 1 dans R-4.1 et plus récent)

  • list("x", 1) est équivalent à function(x) x[["x"]][[1]]. Ces helpers sont un peu plus généraux que [[ - voir ?pluck pour les détails. Pour le reformatage de données, l'argument .default est particulièrement utile.

Mais la plupart du temps, vous n'utilisez pas une seule fonction *apply()/map(), vous en utilisez plusieurs, et l'avantage de purrr est une bien plus grande cohérence entre les fonctions. Par exemple :

  • Le premier argument de lapply() est les données ; le premier argument de mapply() est la fonction. Le premier argument de toutes les fonctions map est toujours les données.

  • Avec vapply(), sapply() et mapply(), vous pouvez choisir de supprimer les noms de la sortie avec USE.NAMES = FALSE ; mais lapply() n'a pas cet argument.

  • Il n'y a pas de méthode cohérente pour transmettre des arguments constants à la fonction de mappage. La plupart des fonctions utilisent ... mais mapply() utilise MoreArgs (que vous vous attendriez à appeler MORE.ARGS), et Map(), Filter() et Reduce() attendent que vous créiez une nouvelle fonction anonyme. Dans les fonctions map, les arguments constants viennent toujours après le nom de la fonction.

  • Presque chaque fonction purrr est stable en termes de type : vous pouvez prédire le type de sortie exclusivement à partir du nom de la fonction. Ce n'est pas vrai pour sapply() ou mapply(). Oui, il y a vapply() ; mais il n'y a pas d'équivalent pour mapply().

Vous pouvez penser que toutes ces distinctions mineures ne sont pas importantes (tout comme certaines personnes pensent qu'il n'y a aucun avantage à utiliser stringr par rapport aux expressions régulières de base de R), mais dans mon expérience, elles causent une friction inutile lors de la programmation (l'ordre des arguments différents me perturbait toujours) et elles rendent les techniques de programmation fonctionnelle plus difficiles à apprendre car en plus des grandes idées, vous devez aussi apprendre une multitude de détails incidentels.

Purrr propose également quelques variantes de map pratiques absentes de base R :

  • modify() préserve le type des données en utilisant [[<- pour les modifier "en place". En conjonction avec la variante _if, cela permet d'écrire du code (selon moi) très élégant comme modify_if(df, is.factor, as.character)

  • map2() vous permet de mapper simultanément sur x et y. Cela facilite l'expression d'idées comme map2(models, datasets, predict)

  • imap() vous permet de mapper simultanément sur x et ses indices (soit les noms soit les positions). Cela facilite par exemple le chargement de tous les fichiers csv dans un répertoire, en ajoutant une colonne filename à chacun.

    dir("\\.csv$") %>%
      set_names() %>%
      map(read.csv) %>%
      imap(~ transform(.x, filename = .y))
  • walk() retourne son entrée de manière invisible ; et est utile lorsque vous appelez une fonction pour ses effets secondaires (c'est-à-dire l'écriture de fichiers sur disque).

Sans oublier les autres helpers comme safely() et partial().

Personnellement, je trouve que lorsque j'utilise purrr, je peux écrire du code fonctionnel avec moins de friction et plus de facilité ; cela réduit l'écart entre la conception d'une idée et sa mise en œuvre. Mais votre expérience peut varier ; il n'est pas nécessaire d'utiliser purrr à moins que cela ne vous aide réellement.

Microbenchmarks

Oui, map() est légèrement plus lent que lapply(). Mais le coût d'utilisation de map() ou de lapply() dépend de ce sur quoi vous effectuez le mapping, et non des frais généraux de l'exécution de la boucle. Le microbenchmark ci-dessous suggère que le coût de map() par rapport à lapply() est d'environ 40 ns par élément, ce qui semble peu susceptible d'avoir un impact matériel sur la plupart du code R.

library(purrr)
n <- 1e4
x <- 1:n
f <- function(x) NULL

mb <- microbenchmark::microbenchmark(
  lapply = lapply(x, f),
  map = map(x, f)
)
summary(mb, unit = "ns")$median / n
#> [1] 490.343 546.880

3 votes

Est-ce que vous vouliez dire utiliser transform() dans cet exemple? Comme dans transform() de base R, ou est-ce que je manque quelque chose? transform() vous donne le nom du fichier en factorielle, ce qui génère des avertissements lorsque vous voulez naturellement lier des lignes ensemble. mutate() me donne la colonne de caractère des noms de fichiers que je veux. Y a-t-il une raison de ne pas l'utiliser ici?

3 votes

Oui, il vaut mieux utiliser mutate(), je voulais juste un exemple simple sans autres dépendances.

1 votes

Ne devrait pas la spécificité de type apparaître quelque part dans cette réponse? map_* est ce qui m'a amené à charger purrr dans de nombreux scripts. Il m'a aidé avec certains aspects de 'flux de contrôle' de mon code (stopifnot(is.data.frame(x))).

80voto

Rich Pauloo Points 2577

Comparer purrr et lapply revient à la commodité et la vitesse.


1. purrr::map est syntaxiquement plus pratique que lapply

extraire le deuxième élément de la liste

map(list, 2)  

comme l'a souligné @F. Privé, c'est la même chose que :

map(list, function(x) x[[2]])

avec lapply

lapply(list, 2) # ne fonctionne pas

nous devons passer une fonction anonyme...

lapply(list, function(x) x[[2]])  # maintenant ça fonctionne

...ou comme l'a souligné @RichScriven, nous passons [[ comme argument dans lapply

lapply(list, `[[`, 2)  # un peu plus simple syntaxiquement

Donc, si vous vous retrouvez à appliquer des fonctions à de nombreuses listes en utilisant lapply, et que vous vous fatiguez de définir une fonction personnalisée ou d'écrire une fonction anonyme, la commodité est une raison de privilégier purrr.

2. Les fonctions de map spécifiques au type simplifient de nombreuses lignes de code

  • map_chr()
  • map_lgl()
  • map_int()
  • map_dbl()
  • map_df()

Chacune de ces fonctions de map spécifiques au type renvoie un vecteur, plutôt que les listes renvoyées par map() et lapply(). Si vous travaillez avec des listes imbriquées de vecteurs, vous pouvez utiliser ces fonctions de map spécifiques au type pour extraire directement les vecteurs, et convertir directement les vecteurs en vecteurs int, dbl, chr. La version de base de R ressemblerait à quelque chose comme as.numeric(sapply(...)), as.character(sapply(...)), etc.

Les fonctions map_ ont également la qualité utile que si elles ne peuvent pas renvoyer un vecteur atomique du type indiqué, elles échouent. Cela est utile lors de la définition d'un contrôle strict du flux, où vous voulez qu'une fonction échoue si elle génère [d'une manière ou d'une autre] le mauvais type d'objet.

3. Mis à part la commodité, lapply est [légèrement] plus rapide que map

L'utilisation des fonctions de commodité de purrr, comme l'a souligné @F. Privé, ralentit un peu le traitement. Voyons la performance des 4 cas que j'ai présentés ci-dessus.

# devtools::install_github("jennybc/repurrrsive")
library(repurrrsive)
library(purrr)
library(microbenchmark)
library(ggplot2)

mbm <- microbenchmark(
  lapply       = lapply(got_chars[1:4], function(x) x[[2]]),
  lapply_2     = lapply(got_chars[1:4], `[[`, 2),
  map_shortcut = map(got_chars[1:4], 2),
  map          = map(got_chars[1:4], function(x) x[[2]]),
  times        = 100
)
autoplot(mbm)

entrer la description de l'image ici

Et le gagnant est....

lapply(list, `[[`, 2)

En résumé, si la vitesse brute est ce que vous recherchez : base::lapply (bien que ce ne soit pas beaucoup plus rapide)

Pour une syntaxe simple et une expressivité : purrr::map


Ce excellent tutoriel sur purrr met en évidence la commodité de ne pas devoir écrire explicitement des fonctions anonymes lors de l'utilisation de purrr, et les avantages des fonctions map spécifiques au type.

2 votes

Notez que si vous utilisez function(x) x[[2]] au lieu de simplement 2, cela serait moins lent. Tout ce temps supplémentaire est dû aux vérifications que lapply ne fait pas.

21 votes

Vous n'avez pas "besoin" de fonctions anonymes. [[ est une fonction. Vous pouvez faire lapply(liste, "[[", 3).

0 votes

@RichScriven cela a du sens. Cela simplifie la syntaxe pour utiliser lapply sur purrr.

54voto

Carlos Cinelli Points 1872

Si nous ne considérons pas les aspects de goût (autrement cette question devrait être fermée) ou la cohérence de la syntaxe, du style, etc., la réponse est non, il n'y a pas de raison spéciale d'utiliser map au lieu de lapply ou d'autres variantes de la famille apply, comme le plus strict vapply.

PS : À ceux qui votent négativement gratuitement, rappelez-vous simplement que l'auteur a écrit :

Ici, je ne demande pas vos préférences ou vos réticences concernant la syntaxe, les autres fonctionnalités fournies par purrr, etc., mais strictement la comparaison de purrr::map avec lapply en supposant l'utilisation de l'évaluation standard

Si vous ne considérez ni la syntaxe ni les autres fonctionnalités de purrr, il n'y a pas de raison spéciale d'utiliser map. J'utilise purrr moi-même et je suis d'accord avec la réponse de Hadley, mais ironiquement cela couvre exactement les choses que l'auteur a précisé dès le départ ne pas vouloir discuter.

0 votes

Je suis venu ici en demandant s'il y a des différences auxquelles je devrais m'inquiéter entre lapply et map ou si je peux les utiliser de manière plus ou moins interchangeable. C'est toi qui as répondu à ma question, merci :) Mon cas d'utilisation est lié à des scripts d'un collègue remplis de map où je veux utiliser soit future.apply::future_lapply (que je connais) soit furrr::future_map (que je ne connais pas). Maintenant je sais que je peux remplacer l'un par l'autre en toute sécurité, c'est tout. Merci encore!

5voto

Knalwous Points 471

Tl;dr

Je ne parle pas des goûts ou des préférences en matière de syntaxe ou d'autres fonctionnalités offertes par purrr.

Choisissez l'outil qui correspond à votre cas d'utilisation, et maximisez votre productivité. Pour un code de production qui privilégie la vitesse, utilisez *apply, pour un code qui nécessite une empreinte mémoire réduite, utilisez map. Basé sur l'ergonomie, map est probablement préférable pour la plupart des utilisateurs et la plupart des tâches ponctuelles.

Convenience

mise à jour octobre 2021 Puisque la réponse acceptée et le 2ème post le plus voté mentionnent la convenance de la syntaxe :

Les versions R 4.1.1 et supérieures supportent désormais la fonction anonyme abrégée \(x) et la syntaxe de pipeline |>. Pour vérifier votre version R, utilisez version[['version.string']].

library(purrr)
library(repurrrsive)
lapply(got_chars[1:2], `[[`, 2) |>
  lapply(\(.) . + 1)
#> [[1]]
#> [1] 1023
#> 
#> [[2]]
#> [1] 1053
map(got_chars[1:2], 2) %>%
  map(~ . + 1)
#> [[1]]
#> [1] 1023
#> 
#> [[2]]
#> [1] 1053

La syntaxe de l'approche purrr est généralement plus courte à taper si votre tâche implique plus de 2 manipulations d'objets de type liste.

nchar(
"lapply(x, fun, y) |>
      lapply(\\(.) . + 1)")
#> [1] 45
nchar(
"library(purrr)
map(x, fun) %>%
  map(~ . + 1)")
#> [1] 45

En considérant qu'une personne pourrait écrire des dizaines ou des centaines de milliers de ces appels dans sa carrière, cette différence de longueur de syntaxe peut équivaloir à l'écriture de 1 ou 2 romans (roman moyen 80 000 lettres), étant donné que le code est tapé. Pensez également à votre vitesse de saisie du code (~65 mots par minute ?), votre précision de saisie (vous arrive-t-il souvent de mal taper certaines syntaxes (\"< ?), votre souvenir des arguments de fonctions, alors vous pouvez faire une comparaison équitable de votre productivité en utilisant un style ou une combinaison des deux.

Une autre considération pourrait être votre public cible. Personnellement, j'ai trouvé plus difficile d'expliquer comment purrr::map fonctionne par rapport à lapply précisément en raison de sa syntaxe concise.

1 |>
  lapply(\(.z) .z + 1)
#> [[1]]
#> [1] 2

1 %>%
  map(~ .z+ 1)
#> Error in .f(.x[[i]], ...) : object '.z' not found

mais,
1 %>%
  map(~ .+ 1)
#> [[1]]
#> [1] 2

Speed

Souvent, lorsqu'on travaille avec des objets de type liste, de multiples opérations sont effectuées. Une nuance à la discussion est que le surcoût de purrr est négligeable dans la plupart des cas - traitant de grandes listes et des cas d'utilisation.

got_large <- rep(got_chars, 1e4) # 300 000 éléments, 1.3 Go en mémoire
bench::mark(
  base = {
    lapply(got_large, `[[`, 2) |>
      lapply(\(.) . * 1e5) |>
      lapply(\(.) . / 1e5) |>
      lapply(\(.) as.character(.))
  },
  purrr = {
    map(got_large, 2) %>%
      map(~ . * 1e5) %>%
      map(~ . / 1e5) %>%
      map(~ as.character(.))
  }, iterations = 100,
)[c(1, 3, 4, 5, 7, 8, 9)]

# A tibble: 2 x 7
  expression   median `itr/sec` mem_alloc n_itr  n_gc total_time

1 base          1.19s     0.807    9.17MB   100   301      2.06m
2 purrr         2.67s     0.363    9.15MB   100   919      4.59m

Cela diverge davantage lorsque plusieurs actions sont effectuées. Si vous écrivez du code qui est utilisé régulièrement par des utilisateurs ou sur lequel dépendent des packages, la vitesse pourrait être un facteur significatif à prendre en compte dans votre choix entre R de base et purr. Remarquez que purrr a une empreinte mémoire légèrement plus faible.

Cependant, il y a un contre-argument : Si vous voulez de la vitesse, passez à un langage de plus bas niveau.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X