9  Iterace nad prvky atomických vektorů a seznamů

Často je potřeba provést nějakou operaci s každým prvkem atomického vektoru, seznamu apod. Většina programovacích jazyků k tomuto účelu používá cyklus for (viz oddíl 6.2.1). R však nabízí dvě lepší řešení. Zaprvé, mnohé funkce jsou v R vektorizované, tj. pracují s vektorem po prvcích a vrátí vektor stejné délky, kde každý prvek je výsledkem aplikace dané funkce na odpovídající prvek původního vektoru. Příkladem takové funkce je např. funkce log(). Druhou možností je použití funkce map() a jí podobných z balíku purrr. Tyto funkce berou jako svůj vstup nejen data, ale také jinou (nevektorizovanou) funkci a aplikují ji na každý prvek dat. (Základní R poskytuje podobné funkce jako balík purrr, my se však zaměříme na funkce z balíku purrr, a to z několika důvodů: 1) jejich ovládání je jednodušší, 2) je kompatibilní s dalšími funkcemi ve skupině balíků tidyverse, 3) funkce usnadňují řešení problémů a ladění kódu a 4) umožňují snadnou paralelizaci pomocí balíku furrr.)

V této kapitole se naučíte

Tato kapitola může být pro první čtení poněkud obtížná. Pokud s jazykem R teprve začínáte, může být rozumné věnovat se nejdříve oddílům 9.1, 9.2 a 9.8 a k ostatním tématům se vrátit později.

Doporučujeme také, abyste si přečetli dokumentaci k balíku purrr (např. na adrese https://purrr.tidyverse.org/) – tento balík obsahuje mnoho dalších funkcí, které vám mohou usnadnit práci. V této kapitole se totiž zaměříme jen na základní funkce, které jsou nejčastěji používané.

Před vlastní prací musíme načíst balík purrr:

library(purrr)

9.1 Iterace nad jedním vektorem

Nejčastějším případem je, že potřebujeme pomocí nějaké funkce transformovat každý prvek vektoru (atomického nebo seznamu). Základní funkce pro iterace nad vektory je funkce map(.x, .f, ...). Její první parametr je vektor .x (atomický nebo seznam) a druhý parametr je funkce .f. Funkce map() spustí funkci .f na každý prvek vektoru .x a výsledky poskládá do seznamu stejné délky, jako má vektor .x. Funkci map() si tedy můžete představit jako výrobní linku, kde nad pásovým dopravníkem pracuje robot. Dopravník nejdříve robotovi doručí první prvek .x, tj. .x[[1]]. Robot na doručeném prvku provede funkci .f(), tj. vyhodnotí .f(x[[1]]) a výsledek uloží zpátky na dopravníkový pás, tj. jako první prvek nově vytvářeného seznamu. Pak se dopravník posune, robot spustí funkci .f() na .x[[2]] atd., dokud linka nezpracuje všechny prvky vektoru .x. Fungování linky ukazuje obrázek 9.1.

Obrázek 9.1: Funkce map(.x, .f) aplikuje funkci .f() na každý prvek vektoru .x a vrací seznam stejné délky.

Všimněte si dvou věcí: 1) Funkci .f() předáváme funkci map() jako proměnnou, tj. bez kulatých závorek. 2) Funkce .f() od map() vždy dostane jeden prvek vektoru .x jako svůj první parametr. Ukážeme si to nejdříve na jednoduchém příkladu. Máme seznam v, který obsahuje různě dlouhé atomické vektory. Chceme zjistit délku jednotlivých vektorů v seznamu:

v <- list(1, 1:2, 1:3, 1:4, 1:5)  # vytvoří seznam vektorů
map(v, length)  # zjistí délku jednotlivých vektorů v seznamu v
[[1]]
[1] 1

[[2]]
[1] 2

[[3]]
[1] 3

[[4]]
[1] 4

[[5]]
[1] 5

V dalším příkladu chceme zjistit, jaký datový typ mají jednotlivé sloupce tabulky. Využijeme toho, že tabulky tříd data.frametibble jsou technicky implementované jako seznamy sloupců a že funkce map() s nimi takto zachází:

df <- tibble::tibble(x = 1:6,  # celá čísla
                     y = c(rnorm(1:5), NA),  # reálná čísla
                     z = c(NA, letters[1:4], NA)  # řetězce
)
df
# A tibble: 6 × 3
      x        y z    
  <int>    <dbl> <chr>
1     1 -0.905   <NA> 
2     2 -0.339   a    
3     3  0.468   b    
4     4 -0.00376 c    
5     5  2.34    d    
6     6 NA       <NA> 
# funkce class() je použita postupně na všechny sloupce df
map(df, class)  
$x
[1] "integer"

$y
[1] "numeric"

$z
[1] "character"

Výše uvedené příklady ukazují, jak pomocí funkce map() aplikovat na jednotlivé prvky vektoru nějakou existující funkci. Často však chceme na prvky vektoru aplikovat nějaký vlastní výpočet. Pokud jej chceme provést jen na jednom vektoru, nestojí za to vytvářet pojmenovanou funkci, která by zaplevelila naše pracovní prostředí. V takovém případě můžeme použít anonymní funkci, jak ukazuje následující příklad. V něm chceme zjistit, kolik každý sloupec tabulky obsahuje hodnot NA. Druhým parametrem funkce map() je anonymní funkce, tj. funkce, kterou jsme před tím neuložili do žádné proměnné. Naše anonymní funkce musí brát jako svůj první parametr jeden prvek vektoru, nad kterým se iteruje:

# x nabývá vždy hodnotu jednoho sloupce z df
map(df, function(x) sum(is.na(x)))
$x
[1] 0

$y
[1] 1

$z
[1] 2

Od verze 4.1 je samozřejmě možné využít i zkrácené syntaxe pro tvorbu anonymních funkcí – se stejným výsledkem:

# \(x) je totéž jako function(x)
map(df, \(x) sum(is.na(x)))

Protože tvorba anonymních funkcí může být protivná, nabízí funkce map() “zkratku”: místo funkce zadat pravostrannou formuli, kterou map() automaticky převede na funkci. Syntaxe této formule je jednoduchá. Začíná vlnkou (symbolem ~) za kterou následuje výraz, který se má vyhodnotit. Hodnotu prvku vektoru, který funkce právě vyhodnocuje, zadáme jako . nebo .x. Předchozí výpočet je tedy možné zadat i takto:

map(df, ~ sum(is.na(.)))

nebo

map(df, ~ sum(is.na(.x)))
$x
[1] 0

$y
[1] 1

$z
[1] 2

Funkce map() umožňuje využít i další zajímavou zkratku, která je užitečná v případě, že chceme ze seznamu extrahovat prvky, které se nachází na určité pozici nebo mají určité jméno. V takovém případě zadáme místo funkce .f vektor celých čísel, řetězců nebo jejich kombinaci. Pokud zadáme celé číslo, map() vrátí z každého prvku vektoru .x jeho prvek na této pozici; pokud zadáme řetězec, pak jeho prvek s tímto jménem. Pokud zadáme víc pozic, map() postupuje rekurzivně. Ukažme si to na příkladu. Vytvoříme datovou strukturu, která obsahuje vybrané informace o hráčích nějaké hry. Všimněte si, že vnější seznam obsahuje dílčí seznamy, které mají stejné pojmenované položky:

dungeon <- list(
    list(
      id = 11,
      name = "Karel", 
      items = list("sword", "key")
    ),
    list(
        id = 12,
        name = "Emma",
        items = list("mirror", "potion", "dagger")
    )
)

Nejdříve chceme získat seznam id jednotlivých hráčů. Protože každý vnitřní seznam má id na prvním místě, můžeme jejich seznam získat takto:

map(dungeon, 1)
[[1]]
[1] 11

[[2]]
[1] 12

