Python, de la zero Lecția 28 / 60

Selectie: .loc, .iloc, indexare booleana

Cele trei moduri de a felia un DataFrame, diferentele dintre ele si SettingWithCopyWarning de care se izbeste toata lumea.

Ai un DataFrame. Acum vrei o bucată din el: o coloană, câteva rânduri, rândurile în care o anumită condiție se ține, celula de pe rândul 7 coloana 3. Pandas îți dă trei moduri să faci asta și arată suficient de asemănător încât începătorii le îmbină până când ceva merge. Rezultatul e cod care rulează azi și se sparge săptămâna viitoare când se schimbă indexul. Astăzi descurcăm cele trei idiomuri (paranteze drepte, .loc, .iloc) și la final ne uităm la SettingWithCopyWarning, cel mai googleat mesaj din lumea pandas.

Vom lucra cu un singur DataFrame de exemplu pe parcurs:

import pandas as pd

df = pd.DataFrame(
    {
        "country": ["IT", "DE", "FR", "IT", "DE", "FR"],
        "year":    [2024, 2024, 2024, 2025, 2025, 2025],
        "revenue": [120.0, 340.0, 210.0, 145.0, 380.0, 225.0],
        "orders":  [12, 25, 18, 15, 28, 19],
    }
)

Șase rânduri, patru coloane, index întreg implicit. Suficient de simplu ca să poți raționa, suficient de real ca să fie util.

Parantezele drepte: comode și ambigue

Sintaxa simplă df[...] e prima pe care o învață lumea, fiindcă seamănă cu un dict Python. Face patru lucruri diferite în funcție de ce pui înăuntru:

df["revenue"]                   # o coloana -> Series
df[["country", "revenue"]]      # lista de coloane -> DataFrame
df[0:3]                         # felie de randuri -> DataFrame (primele 3 randuri)
df[df["revenue"] > 200]         # masca booleana -> DataFrame (randuri filtrate)

Asta înseamnă patru operații diferite care împart o singură sintaxă. E comod la REPL: nu trebuie să-ți amintești care e care, scrii ceva și pandas își dă seama. E și ambiguu: dacă ai o coloană numită literal 0 sau un DataFrame indexat după string, sensul lui df[0:3] se schimbă. Pentru o analiză punctuală, parantezele sunt în regulă. Pentru cod pe care-l vei reciti peste șase luni, preferă .loc sau .iloc, care înseamnă fiecare exact un singur lucru.

Celălalt motiv să eviți parantezele în pipeline-uri e chaining-ul. df[df["revenue"] > 200]["orders"] = 0 arată ca și cum ar trebui să seteze orders la zero pe rândurile cu venit mare. Nu o face, în mod fiabil. Vom reveni la motiv într-un minut.

.loc: selecție pe baza etichetelor

.loc indexează după etichetă: după valoarea indexului pentru rânduri, după valoarea numelui coloanei pentru coloane. Două argumente, separate prin virgulă:

df.loc[row_labels, col_labels]

Pentru indexul nostru întreg implicit, etichetele de rânduri se întâmplă să coincidă cu pozițiile de rânduri: df.loc[2] e al treilea rând fiindcă eticheta lui e 2. Echivalența asta e capcana: merge până când faci df = df.sort_values("revenue") și acum df.loc[2] e oriunde a ajuns rândul cu eticheta originală 2, nu al treilea rând. Gândește-te mereu la .loc ca „după etichetă”, chiar și când etichetele arată ca poziții.

Forme concrete:

df.loc[0]                              # randul cu eticheta 0 (Series)
df.loc[[0, 2, 4]]                      # randuri cu aceste etichete (DataFrame)
df.loc[1:3]                            # randuri de la eticheta 1 la eticheta 3 INCLUSIV
df.loc[:, "revenue"]                   # toate randurile, o coloana (Series)
df.loc[:, ["country", "revenue"]]      # toate randurile, doua coloane (DataFrame)
df.loc[0:2, "country":"revenue"]       # felie dreptunghiulara, ambele capete inclusive

Două lucruri de internalizat:

Feliile sunt inclusive la ambele capete. df.loc[1:3] întoarce etichetele 1, 2 și 3. E opusul oricărei alte felii Python pe care ai văzut-o vreodată și e intenționat: cu valori arbitrare de index (string-uri, date), „oprește-te chiar înainte de eticheta asta” e rar ce vrei. Tot e surprinzător prima dată.

Măștile booleene trăiesc pe axa rândurilor. Ăsta e pattern-ul de producție:

