Listy, pętle i automatyzacja w R

R
Automatyzacja to jedna z najpotężniejszych zalet R, której nie mają programy statystyczne oparte na interfejsie graficznym. Jeśli chcemy zrobić jakąś czynność wiele razy tak samo lub prawie tak samo, możemy wykorzystać siłę programowania i zrobić to setki razy używając ledwie kilku linijek. Podstawowym sposobem automatyzacji w R – czym różni się od wielu innych języków programowania – są listy, czyli zbiory obiektów.
Autor

Jakub Jędrusiak

Opublikowano

8 marca 2023

Nie znoszę mechanicznej pracy. Jestem jedną z tych osób, które wolą spędzić 30 minut na automatyzacji czegoś, co ręcznie da się zrobić w 15 minut. Może jest to strzelanie do muchy z armaty, ale ma swoje zalety. Po pierwsze, poświęciłem na to tyle czasu, że teraz jestem w stanie wiele rzeczy zautomatyzować dość szybko. Tym doświadczeniem chcę się też podzielić. Po drugie, jeśli praca manualna wielokrotnie się powtarza, brutto oszczędzam czas, mimo że na początku muszę zainwestować go więcej. Raz zautomatyzowana czynność już zautomatyzowana całkowicie, ile razy byśmy jej nie wykorzystali. Po trzecie, jeśli automatyzujemy, to tyle samo czasu zajmuje wyczyszczenie 1 pliku i 100 plików. Jeśli czyścimy ręcznie, to 100 plików przekłada się na 100 razy więcej (zmarnowanego) czasu. Po czwarte – mam ciekawsze rzeczy do roboty niż wypisywanie kolejnych liczb. Automatyzacja chociaż mnie nie nudzi, zawsze uczę się czegoś nowego (jak prezydent) i mogę w ten sposób wykorzystać swoje zasoby po prostu lepiej.

Zakładam tutaj, że osoba czytająca zna R na chociaż podstawowym poziomie. Jeśli nie, polecam swoje wprowadzenie do R.

1 Automatyzacja w czyszczeniu danych

Weźmy sobie za przykład następującą sytuację: wykonaliśmy eksperyment w programie PsychoPy. Zbadaliśmy 35 osób. Badanie dotyczyło tego, na ile osoby badane będą w stanie zapamiętać historyjkę opowiadaną im w prawej słuchawce, jeśli w lewej słuchawce będzie im puszczany rozpraszacz. Mamy przy okazji dwa warunki, łatwy i trudny, w zależności od tego, jak intensywnie zachodziło rozpraszanie. Ze względu na specyfikę PsychoPy, otrzymaliśmy 35 osobnych plików z wynikami. Specyfika tego programu (skądinąd świetnego!) jest taka, że pliki z wynikami to kompletny chaos. Spójrzmy sobie na przykładowy taki plik. Cała baza dostępna jest w repozytorium.

read_csv("./dane/automatyzacja/BD_eksperyment_latwy_2021_Apr_24_1631.csv")
#> New names:
#> Rows: 12 Columns: 19
#> ── Column specification
#> ──────────────────────────────────────────────────────── Delimiter: "," chr
#> (13): form.itemText, form.type, form.response, form.rt, form_2.itemText,... dbl
#> (5): form.index, form_2.index, form_2.rt, zakonczenie_instrukcja.starte... lgl
#> (1): ...19
#>  Use `spec()` to retrieve the full column specification for this data. 
#> Specify the column types or set `show_col_types = FALSE` to quiet this message.
#>  `` -> `...19`
#> # A tibble: 12 × 19
#>    form.index form.itemText         form.type form.response form.rt form_2.index
#>         <dbl> <chr>                 <chr>     <chr>         <chr>          <dbl>
#>  1          1 Jaka jest twoja plec? choice    kobieta       3.0891…           NA
#>  2          2 Jaki jest twoj wiek?  free text 17            None              NA
#>  3          3 Czy wyrazasz zgode n… choice    tak           10.844…           NA
#>  4         NA NA                    NA        NA            NA                 1
#>  5         NA NA                    NA        NA            NA                 2
#>  6         NA NA                    NA        NA            NA                 3
#>  7         NA NA                    NA        NA            NA                 4
#>  8         NA NA                    NA        NA            NA                 5
#>  9         NA NA                    NA        NA            NA                 6
#> 10         NA NA                    NA        NA            NA                 7
#> 11         NA NA                    NA        NA            NA                 8
#> 12         NA NA                    NA        NA            NA                NA
#> # ℹ 13 more variables: form_2.itemText <chr>, form_2.type <chr>,
#> #   form_2.response <chr>, form_2.rt <dbl>,
#> #   zakonczenie_instrukcja.started <dbl>, zakonczenie_instrukcja.stopped <chr>,
#> #   participant <chr>, session <chr>, date <chr>, expName <chr>,
#> #   psychopyVersion <chr>, frameRate <dbl>, ...19 <lgl>

