Rzędy probówek laboratoryjnych ustawione do analizy danych
Źródło: Pexels | Autor: Pavel Danilyuk
Rate this post

Nawigacja po artykule:

Krótki schemat: od surowej ramki do gotowego zbioru

Prosty model: jeden pipeline od wejścia do wyjścia

Typowy scenariusz: masz powtarzalne źródło danych (CSV z systemu, cykliczny eksport ankiet, plik z hurtowni). Celem jest jeden główny pipeline dplyr, który prowadzi od surowej ramki danych do gotowego, wyczyszczonego zbioru, zapisanego np. jako RDS lub CSV.

Kluczowe założenie: większość kroków czyszczenia danych jest widoczna w jednym, liniowym ciągu poleceń. Odczytujesz go od góry do dołu jak przepis: wczytaj dane → uporządkuj nazwy → ustaw typy → obsłuż NA → przefiltruj i przekształć → zapisz wynik.

Minimalny szkielet takiego kodu może wyglądać tak:


library(readr)
library(dplyr)
library(janitor)
library(tidyr)

dane_czyste <- read_csv("dane_surowe.csv") %>%
  clean_names() %>%                         # 1. standaryzacja nazw
  mutate(                                   # 2. kluczowe konwersje typów
    data_transakcji = as.Date(data_transakcji, format = "%Y-%m-%d"),
    kwota = as.numeric(kwota)
  ) %>%
  drop_na(id) %>%                           # 3. brakujące dane - ID musi być
  filter(kwota >= 0) %>%                    # 4. proste czyszczenie błędów
  mutate(                                   # 5. nowe, pomocnicze zmienne
    rok = lubridate::year(data_transakcji),
    segment = case_when(
      kwota > 1000 ~ "wysoki",
      kwota > 100  ~ "średni",
      TRUE         ~ "niski"
    )
  )

write_rds(dane_czyste, "dane_czyste.rds")

Taki pipeline dplyr można łatwo uruchamiać za każdym razem, gdy pojawi się nowy plik ze źródła. Trzeba tylko pilnować, aby nie chować ważnych operacji czyszczących w innych miejscach skryptu.

Chaotyczny kod vs uporządkowany pipeline

Częsty stan początkowy to kod w stylu „przypomnę sobie, co robiłem, z historii konsoli”:


dane <- read.csv("dane_surowe.csv")
names(dane) <- tolower(names(dane))
dane$data_transakcji <- as.Date(dane$data_transakcji)
dane <- dane[dane$kwota >= 0, ]
dane$segment[dane$kwota > 1000] <- "wysoki"
dane$segment[dane$kwota <= 100] <- "niski"
# ... kolejne poprawki w różnych miejscach pliku

Problem nie polega tylko na stylu. Taki kod:

  • jest rozrzucony po całym skrypcie,
  • trudno go powtórzyć na nowych danych bez pominięcia któregoś kroku,
  • utrudnia audyt – nie wiesz, w którym miejscu dane zostały przefiltrowane lub nadpisane.

Ten sam proces zapisany jako jeden, główny pipeline jest czytelniejszy i łatwiejszy do uruchomienia na nowej wersji danych. Kod staje się też prostszy do przeglądu przez innego analityka.

Automatyzacja kontra kontrola – gdzie jest granica?

Automatyczne czyszczenie danych w R kusi: „wczytaj, uruchom skrypt, zapomnij”. Problem pojawia się, gdy pipeline zaczyna robić zbyt dużo „po cichu” – usuwa tysiące obserwacji, zmienia typy, obcina wartości odstające, a Ty widzisz tylko końcowy wynik.

Dlatego przy projektowaniu pipeline warto odpowiedzieć sobie na dwa pytania kontrolne:

  • Co musi dziać się zawsze automatycznie? (np. zamiana formatów dat, usuwanie pustych ID)
  • Gdzie potrzebne są punkty kontrolne i ręczna decyzja? (np. agresywne filtry, traktowanie wartości odstających, imputacja ważnych zmiennych wynikowych)

Dalsze wskazówki skupiają się na tym balansie: jak mieć porządny, powtarzalny pipeline dplyr, ale jednocześnie nie stracić kontroli nad tym, co dzieje się z danymi na każdym etapie.

1. Zacznij od nazw i typów – jeden wyraźny blok na początku

Standaryzacja nazw kolumn jednym poleceniem

Najbardziej opłacalny krok na starcie to ułożenie struktury ramki danych. Chodzi przede wszystkim o nazwy kolumn i typy. Dobrze, jeśli tu panuje prosty schemat:

  • małe litery,
  • podkreślenia zamiast spacji,
  • bez polskich znaków i znaków specjalnych,
  • nazwy stabilne w czasie (niezależne od tego, kto wygenerował plik).