Pokud chceme získat seznam jmen hráčů, můžeme jej získat buď pomocí pozice (jména jsou ve vnitřních seznamech na druhém místě) výrazem map(dungeon, 2). Můžeme je však extrahovat i jménem:

map(dungeon, "name")
[[1]]
[1] "Karel"

[[2]]
[1] "Emma"

Podobně můžeme získat i seznam artefaktů, které má každý hráč k dispozici (výsledek je seznamem seznamů):

map(dungeon, "items")
[[1]]
[[1]][[1]]
[1] "sword"

[[1]][[2]]
[1] "key"


[[2]]
[[2]][[1]]
[1] "mirror"

[[2]][[2]]
[1] "potion"

[[2]][[3]]
[1] "dagger"

Jednotlivé artefakty pak můžeme získat zadáním více pozic, které použijí rekurzivně:

map(dungeon, c(3, 1))
[[1]]
[1] "sword"

[[2]]
[1] "mirror"
map(dungeon, list("items", 1))
[[1]]
[1] "sword"

[[2]]
[1] "mirror"

Oba předchozí výrazy vrátili seznam artefaktů, které mají oba hráči na první pozici mezi svými položkami. Všimněte si, že pokud jsme chtěli adresovat items jménem, museli jsme jméno a pozici spojit pomocí seznamu, protože funkce c() by převedla obě pozice na řetězce.

Pokud požádáme o prvky, které neexistují, dostaneme výsledek NULL:

map(dungeon, list("items", 3))
[[1]]
NULL

[[2]]
[1] "dagger"
map(dungeon, "aloha")
[[1]]
NULL

[[2]]
NULL

Pokud chceme, aby map() v takovém případě vrátil jinou hodnotu, můžeme ji nastavit pomocí parametru .default:

map(dungeon, list("items", 3), .default = NA_character_)
[[1]]
[1] NA

[[2]]
[1] "dagger"

Funkci, kterou spouštíme nad jednotlivými prvky vektoru, můžeme předat i další parametry. Ty se zadají na pozici ..., tedy jako třetí a případně další parametry ve funkci map(), jak ukazuje následující příklad. V něm chceme vytvořit několik vektorů gaussovských náhodných čísel o různých délkách a průměru 10 a směrodatné odchylce také 10. K tvorbě náhodných vektorů s normálním rozložením použijeme funkci rnorm():

map(1:5, rnorm, mean = 10, sd = 10)
[[1]]
[1] 20.50563

[[2]]
[1] 18.01696 15.07698

[[3]]
[1]   5.092411   1.445201 -11.628701

[[4]]
[1]  5.805196 15.675946 -3.653992  7.415267

[[5]]
[1] 10.055357 20.165054 -3.091312  3.974498 14.123056

Funkce map() v našem příkladu iteruje nad vektorem 1:5, jehož jednotlivé prvky se předají funkci rnorm() jako její první parametr, tj. jako délka vytvářeného vektoru. Střední hodnotu a směrodatnou odchylku vytvářených náhodných vektorů jsme ve funkci rnorm() nastavili pomocí pojmenovaných parametrů mean a sd. Funkce map() tedy postupně vyhodnocovala výrazy rnorm(1, mean = 10, sd = 10), rnorm(2, mean = 10, sd = 10) atd.

Zjednodušení výsledku na vektor

Jak jsme viděli, funkce map() vrací svůj výsledek jako seznam. To je často užitečné, protože seznamy umožňují v jedné datové struktuře skladovat hodnoty různých délek, datových typů a struktur. Někdy však funkce .f() vrací na našich datech vždy atomický vektor stejného datového typu a délky 1. V tom případě můžeme chtít výsledek zjednodušit na atomický vektor. K tomu slouží funkce map_lgl(), map_int(), map_dbl() a map_chr(), které mají stejné vstupní parametry jako funkce map(), ale svůj výsledek zjednoduší na logický, celočíselný nebo reálný vektor nebo vektor řetězců.

Ukážeme si to nejdříve na příkladu, který jsme viděli výše: Chceme zjistit počet chybějících hodnot v jednotlivých sloupcích tabulky, výsledek však chceme mít uložený v atomickém vektoru (názvy hodnot odpovídají jménům sloupců tabulky):

map_int(df, ~ sum(is.na(.)))
x y z 
0 1 2 

Pokud by výsledek nebylo možné zjednodušit na daný datový typ, volání funkce by skončilo chybou, jak ukazuje následující příklad:

map_int(1:5, log)
Error in `map_int()`:
ℹ In index: 2.
Caused by error:
! Can't coerce from a number to an integer.

Kromě funkcí jako map_int(), které zjednodušují výsledek na předem daný typ, existuje i funkce map_vec(), která výsledek zjednoduší na atomický vektor nejobecnějšího typu. Funguje tak nejen se základními datovými typy, ale i se speciálními, jako je např. typ Date:

map_vec(df, ~sum(is.na(.)))
x y z 
0 1 2 
dates <- map_vec(c("2020-01-01", "2021-01-01"), as.Date)
dates
[1] "2020-01-01" "2021-01-01"
class(dates)
[1] "Date"

Podívejme se nyní na komplexnější ukázku použití funkce map() převzatou z její dokumentace. Řekněme, že chceme zjistit, jak silně závisí spotřeba aut na jejich váze – ve smyslu, kolik procent rozptylu spotřeby vysvětlí váha aut, a to zvlášť pro každý počet válců. K tomu použijeme standardní dataset mtcars (třída data.frame). Rozdělíme jej pomocí funkce split(x, f, drop = FALSE, ...). Funkce split() bere na vstupu atomický vektor, seznam nebo tabulku x a vektor f, který určí rozdělení x do skupin. Funkce vrací seznam, jehož jednotlivé prvky obsahují části proměnné x – každý prvek obsahuje část x, pro kterou má f stejnou hodnotu. Následující kód tedy rozdělí tabulku mtcars na tři tabulky uložené v jednom pojmenovaném seznamu cars. Každá z těchto dílčích tabulek bude obsahovat jen pozorování se stejným počtem válců: první tabulka 4, druhá 6 a třetí 8 válců:

cars <- split(mtcars, mtcars$cyl)

Od verze 4.1 může být f i pravostranná formule, která za vlnkou obsahuje jméno proměnné ze zvoleného datasetu, podle jejíchž hodnot se má dataset rozdělit; o formulích najdete více v oddíle 15.1. Stejnou operaci je pak možné zapsat kompaktněji takto:

cars <- split(mtcars, ~cyl)

Na každé této tabulce zvlášť odhadneme lineární model, který vysvětluje spotřebu (mpg, počet mil, který vůz ujede na galon paliva) pomocí váhy vozidla (wt) a úrovňové konstanty. Vlastní odhad provede funkce lm(). Její první parametr je formule mpg ~ wt, která popisuje odhadovanou rovnici (zde \(\textrm{mpg}_i = b_0 + b_1 \textrm{wt}_i + \epsilon_i\)). Druhý parametr jsou data, na který se má model odhadnout. Pomocí funkce map() aplikujeme funkci lm() na každý prvek seznamu cars, tj. na tři tabulky, které jsme vytvořili v předchozím kroku:

estimates <- map(cars, ~ lm(mpg ~ wt, data = .))

Výsledkem je seznam, který obsahuje opět tři prvky: objekty, které popisují odhad modelu na třech částech rozdělené tabulky. Nás z nich zajímá jediná hodnota: koeficient determinace \(R^2\), který říká, kolik procent rozptylu vysvětlované veličiny model vysvětlil. Ten získáme jako slot r.squared objektu, který vrací funkce summary(). Výpočet opět musíme spustit pro všechny prvky seznamu estimates:

s <- map(estimates, summary)
map_dbl(s, "r.squared")
        4         6         8 
0.5086326 0.4645102 0.4229655 