Pierwsze 3 wiersze zawierają informacje metryczkowe. Kolejnych 8 zawiera odpowiedzi na pytania, ale kolumny są przesunięte. Wiersz 12. nie zawiera żadnych użytecznych informacji, bo tylko czas, jaki osoba badana spędziła na czytaniu ostatniej instrukcji. Co więcej, identyfikatorem osoby badanej były inicjały, więc w wielu przypadkach mamy powtórzenia (np. dwie osoby o inicjałach MK). Ogólnie w bazie jest wiele niedociągnięć, których dało się uniknąć lepiej projektując samo badanie. Mie ma się jednak co dziwić – to prawdziwe dane z projektu studenckiego. Cenne doświadczenie, które pokazuje przede wszystkim, czego w przyszłości unikać.

Trzeba się trochę nagłowić, żeby takie dane wyczyścić. Pewnym ułatwieniem jest, że wszystkie te pliki mają identyczną strukturę, więc każdy czyścimy właściwie tak samo. Moja strategia była tutaj taka – najpierw wczytać całą bazę, potem wyizolować z niej samą metryczkę i wyczyścić, następnie wyizolować główne dane i je również z osobna wyczyścić, a na koniec połączyć uzyskane dane w jedną bazę. Zrobiłem to przy tym tak, żeby na koniec z każdego pliku mieć tylko jeden wiersz.

baza_raw <- read_csv("./dane/automatyzacja/BD_eksperyment_latwy_2021_Apr_24_1631.csv")
#> New names:
#> Rows: 12 Columns: 19
#> ── Column specification
#> ──────────────────────────────────────────────────────── Delimiter: "," chr
#> (13): form.itemText, form.type, form.response, form.rt, form_2.itemText,... dbl
#> (5): form.index, form_2.index, form_2.rt, zakonczenie_instrukcja.starte... lgl
#> (1): ...19
#>  Use `spec()` to retrieve the full column specification for this data. 
#> Specify the column types or set `show_col_types = FALSE` to quiet this message.
#>  `` -> `...19`

# najpierw sama metryczka
metryczka <- baza_raw %>%
    slice(1:2) %>% # wybierz wiersze z samej metryczki
    select(participant, expName, form.itemText, form.response) %>% # wybierz kolumny z id, warunkiem, pytaniem i odpowiedzią
    pivot_wider(names_from = form.itemText, values_from = form.response) %>% # format szeroki
    set_names("id", "warunek", "plec", "wiek") %>%
    mutate(
        wiek = parse_number(wiek),
        warunek = str_remove(warunek, "eksperyment_") # zostaw samo "łatwy" albo "trudny"
    )

# potem same odpowiedzi na pytania
pytania <- baza_raw %>%
    slice(4:11) %>%
    select(form_2.itemText, form_2.response) %>%
    mutate(
        form_2.response = case_match( # tak i nie zamień na 1 i 0
            form_2.response,
            "tak" ~ 1,
            "nie" ~ 0
        )
    ) %>%
    pivot_wider(names_from = form_2.itemText, values_from = form_2.response) %>%
    set_names(paste0("pyt_", 1:8))

# na koniec łączę
baza <- bind_cols(metryczka, pytania) %>%
    mutate(
        id = stringi::stri_rand_strings(1, 5) # zamień id na losowe znaki
    )

baza
#> # A tibble: 1 × 12
#>   id    warunek plec     wiek pyt_1 pyt_2 pyt_3 pyt_4 pyt_5 pyt_6 pyt_7 pyt_8
#>   <chr> <chr>   <chr>   <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 5pb90 latwy   kobieta    17     1     0     1     1     1     0     0     1