Gotowy wzorzec zapewnia funkcja janitor::clean_names():


library(janitor)

dane <- readr::read_csv("dane_surowe.csv") %>%
  clean_names()

W jednym kroku usuwasz spacje, znaki specjalne, ujednolicasz wielkość liter. Jeśli część nazw wymaga ręcznego dostosowania (np. pole „id klienta” ma się nazywać „id_klienta” zamiast „id_klienta_1”), możesz dodać krótki blok:


dane <- dane %>%
  rename(
    id_klienta = id_klienta_1
  )

Taki zestaw – clean_names + pojedynczy rename – jest bardziej przewidywalny niż ręczne poprawianie każdej nazwy oddzielnie i łatwo go zastosować na nowych plikach z tego samego źródła.

Glimpse jako pierwszy punkt kontrolny typów

Po uporządkowaniu nazw dobrze jest natychmiast sprawdzić, jakie typy wczytał R. Krótki, ale użyteczny nawyk:


dane <- readr::read_csv("dane_surowe.csv") %>%
  clean_names()

dplyr::glimpse(dane)

glimpse() wyświetla strukturę ramki: typy kolumn i kilka przykładowych wartości. To moment, w którym widzisz ciche problemy:

  • daty jako chr,
  • liczby jako chr (np. przez przecinki dziesiętne),
  • identyfikatory wczytane jako numeric zamiast character.

Dlaczego to ważne? Bo jeśli zostawisz to „na potem”, pipeline może już korzystać z błędnych typów i wyniki analiz będą mylące. Lepiej skorygować typy od razu i zapisać te konwersje jawnie w pipeline.

Jawne konwersje typów w jednym bloku mutate

Po pierwszym glimpse możesz dodać blok typu:


dane <- dane %>%
  mutate(
    id_klienta = as.character(id_klienta),
    data_zakupu = as.Date(data_zakupu, format = "%Y-%m-%d"),
    kwota_brutto = as.numeric(kwota_brutto)
  )

Dwie zasady, które zmniejszają ryzyko błędów:

  • Zawsze podawaj format daty w as.Date() przy tekście – unikniesz cichych pomyłek, gdy system zmieni ustawienia lokalne lub format w źródle lekko się zmieni.
  • Identyfikatory traktuj jako tekst, nawet jeśli wyglądają na liczby – unikniesz utraty wiodących zer i problemów przy łączeniu tabel.

Przykład typowego błędu dat:


# Ryzykowne:
as.Date("01/02/2022")

# Bezpieczniej:
as.Date("01/02/2022", format = "%d/%m/%Y")

Kiedy można to całkowicie zautomatyzować? Gdy pliki przychodzą cyklicznie z jednego systemu, który nie zmienia formatu. Wtedy blok konwersji jest stały. Gdy źródło bywa „ręczne” (np. plik od partnera, czasem z Excelem, czasem z innym separatorem), warto każdorazowo rzucić okiem na kilka wierszy przed uruchomieniem reszty pipeline.

2. Czyść brakujące dane w jednym, widocznym miejscu pipeline

Jeden blok odpowiedzialny za NA

Brakujące dane to miejsce, w którym automatyzacja najłatwiej może pójść za daleko. Dobrą praktyką jest trzymanie obsługi NA w jednym, wyraźnym fragmencie pipeline, najlepiej oznaczonym komentarzem.


dane <- dane %>%
  # --- brakujące dane: co usuwamy, co uzupełniamy ---
  drop_na(id_klienta) %>%                  # ID nie może być puste
  mutate(
    liczba_zakupow = tidyr::replace_na(liczba_zakupow, 0),
    segment = tidyr::replace_na(segment, "nieznany")
  )

Takie podejście ma dwie zalety:

  • każdy (w tym Ty za miesiąc) od razu widzi, jakie decyzje dotyczą braków,
  • łatwiej policzyć, ile obserwacji tracisz i na jakich polach.

drop_na na całej ramce kontra selektywnie

Najbardziej ryzykowny wzorzec to:


dane <- dane %>% drop_na()

To usuwa wszystkie wiersze, w których jakakolwiek kolumna ma NA. Przy danych z ankietą lub danymi operacyjnymi często oznacza to drastyczne zmniejszenie próby – czasem nawet o kilkadziesiąt procent.

Bezpieczniejszy wariant to selektywne wskazanie kolumn, które absolutnie nie mogą mieć braków:


dane <- dane %>%
  tidyr::drop_na(id_klienta, data_zakupu)

