Curățarea și Preprocesarea Datelor cu Pandas: Ghid Complet 2026

Ghid practic pentru curățarea datelor în Python cu Pandas 3.0: gestionarea valorilor lipsă, eliminarea duplicatelor, detectarea outlierilor, curățarea textului și pipeline-uri de preprocesare reutilizabile cu pipe().

Dacă lucrezi cu date reale, știi deja: cele mai multe ore dintr-un proiect de data science nu se petrec antrenând modele sofisticate sau creând vizualizări spectaculoase. Se petrec curățând date. Estimările variază, dar consensul în industrie e că 60-80% din timpul unui proiect de date se duce pe colectarea, curățarea și preprocesarea lor. Personal, am avut proiecte în care procentul a fost chiar mai mare — și nu exagerez deloc.

De ce contează atât de mult? Pentru că niciun model de machine learning, oricât de avansat, nu poate compensa datele murdare. „Garbage in, garbage out" nu e doar o vorbă — e o lege nescrisă a data science-ului. Valorile lipsă, duplicatele, tipurile de date incorecte, formatările inconsistente și valorile aberante pot denatura complet rezultatele unei analize.

Vestea bună? Pandas ne oferă un arsenal complet de instrumente pentru a rezolva fiecare dintre aceste probleme.

În ghidul ăsta, parcurgem împreună întregul flux de curățare a datelor folosind Pandas 3.0 — versiunea lansată în ianuarie 2026, care aduce schimbări semnificative precum Copy-on-Write activat implicit și backend-ul PyArrow. Dacă nu ați citit încă despre noutățile din Pandas 3.0, vă recomand să parcurgeți mai întâi ghidul nostru de migrare la Pandas 3.0 — vom face referire la aceste schimbări pe parcursul articolului. Vom acoperi totul: de la explorarea inițială a datelor, prin gestionarea valorilor lipsă și a duplicatelor, până la pipeline-uri complete de curățare reutilizabile.

Importul și Explorarea Inițială a Datelor

Primul pas în orice proiect de curățare — și probabil cel mai subestimat — este să înțelegem cu ce date lucrăm. Înainte de a schimba ceva, trebuie să explorăm structura, tipurile de date, distribuțiile și problemele evidente.

Hai să începem cu un set de date realist — un CSV cu date despre angajați care conține intenționat probleme tipice pe care le vom rezolva pe parcursul articolului:

import pandas as pd
import numpy as np

# Citirea datelor cu encoding explicit — esențial pentru fișiere cu caractere românești
df = pd.read_csv(
    'angajati.csv',
    encoding='utf-8',           # sau 'latin-1' pentru fișiere mai vechi
    sep=',',                    # separatorul de coloane
    na_values=['N/A', 'n/a', '-', ''],  # valori custom tratate ca NaN
    parse_dates=['data_angajare'],       # parsare automată a datelor
    dtype_backend='pyarrow'              # backend PyArrow — noutate Pandas 3.0
)

# Pentru exemplele din acest articol, creăm un DataFrame de demonstrație
np.random.seed(42)
n = 200

df = pd.DataFrame({
    'nume': [f'Angajat_{i}' for i in range(n)],
    'departament': np.random.choice(['IT', 'HR', 'Vânzări', 'Marketing', 'IT', 'HR', None], n),
    'salariu': np.random.choice([*np.random.normal(7500, 2500, n-10).round(0), *[None]*10]),
    'varsta': np.random.choice([*np.random.randint(22, 62, n-5), *[None]*5]),
    'data_angajare': pd.date_range('2015-01-01', periods=n, freq='12D').astype(str),
    'email': [f'angajat{i}@companie.ro' if i % 20 != 0 else None for i in range(n)],
    'oras': np.random.choice(['București', 'bucuresti', 'BUCUREȘTI', 'Cluj-Napoca', 'Cluj', 'Iași', 'Timișoara'], n),
    'rating': np.random.choice(['4.5', '3.8', '4.2', 'N/A', '3.9', 'excelent', '4.0'], n),
})

print(f"Dimensiunea DataFrame: {df.shape}")
print(f"Rânduri: {df.shape[0]}, Coloane: {df.shape[1]}")
print()

# Informații despre structură și tipuri de date
print(df.info())
print()

# Statistici descriptive pentru coloanele numerice
print(df.describe())
print()

# Verificarea valorilor lipsă pe fiecare coloană
print("Valori lipsă per coloană:")
print(df.isnull().sum())
print()

# Procentul valorilor lipsă
print("Procentul valorilor lipsă:")
print((df.isnull().sum() / len(df) * 100).round(2))
print()

# Primele rânduri pentru inspecție vizuală
print(df.head(10))

# Tipurile de date — esențial pentru a detecta probleme
print(df.dtypes)

Funcțiile info(), describe(), isnull().sum() și dtypes sunt cele patru instrumente pe care le rulez întotdeauna la începutul unui proiect. Fără excepție. Ele dezvăluie rapid cele mai comune probleme: coloane numerice stocate ca text, valori lipsă ascunse, distribuții neobișnuite și inconsistențe în formatare. Investiți câteva minute în explorarea asta — vă va economisi ore de debugging mai târziu.

Gestionarea Valorilor Lipsă (Missing Values)

Valorile lipsă sunt probabil cea mai frecventă problemă în datele reale. Pot apărea din tot felul de motive: erori de colectare, câmpuri opționale în formulare, join-uri între tabele cu chei nepotrivite sau pur și simplu date care nu au fost înregistrate. Modul în care le gestionăm poate influența semnificativ calitatea analizei — deci merită să le tratăm cu atenție.

Detectarea valorilor lipsă

Pandas reprezintă valorile lipsă prin NaN (Not a Number) pentru date numerice și None sau pd.NA pentru alte tipuri. În Pandas 3.0 cu backend-ul PyArrow, valorile lipsă sunt gestionate nativ prin tipurile Arrow — asta înseamnă performanță mai bună și consistență între tipurile de date.

# Detectarea valorilor lipsă — două funcții echivalente
print(df.isnull())    # returnează DataFrame cu True/False
print(df.isna())      # alias identic cu isnull()