Z ciekawszych rzeczy wykorzystuję tutaj funkcję stri_rand_strings z pakietu stringi, żeby zmienić ID osoby badanej. ID nie musi tutaj nic znaczyć, ma być po prostu unikalne, a z tym mamy tutaj problem, bo się wcześniej nie umówiliśmy na żaden określony sposób kodowania. Dlatego na tym etapie mogę zastąpić inicjały losowym ciągiem 5 znaków, zapewniając tym samym, że w ostatecznej bazie identyfikatory będą unikalne. Jest to też sposób na anonimizację bazy danych.

Uzyskaliśmy w ten sposób jeden wyczyszczony wiersz. Jeszcze tylko 34…

I jak mamy to zrobić? Mamy ten sam kod skopiować jeszcze 34 razy, zamieniając tylko nazwę pliku? A co jeśli mam 1000 osób? A co jeśli jestem Martą Kowal1 i mam do przeanalizowania (ponad) 93 158 osób (Kowal i in., 2022)? Kopiowanie kodu nigdy nie jest dobrą drogą. Jeśli często kopiujemy kod, powinniśmy zrobić z niego funkcję. Jeśli tę samą funkcję chcemy zastosować do wielu obiektów, powinniśmy użyć list albo pętli.

2 Listy

Podstawową metodą automatyzacji we wszystkich językach programowania są pętle. No, prawie wszystkich. Na przykład w R lepiej ich unikać. Specyfika tego języka jest taka, że pętle – mimo że są obecne – to są mało wydajne. W małych zbiorach danych nie ma to większego znaczenia, jednak dobrą praktyką jest – o ile to możliwe – automatyzować przez tak zwane listy.

Lista to szczególny typ obiektu w R, który można sobie wyobrazić jako pudełko na inne obiekty. Mogę tam włożyć ramki danych, wektory (tworzone przez c()), wykresy, a nawet inne listy, tworząc coś na kształt matrioszki. Od wektora, czyli podstawowej formy danych w R, lista różni się tym, że może zawierać dane różnych typów. Wektor musi mieć konkretny typ, np. same liczby. Lista takich ograniczeń nie ma.

Listę tworzymy przede wszystkim za pomocą komendy list(). Przyjmuje one obiekty, z których lista ma powstać. Dla przykładu stwórzmy sobie listę z dwóch wbudowanych baz danych – iris i mpg.

bazy_danych <- list(
    as_tibble(iris), # tibble dla lepszego drukowania w konsoli
    as_tibble(mpg)
)

bazy_danych
#> [[1]]
#> # A tibble: 150 × 5
#>    Sepal.Length Sepal.Width Petal.Length Petal.Width Species
#>           <dbl>       <dbl>        <dbl>       <dbl> <fct>  
#>  1          5.1         3.5          1.4         0.2 setosa 
#>  2          4.9         3            1.4         0.2 setosa 
#>  3          4.7         3.2          1.3         0.2 setosa 
#>  4          4.6         3.1          1.5         0.2 setosa 
#>  5          5           3.6          1.4         0.2 setosa 
#>  6          5.4         3.9          1.7         0.4 setosa 
#>  7          4.6         3.4          1.4         0.3 setosa 
#>  8          5           3.4          1.5         0.2 setosa 
#>  9          4.4         2.9          1.4         0.2 setosa 
#> 10          4.9         3.1          1.5         0.1 setosa 
#> # ℹ 140 more rows
#> 
#> [[2]]
#> # A tibble: 234 × 11
#>    manufacturer model      displ  year   cyl trans drv     cty   hwy fl    class
#>    <chr>        <chr>      <dbl> <int> <int> <chr> <chr> <int> <int> <chr> <chr>
#>  1 audi         a4           1.8  1999     4 auto… f        18    29 p     comp…
#>  2 audi         a4           1.8  1999     4 manu… f        21    29 p     comp…
#>  3 audi         a4           2    2008     4 manu… f        20    31 p     comp…
#>  4 audi         a4           2    2008     4 auto… f        21    30 p     comp…
#>  5 audi         a4           2.8  1999     6 auto… f        16    26 p     comp…
#>  6 audi         a4           2.8  1999     6 manu… f        18    26 p     comp…
#>  7 audi         a4           3.1  2008     6 auto… f        18    27 p     comp…
#>  8 audi         a4 quattro   1.8  1999     4 manu… 4        18    26 p     comp…
#>  9 audi         a4 quattro   1.8  1999     4 auto… 4        16    25 p     comp…
#> 10 audi         a4 quattro   2    2008     4 manu… 4        20    28 p     comp…
#> # ℹ 224 more rows