W praktyce dobrze zadać tu sobie pytanie: czy chcę odrzucić klienta tylko dlatego, że nie podał np. kodu pocztowego? Często odpowiedź brzmi: nie. Wtedy kod pocztowy traktuje się osobno (np. zostawia jako NA albo imputuje), a drop_na stosuje tylko do kluczowych identyfikatorów i dat.

Imputacja i domyślne wartości – kiedy automatyzować

Automatyczne zastępowanie NA domyślną wartością (imputacja) jest wygodne, ale może mocno zmienić rozkład danych. Kilka praktycznych reguł:

  • Zero lub „brak” jest bezpieczne dla pól technicznych: liczba logowań, liczba reklamacji, flaga binarna.
  • Średnie/mediany i inne statystyki jako imputacja dla zmiennych wynikowych wymagają decyzji analityka i często osobnej dokumentacji.
  • Gdy zmienna ma znaczenie biznesowe (np. przychód, czas trwania kontraktu), lepiej odłożyć decyzję o imputacji niż mechanicznie podstawić wartość.

Przykład rozsądnej automatyzacji:


dane <- dane %>%
  mutate(
    liczba_reklamacji = tidyr::replace_na(liczba_reklamacji, 0),
    czy_aktywne = tidyr::replace_na(czy_aktywne, FALSE)
  )

Przykład, gdzie lepiej się zatrzymać i obejrzeć dane:


# Tu decyzja powinna być świadoma, nie „z automatu”:
dane <- dane %>%
  mutate(
    przychod = if_else(is.na(przychod), ???, przychod)
  )

W takich miejscach dobrze zostawić komentarz z pytaniem lub notatką dla siebie / zespołu, zamiast udawać, że NA się nie pojawią.

Jeżeli automatyczna imputacja jest nieunikniona (np. raport ma powstawać codziennie bez Twojej interwencji), sensowne jest przynajmniej ograniczenie jej do wąskiego katalogu pól i udokumentowanie tych decyzji w kodzie. Krótki komentarz nad blokiem mutate() – „# imputacja techniczna, nie dotyka zmiennych finansowych” – informuje, co jest kompromisem automatyzacji, a co nadal wymaga świadomej decyzji analitycznej.

Dobrym zwyczajem jest dodanie po bloku imputacji prostego podglądu, który pokaże skalę zmian. W praktyce wystarczy kilka wierszy, które policzą, ile NA zostało oraz ile imputowano:


dane %>%
  summarise(
    n_przychod_na = sum(is.na(przychod)),
    n_liczba_reklamacji_na = sum(is.na(liczba_reklamacji))
  )

Taki szybki raport odpowiada na pytanie: „co wiemy o brakach po czyszczeniu, a czego nadal nie wiemy?”. Jeśli widzisz, że imputacja dotyczy dużej części obserwacji, decyzję lepiej skonsultować, zamiast przyjmować ją jako „techniczny detal pipeline’u”.

Granica między „bezpieczną automatyzacją” a „zbyt śmiałą ingerencją” zwykle przebiega tam, gdzie NA zaczynają wpływać na wnioski biznesowe, a nie tylko na estetykę tabeli. Blok odpowiedzialny za brakujące dane dobrze traktować jak fragment kontraktu z odbiorcą raportu: jasno pokazuje, co zostało usunięte, co uzupełnione i jakim kosztem. To z kolei ułatwia utrzymanie pipeline’u, gdy zmieniają się źródła, zakres analizy albo pytania, na które dane mają odpowiadać.

3. Filtrowanie, selekcja i transformacje: łączyć czy rozbijać pipeline?

Jeden długi pipe czy kilka krótszych kroków?

Pytanie praktyczne: lepiej mieć jeden imponujący pipeline na 20 linii, czy kilka krótszych etapów? Kryterium jest proste: czy czytelnik kodu rozumie „historię danych” bez przewijania w górę i w dół.

Przykład bardzo złożonego, mało czytelnego pipe:


dane <- readr::read_csv("dane.csv") %>%
  clean_names() %>%
  mutate(
    id_klienta = as.character(id_klienta),
    data_zakupu = as.Date(data_zakupu, format = "%Y-%m-%d"),
    kwota_brutto = as.numeric(kwota_brutto)
  ) %>%
  tidyr::drop_na(id_klienta, data_zakupu) %>%
  filter(kraj == "PL", kwota_brutto > 0) %>%
  select(id_klienta, data_zakupu, kwota_brutto, segment, kanal) %>%
  mutate(
    miesiac = format(data_zakupu, "%Y-%m"),
    kwota_netto = kwota_brutto / 1.23,
    segment_upr = dplyr::case_when(
      segment %in% c("VIP", "PREMIUM") ~ "wysoki",
      is.na(segment) ~ "nieznany",
      TRUE ~ "standard"
    )
  ) %>%
  group_by(id_klienta, miesiac) %>%
  summarise(
    suma_brutto = sum(kwota_brutto, na.rm = TRUE),
    suma_netto = sum(kwota_netto, na.rm = TRUE),
    .groups = "drop"
  )

