//styles, look here: https://cdnjs.com/libraries/highlight.js/9.12.0

June 6, 2020

3683 palavras 18 mins

Cuide da saúde, pare de fazer loops

Disclaimer: eu tenho a formação em ciência da computação de uma batata, não me leve muito a sério

O querido Daniel Duque trouxe um problema para o meu colo e eu gostei tanto da simplicidade da solução em relação à abordagem mais óbvia de montar loops dentro de loops que decidi aproveitar para espalhar a palavra da programação funcional. Não por inteiro, apenas outra concepção de operações repetidas.

Antes do problema interessante do Daniel, um problema comum de simulações estatísticas como motivação. Digamos que nós queremos simular várias normais com médias diferentes. Com loops:

knitr::opts_chunk$set(message = FALSE, warning = FALSE)

library(tidyverse)
## ── Attaching packages ──────────────────────────────────────────────────────────────────── tidyverse 1.3.0 ──
## ✓ ggplot2 3.3.2     ✓ purrr   0.3.4
## ✓ tibble  3.0.3     ✓ dplyr   1.0.2
## ✓ tidyr   1.1.2     ✓ stringr 1.4.0
## ✓ readr   1.3.1     ✓ forcats 0.5.0
## ── Conflicts ─────────────────────────────────────────────────────────────────────── tidyverse_conflicts() ──
## x dplyr::filter() masks stats::filter()
## x dplyr::lag()    masks stats::lag()
library(magrittr)
## 
## Attaching package: 'magrittr'
## The following object is masked from 'package:purrr':
## 
##     set_names
## The following object is masked from 'package:tidyr':
## 
##     extract
n <- 1000

(dados_medias <- tibble(media = seq(-5, 5, .01)))
## # A tibble: 1,001 x 1
##    media
##    <dbl>
##  1 -5   
##  2 -4.99
##  3 -4.98
##  4 -4.97
##  5 -4.96
##  6 -4.95
##  7 -4.94
##  8 -4.93
##  9 -4.92
## 10 -4.91
## # … with 991 more rows
lista <- as.list(rep(0, nrow(dados_medias)))

for(i in 1:nrow(dados_medias)) {
  
 lista[[i]] <- rnorm(n = n, 
                     mean = dados_medias$media[i])

}

dados_medias$sims <- lista

(dados_finais <- unnest(dados_medias, sims))
## # A tibble: 1,001,000 x 2
##    media  sims
##    <dbl> <dbl>
##  1    -5 -5.48
##  2    -5 -5.57
##  3    -5 -5.04
##  4    -5 -6.21
##  5    -5 -6.00
##  6    -5 -3.28
##  7    -5 -3.68
##  8    -5 -5.08
##  9    -5 -6.40
## 10    -5 -4.53
## # … with 1,000,990 more rows

Tenha em mente que com tibbles podemos usar listas como colunas, o que não acontece com os dataframes nativos do R então o código poderia ser ainda menos conciso.

Existe outra abordagem para isso. Se entendermos que estamos na verdade aplicando funções várias vezes. lista[[i]]] <- ... é a composição de duas funções no objeto lista: primeiro localize o i-ésimo elemento em lista e depois atributa o valor ....

Nós temos dificuldade em enxergar isso de primeira porque está tudo coberto de açúcar sintático. Elementos como [[]] e <-, desenhados para trazer certas comodidades, mas que fazem essas operações parecerem se tratar de algo diferente.

Levando essa raciocínio mais adiante, for não é exatamente uma função. Estamos apenas declarando uma uma operação deve ser repetida uma certa quantidade de vezes passando um valor (que usualmente nos referimos como i) tirado de uma lista ou vetor de valores. O que nos impede, no entanto, de pensar em funções que recebem outras funções e as aplicam em elementos variados?