Gdy wywołamy naszą listę w konsoli, zobaczymy, że wyświetlają nam się dwie bazy danych, oznaczone jako [[1]] i [[2]]. Kolejność jest taka, jakiej użyliśmy w komendzie list(). Jeśli chcemy wyciągnąć coś z listy, np. pojedynczą ramkę danych, używamy składni nazwa_listy[[indeks]]2. czyli na przykład bazy_danych[[1]]. W tym wypadku spowoduje to wyciągnięcie samej tylko bazy iris 3. Możemy też wywołać bazy_danych[1], ale taki zapis powoduje subtelną różnicę – efektem jest jednoelementowa lista. Składnia z podwójnymi nawiasami zwróci nam tylko sam element. Jeśli jest to ramka danych, dostaniemy ramkę danych, jeśli jest to wektor, to dostaniemy wektor itd. Nawiasy pojedyncze zawsze zwracają listę.

2.1 Masowe ładowanie danych z list.files

Z tego, co napisałem, wynika, że możemy łatwo wyczyścić nasze dane, jeśli zrobimy z nich listę. Ale jak dopiero otwieramy RStudio, to nie mamy danych jeszcze załadowanych! Nawet jakbyśmy chcieli, zrobić listę, to nie mamy czego wrzucić do komendy list(). Listy pozwolą nam jednak nie tylko masowo dane wyczyścić, ale też masowo je załadować.

Załóżmy, że nasze 35 plików z danymi znajduje się w folderze dane, podfolder automatyzacja. Nie jest to, oczywiście, obowiązek, Twoja struktura może się różnić, ale dobrze jest mieć dane w oddzielnym folderze. Możemy teraz użyć komendy list.files(), żeby stworzyć listę naszych plików4. To samo robi komenda dir(). Obie przyjmują ścieżkę do folderu, w którym są pliki do wrzucenia na listę. Ustawimy jeszcze full.names = TRUE, bo nie chcemy dostać samych nazw plików, ale całe ścieżki.

bazy_lista <- list.files("./dane/automatyzacja", full.names = TRUE)

Jeśli wyświetlimy teraz obiekt bazy_lista, zobaczymy listę ścieżek wszystkich naszych plików z danymi. Nie są to załadowane bazy, tylko lista plików.