Technicznie działa. Problem pojawia się, gdy trzeba odpowiedzieć na pytanie: „w którym miejscu odrzuciliśmy transakcje z zagranicy?” albo „gdzie powstała zmienna segment_upr?”. W gęstym pipe’ie takie decyzje giną.

Czytelniejszy układ to kilka logicznych bloków, nawet kosztem dodatkowych przypisań:


dane_surowe <- readr::read_csv("dane.csv") %>%
  clean_names()

dane_typy <- dane_surowe %>%
  mutate(
    id_klienta = as.character(id_klienta),
    data_zakupu = as.Date(data_zakupu, format = "%Y-%m-%d"),
    kwota_brutto = as.numeric(kwota_brutto)
  )

dane_podstawowe <- dane_typy %>%
  # --- rekordy, które w ogóle bierzemy pod uwagę ---
  tidyr::drop_na(id_klienta, data_zakupu) %>%
  filter(kraj == "PL", kwota_brutto > 0) %>%
  select(id_klienta, data_zakupu, kwota_brutto, segment, kanal)

dane_agg <- dane_podstawowe %>%
  # --- transformacje biznesowe i agregacja ---
  mutate(
    miesiac = format(data_zakupu, "%Y-%m"),
    kwota_netto = kwota_brutto / 1.23,
    segment_upr = case_when(
      segment %in% c("VIP", "PREMIUM") ~ "wysoki",
      is.na(segment) ~ "nieznany",
      TRUE ~ "standard"
    )
  ) %>%
  group_by(id_klienta, miesiac) %>%
  summarise(
    suma_brutto = sum(kwota_brutto, na.rm = TRUE),
    suma_netto = sum(kwota_netto, na.rm = TRUE),
    .groups = "drop"
  )

Koszt: kilka dodatkowych obiektów w środowisku. Zysk: łatwo wskazać etap, w którym:

  • odrzucono wiersze (dane_podstawowe),
  • dodano logikę biznesową (dane_agg),
  • przekształcono typy (dane_typy).

Dla codziennych, powtarzalnych raportów zwykle wygrywa drugi wariant – z podziałem na etapy i komentarzami blokowymi.

Filtruj jak najwcześniej, ale po kluczowych kontrolach

Filtr usuwa dane trwale (w sensie dalszych kroków pipeline), dlatego dobrze wiedzieć, na jakim etapie go użyć.

  • Za wcześnie: filtrujesz zanim uporządkujesz typy i brakujące dane – możesz odrzucić rekordy, które po konwersji byłyby poprawne.
  • Za późno: filtrujesz dopiero po wielu kosztownych transformacjach – tracisz czas obliczeniowy i komplikujesz logikę.

Praktyczny kompromis:

  1. wczytanie + czyszczenie nazw,
  2. konwersja typów (daty, liczby, ID),
  3. obsługa braków krytycznych (drop_na na ID, datach),
  4. filtr logiczny (kraj, zakres dat, flagi jakości),
  5. pozostałe transformacje i agregacje.

Taki porządek zmniejsza ryzyko, że usuniesz wiersze tylko dlatego, że data wczytała się jako tekst i nie przeszła warunku data_zakupu >= ....

select – odchudzanie ramki a ryzyko utraty potrzebnych pól

select() bywa używany automatycznie, żeby „wyczyścić” dane z niepotrzebnych kolumn. To dobry nawyk, o ile jest jasne, co zostaje, a co znika.

Dwa bezpieczne wzorce:

  • pozytywny wybór kluczowych kolumn, szczególnie przed zapisem/eksportem,
  • jawne usuwanie pojedynczych pól technicznych (select(-kolumna_robocza)), ale z komentarzem.

dane_czyste <- dane_podstawowe %>%
  # --- tylko pola wymagane w raporcie miesięcznym ---
  select(
    id_klienta,
    data_zakupu,
    kwota_brutto,
    segment_upr,
    kanal
  )

Pytanie kontrolne przy każdej operacji select(): czy ktoś w zespole za tydzień będzie wiedział, dlaczego te pola zostały, a inne nie? Jeśli nie – dodaj krótki komentarz lub zostaw szeroką ramkę na wcześniejszym etapie, a selekcję wykonaj dopiero tuż przed eksportem.

Transformacje w mutate: lepiej kilka prostych niż jeden gigantyczny wzór