Vidíme, že sama váha vysvětluje velkou část spotřeby. Celý výpočet můžeme zapsat kompaktněji pomocí operátoru |> nebo %>%, který předá výsledek předchozího výpočtu (svou levou stranu) jako první parametr do funkce v následném výpočtu (na své pravé straně), viz oddíl 4.6:

mtcars |>
    split(~ cyl) |>
    map(~ lm(mpg ~ wt, data = .x)) |>
    map(summary) |>
    map_dbl("r.squared")
        4         6         8 
0.5086326 0.4645102 0.4229655 

Více se o ekonometrii dozvíte v kapitole 15.

Zjednodušení výsledku na tabulku

Funkce map_dfc(x, f) a map_dfr(x, f) fungují podobně jako map(), ale výsledek transformují na tabulku po sloupcích a řádcích respektive. Detaily najdete v dokumentaci. Zde si ukážeme jen několik příkladů. Řekněme, že máme pojmenovaný seznam několika atomických vektorů. Chceme je nějak transformovat (např. spočítat jejich druhou mocninu) a výsledky složit vedle sebe jako sloupce tabulky:

s <- list(a = 1:5, b = 6:10)
map_dfc(s, ~. ^ 2)
# A tibble: 5 × 2
      a     b
  <dbl> <dbl>
1     1    36
2     4    49
3     9    64
4    16    81
5    25   100

Podobně můžeme rozdělit tabulku pomocí funkce split() na části, které uložíme do seznamu. Potom, co na každé části tabulky provedeme nějaké operace, můžeme výsledek opět spojit pomocí map_df():

mtcars |> 
    # převod na tibble
    tibble::as_tibble() |>
    # rozdělení tabulky do seznamu tabulek
    split(~ cyl) |>
    # vynechaná operace nad každou částí tabulky
    # ...
    # složení tabulek dohromady
    map_dfr(identity)  
# A tibble: 32 × 11
     mpg   cyl  disp    hp  drat    wt  qsec    vs    am  gear  carb
   <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
 1  22.8     4 108      93  3.85  2.32  18.6     1     1     4     1
 2  24.4     4 147.     62  3.69  3.19  20       1     0     4     2
 3  22.8     4 141.     95  3.92  3.15  22.9     1     0     4     2
 4  32.4     4  78.7    66  4.08  2.2   19.5     1     1     4     1
 5  30.4     4  75.7    52  4.93  1.62  18.5     1     1     4     2
 6  33.9     4  71.1    65  4.22  1.84  19.9     1     1     4     1
 7  21.5     4 120.     97  3.7   2.46  20.0     1     0     3     1
 8  27.3     4  79      66  4.08  1.94  18.9     1     1     4     1
 9  26       4 120.     91  4.43  2.14  16.7     0     1     5     2
10  30.4     4  95.1   113  3.77  1.51  16.9     1     1     5     2
# ℹ 22 more rows

Nakonec si ukážeme poněkud složitější příklad. Řekněme, že máme seznam atomických vektorů s (zde si jej nasimulujeme) a pro každý vektor v seznamu chceme zjistit základní popisné statistiky. Pro jeden vektor nám je spočítá funkce summary(). My však chceme tyto statistiky spočítat pro každý jednotlivý vektor a uložit je do tabulky, kde bude každý řádek odpovídat jednomu původnímu vektoru a sloupec jedné statistice. To můžeme udělat takto:

s <- map(seq_len(5), ~rnorm(100))
map_dfr(
    s,
    ~ set_names(
        as.list(summary(.)),
        nm = names(summary(.))
    )
)
# A tibble: 5 × 6
   Min. `1st Qu.`  Median    Mean `3rd Qu.`  Max.
  <dbl>     <dbl>   <dbl>   <dbl>     <dbl> <dbl>
1 -2.56    -0.903 -0.240  -0.230      0.338  2.82
2 -1.82    -0.380  0.213   0.300      1.08   2.62
3 -2.48    -0.578 -0.0171 -0.0942     0.529  2.23
4 -2.61    -0.835  0.0656 -0.0853     0.628  1.78
5 -1.98    -0.791 -0.195  -0.0762     0.584  2.61