# Numărul total de valori lipsă per coloană
valori_lipsa = df.isnull().sum()
print("Valori lipsă per coloană:")
print(valori_lipsa[valori_lipsa > 0])

# Numărul total de valori lipsă în întreg DataFrame-ul
print(f"\nTotal valori lipsă: {df.isnull().sum().sum()}")

# Procentajul valorilor lipsă — util pentru a decide strategia
procent_lipsa = (df.isnull().sum() / len(df) * 100).round(2)
print("\nProcentaj valori lipsă:")
print(procent_lipsa[procent_lipsa > 0])

# Rânduri care conțin cel puțin o valoare lipsă
randuri_cu_nan = df[df.isnull().any(axis=1)]
print(f"\nRânduri cu cel puțin o valoare lipsă: {len(randuri_cu_nan)}")

# Vizualizarea pattern-ului valorilor lipsă
# (Aceasta se integrează excelent cu vizualizarea datelor —
#  consultați ghidul nostru de vizualizare cu Matplotlib și Seaborn)
print("\nPattern valorilor lipsă (primele 10 rânduri):")
print(df.isnull().head(10).astype(int))

Un truc pe care îl folosesc frecvent: sortez coloanele după procentul de valori lipsă. Coloanele cu peste 50-60% valori lipsă sunt de obicei candidate pentru eliminare completă — rareori merită efortul de a le completa.

Eliminarea rândurilor și coloanelor cu dropna()

Uneori, cea mai simplă soluție e și cea mai bună: eliminarea rândurilor sau coloanelor cu valori lipsă. Funcția dropna() oferă mai mulți parametri pentru a controla exact ce se elimină:

# Eliminarea TUTUROR rândurilor care conțin cel puțin o valoare lipsă
df_curat = df.dropna()
print(f"Rânduri înainte: {len(df)}, după dropna(): {len(df_curat)}")

# how='all' — elimină doar rândurile unde TOATE valorile sunt lipsă
df_curat_all = df.dropna(how='all')

# subset — elimină rândurile cu NaN doar în coloanele specificate
# Util când anumite coloane sunt critice
df_curat_subset = df.dropna(subset=['salariu', 'departament'])
print(f"După dropna(subset=['salariu', 'departament']): {len(df_curat_subset)} rânduri")

# thresh — păstrează rândurile cu cel puțin N valori non-NaN
# Exemplu: păstrează rândurile care au cel puțin 6 valori completate din 8
df_curat_thresh = df.dropna(thresh=6)
print(f"După dropna(thresh=6): {len(df_curat_thresh)} rânduri")

# Eliminarea coloanelor (axis=1) cu valori lipsă
df_fara_coloane = df.dropna(axis=1)
print(f"Coloane înainte: {df.shape[1]}, după dropna(axis=1): {df_fara_coloane.shape[1]}")

# Eliminarea coloanelor cu mai mult de 30% valori lipsă
prag = 0.3
coloane_de_pastrat = df.columns[df.isnull().mean() < prag]
df_filtrat = df[coloane_de_pastrat]
print(f"Coloane păstrate (< 30% NaN): {list(coloane_de_pastrat)}")

Notă importantă despre Pandas 3.0: Datorită comportamentului Copy-on-Write activat implicit, dropna() returnează întotdeauna o copie nouă a datelor, fără a modifica DataFrame-ul original. Sincer, asta singură elimină un întreg set de bug-uri subtile legate de vizualizările (views) vs. copiile din versiunile anterioare — un motiv serios să faceți upgrade dacă nu ați făcut-o deja.

Completarea valorilor lipsă cu fillna()

Când eliminarea datelor nu e o opțiune (poate avem prea puține rânduri sau coloanele sunt critice), putem completa valorile lipsă cu fillna(). Alegerea valorii de completare depinde foarte mult de context:

# Completare cu o valoare scalară
df['email'] = df['email'].fillna('[email protected]')

# Completare cu valori diferite per coloană — folosind un dicționar
df_completat = df.fillna({
    'departament': 'Nespecificat',
    'salariu': df['salariu'].median(),     # mediana este robustă la outlieri
    'varsta': df['varsta'].mean(),          # media pentru distribuții normale
    'email': '[email protected]'
})

# Forward fill — completează cu ultima valoare validă
# Ideal pentru serii temporale
df['salariu_ffill'] = df['salariu'].ffill()

# Backward fill — completează cu următoarea valoare validă
df['salariu_bfill'] = df['salariu'].bfill()

# Completare cu media grupată — mult mai precisă
# Exemplu: completăm salariul cu media departamentului
df['salariu'] = df.groupby('departament')['salariu'].transform(
    lambda x: x.fillna(x.median())
)

# Completare condițională — logică custom
df['varsta'] = df['varsta'].fillna(
    df.groupby('departament')['varsta'].transform('median')
)

print("Valori lipsă după completare:")
print(df.isnull().sum())

Sfatul meu: nu completați orbește cu media globală. Dacă aveți grupuri naturale în date (departamente, categorii, regiuni), completați cu media sau mediana grupului respectiv. Am văzut diferențe uriașe în calitatea analizei finale doar din cauza acestei alegeri aparent minore.

Interpolarea datelor

Pentru serii temporale sau date ordonate, interpolarea oferă o alternativă mai sofisticată decât fillna(). În loc să completeze cu o valoare fixă, interpolate() estimează valorile lipsă pe baza valorilor vecine — ceea ce, în multe cazuri, produce rezultate mult mai realiste:

# Creăm o serie temporală cu valori lipsă
date_index = pd.date_range('2025-01-01', periods=30, freq='D')
vanzari = pd.Series(
    [100, 105, np.nan, np.nan, 120, 118, 125, np.nan, 135, 140,
     np.nan, np.nan, np.nan, 160, 155, 158, np.nan, 170, 175, 180,
     np.nan, 190, 195, np.nan, 205, 210, np.nan, np.nan, 225, 230],
    index=date_index
)

print(f"Valori lipsă: {vanzari.isnull().sum()}")

# Interpolarea liniară — implicită
vanzari_linear = vanzari.interpolate(method='linear')

# Interpolarea bazată pe timp — ține cont de distanța temporală
vanzari_time = vanzari.interpolate(method='time')