W mutate() łatwo ulec pokusie pojedynczej, złożonej formuły. Czytelniej jest rozłożyć transformacje na parę prostszych kroków, szczególnie gdy logika biznesowa jest wieloetapowa.


# Mniej czytelne:
dane <- dane %>%
  mutate(
    marza_proc = (przychod - koszt) / przychod * 100,
    kategoria_marzy = case_when(
      marza_proc >= 40 & przychod >= 10000 ~ "wysoka_i_duzy_przychod",
      marza_proc >= 40 ~ "wysoka",
      marza_proc >= 20 ~ "srednia",
      TRUE ~ "niska"
    )
  )

# Bardziej przejrzyste:
dane <- dane %>%
  mutate(
    marza_proc = (przychod - koszt) / przychod * 100
  ) %>%
  mutate(
    kategoria_marzy = case_when(
      marza_proc >= 40 & przychod >= 10000 ~ "wysoka_i_duzy_przychod",
      marza_proc >= 40 ~ "wysoka",
      marza_proc >= 20 ~ "srednia",
      TRUE ~ "niska"
    )
  )

Technicznie to minimalna różnica. Z punktu widzenia czytelności – łatwiej najpierw ocenić, czy marza_proc jest policzona poprawnie, a dopiero potem interpretować progi.

4. Wbuduj punkty kontrolne: podgląd, liczniki, proste testy

Kontrole w środku pipeline czy osobno?

Pipeline kusi, by „puścić wszystko jednym strzałem”. W praktyce przydaje się kilka wbudowanych kontroli, które zatrzymują lub przynajmniej spowalniają automatyzm, gdy coś jest nie tak.

Dwa sposoby organizacji kontroli:

  • krótkie bloki diagnostyczne między etapami – osobne wywołania, które można komentować / odkomentowywać,
  • kontrole w środku pipe – z użyciem dplyr::count(), glimpse() i funkcji typu rstatix::shapiro_test() w połączeniu z dplyr::do() lub dplyr::reframe() (gdy potrzeba).

W większości projektów wystarczy pierwsza opcja: prosty, jawny „przystanek” po kluczowych zmianach.

glimpse, count, summary – minimalny zestaw diagnostyczny

Po każdym większym bloku transformacji możesz dodać krótki podgląd, który odpowiada na dwa pytania: co wiemy o danych po tym kroku? i czy liczebności są zgodne z intuicją?.

Nowoczesna klawiatura w jasnym, laboratoryjnym otoczeniu
Źródło: Pexels | Autor: Tima Miroshnichenko

dane_podstawowe <- dane_typy %>%
  tidyr::drop_na(id_klienta, data_zakupu) %>%
  filter(kraj == "PL", kwota_brutto > 0)

# --- kontrola po filtrach ---
dane_podstawowe %>%
  summarise(
    n = n(),
    min_data = min(data_zakupu, na.rm = TRUE),
    max_data = max(data_zakupu, na.rm = TRUE),
    min_kwota = min(kwota_brutto, na.rm = TRUE),
    max_kwota = max(kwota_brutto, na.rm = TRUE)
  )

dane_podstawowe %>%
  count(segment, sort = TRUE)

Taki blok diagnostyczny zostawiasz w kodzie. Gdy pipeline ma działać automatycznie (np. w skrypcie cron), można go owinąć w warunek (np. uruchamiać tylko interaktywnie) albo przekierować wynik do logów.

distinct i antyduplikaty: cichy problem, który warto nazwać

Duplikaty rekordów często wychodzą na jaw dopiero przy agregacjach. Zamiast polegać na domysłach, można dodać prostą kontrolę:


# Ilu unikalnych klientów mamy vs ile wierszy?
dane_podstawowe %>%
  summarise(
    n_wierszy = n(),
    n_klientow = n_distinct(id_klienta)
  )

Jeśli dane powinny być jednowierszowe na klienta, a różnica jest duża – to sygnał do interwencji, nie do dalszej automatyzacji.

Gdy celem jest „wymuszenie” unikalności, lepiej zrobić to jawnie niż przez przypadkową agregację:


dane_unikalne <- dane_podstawowe %>%
  distinct(id_klienta, .keep_all = TRUE)

Dobrą praktyką jest poprzedzenie takiego kroku komentarzem wprost: „# jeśli klient występuje kilka razy, bierzemy pierwszy rekord”. To nie rozwiązuje problemu merytorycznie, ale jasno opisuje przyjęte założenie.

warningi i proste asercje: gdzie automatyzacja powinna się zatrzymać

W pełni zautomatyzowany pipeline nie powinien się „cicho” utrzymywać, gdy dane odjechały od oczekiwań. Można dodać lekką warstwę kontroli z użyciem stopifnot() lub własnych warunków:


# Oczekujemy, że dane są z jednego roku raportowego
rok_info <- dane_podstawowe %>%
  mutate(rok = lubridate::year(data_zakupu)) %>%
  count(rok)

if (nrow(rok_info) > 1) {
  warning("Dane zawierają wiele lat: ",
          paste(rok_info$rok, collapse = ", "))
}

To moment, w którym automatyzacja ustępuje kontroli. Zamiast ślepo przeliczać raport „jak zawsze”, kod sygnalizuje, że zmieniło się charakterystyczne założenie (np. raport miał dotyczyć tylko bieżącego roku).

Podobnie można pilnować wymiaru danych po kluczowych filtrach. Jeśli po odfiltrowaniu kraju, okresu i braków liczba rekordów spada niemal do zera, lepiej przerwać bieg niż generować pusty raport:


if (nrow(dane_podstawowe) == 0) {
  stop("Po filtrach nie zostały żadne dane. Sprawdź warunki filtrowania.")
}

if (nrow(dane_podstawowe) < 100) {
  warning("Po filtrach pozostało bardzo mało obserwacji: ",
          nrow(dane_podstawowe))
}

Taki próg „miękki” (ostrzeżenie) i „twardy” (błąd) wyznacza granicę, po której automatyczny pipeline zamienia się w proces wymagający interwencji człowieka.

Przy większych zespołach przydaje się spójny zwyczaj: krytyczne założenia są pilnowane przez stop() lub stopifnot(), a mniej istotne odchylenia (np. zmiana rozkładu segmentów, drobne przesunięcia dat) – przez warning(). Dzięki temu odbiorca raportu wie, kiedy wynik jest potencjalnie niepełny, a kiedy w ogóle nie został wygenerowany.

Ostatni element to decyzja, co dzieje się z komunikatami. W środowisku interaktywnym wystarczy konsola, w produkcji lepsze będzie logowanie do pliku lub systemu monitoringu. Kluczowe pytanie brzmi: czy ktoś realnie zobaczy ten sygnał? Jeśli nie, kontrola istnieje tylko teoretycznie.

5. Typowe funkcje czyszczące: małe, wielokrotnego użytku klocki

Kiedy te same operacje powtarzają się w kilku skryptach, sygnał jest prosty: czas wydzielić funkcję pomocniczą. Zamiast kopiować bloki mutate() i filter() między projektami, można zbudować małą „bibliotekę” klocków czyszczących.

Dobry kandydat na funkcję to powtarzalny schemat: ta sama kolumna, podobne reguły biznesowe, identyczne komunikaty o błędach. Przykład: normalizacja kanałów sprzedaży i segmentów klienta.


normalizuj_kanal <- function(df) {
  df %>%
    mutate(
      kanal = stringr::str_to_lower(kanal),
      kanal = dplyr::case_when(
        kanal %in% c("online", "www", "ecommerce") ~ "online",
        kanal %in% c("sklep", "offline") ~ "offline",
        TRUE ~ "inne"
      )
    )
}

oczysc_segment <- function(df) {
  df %>%
    mutate(
      segment = stringr::str_trim(segment),
      segment = dplyr::na_if(segment, ""),
      segment = tidyr::replace_na(segment, "nieznany")
    )
}

Takie funkcje nie muszą być ogólne dla całego świata. Wystarczy, że dobrze odzwierciedlają reguły w konkretnym zespole lub organizacji, a ich nazwa jasno mówi, co robią.

Funkcje-kompozycje: pakowanie powtarzalnych bloków pipeline

Kolejny krok to funkcje, które składają kilka operacji w logiczny blok. Zamiast rozwijać na ekranie sześć kolejnych mutate() dla dat, walut i stawek, można je zamknąć w jednym wywołaniu o czytelnej nazwie.


standardowy_blok_czyszczenia <- function(df) {
  df %>%
    janitor::clean_names() %>%
    mutate(
      data_zakupu = as.Date(data_zakupu),
      kwota_brutto = readr::parse_number(kwota_brutto)
    ) %>%
    tidyr::drop_na(id_klienta, data_zakupu)
}

dane_czyste <- dane_surowe %>%
  standardowy_blok_czyszczenia() %>%
  normalizuj_kanal() %>%
  oczysc_segment()

Pipeline staje się dzięki temu krótszy, ale ważniejsza jest inna rzecz: ten sam blok czyszczenia działa identycznie w wielu skryptach. Jeśli zmienią się reguły (np. konwersja dat), poprawka trafia do jednego miejsca.

Najczęściej zadawane pytania (FAQ)

Jak zbudować prosty pipeline do czyszczenia danych w R z użyciem dplyr?