mask = df["revenue"] > 200
df.loc[mask, "orders"]                 # coloana orders pentru randurile cu venit mare
df.loc[mask, ["country", "orders"]]    # doua coloane pentru randurile cu venit mare
df.loc[mask, "orders"] = 0             # ATRIBUIRE (asta e forma sigura)

Masca trebuie să fie un Series boolean aliniat la indexul rândurilor. .loc îl acceptă pozițional, primul argument, axa rândurilor, și îl poți combina cu o etichetă de coloană sau o listă pe a doua axă. Asta e forma de memorat; așa ar trebui să arate 80% din filtrarea din lumea reală.

.iloc: selecție pe baza poziției

.iloc e cealaltă. Aceeași formă, reguli diferite: indexează după poziția întregului, nu după etichetă. Feliile se comportă ca felii Python normale (semi-deschise):

df.iloc[0]                  # primul rand (Series)
df.iloc[-1]                 # ultimul rand
df.iloc[0:3]                # primele 3 randuri (stop e exclusiv, ca la o lista)
df.iloc[:, 0]               # prima coloana (Series)
df.iloc[0:3, 0:2]           # primele 3 randuri, primele 2 coloane
df.iloc[[0, 2, 4]]          # randuri pe aceste pozitii

Folosește .iloc când vrei sincer „al treilea rând” indiferent care e eticheta lui: primul rând al unei vizualizări sortate, ultimele 100 de rânduri, fiecare al doilea rând. Folosește .loc când vrei o valoare specifică etichetată, inclusiv orice mască booleană.

O regulă utilă: .iloc pentru instalații sanitare poziționale (head, tail, eșantionare), .loc pentru tot ce ține de ce e în date.

Indexarea booleană: filtrul de zi cu zi

Un Series boolean de aceeași lungime ca axa rândurilor devine un filtru de rânduri:

df.loc[df["revenue"] > 200]
df.loc[df["country"] == "IT"]
df.loc[df["country"].isin(["IT", "FR"])]
df.loc[df["revenue"].between(150, 300)]
df.loc[~df["country"].isin(["DE"])]    # ~ este "not"

Combină măștile cu & (and), | (or), ~ (not). Folosește paranteze, fiindcă & leagă mai strâns decât > și altfel vei primi o eroare derutantă:

df.loc[(df["revenue"] > 200) & (df["country"] == "DE")]
df.loc[(df["country"] == "IT") | (df["country"] == "FR")]

Dacă uiți parantezele, pandas ridică un TypeError care menționează operații pe biți pe bool. Ăsta e mesajul; remedierea sunt mereu parantezele.

Pentru NaN, predicatele de mască au helperi specifici:

df.loc[df["revenue"].isna()]
df.loc[df["revenue"].notna()]

Nu folosi df["revenue"] == None sau df["revenue"] != float("nan"): NaN nu e egal cu nimic, nici cu el însuși. isna/notna e singurul mod corect.

.query: când măștile devin zgomotoase

Dacă stivuiești trei sau patru condiții, parantezele devin obositoare. df.query acceptă un string în stil SQL:

df.query("revenue > 200 and country == 'DE'")
df.query("country in ['IT', 'FR'] and year == 2025")

Câteva reguli:

  • Literalele string sunt în ghilimele simple (fiindcă întreaga expresie e în ghilimele duble).
  • Numele coloanelor cu spații sau puncte au nevoie de backtick-uri: df.query("order count > 5").
  • Referențiază variabile Python cu @: threshold = 200; df.query("revenue > @threshold").

.query e o victorie reală pe lizibilitate pentru filtre de dimensiune medie. Pentru filtre cu o singură condiție, .loc[mask] e mai scurt; pentru filtre cu multe condiții, .query se citește ca SQL-ul pe care analistul oricum încearcă să-l scrie.

Setarea valorilor: pattern-ul corect

Citirea e jumătate din poveste. Scrierea e locul unde încep avertismentele.

Pattern-ul corect e df.loc[mask, col] = value:

df.loc[df["revenue"] > 300, "country"] = "DE-PREMIUM"
df.loc[df["country"] == "IT", "orders"] *= 2
df.loc[df["revenue"].isna(), "revenue"] = 0

Un singur apel .loc, două argumente, atribuire. Pandas știe că e o scriere, mută DataFrame-ul original pe loc și nu există ambiguitate dacă modifici o vizualizare sau o copie.