Se por um lado com loops estamos acessando objetos exteriores ao loop o tempo todo - por isso criamos objetos a serem preenchidos no começo do código - não pensamos apenas em uma função que recebe funções? Desse jeito não somos mais obrigados a preparar o terreno antes de um loop, nem mais pensar em índices. Se nomes de funções forem descritivos, é muito mais legível ler declarações do que acompanhar índices. Essas funções que recebem outras se chamam funções de alta ordem e existem em variados sabores. A que reproduz o comportamento de um loop é a map() do pacote purrr.

Três coisas importantes para se ter em mente:

  • R tem jeitos elegantes de lidar com funções que não precisam ser nomeadas, as anônimas. Podemos usar o construtor ~ das fórmulas e deixar o pacote se encarregar de traduzir isso numa função, ou explicitamente usar function(.x) { ... }. Como regra de bolso: se a operação a ser repetida é simples e cabe em uma sequencia curta de código, vale usar a fórmula, se não é melhor construir uma função.

  • O substituto do i do loop aqui é .x. Isso pode parecer pior no começo, mas tem dois motivos: o ponto no início do nome serve para esse objeto não ir para o seu ambiente global, te poupando alguma dor de cabeça e também porque facilita iterar em objetos diferentes - que você usualmente faria com loops dentro de loops.

  • map() por si só sempre devolve uma lista. De vez em quando queremos vetores e fazemos isso especificando o tipo do qual queremos. map_dbl() devolve números reais de precisão dupla, map_chr() devolve caracteres, map_lgl() devolve lógicos, map_dfr() e map_dfc() devolvem dataframes empilhados, respectivamente, por linha e por coluna.

Alguns exemplos:

map(1:10, # dos números de 1 a 10
    ~ rnorm(n = 5, mean = .x)) # tire 5 números da normal com a média dada
## [[1]]
## [1] 1.2986512 0.7486598 1.4350496 1.1078836 2.9176516
## 
## [[2]]
## [1] 1.8231324 2.3873826 0.3560564 3.2473069 2.6840332
## 
## [[3]]
## [1] 1.022256 2.779507 3.508351 4.074603 3.689808
## 
## [[4]]
## [1] 2.718377 4.453863 4.133579 3.960361 3.165042
## 
## [[5]]
## [1] 3.059132 6.386267 5.676395 3.749667 6.236710
## 
## [[6]]
## [1] 5.446798 6.610045 5.806798 7.323396 7.127649
## 
## [[7]]
## [1] 6.184257 6.909693 7.540982 6.905253 8.675896
## 
## [[8]]
## [1] 9.646311 7.276319 8.880346 8.207127 8.258731
## 
## [[9]]
## [1]  9.411439  9.072689  9.079998 10.168214  9.869733
## 
## [[10]]
## [1]  9.073607  9.168484  9.701572 10.724999 11.063733
map(1:10, 
    function(.x) rnorm(n = 5, mean = .x) ) # mesmo resultado que a abordagem com fórmulas
## [[1]]
## [1]  0.79478576  1.86723711  1.91224409  1.59534831 -0.09859925
## 
## [[2]]
## [1] 1.1424059 3.0807964 1.1306434 2.4033044 0.4679187
## 
## [[3]]
## [1] 2.905119 4.289049 2.701481 4.555509 2.063379
## 
## [[4]]
## [1] 3.599202 1.739325 3.859524 5.459989 4.501931
## 
## [[5]]
## [1] 6.755611 3.735697 5.308191 5.999475 5.039320
## 
## [[6]]
## [1] 6.527621 6.991040 5.361904 5.001168 5.592128
## 
## [[7]]
## [1] 6.973325 5.779717 7.992567 6.485919 7.042540
## 
## [[8]]
## [1] 7.727175 8.383740 9.694532 8.017629 8.770065
## 
## [[9]]
## [1]  7.878371 10.037624  7.231069  7.178682  6.635536
## 
## [[10]]
## [1] 11.184230 12.344337  8.826568 10.390491  9.765765
map_dfc(1:10,
        ~ rnorm(n = 10, mean = .x))
