Čišćenje podataka s pandas bibliotekom: Praktični vodič za Python programere

Praktični vodič za čišćenje podataka s pandas bibliotekom u Pythonu. Od rukovanja NaN vrijednostima i duplikatima do otkrivanja outliera, transformacije tipova i izgradnje automatiziranih pipeline-a. Uključuje novosti iz pandas 2.x i 3.0.

Čišćenje podataka s pandas bibliotekom: Praktični vodič za Python programere

Ruku na srce — čišćenje podataka nije najuzbudljiviji dio data science projekta. Ali jest jedan od najvažnijih. Možete imati najnapredniji model strojnog učenja na svijetu, no ako su mu ulazni podatci neuredni, rezultati će biti jednako kaotični. U ovom vodiču prolazimo kroz sve ključne tehnike čišćenja podataka koristeći pandas, biblioteku koja je praktički postala standard za rad s podatcima u Pythonu.

Zašto je čišćenje podataka toliko bitno?

Vjerovali ili ne, podatkovni znanstvenici provode između 60% i 80% svog radnog vremena na pripremi i čišćenju podataka. Podatak koji su potvrdili i CrowdFlower i Forbes. To vam dovoljno govori o važnosti ovog koraka.

Prljavi podatci mogu izgledati ovako:

  • Nedostajuće vrijednosti — prazna polja koja uzrokuju pogreške u izračunima
  • Duplikati — ponovljeni zapisi koji iskrivljuju statističke analize
  • Nekonzistentni formati — različiti formati datuma, valuta ili teksta
  • Outlieri — ekstremne vrijednosti koje mogu značajno utjecati na rezultate
  • Pogrešni tipovi podataka — brojevi pohranjeni kao tekst, datumi kao stringovi
  • Tipografske pogreške — krivo uneseni podatci koji stvaraju lažne kategorije

Kvalitetno čišćenje osigurava pouzdanost analiza, točnost prediktivnih modela i ispravnost poslovnih odluka. Ignoriranje ovog koraka vodi ravno u "garbage in, garbage out" — loši podatci na ulazu, loši rezultati na izlazu. Jednostavno tako.

Postavljanje radnog okruženja

Prije nego krenemo, trebamo postaviti okruženje. Preporučujem korištenje virtualnog okruženja — nitko ne voli konflikte među paketima.

Instalacija potrebnih biblioteka

# Instalacija pandas i pomoćnih biblioteka
pip install pandas numpy scipy pyarrow

Uvoz biblioteka i učitavanje podataka

import pandas as pd
import numpy as np
from scipy import stats

# Prikaz verzije pandasa
print(f"Verzija pandasa: {pd.__version__}")

# Učitavanje podataka iz različitih izvora
df_csv = pd.read_csv("podatci.csv")
df_excel = pd.read_excel("podatci.xlsx", sheet_name="List1")
df_json = pd.read_json("podatci.json")

# Učitavanje s dodatnim parametrima za bolju kontrolu
df = pd.read_csv(
    "prodaja.csv",
    sep=";",                    # separator stupaca
    encoding="utf-8",          # kodiranje znakova
    parse_dates=["datum"],     # automatsko parsiranje datuma
    na_values=["N/A", "n/a", "-", ""],  # vrijednosti koje tretiramo kao NaN
    dtype={"postanski_broj": str}       # eksplicitno definiranje tipa
)

Za ovaj vodič, napravit ćemo primjer DataFrame-a s tipičnim "greškama" iz stvarnog svijeta:

# Kreiranje primjera "prljavih" podataka
df = pd.DataFrame({
    "ime": ["Ana Kovač", "  Marko Horvat ", "Ivan Novak", "Ana Kovač",
            "Petra BABIĆ", "marko horvat", None, "Luka Jurić"],
    "dob": [28, 35, None, 28, 42, 35, 29, 155],
    "grad": ["Zagreb", "Split", "Rijeka", "Zagreb", "Osijek",
             "split", "Zagreb", "Dubrovnik"],
    "placa": ["5500", "6200", "5800", None, "7100", "6200",
              "4900", "abc"],
    "datum_zaposlenja": ["2020-01-15", "2019-06-01", "2021-03-20",
                         "2020-01-15", "2018-11-10", "2019-06-01",
                         "2022-07-05", "15/08/2023"],
    "odjel": ["IT", "Marketing", "IT", "IT", "Financije",
              "Marketing", "HR", "IT"],
    "ocjena": [4.5, 3.8, 4.2, 4.5, None, 3.8, 4.0, 4.7]
})

print(df)

Razumijevanje strukture podataka

Prije bilo kakvog čišćenja, morate razumjeti s čime radite. Zvuči očito, ali iznenadili biste se koliko ljudi preskoči ovaj korak. Pandas nudi odlične metode za brzu inspekciju.

Osnovni pregled podataka

# Dimenzije DataFrame-a (redci, stupci)
print(f"Oblik podataka: {df.shape}")
# Izlaz: Oblik podataka: (8, 7)

# Prvih i zadnjih 5 redaka
print(df.head())
print(df.tail())

# Detaljne informacije o stupcima i tipovima podataka
print(df.info())

# Izlaz:
# <class 'pandas.core.frame.DataFrame'>
# RangeIndex: 8 entries, 0 to 7
# Data columns (total 7 columns):
#  #   Column           Non-Null Count  Dtype
# ---  ------           --------------  -----
#  0   ime              7 non-null      object
#  1   dob              7 non-null      float64
#  2   grad             8 non-null      object
#  3   placa            7 non-null      object
#  4   datum_zaposlenja 8 non-null      object
#  5   odjel            8 non-null      object
#  6   ocjena           7 non-null      float64

Statistički pregled

# Deskriptivna statistika za numeričke stupce
print(df.describe())

# Deskriptivna statistika za sve stupce (uključujući kategoričke)
print(df.describe(include="all"))