Výraz as.list(summary(.) spočítá statistiky pro právě zpracovávaný prvek a převede je na seznam. Funkce set_names() pojmenuje prvky seznamu podle jednotlivých prvků objektu, který vrací funkce summary(). Funkce map_dfr() tak pro každý vektor v seznamu s získá pojmenovaný seznam statistik. Ten převede na tabulku a jednotlivé tabulky spojí.

Transformace vybraných prvků vektoru

Někdy nechceme transformovat všechny, ale jen vybrané prvky nějakého vektoru. K tomu slouží funkce map_if(.x, .p, .f, ..., .else = NULL) a map_at(.x, .at, .f, ...). Tyto funkce fungují podobně jako map(), liší se však tím, na které prvky se použije funkce .f: map() ji použije všechny prvky vektoru .x, map_at() pouze na vyjmenované prvky a map_if() pouze na prvky, které splňují nějakou podmínku. Funkce map_if() má stejné parametry jako map() plus dva navíc: .p je predikátová funkce (tj. funkce, která vrací logické hodnoty), která určí, které prvky se budou transformovat; funkce .f se použije na ty prvky .x, kde .p() vrací TRUE. Pokud je zadaná i funkce .else, pak se použije na ty prvky .x, kde .p() vrací FALSE. Pokud parametr .else nezadáme, pak se tyto prvky ponechají beze změny.

Ukažme si to na příkladu. Řekněme, že máme seznam, který obsahuje vektory čísel a řetězců. Numerické vektory chceme standardizovat tak, že od nich odečteme jejich střední hodnotu, zatímco řetězce chceme ponechat beze změny. To můžeme snadno udělat takto:

v <- list(1:5, rnorm(5), LETTERS[1:5])  # tvorba seznamu
v
[[1]]
[1] 1 2 3 4 5

[[2]]
[1]  0.6681628  0.5810743 -0.6140840 -1.6046642  0.3306767

[[3]]
[1] "A" "B" "C" "D" "E"
map_if(v, is.numeric, ~ . - mean(.))
[[1]]
[1] -2 -1  0  1  2

[[2]]
[1]  0.7959297  0.7088412 -0.4863171 -1.4768973  0.4584436

[[3]]
[1] "A" "B" "C" "D" "E"

Pokud bychom chtěli v jednom kroku zároveň odečíst střední hodnotu od numerických vektorů a řetězce převést na malá písmena, mohli bychom to provést takto:

map_if(v, is.numeric, ~ . - mean(.), .else = stringr::str_to_lower)
[[1]]
[1] -2 -1  0  1  2

[[2]]
[1]  0.7959297  0.7088412 -0.4863171 -1.4768973  0.4584436

[[3]]
[1] "a" "b" "c" "d" "e"

Protože tabulky jsou v R implementované jako seznam sloupců, můžeme totéž provést i s tabulkou:

df  # původní tabulka
# A tibble: 6 × 3
      x        y z    
  <int>    <dbl> <chr>
1     1 -0.905   <NA> 
2     2 -0.339   a    
3     3  0.468   b    
4     4 -0.00376 c    
5     5  2.34    d    
6     6 NA       <NA> 
map_if(df, is.numeric, ~ . - mean(., na.rm = TRUE))
$x
[1] -2.5 -1.5 -0.5  0.5  1.5  2.5

$y
[1] -1.2161582 -0.6499435  0.1569588 -0.3151954  2.0243383         NA

$z
[1] NA  "a" "b" "c" "d" NA 

Funkce map_at() funguje podobně, ale které prvky se mají transformovat, musíme zadat jejich jménem nebo pozicí. Stejně jako u výběru prvků vektoru je možné používat kladné i záporné vektory pozic: kladné určují, které se mají transformovat, záporné říkají, které prvky se nemají transformovat. Odečíst střední hodnoty od numerických vektorů tedy můžeme i takto:

map_at(df, 1:2, ~ . - mean(., na.rm = TRUE))
$x
[1] -2.5 -1.5 -0.5  0.5  1.5  2.5

$y
[1] -1.2161582 -0.6499435  0.1569588 -0.3151954  2.0243383         NA

$z
[1] NA  "a" "b" "c" "d" NA 
map_at(df, -3, ~ . - mean(., na.rm = TRUE))  # totéž
map_at(df, c("x", "y"), ~ . - mean(., na.rm = TRUE))  # totéž

Práce s prvky bez změny datové struktury

Funkce map() a její odvozeniny vracejí vždy konkrétní objekt: map() vrací seznam, map_lgl() vrací logický vektor apod. Pokud potřebujeme, aby funkce vrátila stejnou datovou strukturu, jako dostala na vstupu, můžete místo nich použít funkci modify(.x, .f, ...) a její odvozeniny, modify_if(.x, .p, .f, ..., .else = NULL), modify_at(.x, .at, .f, ...). Tyto funkce se používají stejně jako odpovídající funkce z rodiny map(). Srovnejte rozdíl:

map(1:3, ~ . + 2L)
[[1]]
[1] 3

[[2]]
[1] 4

[[3]]
[1] 5
modify(1:3, ~ . + 2L)
[1] 3 4 5
map_if(df, is.numeric, ~ . ^ 2) |> str()
List of 3
 $ x: num [1:6] 1 4 9 16 25 36
 $ y: num [1:6] 8.19e-01 1.15e-01 2.19e-01 1.42e-05 5.46 ...
 $ z: chr [1:6] NA "a" "b" "c" ...
modify_if(df, is.numeric, ~ . ^ 2) |> str()
tibble [6 × 3] (S3: tbl_df/tbl/data.frame)
 $ x: num [1:6] 1 4 9 16 25 36
 $ y: num [1:6] 8.19e-01 1.15e-01 2.19e-01 1.42e-05 5.46 ...
 $ z: chr [1:6] NA "a" "b" "c" ...

Funkce map() aplikovaná na celočíselný vektor vrací seznam, zatímco funkce modify() vrací celočíselný vektor. (Zkuste, co se stane, pokud se pokusíte místo 2L přičíst 2.) Podobně funkce map_if() aplikovaná na tabulku vrací seznam, zatímco funkce modify_if() vrací tabulku stejné třídy, jako je její vstup.

Funkce typu modify() se tedy hodí např. pro transformace vybraných sloupců tabulek. Řekněme, že máme tabulku s příjmy a výdaji z hyperinflační ekonomiky. Pokud by došlo k měnové reformě, potřebujeme u všech numerických sloupců škrtnout tři nuly. To můžeme provést např. takto:

df <- tibble::tibble(names = c("Adam", "Bětka", "Cyril"),
                     income = c(1.3, 1.5, 1.7) * 1e6,
                     rent = c(500, 450, 580) * 1e3,
                     loan = c(0, 250, 390) * 1e9)
df <- modify_if(df, is.numeric, ~ . / 1000)
df
# A tibble: 3 × 4
  names income  rent      loan
  <chr>  <dbl> <dbl>     <dbl>
1 Adam    1300   500         0
2 Bětka   1500   450 250000000
3 Cyril   1700   580 390000000

(V kapitole 13 se naučíte jiné funkce specializované pro práci s tabulkami z balíku dplyr.)

Iterace pro vedlejší účinky

Někdy nechceme při iterování nad prvky vektoru vracet hodnoty, nýbrž provést nějaké jiné operace, které iterovaná funkce provádí jako své vedlejší účinky. K tomuto účelu slouží funkce walk(x, f, ...). Její syntaxe je stejná jako u funkce map(). Řekněme, že chceme vypsat všechny prvky nějakého vektoru. To můžeme pomocí funkce walk() udělat např. takto:

v <- list(1, "a", 3)
walk(v, print)
[1] 1
[1] "a"
[1] 3

Funkce walk() tiše vrací vektor v, takže je možné i zařadit i do proudu “trubek” (viz oddíl 4.6):

v |> walk(print) |> map_int(length)
[1] 1
[1] "a"
[1] 3
[1] 1 1 1

To je užitečné např. při ladění dlouhého výrazu s mnoha “trubkami”, protože do proudu můžeme zařadit výpis nebo vykreslení výsledků bez toho, abychom museli “potrubí” narušit.

9.2 Zabezpečení iterací proti chybám

Pokud jedna z iterací ve funkci map() a spol. skončí chybou, skončí chybou celé volání této funkce. Funkce map() sice inteligentně vypíše, na kterém prvku k chybě došlo, ukáže však pouze první výskyt této chyby. Pak může být obtížné zjistit, který prvek vektoru chybu způsobil. Kód navíc padá. Jedním z přínosů balíku purrr je to, že zavádí několik speciálních funkcí, které umožňují tento problém vyřešit. Všechny tyto funkce fungují tak, že transformují funkci .f ještě před tím, než vstoupí do map() – na vstupu vezmou funkci a vrací její zabezpečenou verzi odolnou vůči selhání.

První z těchto funkcí je safely(.f, otherwise = NULL, quiet = TRUE). Tato funkce bere na vstupu iterovanou funkci a vrací její modifikovanou verzi, která nikdy neskončí chybou a která vrací seznam dvou prvků: výsledku a chybového objektu. Pokud původní funkce proběhla, má chybová hlášení hodnotu NULL, pokud skončila chybou, má hodnotu NULL výsledek. Protože funkce vrací seznam, je možné ji použít pouze ve funkci map(), ne v jejích zjednodušujících variantách jako je map_lgl() apod.

Ukážeme si použití této funkce na příkladu. Máme seznam v, který obsahuj většinou čísla, mezi která je však vloudily dva řetězce. Pro každý prvek v chceme spočítat jeho logaritmus pomocí funkce map() (přesto, že funkce log() je vektorizovaná). Přímé volání funkce log() skončí chybou:

v <- list(1, 2, "3", 4, "5")
map(v, log)
Error in `map()`:
ℹ In index: 3.
Caused by error:
! non-numeric argument to mathematical function

Pokud funkci log() “obalíme” funkcí safely(), výpočet proběhne až do konce a výsledkem bude struktura popsaná výše. Všimněte si, že v 1., 2. a 4. prvku struktury má chybová složka hodnotu NULL. Ve 3. a 5. prvku, kde výpočet zhavaroval, obsahuje chybová složka seznam, který obsahuje objekt třídy error. Ruční prohlídka našeho výsledku by nám umožnila zjistit, že je to 3. a 5. prvek vektoru v, které způsobují chyby:

result <- map(v, safely(log))
str(result)  # struktura výsledku
List of 5
 $ :List of 2
  ..$ result: num 0
  ..$ error : NULL
 $ :List of 2
  ..$ result: num 0.693
  ..$ error : NULL
 $ :List of 2
  ..$ result: NULL
  ..$ error :List of 2
  .. ..$ message: chr "non-numeric argument to mathematical function"
  .. ..$ call   : language .Primitive("log")(x, base)
  .. ..- attr(*, "class")= chr [1:3] "simpleError" "error" "condition"
 $ :List of 2
  ..$ result: num 1.39
  ..$ error : NULL
 $ :List of 2
  ..$ result: NULL
  ..$ error :List of 2
  .. ..$ message: chr "non-numeric argument to mathematical function"
  .. ..$ call   : language .Primitive("log")(x, base)
  .. ..- attr(*, "class")= chr [1:3] "simpleError" "error" "condition"

Ruční hledání chyb je však možné jen v případě, že zpracovávaná data jsou velmi malá. Balík purrr naštěstí umožňuje proces hledání chyby zautomatizovat. Stačí na výsledek předchozího výpočtu použít funkci list_transpose(), která změní seznam párů na pár seznamů. Výraz list_transpose(result) tedy vrátí seznam dvou prvků: první obsahuje všechny výsledky a druhý všechna chybová hlášení (oboje uložené jako seznamy):

list_transpose(result)
$result
$result[[1]]
[1] 0

$result[[2]]
[1] 0.6931472

$result[[3]]
NULL

$result[[4]]
[1] 1.386294

$result[[5]]
NULL


$error
$error[[1]]
NULL

$error[[2]]
NULL

$error[[3]]
<simpleError in .Primitive("log")(x, base): non-numeric argument to mathematical function>

$error[[4]]
NULL

$error[[5]]
<simpleError in .Primitive("log")(x, base): non-numeric argument to mathematical function>

To nám umožní najít problematické prvky vektoru v např. tak, že vybereme pouze část chybových hlášení a z ní sestavíme logický vektor, který bude mít hodnotu TRUE tam, kde hodnota chyby není NULL, tj. volání funkce selhalo. Logický vektor pak můžeme použít k nalezení indexů prvků vektoru, kde k chybě došlo (pomocí funkce which()), nebo k vypsání hodnot, které chybu způsobily:

# transpozice a výběr chybové složky
bugs <- transpose(result)$error
# TRUE, kde error není NULL, tj. kde je chyba
bugs <- !map_lgl(bugs, is.null)
# index prvků v, kde nastala chyba (jako vektor)
which(bugs)  
[1] 3 5
# hodnoty prvků v, kde nastala chyba (jako seznam)
v[bugs]
[[1]]
[1] "3"

[[2]]
[1] "5"

Pokud nás nezajímají chyby, ale pouze ty výsledky, které se skutečně spočítaly, můžeme použít funkci possibly(.f, otherwise, quiet = TRUE). Tato funkce zabezpečí funkci .f tak, že nikdy nezhavaruje. Pokud není schopná spočítat výsledek, vrátí místo něj hodnotu zadanou v parametru otherwise (pokud není zadána, funkce zhavaruje). Díky tomu funkce possibly() vrací jen vlastní výsledky, takže může být využitá i ve zjednodušujících variantách map():

# chybná hodnota je nahrazena NA
map_dbl(v, possibly(log, otherwise = NA_real_))
[1] 0.0000000 0.6931472        NA 1.3862944        NA

Pokud byste místo zachytávání chyb potřebovali zachytit zprávy a varování, která vrací iterovaná funkce, můžete použít funkci quietly(.f).

Poslední funkce, kterou balík purrr nabízí k ovlivnění výpočtu, je funkce auto_browse(.f), která transformuje funkci .f tak, že v případě chyby, automaticky spustí ladící mechanismus:

map(v, auto_browse(log))

9.3 Iterace nad několika vektory současně

Někdy potřebujeme iterovat nad více vektory současně. Můžeme např. chtít vytvořit seznam vektorů tisíce gaussovských náhodných čísel, kde každý vektor bude mít jinou střední hodnotu a směrodatnou odchylku. Pomocí funkce map() bychom to mohli udělat např. takto:

m <- 0:5  # požadované střední hodnoty
std <- 1:6  # požadované směrodatné odchylky
z <- map(seq_along(m), ~ rnorm(1000, mean = m[.], sd = std[.]))
str(z)  # struktura výsledného seznamu
List of 6
 $ : num [1:1000] -1.077 0.546 -1.055 -0.224 -0.65 ...
 $ : num [1:1000] 1.693 2.095 -3.495 3.589 -0.251 ...
 $ : num [1:1000] -1.038 3.376 0.521 2.861 -0.742 ...
 $ : num [1:1000] 1.1114 0.8159 -0.5029 3.0334 -0.0942 ...
 $ : num [1:1000] 6.034 3.447 -2.857 0.594 15.486 ...
 $ : num [1:1000] 0.379 2.194 2.741 3.968 6.866 ...
# střední hodnoty jednotlivých vektorů v seznamu
z |> map_dbl(mean)
[1] -0.01371825  1.13764275  1.97202269  3.08658282  4.07432469  5.14791232

Balík purrr však pro tyto účely nabízí příjemnější funkce. Pro iterace nad dvěma vektory zavádí funkci map2(.x, .y, .f, ...) a odpovídající zjednodušující funkce map2_lgl(), map2_int() atd. Všechny tyto funkce berou vektory .x a .y, nad kterými mají iterovat, jako své první dva parametry. Třetí parametr je jméno iterované funkce (musí brát aspoň dva parametry). Případné další parametry jsou předány funkci .f() jako její třetí a další parametr. Postup výpočtu ukazuje obrázek 9.2.

Obrázek 9.2: Funkce map2(.x, .y, .f) aplikuje funkci .f() na odpovídající prvky vektorů .x a .y a vrací seznam stejné délky.

Pokud chceme stejně jako výše vytvořit pět náhodných gaussovsky rozdělených vektorů se středními hodnotami 0, 1 atd. a směrodatnými odchylkami 1, 2 atd., můžeme je sestavit takto (všimněte si, že další parametry, zde délka vytvářených vektorů, musejí být uvedeny až za jménem iterované funkce):

z <- map2(0:5, 1:6, rnorm, n = 1000)

Funkce opět umožňuje zadat místo funkce .f() pravostrannou formuli, kterou na funkci sama převede. Zpracovávaný prvek vektoru .x v zadáme jako .x, prvek vektoru .y jako .y. Řekněme tedy, že chce vytvořit tisíc vektorů náhodných čísel s gaussovským rozdělením a různými středními hodnotami a směrodatnými odchylkami, a z těchto vektorů spočítat jejich střední hodnotu. To můžeme udělat takto:

map2_dbl(0:5, 1:6, ~ mean(rnorm(n = 1000, mean = .x, sd = .y)))
[1] 0.06303678 0.85713130 1.89397788 2.96513635 3.94801965 4.90935030

Pro iterace nad větším počtem vektorů nabízí purrr funkci pmap(.l, .f, ...) a její zjednodušující varianty pmap_lgl() atd., kde .l je buď seznam nebo tabulka stejně dlouhých vektorů, nad kterými se má iterovat, a .f je buď funkce, která bere příslušný počet parametrů, nebo pravostranná formule, kterou pmap() převede na funkci. Pokud je .f funkce a jednotlivé vektory v .l nejsou pojmenované, pak se předávají do .f podle svého pořadí. Pokud jsou pojmenované, pak se předávají jménem, takže na jejich fyzickém pořadí v .l nezáleží.

Řekněme, že chceme opět vytvořit seznam náhodných výběrů z gaussovského rozdělení. Každý výběr bude mít různý počet prvků, různou střední hodnotu a různou směrodatnou odchylku. Pokud seznam parametrů nepojmenujeme, musíme mít jednotlivé parametry v seznamu v tom pořadí, v jakém je očekává funkce rnorm(), která vygeneruje náhodná čísla:

n <- (1:5) * 100  # počet pozorování je 100, 200, ..., 500
mu <- 0:4  # střední hodnota je 0, 1, ..., 4
sd <- 1:5  # směrodatná odchylka je 1, 2, ..., 5
pars <- list(n, mu, sd)  # nepojmenovaný seznam parametrů v pořadí
z <- pmap(pars, rnorm)
str(z)  # struktura výsledku
List of 5
 $ : num [1:100] -0.937 -2.29 0.478 -0.876 0.933 ...
 $ : num [1:200] -0.783 0.241 2.342 2.598 -0.124 ...
 $ : num [1:300] -0.275 -0.747 4.265 5.105 -2.12 ...
 $ : num [1:400] -0.9947 5.8613 3.9296 -0.0286 5.4183 ...
 $ : num [1:500] 1.867 3.475 -6.796 4.332 -0.285 ...

Pokud jednotlivé parametry v seznamu pojmenujeme, na jejich pořadí nezáleží, protože se předají jménem:

pars <- list(sd = sd, mean = mu, n = n)  # pojmenovaný seznam parametrů
z <- pmap(pars, rnorm)
str(z)  # struktura výsledku
List of 5
 $ : num [1:100] -0.693 0.1201 0.0763 -0.3528 -0.1542 ...
 $ : num [1:200] -1.722 -0.644 -1.473 0.471 0.405 ...
 $ : num [1:300] -0.493 1.779 2.823 -0.144 6.917 ...
 $ : num [1:400] 7.352 0.674 0.956 8.114 1.974 ...
 $ : num [1:500] 9.87 5.78 2.15 9.32 4.36 ...

Pohodlnější je však zadat parametry jako tabulku:

pars <- tibble::tibble(sd = sd, mean = mu, n = n)
z <- pmap(pars, rnorm)
str(z)  # struktura výsledku
List of 5
 $ : num [1:100] -0.0501 2.325 0.719 -0.2352 -1.6174 ...
 $ : num [1:200] 4.203 2.625 1.729 1.623 -0.735 ...
 $ : num [1:300] 1.799 -0.985 3.478 -4.679 0.937 ...
 $ : num [1:400] 5.548 2.319 -0.349 0.669 -2.719 ...
 $ : num [1:500] 5.058 -6.628 9.902 0.652 3.195 ...

Pokud místo funkce zadáme .f jako pravostrannou formuli, pak první vektor v seznamu nebo tabulce označíme jako ..1, druhý jako ..2 atd.:

z <- pmap(pars, ~ rnorm(n = ..3, mean = ..2, sd = ..1))
str(z)  # struktura výsledku
List of 5
 $ : num [1:100] -1.84 -0.785 0.406 0.871 1.843 ...
 $ : num [1:200] 1.71 3.99 2.56 2.04 4.32 ...
 $ : num [1:300] 1.84 -2.5 4.26 1.85 1.16 ...
 $ : num [1:400] 5.07 -1.53 -4.29 -2.94 0.87 ...
 $ : num [1:500] 4.033 0.404 1.574 -1.798 1.758 ...

Balík purrr implementuje i funkci modify2() a funkce walk2() a pwalk(), které umožňují iterovat vedlejší efekty nad více vektory.

9.4 Filtrace a detekce prvků vektorů

Balík purrr implementuje i několik funkcí určených k filtraci hodnot vektorů. Funkce keep(.x, .p, ...) vrací ty prvky vektoru .x, pro které predikátová funkce .p() vrací hodnotu TRUE. Naopak funkce discard(.x, .p, ...) vrací ty prvky vektoru .x, pro které predikátová funkce .p() vrací hodnotu FALSE, tj. zahazuje prvky, pro které podmínka platí. Funkce head_while(.x, .p, ...) a tail_while(.x, .p, ...) vrací všechny prvky od začátku nebo od konce, pro které funkce .p() souvisle vrací hodnotu TRUE. Ve všech těchto funkcích nemusí být .p funkce: může to být i logický vektor stejné délky jako .x nebo pravostranná formule, která vrací logickou hodnotu. Jejich použití ukazuje následující příklad:

v <- 1:10
# vrací TRUE, když je číslo liché
is.odd <- function(x) x %% 2 != 0  
keep(v, is.odd)  # výběr lichých hodnot z vektoru v
[1] 1 3 5 7 9
keep(v, ~ . %% 2 != 0)  # totéž pomocí pravostranné formule
[1] 1 3 5 7 9
discard(v, is.odd)  # vrácení vektoru v bez lichých hodnot
[1]  2  4  6  8 10
head_while(v, ~ . < 5)  # vrácení prvních hodnot menších než 5
[1] 1 2 3 4

Funkce compact(.x, .p = identity) umožňuje ze seznamu vypustit ty prvky, které mají buď hodnotu NULL nebo nulovou délku.

compact(list(a = 1, b = 2, c = NULL, d = 4, e = numeric(0)))
$a
[1] 1

$b
[1] 2

$d
[1] 4

Parametr .p umožňuje zadat funkci nebo formuli. Pokud tato funkce vrátí NULL nebo prázdný vektor, pak funkce compact() vynechá odpovídající prvek. Zbývající hodnoty však nejsou funkcí .p nijak transformované. Použití ukazuje triviální příklad:

compact(1:5, .p = ~ .[. < 4])  # zachová pouze prvky menší než 4
[1] 1 2 3

Funkce detect(.x, .f, ..., .dir = c("forward", "backward"), .default = NULL) vrací první položku vektoru .x, pro kterou vrací .f hodnotu TRUE. Funkce detect_index(.x, .f, ..., .dir = c("forward", "backward")) vrací index této položky. Stejně jako výše může .f být funkce nebo pravostranná formule, která vrací logickou hodnotu.

detect(v, is.odd)  # první lichá hodnota ve vektoru v
[1] 1
detect(v, ~ . > 1)  # první hodnota větší než 1
[1] 2
detect_index(v, is.odd)  # index prvního lichého prvku vektoru v
[1] 1
detect_index(v, ~ . > 1)  # index prvního prvku většího než 1
[1] 2

Dva zbývající parametry určují směr, odkud se budou hodnoty hledat (parametr .dir, implicitně zepředu), a jaká hodnota se vrátí, pokud žádný prvek vektoru nesplňuje zadaný predikát (parametr .default).

Funkce every(.x, .p, ...), some(.x, .p, ...) a none(.x, .p, ...) zobecňují logické funkce all() a any(). every() vrací TRUE, pokud zadaná predikátová funkce .p vrací pro každý prvek vektoru .x hodnotu TRUE; funkce some() vrací TRUE, pokud .f vrací TRUE aspoň pro jeden prvek .x. Pomocí těchto funkcí můžeme např. otestovat, zda tabulka df obsahuje aspoň jeden numerický sloupec (some()) nebo jen numerické sloupce (every()):

df |> some(is.numeric)  # obsahuje df aspoň jeden numerický sloupec?
[1] TRUE
df |> every(is.numeric)  # obsahuje df pouze numerické sloupce?
[1] FALSE

Funkce has_element(.x, .y) zobecňuje operátor %in%. Vrací TRUE, pokud vektor .x obsahuje objekt .y.

x <- list(1:5, "a")  # prvky x jsou vektory 1:5 a "a"
has_element(x, 1:5)
[1] TRUE
has_element(x, 3)
[1] FALSE

Balík purrr nabízí i užitečnou funkci negate(), která transformuje zadanou funkci tak, že vrací její negaci. Pokud bychom chtěli pomocí keep() a naší funkce is.odd() vybrat sudé prvky, museli bychom použít formuli:

keep(1:10, ~ !is.odd(.))
[1]  2  4  6  8 10

Pomocí funkce negate() však můžeme negovat celou predikátovou funkci is.odd():

keep(1:10, negate(is.odd))
[1]  2  4  6  8 10

9.5 Výběr a úpravy prvků vektorů

Často potřebujeme ze složitější struktury získat jeden její prvek. Obecně k tomu slouží funkce [[ (dvojité hranaté závorky). Funkce pluck(.x, ..., .default = NULL) tuto myšlenku zobecňuje. Umožňuje vybrat libovolně zanořený prvek vektoru .x. Ukážeme si to na příkladu vektoru hráčů:

dungeon |> str()
List of 2
 $ :List of 3
  ..$ id   : num 11
  ..$ name : chr "Karel"
  ..$ items:List of 2
  .. ..$ : chr "sword"
  .. ..$ : chr "key"
 $ :List of 3
  ..$ id   : num 12
  ..$ name : chr "Emma"
  ..$ items:List of 3
  .. ..$ : chr "mirror"
  .. ..$ : chr "potion"
  .. ..$ : chr "dagger"

První parametr pluck() je vektor, ze kterého vybíráme. Další parametry jsou pozice nebo jména prvků, které vybíráme. Pokud zadáme víc položek, pak výběr funguje rekurzivně: druhá položka vybírá z výsledku prvního výběru atd.:

pluck(dungeon, 1)
$id
[1] 11

$name
[1] "Karel"

$items
$items[[1]]
[1] "sword"

$items[[2]]
[1] "key"
pluck(dungeon, 1, "name")
[1] "Karel"
pluck(dungeon, 1, "items")
[[1]]
[1] "sword"

[[2]]
[1] "key"
pluck(dungeon, 1, "items", 1)
[1] "sword"

Pokud hledaný prvek ve vektoru neexistuje, funkce pluck() vrátí NULL. Tuto hodnotu můžeme změnit pomocí parametru .default:

pluck(dungeon, 3)
NULL
pluck(dungeon, 3, .default = NA)
[1] NA

Pokud bychom potřebovali, aby funkce raději zhavarovala, můžeme místo pluck() použít funkci chuck(x, ...):

chuck(dungeon, 3)
Error in `chuck()`:
! Index 1 exceeds the length of plucked object (3 > 2).

Funkce pluck() umožňuje i měnit hodnotu vybraného prvku (zde bohužel není možné při výběru použít funkci jako je např. naše funkce artefact()):

pluck(dungeon, 1, "items", 1) <- "megaweapon"
str(dungeon)
List of 2
 $ :List of 3
  ..$ id   : num 11
  ..$ name : chr "Karel"
  ..$ items:List of 2
  .. ..$ : chr "megaweapon"
  .. ..$ : chr "key"
 $ :List of 3
  ..$ id   : num 12
  ..$ name : chr "Emma"
  ..$ items:List of 3
  .. ..$ : chr "mirror"
  .. ..$ : chr "potion"
  .. ..$ : chr "dagger"

První hráč má nyní místo meče nějakou “megazbraň”.

Pokud potřebujete změnit nějaký prvek vektoru, můžete použít funkce assign_in() a modify_in(). Na jejich použití se podívejte do dokumentace.

9.6 Rekurzivní kombinace prvků vektorů

Někdy máme seznam objektů, na které potřebujeme aplikovat funkci, která však bere jen dva vstupy. Patrně nejdůležitější příklad takového užití je spojení mnoha tabulek uložených v seznamu pomocí funkce left_join(), se kterým se seznámíte v kapitole 13. V takovém případě chceme aplikovat funkci postupně: nejprve spojit první dvě tabulky, pak k výsledku připojit třetí tabulku, k výsledku tohoto spojení čtvrtou atd. Balík purrr k tomuto účelu nabízí čtyři funkce: reduce(.x, .f, ..., .init, .dir = c("forward", "backward")) a reduce2(.x, .y, .f, ..., .init) a accumulate(.x, .f, ..., .init, .dir = c("forward", "backward")) a accumulate2(.x, .y, .f, ..., .init). My se zde podíváme jen na funkce reduce() a accumulate(), které pracují nad jedním seznamem; druhé dvě funkce pracují paralelně nad dvěma.

Funkce accumulate() postupně aplikuje na vektor .x funkci .f a vrací seznam stejné délky jako .x (nebo o 1 delší, viz dále). Prvním prvkem výsledného vektoru je .x[[1]], druhým .f(.x[[1]], .x[[2]]), třetím .f(.f(.x[[1]], .x[[2]]), .x[[3]]) atd. Pokud tedy “akumulujeme” atomický vektor čísel \(1, 2, \ldots, N\) pomocí funkce součtu +, pak dostaneme atomický vektor čísel \(1, 1 + 2, 1 + 2 + 3, \dots\), tedy totéž, co by nám vrátila funkce cumsum():

accumulate(1:5, `+`)
[1]  1  3  6 10 15

Funkce reduce() funguje podobně, ale vrací jen finální výsledek akumulace, zde tedy součet všech prvků vektoru:

reduce(1:5, `+`)
[1] 15

Podívejme se nyní na realističtější příklad. Řekněme, že máme seznam atomických vektorů, které obsahují id jedinců, kteří se zúčastnili nějakých akcí. Zajímá nás, kteří jedinci, se zúčastnili všech těchto akcí, tj. hledáme průnik všech těchto množin. K tomu slouží funkce intersect(x, y), která však umí vrátit pouze průnik dvou množin. Musíme ji tedy použít na všechny prvky seznamu rekurzivně. Protože nás zajímá jen finální průnik, použijeme funkci reduce():

riots <- list(  # seznam id účastníků různých akcí
  c(1, 2, 3, 7, 9),
  c(1, 4, 8, 9),
  c(1, 3, 5, 9)
)
reduce(riots, intersect)  # celkový průsečík množin
[1] 1 9

Funkce reduce() a accumulate() umožňují zadat i počáteční hodnotu pomocí parametru .init. To se hodí v případě, kdy by akumulovaný vektor mohl být prázdný a my nechceme, aby výpočet zhavaroval.

v <- numeric(0)
reduce(v, `+`)
Error in `reduce()`:
! Must supply `.init` when `.x` is empty.
reduce(v, `+`, .init = 0)
[1] 0

Pokud zadáme parametr .init, bude výsledek funkce accumulate() o 1 delší než vstupní vektor.

Parametr .dir umožňuje nastavit směr akumulace (implicitně se akumuluje od prvního prvku vektoru po poslední). Detaily najdete v dokumentaci.

9.7 Paralelizace výpočtu

Funkce map() a spol. pracují s každým prvkem vektoru samostatně a izolovaně, takže nezáleží na pořadí, v jakém jsou tyto prvky zpracovány. To, mimo jiné, umožňuje paralelizaci výpočtu, tj. zpracování každého prvku na jiném jádře počítače nebo jiném prvku počítačového klastru. To se vyplatí zejména v situaci, kdy jsou data opravdu velká nebo výpočet nad jednotlivými prvky vektoru trvá opravdu dlouho. K jednoduché paralelizaci je možné použít balík furrr.

Balík furrr implementuje paralelizované ekvivalenty funkcí map(), map2(), pmap(), modify() a všech jejich variant, které zjednodušují výsledek na atomický vektor nebo tabulku jako je map_dbl() nebo transformují jen vybrané prvky jako map_at(). Tyto alternativní funkce mají konzistentní pojmenování: před jméno dané funkce připojují future_, takže místo map() máme future_map() apod. Všechny tyto funkce také vracejí stejné výsledky a berou stejné parametry jako odpovídající funkce z balíku purrr. K tomu přídávají tři dašlí volitelné parametry: .progress, který umožňuje sledovat průběh výpočtu pomocí progress baru, parametr .env_globals, který umožňuje zadat prostředí (environment), kde se mají hledat globální proměnné, a parametr .options, který umožňuje předávat dodatečné informace paralelizačnímu stroji.

Před použitím těchto funkcí je třeba nejen načíst balík furrr, ale i nastavit paralelizaci. K tomu slouží funkce plan(). Její hlavní parametr určuje, jak se bude paralelizovat. Implicitní hodnota je sequential, tj. normální výpočet na jednom jádře bez paralelizace. Typické nastavení je

library(furrr)
plan(multisession)

které nastaví nejlepší dostupnou paralelizaci na daném stroji. Vlastní iterace jsou pak stejné jako s balíkem purrr, stačí jen přidat future_ ke jménu funkce, tj. např. volat:

future_map_dbl(1:5, ~ . ^ 2)
[1]  1  4  9 16 25

Na velmi stylizovaném příkladu si ukážeme, jak paralelizace zrychluje výpočet. Pomocí funkce map() a future_map() spustíme třikrát výraz Sys.sleep(1), který na 1 sekundu pozastaví výpočet. Pomocí funkce system.time() změříme, jak dlouho tento “výpočet” trval:

system.time(map(1:3, ~ Sys.sleep(1)))
   user  system elapsed 
  0.002   0.000   3.005 
system.time(future_map(1:3, ~ Sys.sleep(1)))
   user  system elapsed 
  0.272   0.002   1.266 

Zatímco s pomocí map() trval, jak bychom očekávali, zhruba tři sekundy, s pomocí future_map() zabral jen o málo víc než 1 sekundu, protože každé jednotlivé volání Sys.sleep(1) proběhlo na mém šestnáctijádrovém počítači v jiném sezení R. Výsledek by byl ještě rychlejší, kdybych na svém Linuxovém stroji nekompiloval tento text v RStudiu, viz dále.

Implicitně se používají všechna jádra počítače. To je možné změnit tak, že nejprve pomocí funkce availableCores() zjistíme počet dostupných jader, a pak nastavíme počet jader použitých výpočtu ve funkci plan() pomocí parametru workers. Pokud tedy chceme např. jedno jádro ušetřit pro ostatní procesy běžící na počítači, můžeme paralelizaci naplánovat takto:

n_cores <- availableCores()
plan(multisession, workers = n_cores - 1)

Popis podporovaných způsobů paralelizace najdete v dokumentaci k funkci plan() a v základní vinětě k balíku future, který se stará o backend. Základní způsoby paralelizace jsou čtyři: sequential provádí jednotlivé výpočty normálně bez paralelizace na jednom jádře, multisession spouští jednotlivé výpočty v nových kopiích R, multicore vytváří forky procesů R a cluster používá počítačový klastr. Ve většině případů budeme pracovat na jednom počítači, takže volíme mezi multisession a multicore. mulitcore je při tom efektivnější, protože nemusí kopírovat pracovní prostředí (globální proměnné, balíky apod.) do nového procesu, zatímco multisession musí překopírovat potřebné objekty do nového sezení R. multicore však není dostupný ve Windows, které fork vůbec nepodporují, ani při spuštění výpočtu v rámci RStudia.

Při spuštění multisession musí future_map() a spol. překopírovat do nového sezení potřebné proměnné. Většinou to funguje bezproblémově automaticky. Pokud však něco selže, přečtěte si viněty k balíku future, které vysvětlují, jak potřebné proměnné do nového sezení nakopírovat ručně a jak vyřešit i další případné problémy. Ani jeden z autorů tohoto textu však při použití furrr zatím na takový problém nenarazil.

Potřeba přesouvat data do a z nového procesu také znamená, že paralelní výpočet na čtyřech jádrech nebude typicky čtyřikrát rychlejší než na jednom. Pokud byste spouštěli jen malý počet velmi rychlých výpočtů na velkých datech, mohlo by se dokonce stát, že výpočet bude pomalejší než na jednom jádře. Paralelizovat se tak vyplatí jen výpočty, kde každé spuštění funkce nad prvkem seznamu trvá poměrně dlouho nebo se zpracovává poměrně hodně prvků.

Paralelizace pomocí balíku furrr obvykle funguje naprosto automaticky. V některých speciálních případech je však potřeba nastavit několik voleb. K tomu slouží parametr .options. Nejtypičtější použití je nutnost nastavení náhodného generátoru čísel, pokud se při paralelizovaném výpočtu používají. Ve výjimečných případech také selže automatická detekce globálních proměnných a použitých balíků – i ty je pak možné nastavit pomocí voleb. Následující kód ukazuje zjednodušené použití v jedné z mých simulací:

opts <- furrr::furrr_options(
            seed = TRUE,  # je možné použít i\ konkreční počáteční hodnotu
            globals = ls(envir = .GlobalEnv),
            packages = c("tibble", "dplyr", "tidyr", "stringr")
        )
result <- future_map(seq_len(parameters$no_of_simulations),
                          ~simulate_one(parameters),
                          .options = opts)

9.8 Srovnání map() s cyklem for

Většinu začátečníků funkce typu map() děsí a snaží se daný problém řešit pomocí cyklů. To je samozřejmě možné. Vraťme se k příkladu, kdy máme nějakou tabulku a chceme zjistit, kolik který její sloupec obsahuje chybějících hodnot. Pomocí funkce map_int() to uděláme na jednom řádku:

map_int(df, ~ sum(is.na(.)))
 names income   rent   loan 
     0      0      0      0 

Pokud budeme místo toho chtít použít cyklus for, bude náš kód vypadat takto:

result <- integer(ncol(df))
for (k in seq_len(ncol(df)))
    result[k] <- sum(is.na(df[[k]]))
names(result) <- names(df)
result
 names income   rent   loan 
     0      0      0      0 

Výsledek je v obou případech stejný, ale kód napsaný pomocí cyklů má několik nevýhod: 1) Kód napsaný pomocí cyklu for bývá obvykle delší a podstatně méně přehledný. Funkce map() jasně ukazuje, na jakých datech se iteruje a co se na nich iteruje. V kódu cyklu to není zdaleka tak jasně vidět. 2) Při použití cyklu for musíte myslet na víc věcí: musíte si předalokovat vektor pro uložení výsledku, musíte vektor pojmenovat (pokud o to stojíte) a musíte přemýšlet, jestli použijete jednoduché nebo dvojité hranaté závorky (podle toho, zda je původní vektor a výsledek atomický vektor, seznam, nebo tabulka typu data.frame nebo tibble). 3) Zaplevelíte si pracovní prostředí nechtěnými proměnnými: možná proměnnou result, určitě však počítadlem cyklu k. A 4) Mapovací funkce je mnohem jednodušší paralelizovat. Obecně je proto lepší vždy používat funkce typu map() raději než explicitní cyklus for.