## # A tibble: 10 x 10
##      ...1  ...2  ...3  ...4  ...5  ...6  ...7  ...8  ...9 ...10
##     <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
##  1  2.32  2.91  3.27   6.18  4.93  6.40  7.54  7.61 10.1   9.75
##  2  0.552 2.55  4.87   4.08  6.04  5.74  5.89  8.58  9.58 10.9 
##  3  0.874 3.79  2.89   2.50  4.95  7.42  6.63  9.56 10.4  10.3 
##  4  0.715 0.619 2.85   3.73  6.78  7.12  4.08  8.53  8.01 10.7 
##  5  0.646 0.913 2.29   5.53  5.27  5.35  5.15  5.52  7.93 10.4 
##  6 -0.270 3.54  1.77   4.91  5.58  6.42  7.65  7.33 10.4  10.0 
##  7  1.39  2.20  0.917  5.03  3.92  6.85  7.84  6.72 10.6  10.1 
##  8  1.08  4.56  3.39   2.99  3.73  8.26  8.30  8.14  8.59  9.30
##  9  1.00  2.61  3.83   2.81  4.26  6.81  7.82  7.97  8.52 10.0 
## 10  1.48  2.20  4.11   4.19  6.16  6.01  7.67  8.10  9.30 12.6
map_dbl(1:10,
        ~ rnorm(n = 10, mean = .x))
## Error: Result 1 must be a single double, not a double vector of length 10

Note que nem toda conversão a certo tipo de saída funciona. Não podemos pedir que uma lista de vetores saia como um vetor, mas obviamente podemos falar de colar vetores de mesmo comprimento em colunas de um dataframe. Se quisermos uma saída com menos dimensões que uma lista precisamos de outra função de alta ordem, reduce. Esse padrão map-reduce é muito comum e tem aplicações interessantes para quem lida com volumes grandes de dados. Alguns exemplos:

map(1:10,
    ~ rnorm(n = 5, mean = .x)) # lista de vetores
## [[1]]
## [1] 1.0232989 2.4665407 0.1256423 1.3643727 2.0419386
## 
## [[2]]
## [1] 1.8856417 1.4484175 1.3915854 2.4004125 0.6686265
## 
## [[3]]
## [1] 2.073900 3.566305 3.568440 2.636959 3.541926
## 
## [[4]]
## [1] 4.479269 5.532486 4.650690 2.688973 3.557823
## 
## [[5]]
## [1] 6.136452 5.331405 4.361077 4.572062 5.261537
## 
## [[6]]
## [1] 4.577885 6.788825 4.277568 6.134379 6.485530
## 
## [[7]]
## [1] 7.718404 7.105651 6.968658 7.551956 7.634469
## 
## [[8]]
## [1] 6.793204 7.921184 8.485568 7.600133 8.816643
## 
## [[9]]
## [1] 9.554838 8.901278 9.235871 8.583850 6.863581
## 
## [[10]]
## [1] 11.236681  9.191221  9.986998  9.473153  9.542101
map(1:10,
    ~ rnorm(n = 5, mean = .x)) %>%
  reduce(c) # a função c() que combina vetores, apenas um vetor de saída
##  [1]  0.9823800 -0.5590955 -0.2893896  1.5732983  1.9335737  2.1005423
##  [7]  2.2307451  2.5275564  3.5365313  2.6342725  3.7549358  4.1221890
## [13]  3.8850179  1.4343370  3.1133282  5.0036512  4.7592334  3.7514190
## [19]  3.2305121  4.6195347  5.2345191  4.6162969  6.1716742  4.9404750
## [25]  5.3207892  6.0850025  5.9884531  6.7644135  7.4325577  5.5433233
## [31]  6.4363239  7.8573207  6.4639221  6.0220299  8.2033437  9.4111057
## [37]  8.7519945  8.5905086  8.4904456  7.8904876  9.5251413  7.9265442
## [43]  7.6968702  8.1650457  7.1230936  9.7507227  9.8295439  8.6837617
## [49]  9.8014395  9.7632646
map(1:10,
    ~ rnorm(n = 5, mean = .x)) %>%
  reduce(cbind) # a função que une coluna a coluna