# Provjera tipova podataka
print(df.dtypes)

# Broj jedinstvenih vrijednosti po stupcu
print(df.nunique())

# Distribucija vrijednosti za kategoričke stupce
print(df["odjel"].value_counts())
print(df["grad"].value_counts())

Već u ovoj fazi vidimo nekoliko problema: stupac placa je pohranjen kao object (tekst) umjesto broja, postoje nedostajuće vrijednosti u više stupaca, a vrijednost 155 u stupcu dob očito je outlier. Primjećujemo i da "Split" i "split" pandas tretira kao različite kategorije — klasičan problem s velikim i malim slovima.

Vizualizacija nedostajućih vrijednosti

# Postotak nedostajućih vrijednosti po stupcu
nedostajuce = df.isnull().sum()
postotak = (df.isnull().sum() / len(df)) * 100

pregled_nedostajucih = pd.DataFrame({
    "Nedostajuće": nedostajuce,
    "Postotak (%)": postotak.round(2)
})
print(pregled_nedostajucih[pregled_nedostajucih["Nedostajuće"] > 0])

Rukovanje nedostajućim vrijednostima

Ovo je, iskreno, jedan od najčešćih problema. Pandas nedostajuće vrijednosti prikazuje kao NaN (Not a Number) ili NaT (Not a Time) za vremenske podatke. Kako ćete ih riješiti — to ovisi o kontekstu.

Detekcija nedostajućih vrijednosti

# Provjera nedostajućih vrijednosti
print(df.isnull())          # Boolean maska za cijeli DataFrame
print(df.isnull().sum())    # Broj nedostajućih po stupcu
print(df.isnull().any())    # Ima li stupac ijednu nedostajuću vrijednost

# Provjera koja su polja popunjena
print(df.notnull().sum())

# Redci koji imaju barem jednu nedostajuću vrijednost
redci_s_nan = df[df.isnull().any(axis=1)]
print(f"Broj redaka s nedostajućim vrijednostima: {len(redci_s_nan)}")
print(redci_s_nan)

Uklanjanje redaka i stupaca s nedostajućim vrijednostima

# Uklanjanje svih redaka koji imaju barem jednu NaN vrijednost
df_ociscen = df.dropna()

# Uklanjanje redaka gdje su SVE vrijednosti NaN
df_ociscen = df.dropna(how="all")

# Uklanjanje redaka koji imaju NaN u specifičnim stupcima
df_ociscen = df.dropna(subset=["ime", "dob"])

# Uklanjanje redaka koji imaju više od 2 nedostajuće vrijednosti
df_ociscen = df.dropna(thresh=len(df.columns) - 2)

# Uklanjanje stupaca s previše nedostajućih vrijednosti (npr. više od 50%)
prag = len(df) * 0.5
df_ociscen = df.dropna(axis=1, thresh=prag)

Popunjavanje nedostajućih vrijednosti

# Popunjavanje konstantnom vrijednošću
df["ime"] = df["ime"].fillna("Nepoznato")

# Popunjavanje srednjom vrijednošću (za numeričke stupce)
df["dob"] = df["dob"].fillna(df["dob"].median())

# Popunjavanje modom (najčešćom vrijednošću) za kategoričke stupce
df["odjel"] = df["odjel"].fillna(df["odjel"].mode()[0])

# Popunjavanje po grupama — prosječna plaća po odjelu
df["placa_num"] = pd.to_numeric(df["placa"], errors="coerce")
df["placa_num"] = df.groupby("odjel")["placa_num"].transform(
    lambda x: x.fillna(x.mean())
)

Napredne metode popunjavanja

# Kreiranje vremenskog niza za demonstraciju
ts = pd.DataFrame({
    "datum": pd.date_range("2024-01-01", periods=10, freq="D"),
    "temperatura": [15.2, None, 16.8, None, None, 18.5, 19.1, None, 20.3, 21.0],
    "vlaznost": [65, 68, None, 72, None, 60, 58, 55, None, 52]
})

# Forward fill — popunjava NaN prethodnom vrijednošću
ts_ffill = ts.ffill()

# Backward fill — popunjava NaN sljedećom vrijednošću
ts_bfill = ts.bfill()

# Linearna interpolacija — izvrsna za vremenske nizove
ts["temperatura"] = ts["temperatura"].interpolate(method="linear")

# Interpolacija s ograničenjem broja uzastopnih popunjavanja
ts["vlaznost"] = ts["vlaznost"].interpolate(method="linear", limit=1)

# Polinom interpolacija za složenije uzorke
ts["temperatura"] = ts["temperatura"].interpolate(method="polynomial", order=2)

print(ts)

Savjet iz prakse: Uvijek dokumentirajte koju strategiju ste odabrali za nedostajuće vrijednosti i zašto. Za financijske podatke često je bolje ukloniti cijeli redak, dok je za vremenske nizove interpolacija obično pravi izbor.

Uklanjanje duplikata

Duplikati se pojavljuju češće nego što biste očekivali — pogreške pri unosu, spajanje više izvora podataka, problemi s ETL procesima... Moramo ih pronaći i ukloniti.

Detekcija duplikata

# Provjera kompletnih duplikata (svi stupci identični)
print(df.duplicated())
print(f"Broj potpunih duplikata: {df.duplicated().sum()}")

# Prikaz dupliciranih redaka
duplikati = df[df.duplicated(keep=False)]
print("Duplicirani redci:")
print(duplikati)

# Provjera duplikata na temelju specifičnih stupaca
duplikati_ime = df.duplicated(subset=["ime", "dob"], keep=False)
print(f"Duplikati po imenu i dobi: {duplikati_ime.sum()}")

# Parametar 'keep':
# keep='first' — označava sve osim prvog pojavljivanja (zadano)
# keep='last'  — označava sve osim zadnjeg pojavljivanja
# keep=False   — označava sva pojavljivanja