Najprostszy, powtarzalny pipeline to jeden ciąg operacji zaczynający się od wczytania danych, a kończący na zapisaniu wyczyszczonego zbioru. Typowy szkielet wygląda tak: read_csv()clean_names() → blok mutate() z konwersjami typów → blok obsługi NA → filtrowanie i tworzenie nowych zmiennych → zapis write_rds() lub write_csv().

Klucz jest w tym, aby większość kroków czyszczenia była widoczna „w jednym kawałku” i możliwa do uruchomienia na każdym nowym pliku ze źródła. Rozproszone poprawki w różnych miejscach skryptu utrudniają powtarzalność i kontrolę, bo trudno potem odtworzyć, co dokładnie stało się z danymi.

Czym różni się chaotyczny kod od uporządkowanego pipeline dplyr?

Chaotyczny kod to osobne linijki typu: najpierw read.csv(), potem ręczne zmiany nazw, potem gdzieś niżej filtrowanie po kwocie, jeszcze dalej nadpisywanie kolumn. Taki zapis jest trudny do audytu: trudno wskazać, w którym momencie usunięto wiersze albo zmieniono typ zmiennej, co wiemy, a czego nie wiemy o zastosowanych regułach.

W uporządkowanym pipeline większość operacji jest w jednym łańcuchu z operatorem %>%. Dzięki temu można go łatwo powtórzyć na nowych danych, szybciej wychwycić błędną operację i łatwiej przekazać kod innemu analitykowi. Pipeline pełni funkcję przepisu – czyta się go od góry do dołu jak listę kroków.

Jak automatycznie standaryzować nazwy kolumn w R (clean_names vs ręczne poprawki)?

Najprostsze rozwiązanie to użycie janitor::clean_names() od razu po wczytaniu danych. Funkcja zamienia wielkie litery na małe, usuwa spacje i znaki specjalne, wstawia podkreślenia oraz usuwa polskie znaki. Efektem są stabilne, przewidywalne nazwy, które łatwo potem wykorzystać w dplyr.

Jeśli część nazw wymaga doprecyzowania, lepiej dodać pojedynczy, czytelny blok rename() niż poprawiać każdą nazwę osobno w różnych miejscach. Przykład: clean_names() + rename(id_klienta = id_klienta_1) jest łatwy do ponownego użycia na kolejnych eksportach z tego samego systemu.

Jak bezpiecznie zmieniać typy zmiennych w pipeline dplyr?

Konwersje typów najlepiej trzymać w jednym, wczesnym bloku mutate(), zaraz po clean_names() i wstępnym podglądzie struktury. Najpierw warto użyć dplyr::glimpse(), żeby sprawdzić, co faktycznie zostało wczytane jako chr, numeric, Date itd.

Przy konwersjach dat dobrze jest zawsze jawnie podawać format, np. as.Date(data_zakupu, format = "%Y-%m-%d") albo "%d/%m/%Y" przy dacie w stylu „01/02/2022”. Identyfikatory zwykle bezpieczniej traktować jako tekst (as.character()), nawet jeśli wyglądają jak liczby – dzięki temu nie giną wiodące zera i nie pojawiają się problemy przy łączeniu tabel.

Jak obsługiwać brakujące dane (NA) w dplyr, żeby nie stracić za dużo obserwacji?

Praktycznym podejściem jest wydzielenie jednego, wyraźnie oznaczonego fragmentu pipeline odpowiedzialnego za braki danych. W tym miejscu decydujemy, które rekordy usuwamy (np. drop_na(id_klienta)), a które pola uzupełniamy domyślną wartością (np. replace_na(liczba_zakupow, 0) czy replace_na(segment, "nieznany")).

Unikaj bezrefleksyjnego drop_na() bez podawania kolumn, bo usuwa wszystkie wiersze z choć jednym NA. Przy danych ankietowych może to obciąć próbę o dużą część. Bezpieczniej wskazać tylko te kolumny, które muszą być pełne (np. identyfikator, data transakcji), a pola mniej krytyczne pozostawić jako NA lub imputować osobno.

Kiedy warto w pełni zautomatyzować czyszczenie danych, a kiedy dodać ręczne punkty kontroli?

Pełna automatyzacja sprawdza się przy stabilnych źródłach: cykliczny eksport z jednego systemu, stały format dat, brak niespodzianek w strukturze. Wtedy pipeline można uruchamiać bez ingerencji, a blok konwersji typów czy filtrów praktycznie się nie zmienia w czasie.