Pattern-ul greșit, cel care produce avertismentul faimos, e indexarea înlănțuită:

df[df["revenue"] > 300]["country"] = "DE-PREMIUM"   # NU

Asta apelează df[df["revenue"] > 300] (care întoarce un DataFrame filtrat nou, posibil o vizualizare, posibil o copie) și apoi atribuie la ["country"] pe acela. Pandas nu poate spune dacă scrierea ta lovește df original sau o copie temporară pe cale să fie colectată de gunoi. Istoric, uneori mergea, alteori nu, în funcție de layout-ul dtype-ului. Așa că pandas arunca SettingWithCopyWarning și-și ținea pumnii.

SettingWithCopyWarning, pe scurt

Textul avertismentului e faimos de derutant:

A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer, col_indexer] = value instead.

Ce înseamnă: ai folosit indexare înlănțuită, iar pandas nu e sigur dacă atribuirea ta a prins. Remedierea e exact ce-ți spune avertismentul literal: colapsează lanțul într-un singur .loc[mask, col] = value.

Cauza profundă: array-urile NumPy susțin DataFrame-urile, iar felierea unui array NumPy uneori întoarce o vizualizare (scrierile se propagă la original) și uneori întoarce o copie (scrierile nu se propagă). Pandas nu putea spune din interior, așa că avertiza defensiv ori de câte ori structura părea periculoasă.

Remedierea pandas 2.0+: copy-on-write. Pandas are acum un mod copy-on-write care rezolvă ambiguitatea garantând că fiecare operație se comportă ca și cum ar întoarce o copie: scrierile merg mereu la obiectul în care ai scris, niciodată tăcut la părintele lui. Începând cu pandas 2.2 e opt-in prin pd.set_option("mode.copy_on_write", True); în pandas 3.0 devine implicit. În 2026, activarea lui în capul oricărui proiect nou e alegerea corectă:

import pandas as pd

pd.set_option("mode.copy_on_write", True)

Cu CoW activ, pattern-ul de indexare înlănțuită pur și simplu nu scrie nicăieri util (modifică copia aruncată, iar originalul rămâne neatins), iar pandas emite un ChainedAssignmentError în loc de un avertisment, care e mult mai greu de ignorat. Oricum, pattern-ul corect rămâne neschimbat: df.loc[mask, col] = value.

Adăugarea de coloane

Două forme echivalente:

df["revenue_per_order"] = df["revenue"] / df["orders"]
df.loc[:, "revenue_per_order"] = df["revenue"] / df["orders"]

Pentru creare necondiționată de coloană, forma cu paranteze e în regulă și idiomatică: nu e niciun lanț, doar o singură atribuire. Pentru creare condiționată de coloană (setează o valoare doar pe unele rânduri), folosește .loc:

df["tier"] = "standard"
df.loc[df["revenue"] > 300, "tier"] = "premium"

Sau, pentru multe ramuri, np.select:

import numpy as np

df["tier"] = np.select(
    [df["revenue"] > 300, df["revenue"] > 150],
    ["premium", "mid"],
    default="standard",
)

np.select e if/elif/else-ul cu mai multe ramuri pentru atribuire vectorizată.

Punând totul cap la cap

Un pipeline real de filtrare arată cam așa:

import pandas as pd

pd.set_option("mode.copy_on_write", True)

df = pd.read_parquet("sales.parquet")

# Filtreaza la randurile care ne intereseaza.
df = df.loc[
    (df["country"].isin(["IT", "DE", "FR"]))
    & (df["year"] == 2025)
    & df["revenue"].notna()
]

# Adauga o coloana derivata.
df["revenue_per_order"] = df["revenue"] / df["orders"]

# Ajusteaza conditionat unele randuri.
df.loc[df["country"] == "IT", "revenue"] *= 1.05  # 5% corectie IT

# Alege coloanele pe care le vrem in aval.
result = df.loc[:, ["country", "revenue", "orders", "revenue_per_order"]]

Fiecare operație e un singur apel .loc, fără chaining, fără avertismente. Citește înapoi: filtrează, adaugă, ajustează, proiectează. Ăsta e tot vocabularul.

Ce urmează

Lecția 29 e groupby și agregarea: odată ce poți alege rândurile dorite, următorul lucru pe care-l vrei e să le sumarizezi pe grupuri. Vom acoperi agregările denumite, distincția agg/apply/transform (care încurcă aproape pe toată lumea) și pivot table-urile.

Lectură suplimentară

Ne vedem marți.

Caută