Uklanjanje duplikata

# Uklanjanje potpunih duplikata, zadržavanje prvog pojavljivanja
df_bez_dup = df.drop_duplicates()
print(f"Prije: {len(df)} redaka, Poslije: {len(df_bez_dup)} redaka")

# Uklanjanje duplikata na temelju podskupa stupaca
df_bez_dup = df.drop_duplicates(subset=["ime", "datum_zaposlenja"], keep="first")

# Uklanjanje duplikata zadržavajući zadnje pojavljivanje
df_bez_dup = df.drop_duplicates(subset=["ime"], keep="last")

# Resetiranje indeksa nakon uklanjanja duplikata
df_bez_dup = df_bez_dup.reset_index(drop=True)

Napredna detekcija — približni duplikati

Ponekad duplikati nisu identični, ali su gotovo isti. "Marko Horvat" i "marko horvat" — ista osoba, ali pandas ih vidi kao dva različita zapisa. Trik je u normalizaciji prije provjere.

# Normalizacija prije provjere duplikata
df_norm = df.copy()
df_norm["ime_norm"] = df_norm["ime"].str.strip().str.lower()
df_norm["grad_norm"] = df_norm["grad"].str.strip().str.lower()

# Sada provjera duplikata na normaliziranim stupcima
duplikati_norm = df_norm.duplicated(subset=["ime_norm", "grad_norm"], keep=False)
print("Približni duplikati nakon normalizacije:")
print(df_norm[duplikati_norm][["ime", "ime_norm", "grad", "grad_norm"]])

Transformacija tipova podataka

Ispravni tipovi podataka su zapravo važniji nego što se čini na prvi pogled. Osim što omogućuju točne izračune, pravilni tipovi mogu drastično smanjiti korištenje memorije. Česta situacija: numerički podatci učitani kao tekst, datumi zaglavljeni u string formatu.

Osnovna konverzija tipova

# Pretvorba teksta u numeričku vrijednost
df["placa"] = pd.to_numeric(df["placa"], errors="coerce")
# errors="coerce" pretvara neparsabilne vrijednosti u NaN

# Pretvorba u integer (zahtijeva odsutnost NaN u standardnom int,
# ili koristite nullable integer tip)
df["dob"] = df["dob"].astype("Int64")  # Nullable integer

# Pretvorba teksta u datum
df["datum_zaposlenja"] = pd.to_datetime(
    df["datum_zaposlenja"],
    format="mixed",     # automatsko prepoznavanje različitih formata
    dayfirst=False,
    errors="coerce"
)

# Eksplicitna konverzija s astype
df["odjel"] = df["odjel"].astype("category")

# Provjera novih tipova
print(df.dtypes)

Rad s kategoričkim podatcima

# Konverzija u kategorički tip — značajno smanjuje memoriju
df["grad"] = pd.Categorical(df["grad"])
df["odjel"] = pd.Categorical(df["odjel"])

# Usporedba korištenja memorije
print(f"Memorija prije: {df.memory_usage(deep=True).sum() / 1024:.2f} KB")

# Kategorički tip s definiranim redoslijedom (ordinal)
ocjene_kategorije = pd.CategoricalDtype(
    categories=["Loše", "Dovoljno", "Dobro", "Vrlo dobro", "Izvrsno"],
    ordered=True
)

df["ocjena_tekst"] = pd.Categorical(
    ["Vrlo dobro", "Dobro", "Vrlo dobro", "Vrlo dobro",
     "Izvrsno", "Dobro", "Dobro", "Izvrsno"],
    dtype=ocjene_kategorije
)

# Sada možemo raditi usporedbe
print(df[df["ocjena_tekst"] > "Dobro"])

Konverzija boolean i rad s nullable tipovima

# Pandas nullable tipovi — bolja podrška za nedostajuće vrijednosti
df["aktivan"] = pd.array([True, True, False, True, None, True, False, True],
                          dtype=pd.BooleanDtype())

# Nullable integer — dozvoljavaju NaN bez pretvorbe u float
df["godine_iskustva"] = pd.array([5, 8, 3, 5, None, 8, 2, 10],
                                   dtype=pd.Int64Dtype())

# String tip umjesto object — učinkovitiji za tekst
df["ime"] = df["ime"].astype(pd.StringDtype())

print(df.dtypes)

Čišćenje tekstualnih podataka

Tekstualni podatci su, po mom iskustvu, uvijek najproblematičniji. Svaki korisnik unosi podatke na svoj način, i tu nastaje kaos. Srećom, pandas ima moćan str accessor koji nam omogućuje primjenu string metoda na cijele stupce odjednom.

Osnovno čišćenje teksta

# Uklanjanje razmaka na početku i kraju
df["ime"] = df["ime"].str.strip()

# Pretvorba u mala/velika slova
df["grad"] = df["grad"].str.title()     # Prvo slovo veliko: "Split"
df["odjel"] = df["odjel"].str.upper()   # Sva velika: "IT"

# Standardizacija na mala slova za usporedbe
df["email_domena"] = df["ime"].str.lower().str.replace(" ", ".") + "@tvrtka.hr"

# Uklanjanje specifičnih znakova
df["ime"] = df["ime"].str.replace(r"[^\w\s]", "", regex=True)

# Zamjena vrijednosti
df["grad"] = df["grad"].str.replace("Zg", "Zagreb", regex=False)

Napredne string operacije

# Razdvajanje stupca na više stupaca
df[["ime_osobe", "prezime"]] = df["ime"].str.split(" ", n=1, expand=True)

# Izvlačenje podataka pomoću regularnih izraza
podatci_kontakt = pd.DataFrame({
    "kontakt": [
        "Tel: 091-234-5678, Email: [email protected]",
        "Tel: 098-765-4321, Email: [email protected]",
        "Tel: 095-111-2233",
        "Email: [email protected]"
    ]
})