Přesto však existují situace, kdy je využití cyklů nezbytné. Hlavní případy jsou dva:

  1. Při rekurzivním výpočtu, když výpočet \(i\)-té hodnoty záleží na některé z hodnot spočítaných dříve a
  2. když pro ušetření času nahrazujeme hodnoty přímo v původní datové struktuře, místo abychom celou strukturu vytvářeli znovu.

V ostatních situacích je téměř vždy rozumnější použít některou z funkcí typu map().

9.9 Aplikace

Někdy potřebujeme načíst velké množství tabulek z jednotlivých .csv souborů, zkontrolovat jejich konzistenci a spojit je dohromady. To dělá následující kód, který představuje zjednodušenou verze části jednoho mého projektu:

# načtení potřebných balíků
library(readr)
library(purrr)
# adresář s daty
DATADIR <- file.path("..", "testdata")
# seznam jmen souborů, které splňují určitou masku danou regulárním výrazem
PRODUCT_FILES <- dir(DATADIR, "products_.*\\.csv\\.gz", full.names = TRUE)
# načtení jednotlivých souborů, sloupcům vnutíme typ řetězec
product_data <- map(PRODUCT_FILES,
                    ~ read_csv2(file = ., col_types = cols(.default = "c"))
# kontrola, že všechny tabulky mají stejnou strukturu
product_col_names <- map(product_data, colnames)
product_col_names_test <- map_chr(product_col_names,
                                  ~ all.equal(product_col_names[[1]], .))
if (!(all(identical(product_col_names_test,
                    rep(TRUE, length = length(product_col_names))))))
    stop("Product data sets have different columns!")
# spojení jednotlivých tabulek do jedné
product_data <- reduce(product_data, rbind)

Vlastní spojení dat je možné provést efektivněji pomocí funkce bind_rows() z balíku dplyr, která je efektivnější a rychlejší:

product_data <- dplyr::bind_rows(product_data)

(Ve skutečnosti umí balík readr načíst a spojit více souborů automaticky, viz oddíl 10.1.5.)