Wprowadzenie — po co w ogóle czyścić dane?
Jeśli kiedykolwiek pracowałeś z rzeczywistymi danymi w Pythonie, wiesz jedno — surowe dane to bałagan. Brakujące wartości tu, duplikaty tam, literówki w nazwach miast, daty w pięciu różnych formatach... i to dopiero początek. Osobiście szacuję, że jakieś 60–80% czasu w typowym projekcie data science spędzam właśnie na czyszczeniu danych. I nie jestem w tym odosobniony.
W tym przewodniku pokażę Ci, jak profesjonalnie czyścić dane w pandas 3.0 — od podstaw po zaawansowane wzorce produkcyjne. Omówimy obsługę brakujących wartości, duplikaty, outliery, standaryzację tekstu, method chaining z pipe() oraz budowanie powtarzalnych potoków czyszczenia danych.
No to zaczynajmy.
Co nowego w pandas 3.0 dla czyszczenia danych?
Pandas 3.0 to poważny krok naprzód. PyArrow stał się domyślnym backendem, Copy-on-Write jest włączony domyślnie, a nowe typy danych (zwłaszcza StringDtype oparty na Arrow) zmieniają sposób, w jaki pracujemy z tekstem i brakującymi wartościami.
Najważniejsze zmiany z perspektywy czyszczenia danych:
- PyArrow jako domyślny backend — szybsze operacje na stringach, natywna obsługa
NAzamiastNaN, mniejsze zużycie pamięci. - Copy-on-Write domyślnie — koniec z niespodziewanymi efektami ubocznymi przy modyfikacji DataFrame'ów. Szczerze? To zmiana, na którą czekałem latami.
- Nowy
StringDtype— kolumny tekstowe nie są już traktowane jakoobject, co eliminuje wiele typowych pułapek. pd.options.future.infer_string = True— automatyczna konwersja stringów na nowy typ.
import pandas as pd
import numpy as np
# Sprawdzenie wersji
print(f"pandas: {pd.__version__}")
# W pandas 3.0 PyArrow jest domyślnym backendem
df = pd.read_csv("dane_klientow.csv")
# Zwróć uwagę na typy — stringi to teraz string[pyarrow], nie object
print(df.dtypes)
# imie string[pyarrow]
# email string[pyarrow]
# wiek int64[pyarrow]
# data_rejestracji timestamp[ns][pyarrow]
Obsługa brakujących wartości — fundament czyszczenia danych
Diagnostyka braków
Zanim cokolwiek naprawisz, musisz wiedzieć, co jest zepsute. To brzmi banalnie, ale zdziwiłbyś się, ile razy widziałem ludzi, którzy od razu skaczą do imputacji bez zrozumienia wzorca braków.
import pandas as pd
import numpy as np
# Tworzenie przykładowych danych z brakami
df = pd.DataFrame({
"imie": ["Anna", "Bartek", None, "Celina", "Dawid", None],
"wiek": [28, None, 35, 42, None, 29],
"wynagrodzenie": [5500.0, 7200.0, None, None, 4800.0, 6100.0],
"miasto": ["Warszawa", "Kraków", "Gdańsk", None, "Wrocław", "Poznań"],
"ocena": [4.5, None, 3.8, 4.2, None, None]
})
# Podstawowa diagnostyka braków
print("Liczba braków w każdej kolumnie:")
print(df.isna().sum())
print()
# Procent braków
print("Procent braków:")
print((df.isna().sum() / len(df) * 100).round(1))
print()
# Wiersze z jakimikolwiek brakami
print(f"Wiersze z brakami: {df.isna().any(axis=1).sum()} z {len(df)}")
Warto też wizualnie sprawdzić wzorzec braków. Jeśli braki są losowe (MCAR — Missing Completely at Random), to imputacja jest stosunkowo prosta. Ale jeśli brakujące wartości korelują ze sobą lub z innymi zmiennymi (MAR lub MNAR), sprawa się komplikuje.
# Macierz korelacji braków — to sprytna technika
# Pokazuje, czy braki w jednej kolumnie są powiązane z brakami w innej
brak_korelacja = df.isna().corr()
print("Korelacja braków:")
print(brak_korelacja)
Strategie uzupełniania braków
Nie ma jednej „najlepszej" metody imputacji. Wszystko zależy od kontekstu, typu danych i tego, jak chcesz je później wykorzystać. Oto najczęstsze podejścia:
import pandas as pd
import numpy as np
df = pd.DataFrame({
"temperatura": [22.1, np.nan, 23.5, np.nan, 25.0, 24.2, np.nan, 26.1],
"cisnienie": [1013, 1015, np.nan, 1012, 1014, np.nan, 1016, 1013],
"wilgotnosc": [65, 70, np.nan, 68, np.nan, 72, 71, np.nan],
"miasto": ["Warszawa", None, "Kraków", "Gdańsk", None, "Wrocław", "Poznań", None]
})
# 1. Uzupełnianie stałą wartością
df["miasto_clean"] = df["miasto"].fillna("Nieznane")
# 2. Uzupełnianie średnią (dane numeryczne)
df["temp_mean"] = df["temperatura"].fillna(df["temperatura"].mean())
# 3. Uzupełnianie medianą — lepsze przy skośnych rozkładach
df["temp_median"] = df["temperatura"].fillna(df["temperatura"].median())
# 4. Forward fill — wypełnianie poprzednią wartością (dane czasowe)
df["temp_ffill"] = df["temperatura"].ffill()
# 5. Backward fill
df["temp_bfill"] = df["temperatura"].bfill()
# 6. Interpolacja liniowa — świetna dla danych szeregów czasowych
df["temp_interpolated"] = df["temperatura"].interpolate(method="linear")
print(df[["temperatura", "temp_mean", "temp_median", "temp_ffill", "temp_interpolated"]])
Szybka rada: przy danych czasowych (np. pomiary temperatury co godzinę) interpolacja jest zazwyczaj najlepsza. Przy danych ankietowych lepiej działa mediana lub dominanta. A jeśli brakuje Ci ponad 50% wartości w kolumnie... no cóż, może lepiej ją po prostu usunąć.
Zaawansowana imputacja z grupowaniem
Uzupełnianie braków globalną średnią to najprostsze podejście, ale rzadko najlepsze. Dużo lepszym pomysłem jest uzupełnianie w kontekście grup — na przykład mediana wynagrodzenia w ramach danego stanowiska, a nie całego zbioru danych.
import pandas as pd
import numpy as np
df = pd.DataFrame({
"pracownik": ["Anna", "Bartek", "Celina", "Dawid", "Ewa", "Filip"],
"dzial": ["IT", "IT", "HR", "HR", "IT", "HR"],
"wynagrodzenie": [8500, np.nan, 6200, np.nan, 9100, np.nan],
"doswiadczenie_lat": [5, 3, 4, 7, 6, 2]
})
# Imputacja medianą w ramach grupy
df["wynagrodzenie_clean"] = df.groupby("dzial")["wynagrodzenie"].transform(
lambda x: x.fillna(x.median())
)
print(df[["pracownik", "dzial", "wynagrodzenie", "wynagrodzenie_clean"]])
# Bartek (IT) dostanie medianę IT (8800), nie globalną medianę
# Dawid (HR) dostanie medianę HR (6200), nie globalną medianę
To naprawdę robi różnicę w jakości danych — zwłaszcza gdy grupy mają znacząco różne rozkłady.
Usuwanie duplikatów — trudniejsze niż się wydaje
Duplikaty brzmią jak prosty problem. Wywołujesz drop_duplicates() i gotowe, prawda? No... nie do końca.
Identyfikacja duplikatów
import pandas as pd
df = pd.DataFrame({
"email": ["[email protected]", "[email protected]", "[email protected]",
"[email protected]", "[email protected]", "[email protected] "],
"imie": ["Anna", "Bartek", "Anna", "Celina", "Bartek", "Anna"],
"data_zakupu": ["2026-01-15", "2026-01-16", "2026-01-15",
"2026-01-17", "2026-01-18", "2026-01-19"],
"kwota": [150.0, 230.0, 150.0, 89.0, 310.0, 200.0]
})
# Proste duplikaty — identyczne wiersze
print("Dokładne duplikaty:")
print(df[df.duplicated(keep=False)])
print()
# Duplikaty po wybranych kolumnach
print("Duplikaty po emailu:")
print(df[df.duplicated(subset=["email"], keep=False)])
Widzisz problem? "[email protected]", "[email protected]" i "[email protected] " (ze spacją na końcu) to technicznie trzy różne stringi — ale ten sam email. I to jest właśnie pułapka, w którą wpada masa ludzi.
Czyszczenie przed deduplikacją
Kluczowa zasada: najpierw standaryzuj, potem deduplikuj. Kolejność ma ogromne znaczenie.
import pandas as pd
df = pd.DataFrame({
"email": ["[email protected]", "[email protected]", "[email protected]",
"[email protected]", "[email protected]", "[email protected] "],
"imie": ["Anna", "Bartek", "Anna", "Celina", "Bartek", "Anna"],
"data_zakupu": ["2026-01-15", "2026-01-16", "2026-01-15",
"2026-01-17", "2026-01-18", "2026-01-19"],
"kwota": [150.0, 230.0, 150.0, 89.0, 310.0, 200.0]
})
# Standaryzacja przed deduplikacją
df["email_clean"] = (
df["email"]
.str.strip() # usunięcie białych znaków
.str.lower() # małe litery
.str.replace(r"\s+", "", regex=True) # usunięcie wewnętrznych spacji
)
# Teraz deduplikacja po oczyszczonej kolumnie
# keep='last' zachowuje najnowszy wpis
df_dedup = df.drop_duplicates(subset=["email_clean"], keep="last")
print(df_dedup[["email_clean", "imie", "data_zakupu", "kwota"]])
Fuzzy matching — gdy dane są naprawdę brudne
Czasami proste porównanie stringów nie wystarczy. Na przykład „Jan Kowalski" i „Kowalski Jan" to ta sama osoba, ale drop_duplicates() ich nie wyłapie. W takich przypadkach warto sięgnąć po fuzzy matching.
# pip install thefuzz python-Levenshtein
from thefuzz import fuzz, process
import pandas as pd
nazwy_firm = pd.Series([
"Microsoft Corporation",
"Microsoft Corp.",
"Microsoft Corp",
"Apple Inc.",
"Apple Inc",
"Apple Incorporated",
"Google LLC",
"Alphabet/Google"
])
# Grupowanie podobnych nazw
def grupuj_podobne(seria, prog=85):
"""Grupuje podobne stringi na podstawie podobieństwa."""
grupy = {}
przetworzono = set()
for i, wartosc in seria.items():
if i in przetworzono:
continue
grupy[wartosc] = [wartosc]
przetworzono.add(i)
for j, inna_wartosc in seria.items():
if j in przetworzono:
continue
if fuzz.ratio(wartosc.lower(), inna_wartosc.lower()) >= prog:
grupy[wartosc].append(inna_wartosc)
przetworzono.add(j)
return grupy
wynik = grupuj_podobne(nazwy_firm)
for klucz, wartosci in wynik.items():
if len(wartosci) > 1:
print(f"Grupa: {wartosci}")
# Grupa: ['Microsoft Corporation', 'Microsoft Corp.', 'Microsoft Corp']
# Grupa: ['Apple Inc.', 'Apple Inc', 'Apple Incorporated']
Fuzzy matching bywa wolny na dużych zbiorach danych (rzędy milionów rekordów), więc warto rozważyć blocking — najpierw grupujesz po pierwszej literze lub innym prostym kryterium, a dopiero potem porównujesz w ramach bloków.
Wykrywanie i obsługa outlierów
Outliery (wartości odstające) to temat, który potrafi wywołać gorące dyskusje wśród data scientistów. Usuwać? Zostawiać? Transformować? Odpowiedź, jak zwykle, brzmi: to zależy.
Metoda IQR (rozstęp międzykwartylowy)
Klasyczna, prosta i sprawdzona metoda. Działa dobrze w większości przypadków:
import pandas as pd
import numpy as np
np.random.seed(42)
df = pd.DataFrame({
"wynagrodzenie": np.concatenate([
np.random.normal(6000, 1500, 95), # normalne wartości
np.array([25000, 30000, 50000, -1000, 0]) # outliery
]),
"wiek": np.concatenate([
np.random.randint(22, 60, 95),
np.array([150, 200, -5, 15, 99])
])
})
def wykryj_outliery_iqr(seria, mnoznik=1.5):
"""Wykrywa outliery metodą IQR."""
Q1 = seria.quantile(0.25)
Q3 = seria.quantile(0.75)
IQR = Q3 - Q1
dolna_granica = Q1 - mnoznik * IQR
gorna_granica = Q3 + mnoznik * IQR
return (seria < dolna_granica) | (seria > gorna_granica), dolna_granica, gorna_granica
# Wykrycie outlierów
outlier_mask, dolna, gorna = wykryj_outliery_iqr(df["wynagrodzenie"])
print(f"Outliery wynagrodzenia: {outlier_mask.sum()}")
print(f"Granice: [{dolna:.0f}, {gorna:.0f}]")
print(f"Wartości outlierów: {df.loc[outlier_mask, 'wynagrodzenie'].values}")
Z-score — podejście statystyczne
Metoda z-score działa lepiej przy danych o rozkładzie zbliżonym do normalnego. Wartości z |z| > 3 traktujemy jako outliery:
import pandas as pd
import numpy as np
from scipy import stats
np.random.seed(42)
df = pd.DataFrame({
"wynagrodzenie": np.concatenate([
np.random.normal(6000, 1500, 95),
np.array([25000, 30000, 50000, -1000, 0])
])
})
def wykryj_outliery_zscore(seria, prog=3):
"""Wykrywa outliery metodą z-score."""
z_scores = np.abs(stats.zscore(seria.dropna()))
# Trzeba zamapować z powrotem na indeks oryginalny
mask = pd.Series(False, index=seria.index)
mask[seria.dropna().index] = z_scores > prog
return mask
outlier_mask_z = wykryj_outliery_zscore(df["wynagrodzenie"])
print(f"Outliery (z-score): {outlier_mask_z.sum()}")
Co robić z outlierami?
Masz kilka opcji — i każda ma swoje miejsce:
import pandas as pd
import numpy as np
np.random.seed(42)
dane = np.concatenate([
np.random.normal(6000, 1500, 95),
np.array([25000, 30000, 50000, -1000, 0])
])
df = pd.DataFrame({"wynagrodzenie": dane})
# 1. Usunięcie (jeśli to błędy w danych)
Q1, Q3 = df["wynagrodzenie"].quantile([0.25, 0.75])
IQR = Q3 - Q1
df_bez_outlierow = df[
(df["wynagrodzenie"] >= Q1 - 1.5 * IQR) &
(df["wynagrodzenie"] <= Q3 + 1.5 * IQR)
]
# 2. Winsoryzacja — przycinanie do granic
df["wyn_winsorized"] = df["wynagrodzenie"].clip(
lower=Q1 - 1.5 * IQR,
upper=Q3 + 1.5 * IQR
)
# 3. Transformacja logarytmiczna (gdy mamy skośność prawostronną)
df["wyn_log"] = np.log1p(df["wynagrodzenie"].clip(lower=0))
# 4. Zastąpienie medianą
mediana = df["wynagrodzenie"].median()
outlier_mask = (df["wynagrodzenie"] < Q1 - 1.5 * IQR) | (df["wynagrodzenie"] > Q3 + 1.5 * IQR)
df["wyn_mediana"] = df["wynagrodzenie"].where(~outlier_mask, mediana)
print(f"Oryginał — mean: {df['wynagrodzenie'].mean():.0f}, std: {df['wynagrodzenie'].std():.0f}")
print(f"Winsoryzacja — mean: {df['wyn_winsorized'].mean():.0f}, std: {df['wyn_winsorized'].std():.0f}")
Moja rada? Nigdy nie usuwaj outlierów automatycznie. Zawsze najpierw sprawdź, czy to prawdziwe obserwacje (bogaty klient), czy błędy (ujemne wynagrodzenie). To naprawdę ważna różnica.
Standaryzacja tekstu — cichy zabójca jakości danych
Dane tekstowe to chyba najbardziej niedoceniany problem w czyszczeniu danych. Ludzie wpisują co chcą, jak chcą — z literówkami, niespójną wielkością liter, dodatkowymi spacjami... Poniżej kilka sprawdzonych technik.
Podstawowa standaryzacja stringów
import pandas as pd
df = pd.DataFrame({
"imie": [" Anna Kowalska ", "bartek NOWAK", "CELINA Wiśniewska",
"dawid Zieliński", "ewa kowalska"],
"telefon": ["500-100-200", "(500) 100 200", "500 100 200",
"+48500100200", "500100200"],
"kod_pocztowy": ["00-001", "00001", "00 001", "00-001 ", " 00-001"],
"email": [" [email protected] ", "[email protected]", "[email protected] ",
"[email protected]", "[email protected]"]
})
# Standaryzacja imion — tytuł case po strippowaniu
df["imie_clean"] = (
df["imie"]
.str.strip()
.str.replace(r"\s+", " ", regex=True) # wielokrotne spacje na jedną
.str.title()
)
# Standaryzacja emaili
df["email_clean"] = (
df["email"]
.str.strip()
.str.lower()
)
# Standaryzacja telefonów — zostawiamy same cyfry
df["telefon_clean"] = (
df["telefon"]
.str.replace(r"[^\d]", "", regex=True) # usunięcie wszystkiego poza cyframi
.str.replace(r"^48", "", regex=True) # usunięcie prefiksu krajowego
)
# Standaryzacja kodów pocztowych
df["kod_clean"] = (
df["kod_pocztowy"]
.str.strip()
.str.replace(r"[^\d]", "", regex=True)
.str.replace(r"^(\d{2})(\d{3})$", r"\1-\2", regex=True)
)
print(df[["imie", "imie_clean"]])
print()
print(df[["telefon", "telefon_clean"]])
print()
print(df[["kod_pocztowy", "kod_clean"]])
Walidacja danych z wyrażeniami regularnymi
Po standaryzacji warto sprawdzić, czy dane mają prawidłowy format. Regex to tutaj nieocenione narzędzie (nawet jeśli nie każdy je kocha).
import pandas as pd
df = pd.DataFrame({
"email": ["[email protected]", "bartek@", "celina@mail", "[email protected]", "nie-email"],
"pesel": ["90010112345", "123", "85062312345", "abc", "95030198765"],
"nip": ["1234567890", "123-456-78-90", "abc", "9876543210", "123"]
})
# Walidacja emaili
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
df["email_valid"] = df["email"].str.match(email_pattern)
# Walidacja PESEL (11 cyfr)
df["pesel_valid"] = df["pesel"].str.match(r"^\d{11}$")
# Walidacja NIP (10 cyfr, z myślnikami lub bez)
df["nip_clean"] = df["nip"].str.replace(r"[^\d]", "", regex=True)
df["nip_valid"] = df["nip_clean"].str.match(r"^\d{10}$")
print(df[["email", "email_valid"]])
print()
print(df[["pesel", "pesel_valid"]])
Przy walidacji emaili powyższy regex jest uproszczony — w produkcji warto użyć dedykowanej biblioteki (np. email-validator). Ale na potrzeby czyszczenia danych to wystarcza w większości przypadków.
Typowe konwersje i czyszczenie typów danych
Kolejny częsty problem: kolumny, które powinny być liczbami, a są stringami. Albo daty, które pandas zinterpretował jako tekst. Pandas 3.0 z ArrowDtype radzi sobie z tym lepiej, ale i tak warto znać te techniki.
import pandas as pd
import numpy as np
df = pd.DataFrame({
"cena": ["29.99", "zł 15.50", "45,00", "N/A", "100.00"],
"data": ["2026-01-15", "15/01/2026", "Jan 15, 2026", "2026.01.15", None],
"ilosc": ["10", "20szt", "30", "brak", "50"],
"aktywny": ["tak", "nie", "1", "True", "0"]
})
# Czyszczenie cen — usunięcie tekstu, zamiana przecinka na kropkę
df["cena_clean"] = (
df["cena"]
.str.replace(r"[^\d.,]", "", regex=True) # zostaw tylko cyfry, kropki, przecinki
.str.replace(",", ".") # polskie przecinki na kropki
.replace("", pd.NA) # puste stringi na NA
)
df["cena_clean"] = pd.to_numeric(df["cena_clean"], errors="coerce")
# Czyszczenie dat — pandas jest całkiem sprytny z parsowaniem
df["data_clean"] = pd.to_datetime(df["data"], format="mixed", dayfirst=False)
# Czyszczenie ilości
df["ilosc_clean"] = pd.to_numeric(
df["ilosc"].str.replace(r"[^\d]", "", regex=True).replace("", pd.NA),
errors="coerce"
)
# Czyszczenie booli
bool_map = {"tak": True, "nie": False, "1": True, "0": False,
"true": True, "false": False}
df["aktywny_clean"] = df["aktywny"].str.lower().map(bool_map)
print(df[["cena", "cena_clean"]])
print()
print(df[["data", "data_clean"]])
print()
print(df[["aktywny", "aktywny_clean"]])
Zwróć uwagę na errors="coerce" w pd.to_numeric() — zamiast rzucać wyjątek przy nieprawidłowych wartościach, wstawia NaN. To pozwala przetworzyć cały DataFrame, a potem zająć się problematycznymi wierszami osobno.
Method chaining — czyste potoki przetwarzania danych
Okej, to jest moja ulubiona część. Method chaining (łańcuchowanie metod) to styl pisania kodu w pandas, który sprawia, że Twoje transformacje są czytelne, łatwe w utrzymaniu i — co ważne — łatwe do debugowania.
Zamiast tworzyć dziesiątki zmiennych pośrednich, łączysz operacje w jeden płynny potok:
import pandas as pd
import numpy as np
# Przykładowe surowe dane
raw_df = pd.DataFrame({
"Imię i Nazwisko": [" anna kowalska ", "BARTEK NOWAK", "celina Wiśniewska",
"anna kowalska", "Dawid Zieliński", "bartek nowak"],
"Email": [" [email protected]", "[email protected] ", "[email protected]",
"[email protected]", "[email protected]", "[email protected]"],
"Wiek": [28, 150, 35, 28, -5, 42],
"Wynagrodzenie": [5500, 7200, None, 5500, 4800, None],
"Data rejestracji": ["2026-01-15", "2025-13-01", "2026-02-20",
"2026-01-15", "2026-03-10", "2026-01-05"]
})
# Czysty potok czyszczenia danych
cleaned_df = (
raw_df
.rename(columns=lambda c: c.lower().replace(" ", "_").replace("ę", "e")
.replace("ś", "s").replace("ź", "z"))
.assign(
imie_i_nazwisko=lambda df: df["imie_i_nazwisko"].str.strip().str.title(),
email=lambda df: df["email"].str.strip().str.lower(),
data_rejestracji=lambda df: pd.to_datetime(
df["data_rejestracji"], errors="coerce"
),
)
# Usunięcie nierealistycznych wartości wieku
.assign(wiek=lambda df: df["wiek"].where(df["wiek"].between(16, 120)))
# Uzupełnienie braków wynagrodzenia medianą
.assign(wynagrodzenie=lambda df: df["wynagrodzenie"].fillna(
df["wynagrodzenie"].median()
))
# Deduplikacja po emailu
.drop_duplicates(subset=["email"], keep="first")
# Reset indeksu
.reset_index(drop=True)
)
print(cleaned_df)
Widzisz, jak każdy krok jest jasno oddzielony i opisowy? Łatwo dodać nowy krok albo usunąć istniejący bez wpływu na resztę potoku. To jest właśnie siła method chainingu.
Funkcja pipe() — potoki sterydach
Metoda pipe() to game changer, jeśli chodzi o budowanie modularnych potoków czyszczenia danych. Pozwala na przekazywanie DataFrame przez serię funkcji, z których każda odpowiada za konkretny aspekt czyszczenia.
import pandas as pd
import numpy as np
def standaryzuj_tekst(df, kolumny):
"""Standaryzuje kolumny tekstowe — strip, lower/title."""
df = df.copy()
for kol in kolumny:
if kol in df.columns:
df[kol] = df[kol].str.strip()
# Emaile — lower, imiona — title
if "email" in kol.lower():
df[kol] = df[kol].str.lower()
elif "imi" in kol.lower() or "nazw" in kol.lower():
df[kol] = df[kol].str.title()
return df
def usun_outliery(df, kolumna, metoda="iqr", mnoznik=1.5):
"""Zastępuje outliery wartością NaN."""
df = df.copy()
if metoda == "iqr":
Q1, Q3 = df[kolumna].quantile([0.25, 0.75])
IQR = Q3 - Q1
maska = (df[kolumna] < Q1 - mnoznik * IQR) | (df[kolumna] > Q3 + mnoznik * IQR)
df.loc[maska, kolumna] = np.nan
return df
def imputuj_braki(df, strategia="median"):
"""Uzupełnia braki w kolumnach numerycznych."""
df = df.copy()
numeryczne = df.select_dtypes(include="number").columns
for kol in numeryczne:
if strategia == "median":
df[kol] = df[kol].fillna(df[kol].median())
elif strategia == "mean":
df[kol] = df[kol].fillna(df[kol].mean())
return df
def deduplikuj(df, klucz):
"""Usuwa duplikaty na podstawie klucza."""
return df.drop_duplicates(subset=[klucz], keep="first").reset_index(drop=True)
# Przykładowe dane
df = pd.DataFrame({
"imie": [" Anna ", "BARTEK", "celina", "anna", "dawid"],
"email": [" [email protected]", "[email protected] ", "[email protected]",
"[email protected]", "[email protected]"],
"wynagrodzenie": [5500, 50000, 6200, 5500, 4800],
"wiek": [28, 35, 42, 28, 31]
})
# Elegancki potok z pipe()
wynik = (
df
.pipe(standaryzuj_tekst, kolumny=["imie", "email"])
.pipe(usun_outliery, kolumna="wynagrodzenie")
.pipe(imputuj_braki, strategia="median")
.pipe(deduplikuj, klucz="email")
)
print(wynik)
Każda funkcja robi jedną rzecz i robi ją dobrze. Możesz je testować niezależnie, łączyć w różnych kombinacjach i łatwo dodawać nowe kroki. To jest podejście, które sprawdza się w produkcji — a nie tylko w notebookach.
Budowanie produkcyjnego potoku czyszczenia danych
Okej, pokażmy teraz pełny przykład — jak połączyć wszystkie te techniki w jeden spójny potok, który możesz użyć w prawdziwym projekcie.
import pandas as pd
import numpy as np
from dataclasses import dataclass, field
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@dataclass
class RaportCzyszczenia:
"""Zbiera statystyki procesu czyszczenia danych."""
poczatkowa_liczba_wierszy: int = 0
koncowa_liczba_wierszy: int = 0
usuniete_duplikaty: int = 0
uzupelnione_braki: dict = field(default_factory=dict)
wykryte_outliery: dict = field(default_factory=dict)
def podsumowanie(self):
print(f"\n{'='*50}")
print(f"RAPORT CZYSZCZENIA DANYCH")
print(f"{'='*50}")
print(f"Wiersze: {self.poczatkowa_liczba_wierszy} → {self.koncowa_liczba_wierszy}")
print(f"Usunięte duplikaty: {self.usuniete_duplikaty}")
print(f"Uzupełnione braki: {self.uzupelnione_braki}")
print(f"Wykryte outliery: {self.wykryte_outliery}")
print(f"{'='*50}\n")
class PotokCzyszczenia:
"""Produkcyjny potok czyszczenia danych."""
def __init__(self):
self.raport = RaportCzyszczenia()
self._kroki = []
def dodaj_krok(self, nazwa, funkcja):
"""Dodaje krok do potoku."""
self._kroki.append((nazwa, funkcja))
return self # method chaining
def wykonaj(self, df):
"""Wykonuje wszystkie kroki potoku."""
self.raport.poczatkowa_liczba_wierszy = len(df)
wynik = df.copy()
for nazwa, funkcja in self._kroki:
logger.info(f"Wykonuję krok: {nazwa}")
przed = len(wynik)
wynik = funkcja(wynik)
po = len(wynik)
if przed != po:
logger.info(f" Zmiana wierszy: {przed} → {po}")
self.raport.koncowa_liczba_wierszy = len(wynik)
return wynik
def krok_standaryzacja_tekstu(df):
"""Standaryzuje kolumny tekstowe."""
df = df.copy()
text_cols = df.select_dtypes(include=["string", "object"]).columns
for col in text_cols:
df[col] = df[col].str.strip()
if "email" in col.lower():
df[col] = df[col].str.lower()
elif any(kw in col.lower() for kw in ["imi", "nazw", "miasto"]):
df[col] = (
df[col]
.str.replace(r"\s+", " ", regex=True)
.str.title()
)
return df
def krok_walidacja_zakresu(df, reguly):
"""Zastępuje wartości spoza dozwolonego zakresu wartością NaN."""
df = df.copy()
for kolumna, (minimum, maksimum) in reguly.items():
if kolumna in df.columns:
maska = ~df[kolumna].between(minimum, maksimum)
df.loc[maska, kolumna] = np.nan
return df
def krok_imputacja(df, strategia_per_kolumna=None):
"""Uzupełnia braki różnymi strategiami."""
df = df.copy()
if strategia_per_kolumna is None:
strategia_per_kolumna = {}
numeryczne = df.select_dtypes(include="number").columns
for kol in numeryczne:
n_brakow = df[kol].isna().sum()
if n_brakow > 0:
strategia = strategia_per_kolumna.get(kol, "median")
if strategia == "median":
df[kol] = df[kol].fillna(df[kol].median())
elif strategia == "mean":
df[kol] = df[kol].fillna(df[kol].mean())
elif strategia == "zero":
df[kol] = df[kol].fillna(0)
return df
# Pełny przykład użycia
np.random.seed(42)
surowe_dane = pd.DataFrame({
"imie": [" Anna Kowalska ", "bartek NOWAK", " celina wiśniewska",
"anna kowalska", "DAWID Zieliński", " bartek nowak "],
"email": [" [email protected] ", "[email protected]", " [email protected]",
"[email protected]", "[email protected]", "[email protected] "],
"wiek": [28, 35, 200, 28, -5, 35],
"wynagrodzenie": [5500, 7200, 6300, 5500, None, 7200],
"ocena_klienta": [4.5, None, 3.8, 4.5, 4.2, None]
})
# Budowanie potoku
potok = PotokCzyszczenia()
potok.dodaj_krok("standaryzacja_tekstu", krok_standaryzacja_tekstu)
potok.dodaj_krok("walidacja_zakresu", lambda df: krok_walidacja_zakresu(
df, reguly={"wiek": (16, 120), "ocena_klienta": (1.0, 5.0)}
))
potok.dodaj_krok("imputacja", lambda df: krok_imputacja(
df, strategia_per_kolumna={"wiek": "median", "wynagrodzenie": "median"}
))
potok.dodaj_krok("deduplikacja", lambda df: df.drop_duplicates(
subset=["email"], keep="first"
).reset_index(drop=True))
# Wykonanie
czyste_dane = potok.wykonaj(surowe_dane)
potok.raport.podsumowanie()
print(czyste_dane)
Ten wzorzec ma kilka fajnych właściwości: łatwo dodajesz i usuwasz kroki, masz raport z procesu czyszczenia, a każdy krok jest niezależną funkcją, którą możesz testować osobno. W prawdziwym projekcie pewnie dodałbyś jeszcze logowanie do pliku i obsługę wyjątków — ale szkielet jest solidny.
Praktyczne wzorce i najlepsze praktyki na 2026 rok
Wzorzec 1: Validate-Clean-Verify
To wzorzec, który stosuję w prawie każdym projekcie. Idea jest prosta: najpierw sprawdź stan danych, potem wyczyść, potem zweryfikuj, że czyszczenie zadziałało prawidłowo.
import pandas as pd
import numpy as np
def validate_clean_verify(df, nazwa="dataset"):
"""Wzorzec Validate-Clean-Verify."""
# === VALIDATE ===
print(f"[VALIDATE] {nazwa}")
print(f" Wiersze: {len(df)}, Kolumny: {len(df.columns)}")
braki = df.isna().sum()
braki_istotne = braki[braki > 0]
if len(braki_istotne) > 0:
print(f" Braki: {dict(braki_istotne)}")
duplikaty = df.duplicated().sum()
print(f" Duplikaty: {duplikaty}")
# === CLEAN ===
print(f"\n[CLEAN] Czyszczenie...")
df_clean = (
df
.drop_duplicates()
.assign(**{
col: df[col].fillna(df[col].median())
for col in df.select_dtypes(include="number").columns
if df[col].isna().any()
})
)
# === VERIFY ===
print(f"\n[VERIFY] Po czyszczeniu")
print(f" Wiersze: {len(df_clean)}")
braki_po = df_clean.isna().sum()
braki_po_istotne = braki_po[braki_po > 0]
print(f" Pozostałe braki: {dict(braki_po_istotne) if len(braki_po_istotne) > 0 else 'brak'}")
print(f" Duplikaty: {df_clean.duplicated().sum()}")
return df_clean
Wzorzec 2: Schema enforcement
W środowisku produkcyjnym warto zdefiniować oczekiwany schemat danych i walidować przychodzące dane pod jego kątem. To chroni przed cichymi błędami, które mogą propagować się przez cały pipeline.
import pandas as pd
import numpy as np
SCHEMAT = {
"imie": {"typ": "string", "nullable": False, "max_length": 100},
"email": {"typ": "string", "nullable": False, "pattern": r".+@.+\..+"},
"wiek": {"typ": "int", "nullable": True, "min": 0, "max": 150},
"wynagrodzenie": {"typ": "float", "nullable": True, "min": 0},
}
def waliduj_schemat(df, schemat):
"""Waliduje DataFrame pod kątem schematu."""
bledy = []
for kolumna, reguly in schemat.items():
if kolumna not in df.columns:
bledy.append(f"Brak kolumny: {kolumna}")
continue
# Sprawdzenie nullable
if not reguly.get("nullable", True) and df[kolumna].isna().any():
n = df[kolumna].isna().sum()
bledy.append(f"{kolumna}: {n} brakujących wartości (nullable=False)")
# Sprawdzenie zakresu
if "min" in reguly:
ponizej = (df[kolumna].dropna() < reguly["min"]).sum()
if ponizej > 0:
bledy.append(f"{kolumna}: {ponizej} wartości poniżej minimum ({reguly['min']})")
if "max" in reguly:
powyzej = (df[kolumna].dropna() > reguly["max"]).sum()
if powyzej > 0:
bledy.append(f"{kolumna}: {powyzej} wartości powyżej maksimum ({reguly['max']})")
# Sprawdzenie wzorca regex
if "pattern" in reguly and df[kolumna].dtype == "object":
nie_pasuje = (~df[kolumna].dropna().str.match(reguly["pattern"])).sum()
if nie_pasuje > 0:
bledy.append(f"{kolumna}: {nie_pasuje} wartości nie pasuje do wzorca")
return bledy
# Przykład użycia
df = pd.DataFrame({
"imie": ["Anna", None, "Celina"],
"email": ["[email protected]", "zly-email", "[email protected]"],
"wiek": [28, 200, 35],
"wynagrodzenie": [5500.0, -100.0, 6200.0]
})
bledy = waliduj_schemat(df, SCHEMAT)
for blad in bledy:
print(f"BŁĄD: {blad}")
Wzorzec 3: Logowanie zmian
W produkcji chcesz wiedzieć dokładnie, co Twój potok zmienił. Prosty dekorator może tu zdziałać cuda:
import pandas as pd
import functools
import logging
logger = logging.getLogger(__name__)
def loguj_zmiany(nazwa_kroku):
"""Dekorator logujący zmiany w DataFrame."""
def dekorator(func):
@functools.wraps(func)
def wrapper(df, *args, **kwargs):
n_przed = len(df)
braki_przed = df.isna().sum().sum()
wynik = func(df, *args, **kwargs)
n_po = len(wynik)
braki_po = wynik.isna().sum().sum()
logger.info(
f"[{nazwa_kroku}] "
f"Wiersze: {n_przed}→{n_po} ({n_po - n_przed:+d}), "
f"Braki: {braki_przed}→{braki_po} ({braki_po - braki_przed:+d})"
)
return wynik
return wrapper
return dekorator
@loguj_zmiany("deduplikacja")
def deduplikuj_emaile(df):
return df.drop_duplicates(subset=["email"], keep="first")
@loguj_zmiany("imputacja")
def uzupelnij_braki(df):
numeryczne = df.select_dtypes(include="number").columns
for kol in numeryczne:
df[kol] = df[kol].fillna(df[kol].median())
return df
Wydajność — czyszczenie dużych zbiorów danych
Przy małych zbiorach danych (kilka tysięcy wierszy) możesz sobie pozwolić na wszystko. Ale gdy masz miliony rekordów, wydajność zaczyna mieć znaczenie. Kilka wskazówek:
import pandas as pd
import numpy as np
# 1. Używaj PyArrow backendu — domyślny w pandas 3.0
df = pd.read_csv("duzy_plik.csv", engine="pyarrow", dtype_backend="pyarrow")
# 2. Czytaj tylko potrzebne kolumny
df = pd.read_csv("duzy_plik.csv", usecols=["id", "email", "kwota"])
# 3. Używaj kategorycznych typów danych dla kolumn z niską kardynalnością
df["miasto"] = df["miasto"].astype("category")
df["status"] = df["status"].astype("category")
# 4. Przetwarzanie w chunkach dla bardzo dużych plików
def czysc_chunk(chunk):
return (
chunk
.assign(email=lambda df: df["email"].str.strip().str.lower())
.drop_duplicates(subset=["email"])
)
wyniki = []
for chunk in pd.read_csv("ogromny_plik.csv", chunksize=100_000):
wyniki.append(czysc_chunk(chunk))
df_clean = pd.concat(wyniki, ignore_index=True)
# Uwaga: deduplikacja między chunkami wymaga dodatkowego przebiegu
df_clean = df_clean.drop_duplicates(subset=["email"])
I jeszcze jedno — jeśli Twoje dane naprawdę są gigantyczne (setki gigabajtów), pandas może nie być najlepszym narzędziem. Rozważ wtedy Polars lub DuckDB, które radzą sobie z dużymi zbiorami znacznie lepiej dzięki leniwej ewaluacji i wielowątkowości. Ale to temat na osobny artykuł.
Typowe pułapki i jak ich unikać
Na koniec — lista rzeczy, które najczęściej gryzą ludzi pracujących z czyszczeniem danych w pandas. Zbierałem te obserwacje przez lata i wciąż się zdziwiam, jak łatwo jest wpaść w te same pułapki.
- SettingWithCopyWarning — w pandas 3.0 z Copy-on-Write to już (na szczęście) przeszłość. Ale jeśli pracujesz ze starszą wersją, zawsze używaj
.loc[]do modyfikacji. - Porównywanie z
NaN—NaN != NaNtoTrue. Zawsze używaj.isna()do sprawdzania braków, nigdy== np.nan. - Typy danych po imputacji — uzupełnienie braków w kolumnie
intmoże zmienić ją nafloat. W pandas 3.0 z ArrowDtype ten problem jest mniejszy, ale warto być świadomym. - Kolejność operacji — deduplikacja po czy przed standaryzacją? Zawsze standaryzuj najpierw! Inaczej „Anna" i „anna" będą traktowane jak różne wartości.
- Wyciek danych (data leakage) — jeśli uzupełniasz braki średnią z całego zbioru (włącznie z danymi testowymi), masz problem. Przy ML zawsze imputuj wyłącznie na podstawie danych treningowych.
- Ciche konwersje typów —
pd.to_numeric(errors="coerce")zamieni nieprawidłowe wartości naNaNbez ostrzeżenia. To wygodne, ale sprawdź, ile wartości faktycznie straciłeś.
Podsumowanie
Czyszczenie danych to nie jest „nudna" część data science — to fundament, na którym wszystko inne się opiera. Brudne dane oznaczają błędne modele, błędne raporty i błędne decyzje biznesowe. Tyle i aż tyle.
Pandas 3.0 z PyArrow backendem, Copy-on-Write i nowymi typami danych naprawdę ułatwia tę pracę. W połączeniu z method chainingiem i pipe() możesz budować czyste, modularne i łatwe w utrzymaniu potoki czyszczenia danych.
Oto kluczowe zasady, które warto zapamiętać:
- Najpierw diagnozuj — zrozum wzorzec braków i charakter problemu zanim zaczniesz czyścić.
- Standaryzuj przed deduplikacją — kolejność operacji ma znaczenie.
- Używaj method chainingu — Twój kod będzie czytelniejszy i łatwiejszy w utrzymaniu.
- Buduj modularne potoki z
pipe()— każda funkcja robi jedną rzecz dobrze. - Loguj i raportuj — w produkcji musisz wiedzieć, co Twój potok zmienił.
- Waliduj schemat — to chroni przed cichymi błędami propagującymi się dalej.
- Nie usuwaj outlierów automatycznie — najpierw zrozum, co one oznaczają.
Mam nadzieję, że ten przewodnik będzie dla Ciebie przydatny w codziennej pracy z danymi. A jeśli masz swoje sprawdzone triki na czyszczenie danych w pandas — podziel się nimi w komentarzach!