# Interpolarea polinomială — pentru tendințe neliniare
vanzari_poly = vanzari.interpolate(method='polynomial', order=2)

# Interpolarea cu limită — nu completează mai mult de N valori consecutive
vanzari_limita = vanzari.interpolate(method='linear', limit=2)

# Compararea metodelor
comparatie = pd.DataFrame({
    'original': vanzari,
    'linear': vanzari_linear,
    'time': vanzari_time,
    'polynomial': vanzari_poly,
    'limitat_2': vanzari_limita
})
print(comparatie.head(15))

Interpolarea e deosebit de utilă pentru datele de senzori, serii financiare și orice set de date unde valorile consecutive sunt corelate. Pentru vizualizarea diferențelor dintre metode, puteți folosi tehnicile din ghidul nostru de vizualizare cu Matplotlib și Seaborn.

Eliminarea și Gestionarea Duplicatelor

Duplicatele. O problemă frecventă, mai ales când datele provin din surse multiple sau din procese de ETL imperfecte. Pot denatura statisticile descriptive, pot introduce bias în modele și pot crește artificial volumul datelor. Să vedem cum le detectăm și eliminăm:

# Creăm un DataFrame cu duplicate intenționate
df_dup = pd.DataFrame({
    'nume': ['Ana', 'Bogdan', 'Ana', 'Cristina', 'Bogdan', 'Diana', 'Ana'],
    'email': ['[email protected]', '[email protected]', '[email protected]', '[email protected]',
              '[email protected]', '[email protected]', '[email protected]'],
    'departament': ['IT', 'HR', 'IT', 'Vânzări', 'HR', 'Marketing', 'IT'],
    'salariu': [7500, 6000, 7500, 8000, 6000, 7000, 7800],
})

# Detectarea duplicatelor — returnează o Serie booleană
print("Rânduri duplicate:")
print(df_dup.duplicated())

# Verificarea duplicatelor pe un subset de coloane
print("\nDuplicate după 'nume' și 'email':")
print(df_dup.duplicated(subset=['nume', 'email']))

# Numărul total de duplicate
print(f"\nNumăr total duplicate (toate coloanele): {df_dup.duplicated().sum()}")
print(f"Număr duplicate (doar nume+email): {df_dup.duplicated(subset=['nume', 'email']).sum()}")

# Vizualizarea rândurilor duplicate
print("\nRândurile care sunt duplicate:")
print(df_dup[df_dup.duplicated(keep=False)])  # keep=False marchează TOATE duplicatele

# Eliminarea duplicatelor
df_unic = df_dup.drop_duplicates()
print(f"\nRânduri după drop_duplicates(): {len(df_unic)}")

# Eliminarea duplicatelor pe subset — păstrăm prima apariție
df_unic_subset = df_dup.drop_duplicates(subset=['nume', 'email'], keep='first')
print(f"După drop_duplicates pe nume+email (keep='first'): {len(df_unic_subset)}")

# Păstrarea ultimei apariții
df_unic_last = df_dup.drop_duplicates(subset=['nume', 'email'], keep='last')
print(f"După drop_duplicates pe nume+email (keep='last'): {len(df_unic_last)}")

# Eliminarea TUTUROR aparițiilor duplicatelor
df_fara_duplicate = df_dup.drop_duplicates(subset=['nume', 'email'], keep=False)
print(f"După drop_duplicates (keep=False): {len(df_fara_duplicate)}")

Un aspect pe care multe tutoriale îl omit: duplicatele aproximative (fuzzy duplicates). În lumea reală, „Ana Popescu" și „ana popescu" sau „Str. Mihai Viteazu 15" și „Strada Mihai Viteazul nr. 15" sunt de fapt aceleași entități, dar duplicated() nu le va detecta. Pentru cazurile astea, trebuie mai întâi să standardizăm datele sau să folosim biblioteci specializate precum fuzzywuzzy sau rapidfuzz:

# Detectarea duplicatelor aproximative — exemplu conceptual
# Mai întâi, standardizăm textul
df_dup['nume_standardizat'] = (
    df_dup['nume']
    .str.strip()
    .str.lower()
    .str.replace(r'\s+', ' ', regex=True)
)

# Apoi căutăm duplicatele pe versiunea standardizată
duplicate_fuzzy = df_dup.duplicated(subset=['nume_standardizat'], keep=False)
print("Duplicate detectate după standardizare:")
print(df_dup[duplicate_fuzzy][['nume', 'nume_standardizat', 'email']])

Recomandarea mea: rulați întotdeauna verificarea duplicatelor după curățarea textului, nu înainte. Veți fi surprinși câte duplicate „ascunse" descoperiți — am pățit-o de mai multe ori.

Corectarea Tipurilor de Date

Tipurile de date incorecte sunt o problemă insidioasă — nu aruncă erori imediat, dar pot cauza rezultate greșite în calcule și agregări. Cel mai frecvent scenariu: coloane numerice stocate ca text (tip object) din cauza unor valori problematice izolate. Un singur „N/A" ca string într-o coloană de prețuri și brusc toată coloana devine text.

# Creăm un DataFrame cu tipuri problematice
df_tipuri = pd.DataFrame({
    'pret': ['150.5', '200', '175.3', 'N/A', '180', '195.7', 'gratuit'],
    'cantitate': ['10', '5', '8', '12', 'trei', '7', '15'],
    'data_comanda': ['2025-01-15', '2025-02-20', '15/03/2025', '2025-04-10',
                     'mai 2025', '2025-06-05', '2025-07-12'],
    'cod_postal': [10001, 20002, 30003, 40004, 50005, 60006, 70007],
    'activ': ['da', 'nu', 'da', 'Da', 'NU', 'da', 'true'],
})

print("Tipuri de date originale:")
print(df_tipuri.dtypes)
print()

# pd.to_numeric() — conversie cu gestionarea erorilor
# errors='coerce' transformă valorile neconvertibile în NaN
df_tipuri['pret'] = pd.to_numeric(df_tipuri['pret'], errors='coerce')
df_tipuri['cantitate'] = pd.to_numeric(df_tipuri['cantitate'], errors='coerce')

print("După pd.to_numeric(errors='coerce'):")
print(df_tipuri[['pret', 'cantitate']])
print()

