6.2 Kiválasztás és módosítás Copy-on-Write mellett

6.2.1 Cél és konkrét helyzet

A Séma, dtypes és hiányzó értékek alfejezetben explicit sémával betöltöttük az orders_raw.csv fájlt, és sémajelentésben azonosítottuk a hibás sorokat. Most a következő lépéshez érkeztünk: a betöltött DataFrame-ben javítani és kiválasztani szeretnénk adatokat anélkül, hogy a módosítás csendben egy ideiglenes másolaton történne, vagy hogy a pandas címkeillesztését összekeverjük a NumPy pozicionális műveletével.

A konkrét feladatunk két részből áll. Először ellenőrizni fogjuk, hogy két eltérő indexű Series összeadása hogyan viselkedik a pandasban: az indexek unióján, a hiányzó címkéken pd.NA vagy NaN értékkel, nem pedig pozicionálisan. Ezután egy láncolt értékadás (chained assignment) javítását végezzük el: a business szegmensű ügyfelek priority_flag oszlopát szeretnénk módosítani, és a hibás df[df.segment == "business"]["priority_flag"] = 0 formát explicit .loc alakba írjuk át.

Előzetes eredménybecslés: a két eltérő indexű sorozat összeadásánál a kimenet hossza legfeljebb a két index uniójának mérete; ha az egyik sorozatnak van olyan címkéje, amely a másikban nincs, azon a helyen hiányzó érték jelenik meg. A láncolt értékadásnál pedig azt várjuk, hogy a hibás forma nem módosítja az eredeti DataFrame-et, vagy figyelmeztetést ad, míg az explicit .loc forma helyesen frissíti az értékeket.

6.2.2 Fogalmi magyarázat

6.2.2.1 loc és iloc: címke és pozíció

A pandas DataFrame-ből két alapvető módon választhatunk ki sorokat és oszlopokat.

A kettő közötti különbség a matematikai híd lényege: a címkék halmazjellegű azonosítók — nem feltétlenül rendezettek, nem feltétlenül egyediek, és nem feltétlenül egész számok —, míg a pozíciók rendezett egész indexek. Egy (n,)-es táblázatnak mindig van 0…n−1 pozíciója, de a címkéi lehetnek például karakteres customer_id értékek is. Ha a címkék történetesen egész számok, akkor sem keverendő a kettő: df.loc[3] a 3 címkéjű sort keresi, df.iloc[3] a negyedik sort.

Kis kézi példa:

import pandas as pd

df = pd.DataFrame(
    {"ertek": [10, 20, 30]},
    index=["c001", "c002", "c003"],
)
df.loc["c002", "ertek"]   # címke szerint: 20
df.iloc[1, 0]             # pozíció szerint: 20

6.2.2.2 Boolean mask

A boolean mask (logikai maszk) egy olyan, az indexhez illeszkedő True/False sorozat, amely kijelöli a megtartandó sorokat. Előállítása jellemzően összehasonlítással történik:

df = pd.DataFrame({"segment": ["business", "consumer", "business"],
                   "priority_flag": [1, 0, 1]})
mask = df["segment"] == "business"

A mask itt Series[bool], ahol minden sorhoz egy logikai érték tartozik. A pandas Series típusú, címkézett maszkot közvetlenül a .loc fogadja el. A .iloc nem fogad el közvetlenül ilyen címkézett boolean Series-t: pozicionális logikai tömbbé kell alakítani (mask.to_numpy(dtype=bool)), miután ellenőriztük, hogy a sorrend és a hossz egyezik:

df.loc[mask, "priority_flag"]              # indexcímke-alapú kiválasztás
df.iloc[mask.to_numpy(dtype=bool), df.columns.get_loc("priority_flag")]  # pozicionális oszlop

A .loc esetén a Series-maszk indexe illeszkedjen a DataFrame indexéhez (eltéréskor figyelmeztetés vagy kivétel is felléphet); .iloc esetén a numpy boolean tömb hosszának kell megegyeznie a sorok számával, és kizárólag pozíció szerint választ. A két megoldás nem általánosan felcserélhető. Regressziós ellenőrzés:

import pandas as pd
check = pd.DataFrame({"value": [10, 20, 30]})
mask = check["value"] > 10
assert check.loc[mask, "value"].tolist() == [20, 30]
assert check.iloc[mask.to_numpy(dtype=bool), 0].tolist() == [20, 30]
# A check.iloc[mask] pandas 2.2.x alatt NotImplementedError hibát ad.

6.2.2.3 Index-illesztés

A pandas index-illesztés (index alignment) azt jelenti, hogy két Series vagy DataFrame közötti művelet a címkék szerint történik, nem a pozíció szerint. Ez alapvető különbség a NumPy-hoz képest, ahol az azonos alakú tömbök elemenként, pozíció szerint kapcsolódnak össze.

Példa: legyen

s1 = pd.Series([100, 200], index=["a", "b"])
s2 = pd.Series([5, 7], index=["b", "c"])

Az s1 + s2 eredménye az indexek unióján, azaz ["a", "b", "c"] felett értelmezett:

címke s1 s2 összeg
a 100 hiány hiány
b 200 5 205
c hiány 7 hiány

A hiányzó címkéken a pandas NaN-t (numerikus Series esetén) vagy pd.NA-t (nullable dtype esetén) ad. Ha tehát két eltérő indexű sorozattal dolgozunk, az összeg hossza az unió mérete lesz, és a közös címkéken kívül hiányzó értékeket kapunk. Ez a viselkedés a relációs adatbázisok FULL OUTER JOIN-jához hasonlít, nem a NumPy pozicionális műveletéhez.

Az index-illesztés a DataFrame-ek közötti műveletekre is vonatkozik: ha például df1 + df2-t írunk, a kimenet sorait a két sorindex uniója, oszlopait a két oszlopkészlet uniója adja. A hiányzó kombinációkon NaN áll.

6.2.2.4 Copy-on-Write

A Copy-on-Write (CoW, „írásra másolás”) a pandas 2.0-ban bevezetett, a 3.x sorozatban alapértelmezetté vált memóriakezelési mód. A lényege: amikor egy DataFrame-ből szeleteléssel vagy szűréssel új objektumot hozunk létre, a pandas nem másolja azonnal az adatokat, hanem egy nézetet (view) tart fenn, és a tényleges másolás csak akkor történik meg, amikor valaki módosítani próbálja az adatot. Ezzel a gyakori, csak olvasásra szolgáló műveletek memóriaigénye csökken, a módosítások pedig nem „szivárognak vissza” az eredeti objektumba.

A gyakorlati következmény számunkra: a láncolt értékadás (chained assignment) nem megbízható. Az

df[df["segment"] == "business"]["priority_flag"] = 0

kifejezés két egymást követő __getitem__ hívást jelent. Az első df[df["segment"] == "business"] egy szűrt részt ad; a második ["priority_flag"] ennek a résznek egy oszlopát. Az értékadás ezután nem az eredeti df-en, hanem ezen a köztes objektumon történik — amely CoW bekapcsolásával vagy egyáltalán nem módosítja az eredetit, vagy SettingWithCopyWarning figyelmeztetést ad, vagy a pandas verziójától függően egyáltalán nem hajtódik végre. Az eredmény tehát csendben is lehet hibás: a kód lefut, de az eredeti táblázat változatlan marad.

A javított alak egyetlen .loc hívás, amely egyszerre címzi a sorokat és az oszlopot:

df.loc[df["segment"] == "business", "priority_flag"] = 0

Itt a pandas egyetlen műveletben tudja, hogy az eredeti df-et kell módosítani. Ez CoW mellett is helyesen működik, mert a .loc explicit jelzi a módosítás helyét.

6.2.2.5 pandas verzió

A CoW a pandas 2.x sorozatban opcionális: a kezdőprojekt a lab/pyproject.toml szerint pandas 2.2.3-mal futott, ahol a CoW alapértelmezetten kikapcsolt. A pandas 3 a CoW-t alapértelmezetten bekapcsolja. A tanulási környezetünk ezért két dolgot tesz:

  1. A kódot eleve CoW-kompatibilisen írjuk: kizárólag explicit .loc/.iloc értékadást használunk, láncolt assignmentet soha.
  2. A verziót a futás elején ellenőrizzük, hogy a környezeti eltérések ne maradjanak rejtve:
import pandas as pd

print(pd.__version__)
# Szemléltető kimenet a kezdőlaborban: "2.2.3"

A CoW a pandas 2.2-ben a pd.options.mode.copy_on_write = True beállítással kapcsolható be. A könyv példái nem feltételezik ezt a beállítást; helyette az explicit műveleti stílust tanítjuk, amely mindkét verziócsaládban azonos eredményt ad. A Broadcasting, indexelés és másolás alfejezetben látott NumPy-s view/copy megkülönböztetés itt is visszaköszön: a pandas a NumPy-tömbök view/copy viselkedésére épül, de az index-illesztés rétege megváltoztatja, hogy melyik művelet mikor ad másolatot.

6.2.3 Teljes minimális munkapélda

A laborhoz az orders_raw.csv már betöltött, explicit sémás változatát használjuk. A példát úgy építjük fel, hogy egy kis, kézzel ellenőrizhető DataFrame-en mutatjuk be a két kulcsműveletet, majd ugyanazt a formát alkalmazzuk a nagyobb táblázatra.

6.2.3.1 1. lépés: kis kézi táblázat

import pandas as pd

# Kézi példa: három rendelés, karakteres indexszel
df = pd.DataFrame(
    {
        "order_id": ["o1", "o2", "o3"],
        "segment": ["consumer", "business", "consumer"],
        "priority_flag": [0, 1, 0],
        "basket_minor": [1200, 4500, 800],
    }
).set_index("order_id")
df

Szemléltető kimenet:

order_id segment priority_flag basket_minor
o1 consumer 0 1200
o2 business 1 4500
o3 consumer 0 800

6.2.3.2 2. lépés: két eltérő indexű sorozat művelete

# Két eltérő indexű sorozat
s1 = pd.Series([100, 200], index=["a", "b"])
s2 = pd.Series([5, 7], index=["b", "c"])

s_sum = s1 + s2
s_sum

Szemléltető kimenet:

index érték
a NaN
b 205
c NaN

Az ellenőrzés kézzel: a b címkén 200 + 5 = 205; az a és c címkék csak az egyik sorozatban szerepelnek, ezért ott hiányzó érték áll. A kimenet hossza 3, ami a két index uniójának mérete.

6.2.3.3 3. lépés: láncolt értékadás hiba és javítás

Hibás forma:

# Rossz: láncolt assignment
df[df["segment"] == "business"]["priority_flag"] = 0
# pandas 2.2-ben CoW nélkül: SettingWithCopyWarning,
# és az eredeti df NEM módosul megbízhatóan.

Javított forma:

# Helyes: egyetlen explicit .loc
df.loc[df["segment"] == "business", "priority_flag"] = 0
df

Szemléltető kimenet a javítás után:

order_id segment priority_flag basket_minor
o1 consumer 0 1200
o2 business 0 4500
o3 consumer 0 800

Az o2 rendelés priority_flag értéke 1-ről 0-ra változott, a többi sor érintetlen. Ez a módosítás egyetlen .loc hívással, az eredeti DataFrame-en történt.

6.2.3.4 4. lépés: a labor orders_raw tábláján

# Feltételezzük az s021-ből ismert betöltést:
# df_raw = pd.read_csv("lab/data/orders_raw.csv", dtype=schema)
# Itt a már betöltött df_raw egy részletével dolgozunk.

# Ellenőrzés: a business sorok módosítása explicit .loc formában
business_mask = df_raw["segment"] == "business"
df_raw.loc[business_mask, "priority_flag"] = 0

# Elvárt ellenőrzés: a business sorokban minden priority_flag 0
assert (df_raw.loc[business_mask, "priority_flag"] == 0).all()

6.2.4 Eredmény értelmezése

A kézi példa ellenőrizhető invariantja: a s_sum = s1 + s2 sorozat indexe megegyezik a két bemeneti index uniójával (["a", "b", "c"]), és a közös címkén az érték a két bemenet összege (205). A hiányzó címkéken NaN áll. Ez a FULL OUTER JOIN-szemantika, nem a NumPy pozicionális művelete: ha a két sorozatot NumPy-tömbökként adnánk össze, a kimenet [105, 207] lenne, ami a második elemet tévesen 200 + 7 = 207-ként számolná, mert a NumPy nem tud a címkékről.