bazy_lista
#>  [1] "./dane/automatyzacja/AC_eksperyment_trudny_2021_Apr_22_2124.csv"     
#>  [2] "./dane/automatyzacja/AG_eksperyment_latwy_2021_Apr_25_0233.csv"      
#>  [3] "./dane/automatyzacja/AT_eksperyment_latwy_2021_Apr_25_1402.csv"      
#>  [4] "./dane/automatyzacja/BD_eksperyment_latwy_2021_Apr_24_1631.csv"      
#>  [5] "./dane/automatyzacja/BM_eksperyment_trudny_2021_Apr_25_2042.csv"     
#>  [6] "./dane/automatyzacja/DF_eksperyment_latwy_2021_Apr_22_1456.csv"      
#>  [7] "./dane/automatyzacja/DJ_eksperyment_trudny_2021_Apr_25_2048.csv"     
#>  [8] "./dane/automatyzacja/DJ_eksperyment_trudny_2021_Apr_25_2102.csv"     
#>  [9] "./dane/automatyzacja/FB_eksperyment_latwy_2021_Apr_24_1649.csv"      
#> [10] "./dane/automatyzacja/GM_eksperyment_latwy_2021_Apr_24_1434.csv"      
#> [11] "./dane/automatyzacja/HJ_eksperyment_trudny_2021_kwi_21_1805.csv"     
#> [12] "./dane/automatyzacja/JJ_eksperyment_trudny_2021_Apr_24_1112.csv"     
#> [13] "./dane/automatyzacja/JK_eksperyment_latwy_2021_Apr_25_1422.csv"      
#> [14] "./dane/automatyzacja/JK_eksperyment_trudny_2021_Apr_23_2129.csv"     
#> [15] "./dane/automatyzacja/JK_eksperyment_trudny_2021_kwi_21_1326.csv"     
#> [16] "./dane/automatyzacja/julka_eksperyment_latwy_2021_Apr_25_2150.csv"   
#> [17] "./dane/automatyzacja/JW_eksperyment_latwy_2021_Apr_24_1657.csv"      
#> [18] "./dane/automatyzacja/kacperek_eksperyment_latwy_2021_Apr_25_2158.csv"
#> [19] "./dane/automatyzacja/Kasia_eksperyment_latwy_2021_Apr_25_1421.csv"   
#> [20] "./dane/automatyzacja/Kinga_eksperyment_latwy_2021_Apr_25_1419.csv"   
#> [21] "./dane/automatyzacja/kk_eksperyment_trudny_2021_Apr_22_2104.csv"     
#> [22] "./dane/automatyzacja/KK_eksperyment_trudny_2021_Apr_23_2136.csv"     
#> [23] "./dane/automatyzacja/KP_eksperyment_trudny_2021_kwi_21_2037.csv"     
#> [24] "./dane/automatyzacja/KW_eksperyment_trudny_2021_Apr_22_2254.csv"     
#> [25] "./dane/automatyzacja/maciek_eksperyment_latwy_2021_Apr_22_1138.csv"  
#> [26] "./dane/automatyzacja/Magda_eksperyment_latwy_2021_Apr_25_1932.csv"   
#> [27] "./dane/automatyzacja/Michal_eksperyment_latwy_2021_Apr_25_1405.csv"  
#> [28] "./dane/automatyzacja/MJ_eksperyment_trudny_2021_Apr_25_2054.csv"     
#> [29] "./dane/automatyzacja/MK_eksperyment_latwy_2021_Apr_24_1706.csv"      
#> [30] "./dane/automatyzacja/MK_eksperyment_trudny_2021_Apr_22_2109.csv"     
#> [31] "./dane/automatyzacja/OK_eksperyment_trudny_2021_Apr_24_1343.csv"     
#> [32] "./dane/automatyzacja/SG_eksperyment_trudny_2021_Apr_24_1546.csv"     
#> [33] "./dane/automatyzacja/SJ_eksperyment_trudny_2021_kwi_21_1621.csv"     
#> [34] "./dane/automatyzacja/SP_eksperyment_trudny_2021_kwi_21_2030.csv"     
#> [35] "./dane/automatyzacja/ZD_eksperyment_latwy_2021_Apr_26_1036.csv"

Nasze dane możemy załadować np. komendą read_csv() z pakietu readr. Ta funkcja może przyjąć całą serię różnych argumentów, ale najważniejszym jest… ścieżka do pliku do załadowania. Dokładnie to, co mamy zgromadzone w obiekcie bazy_lista! Każdą z tych ścieżek moglibyśmy teraz wrzucić do funkcji read_csv() i dostalibyśmy nie ścieżkę, ale załadowaną bazę. Chcemy więc teraz powiedzieć R „po kolei weź każdą ścieżkę z bazy_lista i wrzuć ją do read_csv(). Do tego służą funkcje z rodziny apply, czyli apply(), sapply(), lapply() lub tapply(). Jak w przypadku wielu funkcji, rodzina apply ma też swoje odpowiedniki w tidyverse. Jak raz „oryginały” są używane częściej5, ale i tak my skorzystamy z funkcji map() z pakietu purrr, bo jest bardziej intuicyjna.

bazy_lista <- map(bazy_lista, read_csv, show_col_types = FALSE, name_repair = "unique_quiet")

map przyjmuje, w podstawowej wersji, dwa argumenty. Pierwszym jest obiekt, do którego chcemy zastosować naszą funkcję, a drugim sama funkcja. My chcemy zastosować funkcję read_csv() na każdym elemencie wektora bazy_lista. Podobnie jak w across (patrz tutaj), gdy podajemy, jaką funkcję chcemy zastosować, to musimy zapisać obiekt zawierający funkcję, a nie o efekt działania funkcji, dlatego po nazwie funkcji nie dajemy nawiasów. To bardzo częsty błąd. Na koniec możemy dorzucić parę innych argumentów, takich jak show_col_types = FALSE i name_repair = "unique_quiet", żeby nasza konsola nie została zalana milionem informacji o aktualnie wczytywanej bazie6.