# pd.to_datetime() — conversie flexibilă a datelor
# format='mixed' permite formate mixte (noutate utilă)
df_tipuri['data_comanda'] = pd.to_datetime(
    df_tipuri['data_comanda'],
    format='mixed',
    dayfirst=False,
    errors='coerce'
)
print("După pd.to_datetime():")
print(df_tipuri['data_comanda'])
print()

# astype() — conversie explicită a tipului
# Ideal pentru coloane unde suntem siguri de format
df_tipuri['cod_postal'] = df_tipuri['cod_postal'].astype(str)

# Conversia coloanelor booleane din text
mapare_bool = {'da': True, 'nu': False, 'true': True, 'false': False}
df_tipuri['activ'] = df_tipuri['activ'].str.lower().map(mapare_bool)

print("Tipuri de date finale:")
print(df_tipuri.dtypes)
print()
print(df_tipuri)

Backend-ul PyArrow în Pandas 3.0: Una dintre cele mai importante schimbări din Pandas 3.0 e suportul nativ pentru backend-ul PyArrow. Oferă tipuri de date mai eficiente, mai bun suport pentru valori lipsă în coloane de orice tip (nu doar numerice) și performanță semnificativ mai bună la citirea fișierelor Parquet și Arrow. Dacă lucrați cu seturi mari de date, activarea backend-ului PyArrow merită cu siguranță — detalii complete în ghidul de migrare la Pandas 3.0.

# Citirea datelor cu backend PyArrow — Pandas 3.0
df_arrow = pd.read_csv(
    'date_mari.csv',
    dtype_backend='pyarrow'      # activează tipurile Arrow
)

# Conversia unui DataFrame existent la tipuri Arrow
df_arrow = df.convert_dtypes(dtype_backend='pyarrow')

print("Tipuri de date cu PyArrow:")
print(df_arrow.dtypes)
# Observați sufixul [pyarrow] la fiecare tip:
# string[pyarrow], int64[pyarrow], double[pyarrow], etc.

Curățarea Datelor Text (String Operations)

Datele text sunt, fără îndoială, cele mai problematice de curățat. Spații în plus, capitalizare inconsistentă, caractere speciale, formate diferite — toate pot face imposibilă o grupare sau un join corect. Din fericire, Pandas oferă accesorul .str care expune un set complet de metode pentru manipularea textului, vectorizate și eficiente.

# DataFrame cu date text murdare
df_text = pd.DataFrame({
    'nume': ['  Ana Popescu  ', 'bogdan ionescu', 'CRISTINA MATEI', 'Diana   Pop', 'elena vasilescu'],
    'oras': ['București', 'bucuresti', 'BUCUREȘTI', 'Cluj-Napoca', 'Cluj'],
    'telefon': ['0721-123-456', '0722 234 567', '0723.345.678', '+40724456789', '0725-567 890'],
    'cod_produs': ['ABC-001', 'abc-002', 'ABC 003', 'abc004', 'ABC--005'],
    'comentariu': ['Excelent produs!!!', 'bun, dar scump...', 'N/A', '  ', 'Foarte mulțumit!!! 👍'],
})

# strip() — eliminarea spațiilor de la început și sfârșit
df_text['nume'] = df_text['nume'].str.strip()

# lower() / upper() / title() — standardizarea capitalizării
df_text['nume'] = df_text['nume'].str.title()    # Prima Literă Mare
df_text['oras'] = df_text['oras'].str.title()     # Cluj-napoca → Cluj-Napoca

# replace() — înlocuirea pattern-urilor
# Standardizarea formatului de telefon
df_text['telefon'] = (
    df_text['telefon']
    .str.replace(r'[^0-9+]', '', regex=True)    # eliminăm tot ce nu e cifră sau +
    .str.replace(r'^\+40', '0', regex=True)      # +40 → 0
)

# contains() — filtrarea pe baza unui pattern
# Găsirea rândurilor care conțin un anumit text
contine_excelent = df_text['comentariu'].str.contains('excelent', case=False, na=False)
print("Comentarii care conțin 'excelent':")
print(df_text[contine_excelent])

# extract() — extragerea de informații cu regex
# Extragerea prefixului și numărului din codul produsului
df_text[['prefix', 'numar']] = df_text['cod_produs'].str.extract(
    r'([A-Za-z]+)\D*(\d+)'
)
df_text['prefix'] = df_text['prefix'].str.upper()
df_text['numar'] = df_text['numar'].astype(int)

print("\nDatele curățate:")
print(df_text)

Standardizarea categoriilor e o altă operație critică. Când aceeași categorie apare sub forme diferite, grupările și agregările vor da rezultate incorecte:

# Standardizarea categoriilor cu map() și replace()
df_categorii = pd.DataFrame({
    'oras': ['București', 'bucuresti', 'Buc.', 'BUCUREȘTI', 'Bucureşti',
             'Cluj-Napoca', 'Cluj', 'cluj-napoca', 'CJ',
             'Iași', 'iasi', 'Iaşi', 'IASI'],
})

# Metoda 1: Dicționar de mapare explicită
mapare_orase = {
    'bucurești': 'București',
    'buc.': 'București',
    'bucureşti': 'București',
    'cluj-napoca': 'Cluj-Napoca',
    'cluj': 'Cluj-Napoca',
    'cj': 'Cluj-Napoca',
    'iași': 'Iași',
    'iasi': 'Iași',
    'iaşi': 'Iași',
}

df_categorii['oras_curat'] = (
    df_categorii['oras']
    .str.lower()
    .str.strip()
    .map(mapare_orase)
    .fillna(df_categorii['oras'].str.title())  # fallback pentru valori nemapate
)

print("Orașe standardizate:")
print(df_categorii)
print(f"\nValori unice înainte: {df_categorii['oras'].nunique()}")
print(f"Valori unice după: {df_categorii['oras_curat'].nunique()}")

# Metoda 2: replace() cu regex pentru pattern-uri mai complexe
df_categorii['oras_v2'] = (
    df_categorii['oras']
    .str.lower()
    .str.strip()
    .replace({
        r'^buc.*$': 'București',
        r'^cluj.*$': 'Cluj-Napoca',
        r'^ia[sș][iî]?$': 'Iași',
    }, regex=True)
    .str.title()
)