##             out      elt      elt      elt      elt      elt      elt      elt
## [1,]  1.1954326 2.003682 1.937710 2.568004 4.782914 5.635794 7.040474 7.138417
## [2,]  0.4550071 1.875988 4.369643 4.522479 3.786131 6.058918 7.009364 8.454644
## [3,] -0.3365051 2.993770 2.220321 4.371179 6.289199 3.990460 6.730721 8.739152
## [4,]  0.2914277 1.712711 1.313547 5.873006 3.442698 6.244251 6.870368 7.923810
## [5,]  1.2157099 1.290400 2.173671 2.974740 3.015927 5.541186 7.441456 8.615194
##            elt      elt
## [1,]  8.931515 11.05303
## [2,]  8.644173 10.01046
## [3,]  9.435867 10.52089
## [4,] 10.262942 10.90799
## [5,]  9.965202 10.48335
map(1:10,
    ~ rnorm(n = 5, mean = .x)) %>%
  reduce(rbind) # a função que une linha a linha (do ingles, row)
##           [,1]      [,2]      [,3]      [,4]       [,5]
## out  0.7291609  2.146816 -1.662501 2.3522701  1.8458298
## elt  4.1847018  3.289067  2.593366 0.9760658  0.3947473
## elt  4.1866884  3.035825  2.029659 2.6510648  2.0081253
## elt  5.3270356  4.943940  2.976580 4.5063331  4.2678024
## elt  5.1702530  4.367673  4.162555 4.4240096  5.8769766
## elt  7.5628398  4.910778  4.964045 6.2312263  4.5634939
## elt  6.0725933  7.924978  7.755264 6.9505149  7.6193810
## elt  7.6984709  5.818453  8.809182 7.9845237  7.5133789
## elt 10.4491688  8.119751  7.471278 9.7305512 10.2725894
## elt 12.6119036 10.663322  9.498496 9.3556920  8.7113770

Importante deixar claro que não precisamos nos limitar ao fluxo do map-reduce - e operadores podem ser chamados se usarmos as aspas backtick.

reduce(1:10, c) # a função c, de novo
##  [1]  1  2  3  4  5  6  7  8  9 10
reduce(1:10, `+`) # podemos usar operadores com aspas
## [1] 55

Vamos olhar a constante letters, com o alfabateo latino. A princípio temos um vetor com 26 entradas.

letters
##  [1] "a" "b" "c" "d" "e" "f" "g" "h" "i" "j" "k" "l" "m" "n" "o" "p" "q" "r" "s"
## [20] "t" "u" "v" "w" "x" "y" "z"
letters %>%
  length() # 26 letras do alfabeto
## [1] 26

E sendo um vetor de texto, podemos reduzi-lo a um vetor com menos entradas e apenas texto.

letters %>%
  reduce(paste) # colando todas as entradas em uma
## [1] "a b c d e f g h i j k l m n o p q r s t u v w x y z"
letters %>%
  reduce(~ paste(.x)) %>% # reduce também aceita fórmulas
  length() # apenas uma entrada, agora com todas as letras juntas
## [1] 1

Antes avançar, vamos apreciar um propriedade muito boa de R: funções são cidadãs de primeira classe e isso explica por que coisas como %>% reduce(cbind) funcionam. Assim como tibbles, listas, vetores e outras formas de armazenar dados, funções também são objetos e podemos notar isso ao parar de usar () para sinalizar que aquela função deve ser aplicada (outro açúcar sintático por sinal).

Sys.time()
## [1] "2020-10-09 21:06:34 -03"
Sys.time
## function () 
## .POSIXct(.Internal(Sys.time()))
## <bytecode: 0x5640d70485f8>
## <environment: namespace:base>
rnorm(10)
##  [1]  0.52452822  0.15413661  0.65484095  0.36934282 -1.32381920  0.28209009
##  [7] -0.67200562 -0.09619135  0.07626069  0.26644798
rnorm
## function (n, mean = 0, sd = 1) 
## .Call(C_rnorm, n, mean, sd)
## <bytecode: 0x5640d9346f88>
## <environment: namespace:stats>