Teraz R weźmie każdą ścieżkę i wrzuci ją do funkcji read_csv(). Każda ścieżka zostanie więc załadowana, a wynikowe bazy wrzucone na listę. Jeśli więc teraz byśmy spojrzeli w obiekt bazy_lista, to nie zobaczymy tam już ścieżek, ale prawdziwe ramki danych.

2.2 Masowe czyszczenie z map() lub lapply()

Skoro poznaliśmy już funkcję map(), wyczyszczenie naszych baz nie powinno stanowić problemu. W końcu są one na liście, a my właśnie nauczyliśmy się, zastosować jakąś funkcję do każdego elementu listy z osobna. Problem polega jednak na tym, że nie mamy pojedynczej funkcji czyszczącej, a cały wielki zestaw tych funkcji. Jednak jak dowiedzieliśmy się tutaj, możemy nasz zestaw po prostu przerobić na pojedynczą funkcję.

wyczysc <- function(df) {
    metryczka <- df %>%
        slice(1:2) %>% # wybierz wiersze z samej metryczki
        select(participant, expName, form.itemText, form.response) %>% # wybierz kolumny z id, warunkiem, pytaniem i odpowiedzią
        pivot_wider(names_from = form.itemText, values_from = form.response) %>% # format długi
        set_names("id", "warunek", "plec", "wiek") %>%
        mutate(
            wiek = parse_number(wiek),
            warunek = str_remove(warunek, "eksperyment_") # zostaw samo "łatwy" albo "trudny"
        )

    pytania <- df %>%
        slice(4:11) %>%
        select(form_2.itemText, form_2.response) %>%
        mutate(
            form_2.response = case_match( # tak i nie na 1 i 0
            form_2.response,
            "tak" ~ 1,
            "nie" ~ 0
            )
        ) %>%
        pivot_wider(names_from = form_2.itemText, values_from = form_2.response) %>%
        set_names(paste0("pyt_", 1:8))

    bind_cols(metryczka, pytania) %>%
        mutate(
            id = stringi::stri_rand_strings(1, 5) # zamień id na losowe znaki
        )
}

Uzyskałem w ten sposób funkcję wyczysc(), która wykonuje wszystkie nasze przekształcenia. Warto zwrócić uwagę, że bazę danych nazwałem df i zrobiłem z niej argument naszej funkcji, a także, że ostatni blok nie ma przypisania do zmiennej baza. Wynika to z tego, że domyślnie funkcje w R zwracają ostatnią rzecz, którą zrobią. W tym wypadku wynikiem działania funkcji wyczysc() będzie efekt działania bind_cols(). Jeśli zapisałbym ostateczną bazę do zmiennej baza, musiałbym w ostatniej linijce dopisać samo baza albo return(baza), ponieważ to jest to, co ostatecznie chcemy dostać – wyczyszczoną bazę.

Przy tej okazji ostrzegam, że używanie nazw kolumn jako argumentów takich własnych funkcji może spowodować niespodziewane problemy. Jeśli nasza funkcja dostanie argument nazwa_kolumny, to funkcje typu select() będą w bazie szukać kolumny, która nazywa się nazwa_kolumny, a nie wartości tego argumentu (np. jeśli ustawimy nazwa_kolumny = pyt_1, to select() czy mutate() nie będą szukały kolumny pyt_1, tylko kolumny o nazwie nazwa_kolumny). Więcej o tym piszę tutaj, ale ad hoc można sobie z tym poradzić pisząc nazwę argumentu wewnątrz funkcji typu select() w podwójnych nawiasach klamrowych, np. { nazwa_kolumny }.

Użyjmy teraz naszej nowej funkcji wyczysc do wyczyszczenia naszych plików.

bazy <- map(bazy_lista, wyczysc)

Efektem działania tej funkcji jest lista wyczyszczonych już baz. Każda taka baza ma tylko jeden wiersz (bo tak zrobiliśmy nasze czyszczenie). Jednak do analizy statystycznej nie jest potrzebna lista, tylko jedna całościowa baza. Połączmy więc wszystkie bazy na liście w jedną bazę za pomocą funkcji bind_rows. Możemy ją wywołać na wyczyszczonej zmiennej bazy albo już wcześniej dodać ją potokiem do mapowania. Wygląda to tak:

# tak jest dobrze
bazy <- map(bazy_lista, wyczysc)
bazy <- bind_rows(bazy)

# tak też jest dobrze
bazy <- map(bazy_lista, wyczysc) %>%
    bind_rows()