print("\nOrașe standardizate (metoda regex):")
print(df_categorii['oras_v2'].value_counts())

Curățarea textului poate fi consumatoare de timp, recunosc. Dar e esențială. Un truc pe care l-am învățat din experiență: creați dicționare de mapare reutilizabile și salvați-le ca fișiere JSON sau YAML. Le veți refolosi în mod sigur în proiectele viitoare (și colegii vă vor mulțumi).

Detectarea și Tratarea Valorilor Aberante (Outliers)

Valorile aberante — punctele de date care se abat semnificativ de la restul distribuției — pot avea un impact dramatic asupra analizelor statistice și modelelor de machine learning.

Dar atenție: nu toate valorile aberante sunt erori. Uneori, un salariu foarte mare e real — aparține CEO-ului. De aceea, detecția trebuie întotdeauna urmată de analiză contextuală. Nu ștergeți outlieri pe pilot automat.

Metoda IQR (Interquartile Range)

Metoda IQR e cea mai populară și robustă abordare pentru detectarea outlierilor. Se bazează pe cuartile și nu e sensibilă la valori extreme, ceea ce o face ideală ca primă linie de verificare:

import pandas as pd
import numpy as np

# Date cu outlieri intenționați
np.random.seed(42)
df_outlieri = pd.DataFrame({
    'salariu': [*np.random.normal(7500, 1500, 95).round(0), 25000, 30000, 450, 50000, 35000],
    'varsta': [*np.random.randint(25, 55, 95), 15, 85, 90, 18, 95],
    'ore_lucrate': [*np.random.normal(40, 5, 95).round(1), 80, 85, 5, 90, 2],
})

def detecteaza_outlieri_iqr(df, coloana, factor=1.5):
    """Detectează outlierii folosind metoda IQR."""
    Q1 = df[coloana].quantile(0.25)
    Q3 = df[coloana].quantile(0.75)
    IQR = Q3 - Q1

    limita_inf = Q1 - factor * IQR
    limita_sup = Q3 + factor * IQR

    outlieri = (df[coloana] < limita_inf) | (df[coloana] > limita_sup)

    print(f"Coloana '{coloana}':")
    print(f"  Q1={Q1:.0f}, Q3={Q3:.0f}, IQR={IQR:.0f}")
    print(f"  Interval valid: [{limita_inf:.0f}, {limita_sup:.0f}]")
    print(f"  Outlieri detectați: {outlieri.sum()}")

    return outlieri, limita_inf, limita_sup

# Detectarea outlierilor pentru fiecare coloană numerică
for col in ['salariu', 'varsta', 'ore_lucrate']:
    outlieri, _, _ = detecteaza_outlieri_iqr(df_outlieri, col)
    print()

# Eliminarea outlierilor
mask_outlieri = pd.Series([False] * len(df_outlieri))
for col in ['salariu', 'varsta', 'ore_lucrate']:
    out, _, _ = detecteaza_outlieri_iqr(df_outlieri, col)
    mask_outlieri = mask_outlieri | out

df_fara_outlieri = df_outlieri[~mask_outlieri]
print(f"Rânduri originale: {len(df_outlieri)}")
print(f"Rânduri după eliminarea outlierilor: {len(df_fara_outlieri)}")

Metoda Z-Score

Metoda Z-Score măsoară câte deviații standard se abate o valoare de la medie. Valorile cu |Z| > 3 sunt considerate de obicei outlieri. Funcționează bine pentru distribuții aproximativ normale, dar are o vulnerabilitate: media și deviația standard sunt ele însele influențate de outlieri. De asta există și varianta modificată:

from scipy import stats

def detecteaza_outlieri_zscore(df, coloana, prag=3):
    """Detectează outlierii folosind Z-Score."""
    z_scores = np.abs(stats.zscore(df[coloana].dropna()))

    # Aliniem indexul cu DataFrame-ul original
    outlieri = pd.Series(False, index=df.index)
    idx_valid = df[coloana].dropna().index
    outlieri.loc[idx_valid] = z_scores > prag

    print(f"Coloana '{coloana}' (prag Z={prag}):")
    print(f"  Media: {df[coloana].mean():.1f}, Std: {df[coloana].std():.1f}")
    print(f"  Outlieri detectați: {outlieri.sum()}")

    return outlieri

# Aplicarea pe coloanele numerice
for col in ['salariu', 'varsta', 'ore_lucrate']:
    detecteaza_outlieri_zscore(df_outlieri, col)
    print()

# Z-Score modificat (folosind mediana — mai robust)
def zscore_modificat(serie):
    """Z-Score bazat pe mediană și MAD — robust la outlieri."""
    mediana = serie.median()
    mad = np.median(np.abs(serie - mediana))
    z_mod = 0.6745 * (serie - mediana) / mad  # 0.6745 = constantă de normalizare
    return z_mod

z_mod = zscore_modificat(df_outlieri['salariu'].dropna())
print("Outlieri cu Z-Score modificat (|Z| > 3.5):")
print(df_outlieri.loc[np.abs(z_mod) > 3.5, 'salariu'])

Winsorization (trunchierea valorilor)

Uneori nu vrem să eliminăm outlierii, ci doar să le atenuăm impactul. Winsorization-ul înlocuiește valorile extreme cu limita superioară sau inferioară, păstrând astfel toate rândurile. E o abordare pe care o prefer des în practică:

from scipy.stats import mstats

# Winsorization cu scipy — trunchiază valorile la percentilele specificate
salariu_wins = mstats.winsorize(df_outlieri['salariu'], limits=[0.05, 0.05])
df_outlieri['salariu_winsorized'] = salariu_wins

# Winsorization manuală — mai mult control
def winsorize_manual(serie, percentil_inf=0.05, percentil_sup=0.95):
    """Trunchiază valorile la percentilele specificate."""
    limita_inf = serie.quantile(percentil_inf)
    limita_sup = serie.quantile(percentil_sup)
    return serie.clip(lower=limita_inf, upper=limita_sup)

df_outlieri['salariu_trunchiat'] = winsorize_manual(df_outlieri['salariu'])