A láncolt értékadás ellenőrzése: a hibás forma után az eredeti df priority_flag oszlopát megvizsgálva azt látjuk, hogy a business sor továbbra is 1 maradt (vagy a pandas verziójától függően figyelmeztetést kapunk). Az explicit .loc forma után az érték 0, és ezt assert-tel automatikusan ellenőrizzük. A maszk (business_mask) maga is Series[bool], amelynek indexe illeszkedik a DataFrame sorindexéhez; a .loc ezt a címkék szerint illeszti, ezért a hossza pontosan megegyezik a sorok számával.

Verzióérzékenység: a fenti példák pandas 2.2.3-mal készültek. Pandas 3-ban a CoW alapértelmezett, ezért a hibás láncolt forma ott már nem is ad SettingWithCopyWarning-t, hanem egyszerűen nem módosítja az eredetit, vagy kivételt dob. Az explicit .loc forma mindkét verziócsaládban azonos eredményt ad; ezért ez a hordozható stílus.

6.2.5 Feladatok

1. feladat — Értelmezés. Adottak az alábbi sorozatok:

a = pd.Series([1, 2, 3], index=["x", "y", "z"])
b = pd.Series([10, 20], index=["y", "w"])

Mi lesz az a + b eredmény indexe és értékei? Válaszát indokolja az index-illesztés szabálya alapján, majd nevezze meg, mi a különbség a NumPy pozicionális összeadásához képest.

2. feladat — Implementálás. Töltse be a lab/data/orders_raw.csv fájlt a Séma, dtypes és hiányzó értékek alfejezetben megismert explicit sémával. Készítsen boolean maszkot azokra a sorokra, ahol a distance_km hiányzik. Javítsa a hiányzó távolságokat explicit .loc használatával a business szegmensű rendelések medián távolságára, ha a segment oszlop értéke business; a consumer sorokban használja a consumer mediánt. Ellenőrizze, hogy a javítás után nincs hiányzó distance_km érték. (A mediánnal a Közép, szóródás és robusztus összegzés fog részletesen foglalkozni; itt a df["distance_km"].median() hívás elégséges.)

3. feladat — Hibadiagnózis. A következő kódot találja egy notebookban:

df[df["basket_minor"] < 0]["basket_minor"] = 0

A futtatás után a negatív basket_minor érték továbbra is jelen van a df-ben, bár a kód nem dobott kivételt. Magyarázza el, mi a hiba oka, és írja át a kódot explicit .loc formára. Indokolja, miért működik a javított változat CoW mellett is.

4. feladat — Transzfer megváltozott feltételre. A laborban a priority_flag 0/1 jelzés. Tegyük fel, hogy az üzleti szabály megváltozik: a consumer szegmensű rendelések priority_flag értékét 1-re kell állítani, ha a basket_minor nagyobb, mint 5000. Írjon olyan kódot, amely ezt a módosítást kizárólag explicit .loc művelettel végzi el, majd ellenőrizze a feltétel teljesülését assert segítségével. A megoldásban a feltétel és a beírandó érték is a rendelés pillanatában ismert adatból származzon.

5. feladat — Index-illesztés ellenőrző invariant. Két eltérő indexű Series összeadásánál a kimeneti indexnek meg kell egyeznie a két bemeneti index uniójával. Írjon egy kis függvényt assert-ekkel, amely két tetszőleges Series-re ellenőrzi: (a) a kimeneti index mint halmaz megegyezik a két bemeneti index uniójával; (b) a közös címkéken a kimeneti érték a két bemenet összege. Tesztelje a függvényt az 1. feladat a és b sorozatán.

6.2.6 Megoldások és indoklás

1. megoldás. Az a + b indexe a két index uniója: ["w", "x", "y", "z"] (a sorrend a pandas rendezési szabálya szerint alakul, az itteni sorrend az unió rendezett formája). A y címke mindkét sorozatban szerepel, ott az érték 2 + 20 = 22; a többi címkén NaN:

index érték
w NaN
x NaN
y 22
z NaN