bazy
#> # A tibble: 35 × 12
#>    id    warunek plec       wiek pyt_1 pyt_2 pyt_3 pyt_4 pyt_5 pyt_6 pyt_7 pyt_8
#>    <chr> <chr>   <chr>     <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#>  1 nN3zb trudny  kobieta      20     0     1     1     0     1     0     0     1
#>  2 9X7x2 latwy   kobieta      21     1     1     1     1     1     0     0     1
#>  3 Awyis latwy   mezczyzna    24     1     1     0     1     1     1     0     0
#>  4 y2Rdz latwy   kobieta      17     1     0     1     1     1     0     0     1
#>  5 JrXsV trudny  mezczyzna    17     0     0     1     1     1     1     1     1
#>  6 rrL0C latwy   mezczyzna    23     1     0     1     1     1     1     0     1
#>  7 wHsx0 trudny  kobieta      17     1     0     1     1     1     0     0     1
#>  8 YDejs trudny  kobieta      17     0     1     1     1     1     0     0     0
#>  9 Ntqj2 latwy   mezczyzna    20     0     0     1     1     1     1     0     0
#> 10 KkuCT latwy   kobieta      20     1     1     0     1     0     1     1     0
#> # ℹ 25 more rows

Ostatecznie uzyskujemy piękną, czystą i pojedynczą bazę danych, na której możemy wykonywać analizy.

3 Pętle

Pętle to w wielu językach programowania podstawowa metoda automatyzacji. Pozwalają nam wykonać tę samą operację wiele, wiele razy, za każdym razem coś zmieniając. W R ich za bardzo nie używamy. Niby można, ale R to nie jest język ogólnego przeznaczenia, dlatego rzadko musimy robić w nim rzeczy, których nie możemy zrobić listami i map. Czasami jednak taka umiejętność może się przydać. Co więcej, czasami da się coś zrobić listą, ale łatwiej to zrozumieć, gdy używamy pętli. Na sam początek pętle mogą być przyjemniejsze w odbiorze dla niewprawionego programisty (albo właśnie wprawionego, który zna pętle z innych języków). Istnieją dwa rodzaje pętli – for i while.

3.1 Pętle for

Pętla for służy do wykonania danej czynności określoną liczbę razy albo dla określonych rzeczy. Tutaj przykład wykorzystania tej pętli do wykonania czynności, którą robiliśmy wcześniej, czyli ładowania baz danych na podstawie listy plików.

bazy_lista <- list.files("./dane/automatyzacja", full.names = TRUE)
bazy <- list() # pusta lista na przyszłość

for (i in 1:35) {
    bazy[[i]] <- read_csv(bazy_lista[[i]], show_col_types = FALSE, name_repair = "unique_quiet")
}

Zapis ten jest bardziej programistyczny i korzysta z klasycznego R. Najpierw tworzymy pustą listę bazy. Następnie pętla for wykona to, co zapisaliśmy w jej klamrach, za każdym razem zamieniając i na kolejną liczbę. Czyli najpierw wczyta bazę ze ścieżki bazy_lista[[1]], potem bazy_lista[[2]] i tak dalej aż do bazy_lista[[35]]. Wczytane bazy zapisze do listy bazy na stosownym miejscu. 1:35 moglibyśmy zamienić na 1:length(bazy_lista), żeby nie zapisywać na sztywno 35, jakby miała się ta liczba zmienić.

Moglibyśmy też użyć nieco innej składni.

bazy_lista <- list.files("./dane/automatyzacja", full.names = TRUE)
bazy <- list() # pusta lista na przyszłość

for (i in bazy_lista) {
    bazy <- c(
        bazy,
        list(read_csv(i, show_col_types = FALSE, name_repair = "unique_quiet"))
    )
}

Nie jest to najlepszy, najbardziej wydajny kod, ale nie chcę wchodzić w bardziej zaawansowane koncepcje jak rezerwowanie miejsca w pamięci przed uruchomieniem pętli. Ta składnia działa nieco inaczej, bo i nie jest tutaj liczbą, tylko kolejnymi ścieżkami do naszych plików i to one lądują w read_csv(). Tak wczytaną bazę dołączam do listy baz za pomocą c. Co ważne, nasza nowa baza też musi mieć format listy, dlatego całe read_csv opakowałem w list.