# Izvlačenje telefonskog broja
podatci_kontakt["telefon"] = podatci_kontakt["kontakt"].str.extract(
    r"(\d{3}-\d{3}-\d{4})"
)

# Izvlačenje email adrese
podatci_kontakt["email"] = podatci_kontakt["kontakt"].str.extract(
    r"([\w.-]+@[\w.-]+\.\w+)"
)

print(podatci_kontakt)

# Provjera sadrži li tekst određeni uzorak
df["je_iz_zagreba"] = df["grad"].str.contains("Zagreb", case=False, na=False)

# Zamjena pomoću rječnika — za standardizaciju kategorija
mapiranje_gradova = {
    "Zg": "Zagreb",
    "St": "Split",
    "Ri": "Rijeka",
    "Os": "Osijek"
}
df["grad"] = df["grad"].replace(mapiranje_gradova)

Čišćenje s regularnim izrazima

# Uklanjanje svih ne-alfanumeričkih znakova osim razmaka
df["ime"] = df["ime"].str.replace(r"[^a-zA-ZčćžšđČĆŽŠĐ\s]", "", regex=True)

# Uklanjanje višestrukih razmaka
df["ime"] = df["ime"].str.replace(r"\s+", " ", regex=True).str.strip()

# Standardizacija formata telefonskih brojeva
telefoni = pd.Series(["091 234 5678", "091-234-5678", "0912345678", "+385912345678"])
telefoni_std = telefoni.str.replace(r"[\s\-\+]", "", regex=True)
telefoni_std = telefoni_std.str.replace(r"^385", "0", regex=True)
print(telefoni_std)

# Provjera valjanosti email adresa
emailovi = pd.Series(["[email protected]", "neispravan", "[email protected]", "a@b"])
valjan_email = emailovi.str.match(r"^[\w.-]+@[\w.-]+\.\w{2,}$")
print(valjan_email)

Otkrivanje i rukovanje outlierima

Outlieri — te "čudne" vrijednosti koje vam mogu potpuno pokvariti analizu. Dob od 155 godina? Plaća od -500 eura? Jasno je da nešto ne štima. Ali pozor: ne uklanjajte outliere automatski. Ponekad su legitimni.

IQR metoda (Interkvartilni raspon)

# IQR metoda — robustna na ne-normalne distribucije
def detektiraj_outliere_iqr(df, stupac, faktor=1.5):
    """Detektira outliere koristeći IQR metodu."""
    Q1 = df[stupac].quantile(0.25)
    Q3 = df[stupac].quantile(0.75)
    IQR = Q3 - Q1
    donja_granica = Q1 - faktor * IQR
    gornja_granica = Q3 + faktor * IQR

    outlieri = df[(df[stupac] < donja_granica) | (df[stupac] > gornja_granica)]

    print(f"Stupac: {stupac}")
    print(f"  Q1: {Q1}, Q3: {Q3}, IQR: {IQR}")
    print(f"  Granice: [{donja_granica:.2f}, {gornja_granica:.2f}]")
    print(f"  Broj outliera: {len(outlieri)}")

    return outlieri, donja_granica, gornja_granica

# Primjena na stupac 'dob'
outlieri_dob, donja, gornja = detektiraj_outliere_iqr(df, "dob")
print("Outlieri u stupcu 'dob':")
print(outlieri_dob)

Z-score metoda

# Z-score metoda — pretpostavlja normalnu distribuciju
def detektiraj_outliere_zscore(df, stupac, prag=3):
    """Detektira outliere koristeći Z-score metodu."""
    z_scores = np.abs(stats.zscore(df[stupac].dropna()))
    outlieri_maska = z_scores > prag

    # Moramo poravnati indekse
    indeksi_bez_nan = df[stupac].dropna().index
    outlieri_indeksi = indeksi_bez_nan[outlieri_maska]

    print(f"Stupac: {stupac}")
    print(f"  Prag Z-score: {prag}")
    print(f"  Broj outliera: {outlieri_maska.sum()}")

    return df.loc[outlieri_indeksi]

# Kreiranje većeg skupa podataka za demonstraciju
np.random.seed(42)
df_veliki = pd.DataFrame({
    "placa": np.concatenate([
        np.random.normal(6000, 1000, 95),  # normalni podatci
        np.array([15000, 20000, 25000, 1, 50000])  # outlieri
    ]),
    "godine": np.concatenate([
        np.random.randint(22, 65, 95),
        np.array([5, 99, 102, 15, 120])
    ])
})

outlieri_placa = detektiraj_outliere_zscore(df_veliki, "placa")
print(outlieri_placa)

Strategije za rukovanje outlierima

# 1. Uklanjanje outliera
df_bez_outliera = df_veliki[
    (df_veliki["placa"] >= donja) & (df_veliki["placa"] <= gornja)
]

# 2. Winzorizacija — ograničavanje na granične vrijednosti (capping)
def winzoriziraj(df, stupac, donji_percentil=0.05, gornji_percentil=0.95):
    """Ograničava ekstremne vrijednosti na zadane percentile."""
    donja = df[stupac].quantile(donji_percentil)
    gornja = df[stupac].quantile(gornji_percentil)
    df[stupac] = df[stupac].clip(lower=donja, upper=gornja)
    return df

df_veliki = winzoriziraj(df_veliki, "placa")

# 3. Zamjena medijanom
medijan_placa = df_veliki["placa"].median()
_, donja_gr, gornja_gr = detektiraj_outliere_iqr(df_veliki, "placa")
df_veliki.loc[
    (df_veliki["placa"] < donja_gr) | (df_veliki["placa"] > gornja_gr),
    "placa"
] = medijan_placa

# 4. Logaritamska transformacija — za desno iskrivljene distribucije
df_veliki["placa_log"] = np.log1p(df_veliki["placa"])

# 5. Označavanje outliera umjesto uklanjanja
df_veliki["je_outlier"] = (
    (df_veliki["placa"] < donja_gr) | (df_veliki["placa"] > gornja_gr)
)