A NumPy pozicionális összeadás ezzel szemben az [1+10, 2+20] = [11, 22] eredményt adná, mert a tömböknek nincs címke-fogalmuk; a harmadik elem (3) elveszne, mert a második tömb rövidebb. A pandas ezt a hibát elkerüli, de hiányzó értékeket hoz be — ezért a kimeneti NaN-eket mindig ellenőrizni kell, mielőtt az összeget további számításba vonnánk.

2. megoldás. A Séma, dtypes és hiányzó értékek sémáját használva:

import pandas as pd

schema = {
    "order_id": "string",
    "customer_id": "string",
    "order_ts": "string",        # majd parse_dates
    "promised_days": "Int64",
    "quantity_sum": "Int64",
    "item_rows": "Int64",
    "basket_minor": "Int64",
    "shipping_fee_minor": "Int64",
    "currency": "string",
    "distance_km": "Float64",
    "priority_flag": "Int64",
    "delivery_days": "Int64",
    "actual_delivery_at": "string",
    "label_available_at": "string",
    "target_late": "Int64",
    "segment": "string",
    "region": "string",
    "acquisition_channel": "string",
}

df_raw = pd.read_csv(
    "lab/data/orders_raw.csv",
    dtype=schema,
    parse_dates=["order_ts", "actual_delivery_at", "label_available_at"],
)

missing = df_raw["distance_km"].isna()
business_med = df_raw.loc[df_raw["segment"] == "business", "distance_km"].median()
consumer_med = df_raw.loc[df_raw["segment"] == "consumer", "distance_km"].median()

df_raw.loc[missing & (df_raw["segment"] == "business"), "distance_km"] = business_med
df_raw.loc[missing & (df_raw["segment"] == "consumer"), "distance_km"] = consumer_med

assert not df_raw["distance_km"].isna().any()

Indoklás: a mediánt külön számoljuk a két szegmensre, mert a távolság eloszlása szegmensfüggő lehet; a csoportos medián jobb, mint egyetlen globális érték. A .loc explicit sor-oszlop párt ad, így a módosítás az eredeti df_raw-n történik. Az assert a végső invariantet ellenőrzi: nincs hiányzó távolság. (A szegmens szerinti medián imputáció modellezési döntés; a valós munkában ez egy indokolt, naplózott döntés, nem automatikus lépés.)

3. megoldás. A hiba oka a láncolt értékadás. A df[df["basket_minor"] < 0] kifejezés egy új, szűrt DataFrame-et ad, amely CoW nélkül nézet lehet, CoW-val pedig másolat. A rákövetkező ["basket_minor"] = 0 ezen a köztes objektumon hajtódik végre, nem az eredeti df-en. A pandas ezért SettingWithCopyWarning-ot adhat, de az eredeti táblázatban a negatív érték megmarad — a kód „sikeres”, de hatástalan.

Javított alak:

df.loc[df["basket_minor"] < 0, "basket_minor"] = 0

Ez a forma egyetlen műveletben adja meg a sorokat (boolean mask) és az oszlopot, így a pandas pontosan tudja, hogy az eredeti df módosítandó. CoW mellett is helyes, mert a .loc explicit módosítást jelez, nem egy ideiglenes objektumon operál.

4. megoldás.

cond = (df_raw["segment"] == "consumer") & (df_raw["basket_minor"] > 5000)
df_raw.loc[cond, "priority_flag"] = 1

# Ellenőrző invariant: a feltételnek megfelelő sorokban a zászló 1
assert (df_raw.loc[cond, "priority_flag"] == 1).all()
# Ellenőrző invariant: a feltételnek nem megfelelő consumer sorokban a zászló változatlan
# (itt csak azt ellenőrizzük, hogy a módosítás nem érintette a többi sort
#  egy korábban rögzített mintán; a teljes ellenőrzéshez a módosítás előtti állapot kell.)

A feltétel két részből áll: a szegmens consumer, és a kosárérték meghaladja az 5000 minor egységet. A & operátor elemenkénti logikai ÉS a két boolean Series között; a kerek zárójelek a Python precedenciája miatt kötelezőek. A módosítás kizárólag a feltételnek megfelelő sorokat érinti.

5. megoldás.