Jak widać, takie rozwiązanie wymaga podejścia do R od bardziej programistycznej strony. Tak jak jednak wspominałem, w rzeczywistości rzadko jest to potrzebne, bo możemy używać funkcji lapply() lub map().

3.2 Pętle while

Pętle while wykonują jakieś polecenie dopóty, dopóki spełniony jest warunek, który jej podamy. Musimy być jednak ostrożni, bo jeśli ten warunek nie zostanie osiągnięty nigdy (bo np. źle go zaplanowaliśmy), to pętla będzie działała w nieskończoność, nierzadko zapychając pamięć komputera. Pętla while, choć wykorzystywana nawet rzadziej niż pętla for, przydała mi się w celach testowo-dydaktycznych, bo wykorzystywałem ją do znalezienia zbioru losowych liczb o określonych parametrach statystycznych. Żeby podać przykład:

liczby <- rnorm(100, mean = 101, sd = 15)

while (mean(liczby) != 105) {
    liczby <- rnorm(100, mean = 101, sd = 15) %>%
        round()
}

Ten kod wykorzystałem do znalezienia zestawu 100 losowych liczb z rozkładu normalnego IQ (\(M = 100\), \(SD = 15\)), których średnia będzie wynosiła dokładnie 101. Wykorzystałem to w tekście o wartości \(p\) do znalezienia ładnej próbki, której średnia nie będzie wynosiła dokładnie tyle, ile średnia z populacji. Funkcja rnorm losuje liczby z rozkładu normalnego o podanych parametrach 5, round zaokrągla je do całości. Pętla while mówi tutaj „Sprawdź, czy średnia wylosowanych liczb nie równa się 105. Jeśli nie, to wylosuj ponownie.” albo inaczej „Powtarzaj losowanie dopóki średnia liczb nie będzie się równała 101”.

4 Podsumowanie

  1. R daje nam duże możliwości automatyzacji powtarzalnych czynności.
  2. Podstawową metodą automatyzacji w R są listy.
  3. Funkcja map() (lapply()) stosuje daną funkcję do każdego elementu listy.
  4. W R istnieją pętle, ale lepiej ich unikać.
  5. Pętla for powtarza zestaw funkcji określoną liczbę razy albo dla wszystkich wartości zbioru.
  6. Pętla while powtarza zestaw funkcji tak długo, jak spełniony jest podany jest warunek.

Bibliografia

Kowal, M., Sorokowski, P., Pisanski, K., Valentova, J. V., Varella, M. A. C., Frederick, D. A., … Zumárraga-Espinosa, M. (2022). Predictors of enhancing human physical attractiveness: Data from 93 countries. Evolution and Human Behavior, 43(6), 455–474. https://doi.org/10.1016/j.evolhumbehav.2022.08.003

Przypisy

  1. Którą serdecznie pozdrawiam.↩︎

  2. W R indeksowanie zaczyna się od 1, a nie od 0 jak w C czy w Pythonie. Takie indeksy są bardziej intuicyjne dla osób bez wprawy informatycznej.↩︎

  3. Jeśli chcemy wybrać jakiś element z listy zagnieżdżonej w innej liście, użyjemy składni w stylu lista[2][5]. W ten sposób z listy o nazwie lista wyciągamy drugi element, a następnie z owego elementu (którym może być inna lista) wyciągamy piąty element.↩︎

  4. Tak, wiem, że to nie jest lista, tylko wektor. Nie chcę tutaj dodatkowo gmatwać wprowadzając pojęcie wektora. Skutek jest jednak ten sam, nawet jeśli list.files rzeczywiście wyrzucałoby listę, dalej zrobilibyśmy dokładnie to samo.↩︎

  5. Funkcja niżej mogłaby być zapisana równie dobrze jako lapply(bazy_lista, read_csv, show_col_types = FALSE, name_repair = "unique_quiet"), ale map, a głównie jej pochodne, mają bardziej przejrzystą i konsekwentną konwencję nazywania.↩︎

  6. Od jakiegoś czasu tidyverse wycofuje się z tej konwencji dodawania argumentów. Jeśli mamy jakieś dodatkowe argumenty do dodania, powinniśmy zrobić to w postaci funkcji anonimowej. Tym sposobem powinniśmy byli zapisać raczej map(bazy_lista, \(x) {read_csv(x, show_col_types = FALSE, name_repair = "unique_quiet")}).↩︎