R é uma linguagem inspirada no paradigma funcional e as marcas estão por todo canto. Até os insuspeitos operadores são funções.

is.function(`+`) # o operador de soma é uma função?
## [1] TRUE
is.function(`<-`) # o operador de designação é uma função?
## [1] TRUE
is.function(`%>%`) # pipe é uma função?
## [1] TRUE
is.function(`%*%`) # multiplicação por matrizes é uma função
## [1] TRUE

O exemplo revisitado

E bem, como podemos substituir o nosso loop de exemplo com esse raciocínio? mutate do dplyr recebe um dataframe e causa uma mutação em seu estado (adiciona ou mata variáveis, em bom português), já unnest puxa dados aninhados para fora. Vamos construir então passo a passo:

tibble(media = seq(1, 5, .001)) 
## # A tibble: 4,001 x 1
##    media
##    <dbl>
##  1  1   
##  2  1.00
##  3  1.00
##  4  1.00
##  5  1.00
##  6  1.00
##  7  1.01
##  8  1.01
##  9  1.01
## 10  1.01
## # … with 3,991 more rows
tibble(media = seq(1, 5, .001)) %>%
  mutate(sims = map(media, ~ rnorm(1000, .x)))
## # A tibble: 4,001 x 2
##    media sims         
##    <dbl> <list>       
##  1  1    <dbl [1,000]>
##  2  1.00 <dbl [1,000]>
##  3  1.00 <dbl [1,000]>
##  4  1.00 <dbl [1,000]>
##  5  1.00 <dbl [1,000]>
##  6  1.00 <dbl [1,000]>
##  7  1.01 <dbl [1,000]>
##  8  1.01 <dbl [1,000]>
##  9  1.01 <dbl [1,000]>
## 10  1.01 <dbl [1,000]>
## # … with 3,991 more rows
tibble(media = seq(1, 5, .001)) %>%
  mutate(sims = map(media, ~ rnorm(1000, .x))) %>%
  unnest(sims)
## # A tibble: 4,001,000 x 2
##    media    sims
##    <dbl>   <dbl>
##  1     1 -0.0666
##  2     1  1.98  
##  3     1  1.08  
##  4     1  2.41  
##  5     1  1.44  
##  6     1  2.09  
##  7     1 -0.296 
##  8     1  2.56  
##  9     1 -0.0707
## 10     1  1.86  
## # … with 4,000,990 more rows

Não é tão mais simples que fazer um loop? Existe um custo mental inicial de pensar sobre o código de outro jeito, mas ele logo se paga em soluções mais rápidas e baratas para problemas de todo tipo. Programação funcional te permite ser mais expressivo, fazer mais falando menos.

Lembra que ao invés de usar i para nos referir ao valor da iteração, usamos .x? Eu já te dei os motivos, antes de mostrar como isso tudo se aplica num problema prático muito típico, quero mostrar por quê o .x faz sentido.

Digamos que agora, para cada média sendo usada na simulação dos dados eu queira variar também o desvio-padrão? Estamos simulando dados de uma normal tirado de uma “grade” contendo as combinações de médias e desvios-padrão. Pense um pouco em como abordar esse problema com loops. Você vai adicionar mais uma camada de loop, provavelmente irá se referir ao segundo índice como j e terá que acompanhar mentalmente duas camadas de peças móveis. Não é muito agradável e o problema piora exponencialmente com cada camada extra adicionada.

Uma solução muito mais elegante te aguarda usando map2(), que serve para - como o nome entrga - iterar em dois objetos, pmap() faz isso para um número arbitrário. Para criar a grade com os dados usamos expand_grid(), que gera as combinações. Construindo passo a passo:

expand_grid(media = seq(1, 5, .1), 
            sd = seq(1, 5, .1)) # as combinações