print(f"Označeno outliera: {df_veliki['je_outlier'].sum()}")

Važno: Prije nego što uklonite outliere, uvijek provjerite jesu li te vrijednosti stvarno pogrešne ili su legitimni ekstremni slučajevi. Dob od 155 godina je očito pogreška, ali plaća od 15.000 EUR može biti sasvim normalna za višu menadžersku poziciju. Kontekst je sve.

Validacija i konzistentnost podataka

Nakon čišćenja pojedinačnih stupaca, dolazi korak koji mnogi preskoče — provjera konzistentnosti na razini cijelog skupa podataka. Rasponi vrijednosti, logičke provjere, međuovisnosti stupaca... sve to treba provjeriti.

Provjera raspona vrijednosti

# Definiranje pravila validacije
pravila_validacije = {
    "dob": {"min": 18, "max": 70},
    "placa": {"min": 3000, "max": 50000},
    "ocjena": {"min": 1.0, "max": 5.0}
}

def validiraj_raspon(df, pravila):
    """Provjerava jesu li vrijednosti unutar dozvoljenih raspona."""
    rezultati = {}
    for stupac, granice in pravila.items():
        if stupac in df.columns:
            izvan_raspona = df[
                (df[stupac] < granice["min"]) | (df[stupac] > granice["max"])
            ]
            rezultati[stupac] = {
                "broj_neispravnih": len(izvan_raspona),
                "indeksi": izvan_raspona.index.tolist()
            }
            if len(izvan_raspona) > 0:
                print(f"UPOZORENJE: {stupac} — {len(izvan_raspona)} "
                      f"vrijednosti izvan raspona [{granice['min']}, {granice['max']}]")
                print(izvan_raspona[[stupac]])
    return rezultati

rezultati = validiraj_raspon(df, pravila_validacije)

Međustupačna validacija

# Kreiranje podataka za demonstraciju međustupačne validacije
df_narudzbe = pd.DataFrame({
    "id_narudzbe": range(1, 8),
    "datum_narudzbe": pd.to_datetime([
        "2024-01-10", "2024-02-15", "2024-03-01",
        "2024-04-20", "2024-05-05", "2024-06-12", "2024-03-25"
    ]),
    "datum_isporuke": pd.to_datetime([
        "2024-01-15", "2024-02-10", "2024-03-05",
        "2024-04-25", "2024-05-10", "2024-06-18", "2024-03-20"
    ]),
    "kolicina": [5, 10, -3, 8, 0, 15, 7],
    "cijena_po_komadu": [100, 50, 200, 75, 150, 30, 80],
    "ukupna_cijena": [500, 500, -600, 600, 0, 450, 560]
})

# Provjera: datum isporuke mora biti nakon datuma narudžbe
neispravni_datumi = df_narudzbe[
    df_narudzbe["datum_isporuke"] < df_narudzbe["datum_narudzbe"]
]
print("Narudžbe s neispravnim datumima isporuke:")
print(neispravni_datumi[["id_narudzbe", "datum_narudzbe", "datum_isporuke"]])

# Provjera: količina mora biti pozitivna
neispravne_kolicine = df_narudzbe[df_narudzbe["kolicina"] <= 0]
print("\nNarudžbe s neispravnom količinom:")
print(neispravne_kolicine[["id_narudzbe", "kolicina"]])

# Provjera: ukupna cijena = količina * cijena po komadu
df_narudzbe["ocekivana_cijena"] = (
    df_narudzbe["kolicina"] * df_narudzbe["cijena_po_komadu"]
)
neuskladene = df_narudzbe[
    df_narudzbe["ukupna_cijena"] != df_narudzbe["ocekivana_cijena"]
]
print("\nNarudžbe s neusklađenom ukupnom cijenom:")
print(neuskladene[["id_narudzbe", "ukupna_cijena", "ocekivana_cijena"]])

Kreiranje izvještaja o kvaliteti podataka

Ovo je nešto što mi osobno jako koristim u praksi. Jedan brzi izvještaj koji vam pokaže stanje vaših podataka — nedostajuće vrijednosti, duplikati, tipovi, memorija. Sve na jednom mjestu.

def izvjestaj_kvalitete(df):
    """Generira sveobuhvatni izvještaj o kvaliteti podataka."""
    print("=" * 60)
    print("IZVJEŠTAJ O KVALITETI PODATAKA")
    print("=" * 60)
    print(f"\nUkupan broj redaka: {len(df)}")
    print(f"Ukupan broj stupaca: {len(df.columns)}")

    # Nedostajuće vrijednosti
    print("\n--- Nedostajuće vrijednosti ---")
    for stupac in df.columns:
        nedostaje = df[stupac].isnull().sum()
        if nedostaje > 0:
            postotak = (nedostaje / len(df)) * 100
            print(f"  {stupac}: {nedostaje} ({postotak:.1f}%)")

    # Duplikati
    dup_count = df.duplicated().sum()
    print(f"\n--- Duplikati ---")
    print(f"  Potpunih duplikata: {dup_count}")

    # Tipovi podataka
    print("\n--- Tipovi podataka ---")
    for tip, count in df.dtypes.value_counts().items():
        print(f"  {tip}: {count} stupaca")

    # Memorija
    mem = df.memory_usage(deep=True).sum() / 1024
    print(f"\n--- Korištenje memorije ---")
    print(f"  Ukupno: {mem:.2f} KB")
    print("=" * 60)

# Pokretanje izvještaja
izvjestaj_kvalitete(df)

Izgradnja pipeline-a za čišćenje

Umjesto da svaki korak čišćenja radite zasebno (i svaki put zaboravite nešto), puno je pametnije izgraditi pipeline — niz koraka koji se mogu reproducirati. Ovo vam štedi vrijeme i osigurava konzistentnost.