def check_series_alignment(s1: pd.Series, s2: pd.Series) -> None:
    out = s1 + s2
    expected_index = s1.index.union(s2.index)
    # (a) a kimeneti index mint halmaz megegyezik az unióval
    assert set(out.index) == set(expected_index)
    # (b) közös címkéken az összeg helyes
    common = s1.index.intersection(s2.index)
    for idx in common:
        assert out.loc[idx] == s1.loc[idx] + s2.loc[idx]
        # Numerikus pontosság miatt éles tesztben:
        # assert abs(out.loc[idx] - (s1.loc[idx] + s2.loc[idx])) < 1e-12

# Teszt az 1. feladat adatain
a = pd.Series([1, 2, 3], index=["x", "y", "z"])
b = pd.Series([10, 20], index=["y", "w"])
check_series_alignment(a, b)
print("Az index-illesztési invariant teljesül.")

A függvény két invariantet ellenőriz: a kimeneti index mint halmaz az unió, és a közös címkéken az összeg pontos. Lebegőpontos sorozatoknál a == helyett toleranciás összehasonlítást kell használni, ahogy a Lebegőpont, hiányzó érték és stabil számítás alfejezetben tárgyaltuk; itt egész értékekkel a pontos egyenlőség is elfogadható a szemléltető példában.

6.2.7 Gyakori tévedés ellenpéldával

A leggyakoribb tévedés, hogy a pandas Series műveleteket NumPy pozicionális szemantikával várjuk. Tekintsük a következő két rendelés-összeg sorozatot:

rev_a = pd.Series([100, 200], index=["o1", "o2"])  # o1, o2 rendelések
rev_b = pd.Series([50, 70], index=["o2", "o3"])    # o2, o3 rendelések

Ha valaki NumPy-szokás szerint azt várja, hogy rev_a + rev_b eredménye [150, 270], akkor téved: a pandas a címkék szerint illeszt, és az eredmény indexe ["o1", "o2", "o3"], értékei pedig [NaN, 250, NaN]. Az o1 és o3 rendeléseknél hiányzó érték keletkezik, mert azok a másik sorozatban nem szerepelnek. A helyes összegzéshez az indexeket előbb igazítani kell, például rev_a.add(rev_b, fill_value=0) hívással, amely a hiányzó címkéket 0-ként kezeli, és így [100, 250, 70] eredményt ad.

Egy másik, kifejezetten a loc/iloc keveréséből fakadó hiba: ha a sorindex 0, 1, 2, ... egész számokból áll, a df[0] kifejezés nem az első sort, hanem a 0 oszlopnevet keresi (ha ilyen nincs, KeyError-t ad). Az iloc[0] az első sort adja. Az index értékeinek és a pozícióknak az összekeverése olyan csendes hibát okozhat, amely csak az index átrendezésekor derül ki. Ezért minden módosításnál dönteni kell: loc címkével, vagy iloc pozícióval, de a kettőt soha nem szabad implicit módon váltogatni.

6.2.8 Önellenőrzés

Copy-on-Write használatakor a df.loc[maszk, oszlop] = érték alakú, egy lépésben végzett módosítás előnyösebb a láncolt hozzárendelésnél. A nézetek és másolatok viselkedése, illetve a címkék szerinti automatikus indexillesztés két külön kérdés: mindkettőt önállóan kell ellenőrizni.

6.2.9 Rövid összefoglaló

A pandas kiválasztás két útja a loc (címke szerint, a szeletvégpontot is beleértve) és az iloc (pozíció szerint, a végpontot kizárva); a címkézett Series maszk a .loc-kal használható; a .iloc pozicionális boolean tömböt fogad, például mask.to_numpy(dtype=bool) alakban. A Series-ek közötti műveletek index-illesztéssel történnek: a kimeneti index a bemeneti indexek uniója, a hiányzó címkéken NaN vagy pd.NA áll — ez nem NumPy pozicionális szemantika. A Copy-on-Write a pandas 3-ban alapértelmezett, a kezdőlabor 2.2.3-as verziójában opcionális; a láncolt értékadás egyik módban sem megbízható, ezért minden módosítást egyetlen explicit .loc/.iloc hívással végzünk. A következő alfejezetben a duplikáció, a kulcs és az adatminőségi szerződés ellenőrzését építjük ezekre a kiválasztási műveletekre.