## # A tibble: 1,681 x 2
##    media    sd
##    <dbl> <dbl>
##  1     1   1  
##  2     1   1.1
##  3     1   1.2
##  4     1   1.3
##  5     1   1.4
##  6     1   1.5
##  7     1   1.6
##  8     1   1.7
##  9     1   1.8
## 10     1   1.9
## # … with 1,671 more rows
expand_grid(
  media = seq(1, 5, .1), 
  sd = seq(1, 5, .1)) %>%
    mutate(
      sims = map2(
        .x = media,
        .y = sd,
        ~ rnorm(n = 100, mean = .x, sd = .y))
    ) # criando a coluna
## # A tibble: 1,681 x 3
##    media    sd sims       
##    <dbl> <dbl> <list>     
##  1     1   1   <dbl [100]>
##  2     1   1.1 <dbl [100]>
##  3     1   1.2 <dbl [100]>
##  4     1   1.3 <dbl [100]>
##  5     1   1.4 <dbl [100]>
##  6     1   1.5 <dbl [100]>
##  7     1   1.6 <dbl [100]>
##  8     1   1.7 <dbl [100]>
##  9     1   1.8 <dbl [100]>
## 10     1   1.9 <dbl [100]>
## # … with 1,671 more rows
expand_grid(
  media = seq(1, 5, .1), 
  sd = seq(1, 5, .1)) %>%
    mutate(
      sims = map2(
        .x = media,
        .y = sd,
        ~ rnorm(n = 5, mean = .x, sd = .y))
    ) %>%
    unnest(sims)
## # A tibble: 8,405 x 3
##    media    sd    sims
##    <dbl> <dbl>   <dbl>
##  1     1   1    1.36  
##  2     1   1    1.43  
##  3     1   1    0.401 
##  4     1   1    1.21  
##  5     1   1    1.10  
##  6     1   1.1  2.52  
##  7     1   1.1  0.599 
##  8     1   1.1  2.58  
##  9     1   1.1 -0.0285
## 10     1   1.1  2.65  
## # … with 8,395 more rows

Iterando sobre mais variáveis padrões mais sofisiticados podem ser escritos com pouco trabalho adicional. Em um loop cada camada extra requer a mesma quantidade de “infraestrutura” em código e cada vez mais atenção para acompanhar todos os procedimentos. Perceba, trabalhar com processos aninhados é muito menos intuitivo que acompanhar o varrimento uniforme de uma grade de parâmetros.

Um caso

Bem, afinal qual é o problema? Bem, alguns projetos de lei estão sendo mapeados de acordo com seu risco de votação e impacto fiscal:

E queremos uma matriz listando os projetos em grupo:

Antes de prosseguir, pense um pouco em como você resolveria isso com loops. Se estiver com paciência, esboce algumas soluções. Vamos gerar dados falsos:

library(lorem)
library(glue)

(tabela <- tibble(
  PL = as.character(glue("{ipsum_starts(30)} {ipsum_starts(30)}")),
  risco = sample(c("alto", "baixo", "médio"), 
                 size = length(PL), 
                 replace = TRUE),
  impacto = sample(c("alto", "baixo", "médio"), 
                   size = length(PL),
                   replace = TRUE)))
## # A tibble: 30 x 3
##    PL                    risco impacto
##    <chr>                 <chr> <chr>  
##  1 consectetur lorem     baixo alto   
##  2 adipiscing dolor      médio médio  
##  3 ipsum lorem           alto  baixo  
##  4 amet ipsum            médio médio  
##  5 sit amet              alto  baixo  
##  6 adipiscing adipiscing baixo alto   
##  7 dolor consectetur     alto  médio  
##  8 amet ipsum            baixo alto   
##  9 dolor sit             alto  médio  
## 10 dolor consectetur     baixo alto   
## # … with 20 more rows

Vamos passo a passo construir a solução. Primeiro construímos a grade:

tabela %$%
  expand_grid(risco = unique(risco), 
              impacto = unique(impacto)) 
## # A tibble: 9 x 2
##   risco impacto
##   <chr> <chr>  
## 1 baixo alto   
## 2 baixo médio  
## 3 baixo baixo  
## 4 médio alto   
## 5 médio médio  
## 6 médio baixo  
## 7 alto  alto   
## 8 alto  médio  
## 9 alto  baixo