Ulančavanje metoda (Method Chaining)

# Elegantno ulančavanje operacija čišćenja
df_ociscen = (
    df
    .copy()
    .dropna(subset=["ime"])
    .assign(
        ime=lambda x: x["ime"].str.strip().str.title(),
        grad=lambda x: x["grad"].str.strip().str.title(),
        placa=lambda x: pd.to_numeric(x["placa"], errors="coerce"),
        datum_zaposlenja=lambda x: pd.to_datetime(
            x["datum_zaposlenja"], format="mixed", errors="coerce"
        )
    )
    .drop_duplicates(subset=["ime", "datum_zaposlenja"])
    .reset_index(drop=True)
)

print(df_ociscen)
print(df_ociscen.dtypes)

Korištenje pipe() metode

# Definiranje funkcija za svaki korak čišćenja
def ukloni_razmake(df):
    """Uklanja vodeće i prateće razmake iz tekstualnih stupaca."""
    tekstualni = df.select_dtypes(include=["object", "string"]).columns
    for stupac in tekstualni:
        df[stupac] = df[stupac].str.strip()
    return df

def standardiziraj_tekst(df, stupci_title=None, stupci_upper=None):
    """Standardizira format teksta."""
    if stupci_title:
        for stupac in stupci_title:
            df[stupac] = df[stupac].str.title()
    if stupci_upper:
        for stupac in stupci_upper:
            df[stupac] = df[stupac].str.upper()
    return df

def konvertiraj_tipove(df, numericke=None, datumske=None):
    """Konvertira stupce u ispravne tipove podataka."""
    if numericke:
        for stupac in numericke:
            df[stupac] = pd.to_numeric(df[stupac], errors="coerce")
    if datumske:
        for stupac in datumske:
            df[stupac] = pd.to_datetime(df[stupac], format="mixed", errors="coerce")
    return df

def ukloni_outliere(df, stupac, metoda="iqr", faktor=1.5):
    """Uklanja outliere iz zadanog stupca."""
    if metoda == "iqr":
        Q1 = df[stupac].quantile(0.25)
        Q3 = df[stupac].quantile(0.75)
        IQR = Q3 - Q1
        maska = (df[stupac] >= Q1 - faktor * IQR) & (df[stupac] <= Q3 + faktor * IQR)
        return df[maska]
    return df

# Primjena pipeline-a koristeći pipe()
df_ociscen = (
    df
    .copy()
    .pipe(ukloni_razmake)
    .pipe(standardiziraj_tekst, stupci_title=["ime", "grad"])
    .pipe(konvertiraj_tipove,
          numericke=["placa"],
          datumske=["datum_zaposlenja"])
    .dropna(subset=["ime"])
    .drop_duplicates(subset=["ime", "datum_zaposlenja"])
    .pipe(ukloni_outliere, stupac="dob")
    .reset_index(drop=True)
)

print(df_ociscen)

Izgradnja klase za čišćenje podataka

Za ozbiljnije projekte, vrijedi izgraditi klasu koja enkapsulira cijeli proces. Evo pristupa koji sam koristio na više projekata i koji se pokazao odličnim:

class CistacPodataka:
    """Klasa koja enkapsulira cjelokupni proces čišćenja podataka."""

    def __init__(self, df):
        self.df = df.copy()
        self.log = []

    def _zabilijezi(self, poruka):
        """Interno bilježenje koraka čišćenja."""
        self.log.append(poruka)
        print(f"  [LOG] {poruka}")

    def ukloni_razmake(self):
        """Uklanja razmake iz tekstualnih stupaca."""
        tekstualni = self.df.select_dtypes(include=["object"]).columns
        for stupac in tekstualni:
            self.df[stupac] = self.df[stupac].str.strip()
        self._zabilijezi(f"Uklonjeni razmaci u {len(tekstualni)} stupaca")
        return self

    def standardiziraj_imena(self, stupci, format_tipa="title"):
        """Standardizira format teksta u zadanim stupcima."""
        for stupac in stupci:
            if stupac in self.df.columns:
                self.df[stupac] = self.df[stupac].str.title() if format_tipa == "title" \
                    else self.df[stupac].str.upper()
        self._zabilijezi(f"Standardiziran tekst u stupcima: {stupci}")
        return self

    def konvertiraj_numericke(self, stupci):
        """Konvertira stupce u numeričke tipove."""
        for stupac in stupci:
            prije_nan = self.df[stupac].isnull().sum()
            self.df[stupac] = pd.to_numeric(self.df[stupac], errors="coerce")
            poslije_nan = self.df[stupac].isnull().sum()
            novi_nan = poslije_nan - prije_nan
            if novi_nan > 0:
                self._zabilijezi(
                    f"Stupac '{stupac}': {novi_nan} vrijednosti pretvoreno u NaN"
                )
        return self

    def popuni_nedostajuce(self, stupac, metoda="medijan"):
        """Popunjava nedostajuće vrijednosti zadanom metodom."""
        prije = self.df[stupac].isnull().sum()
        if metoda == "medijan":
            self.df[stupac] = self.df[stupac].fillna(self.df[stupac].median())
        elif metoda == "srednja":
            self.df[stupac] = self.df[stupac].fillna(self.df[stupac].mean())
        elif metoda == "mod":
            self.df[stupac] = self.df[stupac].fillna(self.df[stupac].mode()[0])
        poslije = self.df[stupac].isnull().sum()
        self._zabilijezi(
            f"Popunjeno {prije - poslije} NaN u '{stupac}' metodom '{metoda}'"
        )
        return self

    def ukloni_duplikate(self, subset=None):
        """Uklanja duplikate."""
        prije = len(self.df)
        self.df = self.df.drop_duplicates(subset=subset)
        poslije = len(self.df)
        self._zabilijezi(f"Uklonjeno {prije - poslije} duplikata")
        return self

    def primijeni_raspon(self, stupac, min_val=None, max_val=None):
        """Primjenjuje ograničenje raspona na stupac."""
        prije = len(self.df)
        if min_val is not None:
            self.df = self.df[self.df[stupac] >= min_val]
        if max_val is not None:
            self.df = self.df[self.df[stupac] <= max_val]
        self._zabilijezi(
            f"Filtrirano {prije - len(self.df)} redaka prema rasponu "
            f"'{stupac}': [{min_val}, {max_val}]"
        )
        return self

    def dohvati_rezultat(self):
        """Vraća očišćeni DataFrame."""
        self.df = self.df.reset_index(drop=True)
        print(f"\nZavršeno! Ukupno koraka: {len(self.log)}")
        print(f"Konačni oblik podataka: {self.df.shape}")
        return self.df