Punkty kontroli przydają się, gdy dane przychodzą z „ręcznych” źródeł: pliki od różnych partnerów, arkusze Excela z niestandardowym formatem, zmienne o zmiennym znaczeniu. W takich sytuacjach ryzykowne są agresywne filtry (np. usuwanie wartości odstających) i imputacja ważnych zmiennych wynikowych. Dobrym kompromisem jest: automatyzacja prostych, powtarzalnych kroków (nazwy, typy, podstawowe filtry) i ręczne sprawdzenie logów lub krótkiego podsumowania po każdym „mocnym” kroku czyszczenia.

Jak uniknąć ukrytych błędów, gdy pipeline usuwa lub zmienia wiele rekordów?

Podstawowe zabezpieczenie to jawne, komentowane bloki operacji, które mogą mocno zmieniać dane: filtrowanie, drop_na(), obcinanie wartości odstających, grupowe podsumowania. Przy większych cięciach dobrym nawykiem jest krótkie summarise() lub count() przed i po operacji, aby widzieć, ile obserwacji odpada i z jakiego powodu.

Można też zapisywać pośrednie wersje danych (np. po blokach: „nazwy i typy”, „braki danych”, „filtrowanie błędów”) jako pliki techniczne albo RDS. Dzięki temu, gdy pojawi się pytanie „dlaczego zniknęło tyle rekordów?”, łatwiej prześledzić, na którym etapie pipeline nastąpiła największa zmiana.

Co warto zapamiętać

  • Jeden główny pipeline dplyr od surowych danych do gotowego zbioru porządkuje pracę: wszystkie kluczowe kroki czyszczenia są w jednym, liniowym ciągu poleceń, łatwym do ponownego uruchomienia przy każdym nowym eksporcie.
  • Chaotyczny kod z rozproszonymi poprawkami (pojedyncze linijki w różnych miejscach skryptu, ręczne nadpisywanie kolumn) utrudnia replikację, kontrolę i audyt; spójny pipeline ułatwia zrozumienie, gdzie dokładnie dane są filtrowane i przekształcane.
  • Automatyzacja ma granice: część operacji powinna dziać się zawsze (np. standaryzacja nazw, konwersja dat, usuwanie pustych ID), ale agresywne filtry, obsługa odstających wartości czy imputacja kluczowych zmiennych wymagają świadomych „punktów kontrolnych”.
  • Standaryzacja nazw kolumn na początku (np. przez janitor::clean_names() plus ewentualny krótki blok rename()) daje stabilną, przewidywalną strukturę danych, niezależnie od tego, jak ktoś nazwie pola w źródłowym pliku.
  • Natychmiastowe sprawdzenie typów za pomocą dplyr::glimpse() tuż po wczytaniu i uporządkowaniu nazw pozwala wychwycić „ciche” problemy (daty i liczby jako tekst, identyfikatory jako liczby) zanim pipeline zacznie na nich operować.
  • Jawne, zebrane w jednym bloku mutate() konwersje typów (z podanym formatem daty i traktowaniem ID jako tekstu) zmniejszają ryzyko błędów, zapewniają powtarzalność i ułatwiają późniejsze przeglądy kodu przez inne osoby.
  • Źródła

  • R for Data Science. O'Reilly Media (2017) – Wprowadzenie do tidyverse, dplyr, pipeline i czyszczenia danych
  • Advanced R. Chapman and Hall/CRC (2014) – Dobre praktyki programistyczne w R, struktura kodu i funkcje
  • Data Cleaning. John Wiley & Sons (2019) – Ogólne zasady czyszczenia danych, brakujące dane, wartości odstające
  • Tidy Data. Journal of Statistical Software (2014) – Koncepcja tidy data, struktura ramek danych i transformacje
  • An Introduction to Data Cleaning with R. Statistics Netherlands (2013) – Przewodnik po czyszczeniu danych w R, typy, brakujące dane, filtry
  • R Language Definition. R Foundation for Statistical Computing (2000) – Oficjalny opis języka R, typy obiektów i konwersje
  • janitor: Simple Tools for Examining and Cleaning Dirty Data. Comprehensive R Archive Network – Dokumentacja pakietu janitor, funkcja clean_names i inne narzędzia

Poprzedni artykułSpearman vs Pearson: jak wybrać korelację do swoich danych?
Ryszard Domański
Ryszard Domański koncentruje się na tym, by statystyka była narzędziem do podejmowania trafnych decyzji, a nie zbiorem wzorów. W artykułach porządkuje proces analizy: od hipotez i doboru próby, przez przygotowanie danych, po interpretację wyników i wnioski. Ceni metodyczne podejście, dlatego zwraca uwagę na założenia testów, błędy I i II rodzaju oraz sens praktyczny efektów. Na itplock.pl tworzy materiały, które pomagają pisać rzetelne raporty i unikać nadinterpretacji.