# Compararea distribuțiilor
print("Statistici comparative:")
comparatie = pd.DataFrame({
    'Original': df_outlieri['salariu'].describe(),
    'Winsorized': df_outlieri['salariu_winsorized'].describe(),
    'Trunchiat': df_outlieri['salariu_trunchiat'].describe(),
}).round(0)
print(comparatie)

# Transformare logaritmică — alternativă pentru distribuții puternic asimetrice
df_outlieri['salariu_log'] = np.log1p(df_outlieri['salariu'])
print(f"\nAsimetrie originală: {df_outlieri['salariu'].skew():.2f}")
print(f"Asimetrie după log: {df_outlieri['salariu_log'].skew():.2f}")

Un sfat important: alegeți metoda de tratare a outlierilor în funcție de scopul analizei. Pentru modele de regresie, winsorization-ul e adesea preferabil eliminării. Pentru analize descriptive, păstrarea outlierilor cu o notă explicativă poate fi cea mai onestă abordare. Și nu uitați — vizualizați întotdeauna datele înainte și după tratarea outlierilor, folosind boxplot-uri sau histograme (vedeți ghidul nostru de vizualizare).

Transformarea și Restructurarea Datelor

După curățarea valorilor lipsă, duplicatelor și outlierilor, adesea trebuie să transformăm și restructurăm datele pentru a le aduce în forma necesară analizei sau modelării. Asta e partea unde Pandas chiar strălucește — instrumentele de transformare sunt incredibil de flexibile.

import pandas as pd
import numpy as np

np.random.seed(42)
df_transform = pd.DataFrame({
    'id_angajat': range(1, 11),
    'prenume': ['Ana', 'Bogdan', 'Cristina', 'Dan', 'Elena',
                'Florin', 'Gabriela', 'Horia', 'Ioana', 'Jan'],
    'salariu_brut': np.random.normal(7500, 2000, 10).round(0),
    'departament': ['IT', 'HR', 'IT', 'Vânzări', 'HR',
                    'IT', 'Marketing', 'Vânzări', 'IT', 'HR'],
    'data_angajare': pd.date_range('2020-01-15', periods=10, freq='75D'),
})

# rename() — redenumirea coloanelor
df_redenumit = df_transform.rename(columns={
    'salariu_brut': 'salariu',
    'prenume': 'nume_angajat',
    'departament': 'dept'
})

# apply() — aplicarea unei funcții pe coloane sau rânduri
# Calculul salariului net (simplificat)
df_transform['salariu_net'] = df_transform['salariu_brut'].apply(
    lambda x: round(x * 0.575)  # ~42.5% taxe simplificate
)

# apply() pe rânduri (axis=1) — acces la toate coloanele
df_transform['descriere'] = df_transform.apply(
    lambda row: f"{row['prenume']} din {row['departament']} — {row['salariu_brut']:.0f} RON",
    axis=1
)

# map() — maparea valorilor dintr-o coloană
nivel_salariu = {True: 'Senior', False: 'Junior/Mid'}
df_transform['nivel'] = (df_transform['salariu_brut'] > 8000).map(nivel_salariu)

# assign() — adăugarea de coloane noi într-un lanț (method chaining)
df_final = (
    df_transform
    .assign(
        ani_experienta=lambda x: ((pd.Timestamp.now() - x['data_angajare']).dt.days / 365).round(1),
        salariu_anual=lambda x: x['salariu_brut'] * 12,
        bonus=lambda x: np.where(x['salariu_brut'] > 8000, x['salariu_brut'] * 0.15, x['salariu_brut'] * 0.10)
    )
)

print("DataFrame cu coloane noi:")
print(df_final.head())

Restructurarea datelor între format lung (long) și lat (wide) e o operație frecventă, mai ales la pregătirea datelor pentru vizualizare sau modelare. Dacă nu ați lucrat cu melt() și pivot_table() până acum, merită să le înțelegeți bine — le veți folosi constant:

# Date în format lat (wide) — o coloană per trimestru
df_wide = pd.DataFrame({
    'departament': ['IT', 'HR', 'Vânzări', 'Marketing'],
    'Q1_2025': [150000, 80000, 200000, 120000],
    'Q2_2025': [160000, 82000, 210000, 135000],
    'Q3_2025': [170000, 85000, 195000, 140000],
    'Q4_2025': [180000, 88000, 220000, 145000],
})

print("Format lat (wide):")
print(df_wide)

# melt() — din format lat în format lung
df_long = df_wide.melt(
    id_vars=['departament'],         # coloanele care rămân
    value_vars=['Q1_2025', 'Q2_2025', 'Q3_2025', 'Q4_2025'],  # coloanele care se topesc
    var_name='trimestru',            # numele coloanei cu headerele vechi
    value_name='venituri'            # numele coloanei cu valorile
)

print("\nFormat lung (long) — după melt():")
print(df_long)

# pivot_table() — din format lung în format lat + agregare
df_pivot = df_long.pivot_table(
    index='departament',
    columns='trimestru',
    values='venituri',
    aggfunc='sum'
)

print("\nÎnapoi la format lat — după pivot_table():")
print(df_pivot)

# pivot_table() cu agregare multiplă
df_pivot_agg = df_long.pivot_table(
    index='departament',
    columns='trimestru',
    values='venituri',
    aggfunc=['sum', 'mean'],
    margins=True,              # adaugă totaluri
    margins_name='TOTAL'
)

print("\nPivot cu agregare multiplă și totaluri:")
print(df_pivot_agg)

Restructurarea datelor e esențială și pentru integrarea cu alte instrumente. Seaborn preferă formatul lung pentru majoritatea vizualizărilor, în timp ce anumite modele de machine learning necesită formatul lat. Dacă lucrați și cu Polars, veți regăsi operații similare (melt, pivot), dar cu o sintaxă ușor diferită.

Pipeline-uri de Curățare cu Method Chaining și pipe()

Până acum am tratat fiecare problemă separat. Dar în practică, vrem să combinăm toți pașii ăștia într-un pipeline coerent, reproductibil și ușor de întreținut. Iar Pandas 3.0 face acest lucru mai sigur ca niciodată datorită Copy-on-Write, care e acum comportamentul implicit.

