Č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
- Razumijevanje podataka — Počnite s
info(),describe()ishape. Uvijek. - Nedostajuće vrijednosti — Koristite
isnull()za detekciju, adropna(),fillna()iliinterpolate()za rukovanje. - Duplikati —
duplicated()za pronalaženje,drop_duplicates()za uklanjanje. - Tipovi podataka —
astype(),to_numeric()ito_datetime()su vaši prijatelji. - Čišćenje teksta —
straccessor za strip, standardizaciju i regex operacije. - Outlieri — IQR ili Z-score za detekciju; uklanjanje, winzorizacija ili zamjena za rukovanje.
- Validacija — Provjerite raspone i logičku konzistentnost između stupaca.
- 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.