# Korištenje klase
cistac = CistacPodataka(df)
df_finalni = (
    cistac
    .ukloni_razmake()
    .standardiziraj_imena(["ime", "grad"], "title")
    .konvertiraj_numericke(["placa"])
    .popuni_nedostajuce("placa", metoda="medijan")
    .popuni_nedostajuce("ocjena", metoda="medijan")
    .ukloni_duplikate(subset=["ime", "grad"])
    .primijeni_raspon("dob", min_val=18, max_val=70)
    .dohvati_rezultat()
)

print(df_finalni)

Prednosti ovog pristupa? Kod je čitljiv i samodokumentirajući, svaki korak je izoliran i lako testabilan, pipeline se može proširiti bez problema, a ugrađeno logiranje vam govori točno što se dogodilo s vašim podatcima.

Nove značajke u pandas 2.x i 3.0

Pandas ne stoji na mjestu. Verzije 2.x (i nadolazeća 3.0) donose neke stvarno korisne promjene koje utječu na to kako pristupamo čišćenju podataka. Ajmo pogledati najvažnije.

Copy-on-Write (CoW)

# Copy-on-Write je zadano ponašanje u pandas 2.x/3.0
# Eliminira neočekivane promjene podataka kroz poglede (views)

# STARI način — moglo je uzrokovati probleme
# df2 = df[["ime", "grad"]]
# df2["ime"] = "Test"  # Moglo je promijeniti i originalni df!

# NOVI način s Copy-on-Write — sigurno ponašanje
pd.options.mode.copy_on_write = True

df_original = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})
df_prikaz = df_original[["A"]]
df_prikaz["A"] = 999  # Kreira automatsku kopiju, original ostaje netaknut

print("Original:", df_original["A"].tolist())  # [1, 2, 3]
print("Prikaz:", df_prikaz["A"].tolist())       # [999, 999, 999]

# CoW eliminira upozorenja poput SettingWithCopyWarning
# i čini kod predvidljivijim

case_when metoda

# Nova case_when metoda — čistija alternativa višestrukim np.where pozivima
# Dostupna od pandas 2.2+

df_place = pd.DataFrame({
    "ime": ["Ana", "Marko", "Ivan", "Petra", "Luka"],
    "placa": [3500, 5500, 7200, 9800, 12000],
    "odjel": ["IT", "Marketing", "IT", "Financije", "IT"]
})

# Kategorizacija plaća koristeći case_when
df_place["kategorija"] = pd.Series(
    ["Niska"] * len(df_place)  # zadana vrijednost
).case_when(
    caselist=[
        (df_place["placa"] >= 10000, "Vrlo visoka"),
        (df_place["placa"] >= 7000, "Visoka"),
        (df_place["placa"] >= 5000, "Srednja"),
    ]
)

print(df_place[["ime", "placa", "kategorija"]])

# Usporedba sa starim pristupom koristeći np.select
uvjeti = [
    df_place["placa"] >= 10000,
    df_place["placa"] >= 7000,
    df_place["placa"] >= 5000,
]
izbori = ["Vrlo visoka", "Visoka", "Srednja"]
df_place["kategorija_stari"] = np.select(uvjeti, izbori, default="Niska")

Arrow backend i novi string tip

# Arrow backend — značajno brži za string operacije i manje memorije
# Instalacija: pip install pyarrow

# Korištenje Arrow-baziranih tipova
df_arrow = pd.DataFrame({
    "ime": pd.array(["Ana Kovač", "Marko Horvat", "Ivan Novak"],
                     dtype="string[pyarrow]"),
    "grad": pd.array(["Zagreb", "Split", "Rijeka"],
                      dtype="string[pyarrow]"),
    "placa": pd.array([5500, 6200, 5800],
                       dtype="int64[pyarrow]"),
    "datum": pd.array(
        pd.to_datetime(["2024-01-15", "2024-02-20", "2024-03-10"]),
        dtype="timestamp[ns][pyarrow]"
    )
})

print(df_arrow.dtypes)

# Prednosti Arrow backenda:
# - Znatno manji utrošak memorije za string stupce
# - Brže string operacije (str.contains, str.replace, itd.)
# - Bolji nullable tipovi (nema konverzije int → float zbog NaN)
# - Nativna podrška za više tipova podataka

# Usporedba memorije
df_object = pd.DataFrame({"tekst": ["abc"] * 100000})
df_pyarrow = pd.DataFrame({"tekst": pd.array(["abc"] * 100000,
                                                dtype="string[pyarrow]")})

print(f"Object dtype:  {df_object.memory_usage(deep=True).sum() / 1024:.1f} KB")
print(f"PyArrow dtype: {df_pyarrow.memory_usage(deep=True).sum() / 1024:.1f} KB")

Novi zadani string tip u pandas 3.0

# U pandas 3.0, zadani tip za tekstualne podatke bit će StringDtype
# umjesto dosadašnjeg object tipa

# Ručno postavljanje StringDtype za kompatibilnost
df_buducnost = pd.DataFrame({
    "ime": pd.array(["Ana", "Marko", "Ivan"], dtype=pd.StringDtype()),
    "prezime": pd.array(["Kovač", "Horvat", "Novak"], dtype=pd.StringDtype())
})