Ce înseamnă Copy-on-Write pentru pipeline-uri? Pe scurt: în versiunile anterioare de Pandas, method chaining-ul putea genera avertismente SettingWithCopyWarning sau, mai rău, bug-uri subtile când modificarea unui DataFrame afecta accidental altul. Cu Copy-on-Write, fiecare operație care modifică date creează automat o copie internă. Rezultatul? O întreagă clasă de bug-uri dispare pur și simplu.

import pandas as pd
import numpy as np

# Method chaining — un lanț de transformări
# Fiecare metodă returnează un DataFrame nou (Copy-on-Write în Pandas 3.0)

np.random.seed(42)

# Date brute cu multiple probleme
df_brut = pd.DataFrame({
    'Nume Complet': ['  ana popescu  ', 'BOGDAN IONESCU', 'cristina matei', None, 'Dan Pop'],
    'Departament': ['IT', 'it', 'HR', 'IT', ' Vanzari '],
    'Salariu (RON)': ['7500', '8200', 'N/A', '6800', '9100'],
    'Vârsta': [28, 35, None, 42, 55],
    'Data Angajare': ['2020-01-15', '2019-06-20', '2021-03-10', '2018-11-05', '2022-08-01'],
})

# Pipeline complet de curățare cu method chaining
df_curat = (
    df_brut
    # Redenumirea coloanelor — format consistent
    .rename(columns=lambda c: c.lower().replace(' ', '_').replace('(', '').replace(')', ''))

    # Eliminarea rândurilor complet goale
    .dropna(how='all')

    # Curățarea textului
    .assign(
        nume_complet=lambda x: x['nume_complet'].str.strip().str.title(),
        departament=lambda x: x['departament'].str.strip().str.title(),
    )

    # Corectarea tipurilor de date
    .assign(
        salariu_ron=lambda x: pd.to_numeric(x['salariu_ron'], errors='coerce'),
        data_angajare=lambda x: pd.to_datetime(x['data_angajare'], errors='coerce'),
    )

    # Completarea valorilor lipsă
    .assign(
        salariu_ron=lambda x: x['salariu_ron'].fillna(x['salariu_ron'].median()),
        vârsta=lambda x: x['vârsta'].fillna(x['vârsta'].median()),
    )

    # Eliminarea duplicatelor
    .drop_duplicates(subset=['nume_complet'])

    # Resetarea indexului
    .reset_index(drop=True)
)

print("Date brute:")
print(df_brut)
print("\nDate curățate:")
print(df_curat)
print("\nTipuri de date:")
print(df_curat.dtypes)

Frumos, nu? Dar pentru pipeline-uri mai complexe sau funcții de curățare pe care vrem să le reutilizăm, pipe() e instrumentul ideal. Permite inserarea oricărei funcții custom în lanțul de method chaining:

import pandas as pd
import numpy as np

# Funcții de curățare reutilizabile
def standardizeaza_coloane(df):
    """Redenumește coloanele în format snake_case."""
    df = df.copy()
    df.columns = (
        df.columns
        .str.lower()
        .str.strip()
        .str.replace(r'[^a-z0-9_]', '_', regex=True)
        .str.replace(r'_+', '_', regex=True)
        .str.strip('_')
    )
    return df

def curata_text(df, coloane):
    """Curăță coloanele text: strip, title case."""
    df = df.copy()
    for col in coloane:
        if col in df.columns:
            df[col] = df[col].str.strip().str.title()
    return df

def trateaza_outlieri(df, coloane, metoda='iqr', factor=1.5):
    """Aplică winsorization pe coloanele specificate."""
    df = df.copy()
    for col in coloane:
        if col in df.columns and df[col].dtype in ['float64', 'int64']:
            Q1 = df[col].quantile(0.25)
            Q3 = df[col].quantile(0.75)
            IQR = Q3 - Q1
            limita_inf = Q1 - factor * IQR
            limita_sup = Q3 + factor * IQR
            df[col] = df[col].clip(lower=limita_inf, upper=limita_sup)
    return df

def adauga_features(df):
    """Adaugă coloane derivate."""
    df = df.copy()
    if 'data_angajare' in df.columns:
        df['ani_vechime'] = (
            (pd.Timestamp.now() - pd.to_datetime(df['data_angajare'])).dt.days / 365
        ).round(1)
    if 'salariu' in df.columns:
        df['categorie_salariu'] = pd.cut(
            df['salariu'],
            bins=[0, 5000, 8000, 12000, float('inf')],
            labels=['Mic', 'Mediu', 'Mare', 'Foarte Mare']
        )
    return df

# Pipeline complet cu pipe()
np.random.seed(42)
n = 100
df_raw = pd.DataFrame({
    'Nume Angajat': [f'  angajat {i}  ' for i in range(n)],
    'Departament': np.random.choice(['IT', 'it', ' HR ', 'vanzari', 'MARKETING'], n),
    'Salariu': np.random.normal(7500, 2500, n).round(0),
    'Varsta': np.random.randint(22, 62, n),
    'Data Angajare': pd.date_range('2018-01-01', periods=n, freq='15D').astype(str),
})

# Inserăm câțiva outlieri
df_raw.loc[0, 'Salariu'] = 50000
df_raw.loc[1, 'Salariu'] = 300

df_curat = (
    df_raw
    .pipe(standardizeaza_coloane)
    .pipe(curata_text, coloane=['nume_angajat', 'departament'])
    .pipe(trateaza_outlieri, coloane=['salariu', 'varsta'])
    .pipe(adauga_features)
    .dropna()
    .drop_duplicates()
    .reset_index(drop=True)
)

print(f"Rânduri: {len(df_raw)} → {len(df_curat)}")
print(f"\nPrimele 5 rânduri curățate:")
print(df_curat.head())
print(f"\nColoane: {list(df_curat.columns)}")
print(f"\nStatistici salariu (după winsorization):")
print(df_curat['salariu'].describe().round(0))

Această abordare cu funcții modulare și pipe() se integrează perfect cu pipeline-urile scikit-learn. Puteți crea transformări custom care se încadrează în sklearn.pipeline.Pipeline, combinând curățarea Pandas cu preprocesarea și modelarea într-un flux unitar. E o practică pe care o recomand cu tărie pentru proiectele de producție — vă va scuti de mult haos pe termen lung.