Agora adicionamos mutate e dentro definimos a coluna PLs que é resultado da iteração sobre risco e impacto fiscal e filtra, na tabela original, apenas os nomes de projetos de lei que atendem simultaneamente a terem um certo risco e um certo impacto fiscal. Como estamos iterando sobre a grade, sabemos que todas as combinações serão avalidas.

tabela %$%
  expand_grid(risco = unique(risco), 
              impacto = unique(impacto)) %>%
  mutate(PLs = 
      map2(.x = risco, 
           .y = impacto,
           function(.x, .y) {
              tabela %>% # acessamos a tabela
                filter(risco == .x,  # filtramos para as condições
                       impacto == .y) %>%
                pull(PL) # puxamos o vetor com os nomes de PLs
           })
  ) 
## # A tibble: 9 x 3
##   risco impacto PLs      
##   <chr> <chr>   <list>   
## 1 baixo alto    <chr [7]>
## 2 baixo médio   <chr [1]>
## 3 baixo baixo   <chr [1]>
## 4 médio alto    <chr [3]>
## 5 médio médio   <chr [2]>
## 6 médio baixo   <chr [3]>
## 7 alto  alto    <chr [1]>
## 8 alto  médio   <chr [5]>
## 9 alto  baixo   <chr [7]>

Agora precisamos desaninhar essa lista em texto. Passamos a execução de map2() como argumento de outro mapeamento, agora com saída definida como vetor de texto basta usar reduce com uma versão customizada de paste para reduzir tudo em um vetor.

tabela %$%
  expand_grid(risco = unique(risco), 
              impacto = unique(impacto)) %>%
  mutate(PLs = 
      map2_chr(
        .x = risco, 
        .y = impacto,
        function(.x, .y) {
          tabela %>%
            filter(risco == .x, 
                   impacto == .y) %>%
            pull(PL) %>%
            reduce(partial(paste, sep = ", "))
        })
  ) 
## # A tibble: 9 x 3
##   risco impacto PLs                                                             
##   <chr> <chr>   <chr>                                                           
## 1 baixo alto    consectetur lorem, adipiscing adipiscing, amet ipsum, dolor con…
## 2 baixo médio   adipiscing consectetur                                          
## 3 baixo baixo   adipiscing amet                                                 
## 4 médio alto    elit dolor, elit consectetur, consectetur elit                  
## 5 médio médio   adipiscing dolor, amet ipsum                                    
## 6 médio baixo   dolor dolor, ipsum sit, amet ipsum                              
## 7 alto  alto    ipsum consectetur                                               
## 8 alto  médio   dolor consectetur, dolor sit, sit elit, adipiscing dolor, ipsum…
## 9 alto  baixo   ipsum lorem, sit amet, dolor ipsum, lorem elit, lorem adipiscin…

Algumas vantagens não são óvbias. A primeira é que é apenas uma expressão a ser executada então você pode ter certeza que nunca terá erros em pedaços específicos do código então todo dado resultado dessa operação é válido. Se uma peça móvel falhar, a avaliação toda é cancelada e está claro que há um problema. A segunda é que - apesar de parecer mais difícil no começo - essa abordagem te faz precisar acompanhar menos detalhes da operação si, te liberado para focar no resultado desejado.

A terceira é que se você inicia uma expressão atribuindo o resultado dessa solução a um objeto, é uma operação idempotente, no sentido de que ao ser repetida sempre gera a mesma saída. Se você está sobreescrevendo o mesmo objeto várias vezes, então só terá o mesmo dado se repetir todas as operações. Essa é uma preocupação desnecessária se seu código for idempotente. Idempotência é uma propriedade especialmente importante quando se lida com código de execução automática (algo que rode todo dia às 9h da manhã por exemplo). Você não quer que um erro no sistema de filas ou execução do código altere o resultado do seus programas.

Cuide da saúde, largue os loops.