# StringDtype pravilno razlikuje NaN i None
df_buducnost.loc[1, "ime"] = pd.NA  # pd.NA umjesto np.nan za stringove
print(df_buducnost)
print(df_buducnost.dtypes)

# Provjera nedostajućih s pd.NA
print(df_buducnost["ime"].isna())  # Radi ispravno s pd.NA

Ostala poboljšanja u pandas 2.x

# Poboljšane performanse s NumPy 2.0 integracijom
# Brže grupiranje i agregacija

# Unaprijeđena metoda map() — zamjena za applymap()
df_primjer = pd.DataFrame({
    "A": [1, 2, 3],
    "B": [4, 5, 6]
})

# Stari način (zastarjelo u 2.1+):
# df_primjer.applymap(lambda x: x * 2)

# Novi način:
df_primjer = df_primjer.map(lambda x: x * 2)
print(df_primjer)

# Poboljšani merge/join s validacijom
df1 = pd.DataFrame({"kljuc": [1, 2, 3], "vrijednost_a": ["a", "b", "c"]})
df2 = pd.DataFrame({"kljuc": [1, 2, 4], "vrijednost_b": ["x", "y", "z"]})

# Validacija osigurava ispravnost spajanja
rezultat = pd.merge(df1, df2, on="kljuc", how="inner", validate="one_to_one")
print(rezultat)

Zaključak

Čišćenje podataka nije glamurozan posao, ali jest temelj svake pouzdane analize. Bez njega, sve ostalo pada u vodu. Kroz ovaj vodič prošli smo od osnovnih operacija do naprednih tehnika i novih mogućnosti u pandas 2.x i 3.0.

Kratki sažetak tehnika

  1. Razumijevanje podataka — Počnite s info(), describe() i shape. Uvijek.
  2. Nedostajuće vrijednosti — Koristite isnull() za detekciju, a dropna(), fillna() ili interpolate() za rukovanje.
  3. Duplikatiduplicated() za pronalaženje, drop_duplicates() za uklanjanje.
  4. Tipovi podatakaastype(), to_numeric() i to_datetime() su vaši prijatelji.
  5. Čišćenje tekstastr accessor za strip, standardizaciju i regex operacije.
  6. Outlieri — IQR ili Z-score za detekciju; uklanjanje, winzorizacija ili zamjena za rukovanje.
  7. Validacija — Provjerite raspone i logičku konzistentnost između stupaca.
  8. Pipeline — Organizirajte čišćenje u ponovljive pipeline-e s pipe() ili vlastitim klasama.

Najbolje prakse

# Primjer kompletnog pipeline-a s najboljim praksama

import pandas as pd
import numpy as np
from datetime import datetime

def kompletno_ciscenje(putanja_datoteke):
    """
    Kompletni pipeline za čišćenje podataka.
    Primjenjuje sve naučene tehnike u ispravnom redoslijedu.
    """

    # 1. Učitavanje s eksplicitnim parametrima
    df = pd.read_csv(
        putanja_datoteke,
        na_values=["N/A", "n/a", "-", "", "null", "NULL"],
        dtype_backend="pyarrow"  # koristimo Arrow za bolje performanse
    )

    pocetni_oblik = df.shape
    print(f"Učitano: {pocetni_oblik[0]} redaka, {pocetni_oblik[1]} stupaca")

    # 2. Čišćenje tekstualnih stupaca
    tekstualni = df.select_dtypes(include=["object", "string"]).columns
    for stupac in tekstualni:
        df[stupac] = df[stupac].str.strip().str.title()

    # 3. Konverzija tipova podataka
    # (prilagoditi prema specifičnim stupcima)

    # 4. Uklanjanje potpunih duplikata
    prije = len(df)
    df = df.drop_duplicates()
    print(f"Uklonjeno duplikata: {prije - len(df)}")

    # 5. Rukovanje nedostajućim vrijednostima
    # (strategija ovisi o poslovnom kontekstu)

    # 6. Validacija raspona i konzistentnosti

    # 7. Resetiranje indeksa
    df = df.reset_index(drop=True)

    print(f"Konačno: {df.shape[0]} redaka, {df.shape[1]} stupaca")
    return df
  • Uvijek radite na kopiji — Ne dirajte originalne podatke. Koristite df.copy() ili aktivirajte Copy-on-Write.
  • Dokumentirajte korake — Bilježite što ste promijenili i zašto. Vaš budući ja će vam biti zahvalan.
  • Automatizirajte — Izgradite pipeline koji se može ponovno pokrenuti bez ručne intervencije.
  • Validirajte nakon svakog koraka — Provjerite jesu li promjene očekivane. Asercije su tu s razlogom.
  • Koristite najnoviji pandas — Arrow backend, nullable tipovi i CoW donose značajne prednosti.
  • Razumijte domenu — Tehnička znanja nisu dovoljna. Morate razumjeti kontekst podataka da biste donosili ispravne odluke.
  • Iterativni pristup — Čišćenje rijetko završava u jednom prolazu. Planirajte više iteracija.

Čišćenje podataka zahtijeva strpljenje i sustavni pristup. Ali s tehnikama iz ovog vodiča, spremni ste za suočavanje s najčešćim problemima u pripremi podataka. Pandas, posebno u novijim verzijama, pruža sve potrebne alate za pretvaranje neurednih sirovih podataka u čist i pouzdan temelj za analizu.

Vrijeme uloženo u čišćenje podataka višestruko se isplati kroz pouzdanije analize, točnije modele i bolje poslovne odluke. To je jednostavna matematika — kvaliteta rezultata ovisi o kvaliteti podataka, a kvaliteta podataka ovisi o temeljitosti vašeg procesa čišćenja.

O Autoru Editorial Team

Our team of expert writers and editors.