Checklist Finală pentru Curățarea Datelor

După toate tehnicile de mai sus, iată un checklist pe care îl parcurg la fiecare proiect. Îl puteți folosi ca referință rapidă (serios, salvați-l undeva):

  1. Explorare inițială — Rulați df.info(), df.describe(), df.shape și df.isnull().sum() pentru a înțelege structura și problemele datelor.
  2. Valori lipsă — Identificați procentul de valori lipsă per coloană. Eliminați coloanele cu peste 60% NaN. Pentru restul, alegeți între eliminare, completare cu mediană/medie grupată sau interpolare.
  3. Duplicate — Verificați duplicatele exacte și cele aproximative (după standardizarea textului). Decideți dacă păstrați prima, ultima apariție sau eliminați toate.
  4. Tipuri de date — Asigurați-vă că fiecare coloană are tipul corect: numerice ca float64/int64, date ca datetime64, text ca string. Utilizați backend-ul PyArrow pentru eficiență.
  5. Curățarea textului — Eliminați spațiile extra, standardizați capitalizarea, normalizați categoriile și eliminați caracterele speciale irelevante.
  6. Outlieri — Detectați outlierii cu IQR sau Z-Score. Decideți dacă îi eliminați, îi trunchiați (winsorization) sau îi păstrați cu documentarea explicită.
  7. Transformări și restructurare — Aplicați transformările necesare (apply, map, assign) și restructurați datele în formatul cerut (melt, pivot_table).
  8. Validare finală — Reverificați df.info(), df.isnull().sum() și df.describe() pentru a confirma că toate problemele au fost rezolvate.
  9. Documentare — Documentați pașii de curățare aplicați, deciziile luate (de ce ați ales fillna vs dropna, de ce ați trunchiat outlierii) și orice presupuneri.
  10. Reproductibilitate — Încapsulați pipeline-ul de curățare în funcții reutilizabile cu pipe() sau integrați-l într-un pipeline scikit-learn.

Întrebări Frecvente (FAQ)

Cum aleg între dropna() și fillna() pentru valorile lipsă?

Depinde de mai mulți factori. Folosiți dropna() când procentul de valori lipsă e mic (sub 5%), când aveți suficiente date rămase pentru analiză și când valorile lipsă sunt distribuite aleatoriu (MCAR — Missing Completely At Random). Folosiți fillna() când eliminarea ar reduce semnificativ setul de date, când coloanele cu valori lipsă sunt critice pentru analiză sau când puteți estima rezonabil valorile lipsă.

Regula mea practică: pentru coloane cu sub 5% NaN, dropna() e de obicei suficient. Pentru 5-30%, fillna() cu mediana grupată e o alegere solidă. Peste 30%? Întrebați-vă serios dacă acea coloană merită inclusă în analiză.

Care este diferența între apply() și map() în pandas?

Funcția map() e disponibilă doar pe obiecte Series (o singură coloană) și e optimizată pentru mapări element-cu-element — fie printr-o funcție, fie printr-un dicționar. Funcția apply() funcționează atât pe Series cât și pe DataFrame și e mai flexibilă: pe un DataFrame cu axis=0 aplică funcția pe fiecare coloană, iar cu axis=1 pe fiecare rând.

În practică, folosiți map() pentru transformări simple ale unei coloane (înlocuiri, conversii) și apply() când aveți nevoie de acces la multiple coloane simultan sau de logică mai complexă. Și rețineți: operațiile vectorizate native Pandas sunt întotdeauna preferabile ambelor funcții din punct de vedere al performanței.

Cum detectez și tratez valorile aberante fără să pierd date importante?

Cheia e să separați detecția de tratare. Mai întâi, detectați outlierii folosind metoda IQR sau Z-Score și vizualizați-i — un boxplot sau un scatter plot vă vor arăta rapid dacă sunt erori sau valori legitime.

Pentru tratare, aveți trei opțiuni care nu implică pierderea datelor: winsorization-ul (trunchierea la percentilele 5 și 95), transformarea logaritmică (pentru distribuții asimetrice) și crearea unei coloane flag care marchează outlierii fără a-i elimina. Personal, prefer winsorization-ul pentru modele predictive și păstrarea outlierilor cu flag-uri pentru analize descriptive. Nu uitați: documentați întotdeauna de ce ați ales o anumită abordare.

Cum curăț eficient coloane de text cu caractere speciale sau formate inconsistente?

Abordarea sistematică e esențială aici. Începeți întotdeauna cu str.strip() pentru a elimina spațiile extra, apoi str.lower() pentru a standardiza capitalizarea. Folosiți str.replace() cu expresii regulate pentru a elimina sau standardiza pattern-uri specifice (formatele de telefon, codurile poștale, etc.).

Pentru categorii, creați dicționare de mapare explicite cu map() sau replace() — e mai sigur decât regex-urile complexe. Și un detaliu specific limbii române: atenție la encoding-ul caracterelor speciale. Verificați dacă nu aveți mixuri între ș (s-comma-below) și ş (s-cedilla) — e o sursă clasică de duplicate „fantomă". După curățare, verificați cu value_counts() că numărul de categorii unice e cel așteptat.

Ce noutăți aduce Pandas 3.0 pentru curățarea datelor?

Pandas 3.0, lansat în ianuarie 2026, vine cu trei schimbări majore relevante pentru curățarea datelor. Prima și cea mai importantă: Copy-on-Write activat implicit — asta elimină complet avertismentele SettingWithCopyWarning și face method chaining-ul absolut sigur, pentru că fiecare operație lucrează pe o copie independentă a datelor.

A doua schimbare e backend-ul PyArrow, care oferă tipuri de date mai eficiente, suport nativ pentru valori lipsă în toate tipurile de coloane (nu doar numerice) și performanță semnificativ mai bună la I/O. A treia e îmbunătățirea generală a performanței la operațiile pe string-uri și la grupări, ceea ce accelerează direct pipeline-urile de curățare. Pentru detalii complete și ghid de migrare, consultați articolul nostru dedicat Pandas 3.0.

Despre Autor Editorial Team

Our team of expert writers and editors.