6.4 Dátumok, időzónák és kategóriák

6.4.1 Cél és konkrét helyzet

Az előző alfejezetben az orders_raw.csv hibáit dokumentáltan karanténba helyeztük, és a tiszta kontroll, a lab/orders.csv 2400 rendelési sorát kaptuk. Ebben az alfejezetben a rendelési időpontot, az order_ts oszlopot típusbiztos datetime oszloppá alakítjuk, és napi aggregációt készítünk belőle. Megfigyelési egységünk továbbra is egy rendelés; a cél egy olyan napi rendelésszám-tábla, amely a megjelenítési zóna szerinti naptári napokon alapul. Az üzleti jelentés nem UTC-ben, hanem például Europe/Budapest zónában gondolkodik, ezért a naphatárt és a hiányzó napokat külön dokumentálnunk kell.

Mielőtt kódot írnánk, becsüljük meg, hány rendelés kerülhet át másik naptári napra. A 2400 sor 180 napra oszlik, tehát óránként átlagosan 2400/(180⋅24)≈0,562400 / (180 \cdot 24) \approx 0{,}56 rendelés érkezik. A budapesti és az UTC naphatár közötti eltérés télen 1, nyáron 2 óra; a határhoz közeli órákban naponta jellemzően legfeljebb egy sor, a teljes adaton néhány tucat sor kerülhet át másik naptári napra. A pontos szám az adat tényleges eloszlásától függ; ezt a záró ellenőrzésben mérjük.

6.4.2 UTC-időpont, időzóna és naphatár

Egy időbélyeg (timestamp) egyetlen pillanatot jelöl a világ időtengelyén. Az order_ts a CSV-ben ISO 8601 formátumú, explicit +00:00 offsettel, tehát UTC. A pd.to_datetime ezt datetime64[us, UTC] típusú, úgynevezett timezone-aware (zónatudatos) értékké alakítja; a pontos felbontás pandas-verziónként változhat, ezért a dtype-sztringre nem építünk üzleti logikát. A naive (zóna nélküli) időpont ezzel szemben nem hordozza a pillanat világbeli helyét; ugyanaz a 2025-03-01 12:00 jelenthet UTC-t vagy budapesti időt is.

import pandas as pd

orders = pd.read_csv("lab/orders.csv")

# A CSV-ből stringként érkező oszlop típusos datetime lesz.
orders["order_ts"] = pd.to_datetime(orders["order_ts"], utc=True)

print(orders["order_ts"].dtype)              # szemléltető: datetime64[us, UTC]
print(orders["order_ts"].dt.tz)              # UTC
print(orders["order_ts"].min())              # 2025-01-01 ...
print(orders["order_ts"].max())              # 2025-06-30 ...

A utc=True azért fontos, mert akkor is garantáltan zónatudatos oszlopot kapunk, ha egy későbbi fájlformátum vagy CSV-változat véletlenül offset nélkül adná az időpontot. A zónatudatos értéken két alapművelet van: a tz_localize egy naive értékhez hozzárendel egy zónát, a tz_convert pedig egy már zónatudatos értéket átszámol másik zónába. A kettő nem cserélhető fel: a Northstar oszlop már aware, ezért a tz_localize hívása TypeError-t ad, a tz_convert pedig helyes.

A naptári nap az időablak, nem pont. A nap [a,b)[a, b) intervallum, ahol aa a helyi éjfél, bb a következő helyi éjfél; a hossza b−ab - a időtartam (Timedelta). Az UTC szerinti nap mindig 24 óra, de a budapesti zóna 2025. március 30-án 02:00-ról 03:00-ra ugrik, ezért az a naptári nap csak 23 óra hosszú. A resample("D") zónatudatos indexen a helyi naptári napokat követi; UTC indexen viszont a fix 24 órás ablakokat. A naphatár tehát nem pusztán megjelenítés: az határozza meg, melyik naphoz tartozik egy rendelés.

A resample előfeltétele, hogy az index DatetimeIndex legyen, és a sorok időben rendezettek legyenek. A groupby nem adja ingyen a hiányzó napokat; a resample a teljes min–max tartományra generál napokat, így az üres napok 0 értékkel jelennek meg.

6.4.3 Kategóriák mint explicit szintek

A segment és region oszlopok kis számú, ismert értéket tartalmazó kategóriák. A category dtype három dolgot javít: kevesebb memóriát foglal, rögzíti a megengedett szinteket, és a véletlen szöveghibák hamarabb kiderülnek. A category nem rendezett numerikus kód; az astype("category") nem ad sorrendet, és a .cat.codes értékeit nem szabad a segment vagy region közötti távolságként értelmezni.

orders["segment"] = orders["segment"].astype("category")
orders["region"] = orders["region"].astype("category")

print(orders["segment"].cat.categories)     # Index(['business', 'consumer'], dtype='object')
print(orders.dtypes[["segment", "region"]]) # category, category

Az időtartam (Timedelta) az időpontok különbsége. A delivery_days oszlop a szerződés szerint pozitív egész nap, míg az actual_delivery_at - order_ts egy Timedelta. A kettő nem ugyanaz: az egész nap egy mennyiség, a Timedelta pedig az időtengelyen mért hossz.

6.4.4 Teljes minimális munkapélda: napi aggregáció két zónában

A következő példa a lab/orders.csv tiszta fájlra épül, a projektgyökérből futtatva. A kimeneteket szemléltetőnek jelöljük, mert ebben az írási módban nem történt valódi futás; a tanuló saját környezetében a számoknak a manifest.json kontrolljával kell egyezniük.

import pandas as pd

orders = pd.read_csv("lab/orders.csv")
orders["order_ts"] = pd.to_datetime(orders["order_ts"], utc=True)
orders["segment"] = orders["segment"].astype("category")
orders["region"] = orders["region"].astype("category")

# Invariáns: a tiszta fájlban egy sor egy rendelés.
assert orders["order_id"].is_unique
# A resample rendezett időindexet vár.
orders = orders.sort_values("order_ts")
assert orders["order_ts"].is_monotonic_increasing

# UTC napok: fix 24 órás ablakok.
utc_daily = (
    orders.set_index("order_ts")
    .resample("D")
    .size()
    .rename("n_orders_utc")
)

# Budapest zóna szerinti naptári napok.
budapest_ts = orders["order_ts"].dt.tz_convert("Europe/Budapest")
budapest_daily = (
    orders.assign(local_ts=budapest_ts)
    .set_index("local_ts")
    .resample("D")
    .size()
    .rename("n_orders_budapest")
)

# Ellenőrzés: a zónakonverzió nem veszít el és nem duplikál sort.
assert utc_daily.sum() == len(orders)
assert budapest_daily.sum() == len(orders)

# Hiányzó napok dokumentálása: a resample teljes naptári tartományt ad,
# az üres napok 0 értékkel szerepelnek.
zero_utc = utc_daily[utc_daily == 0]
print("Üres UTC-napok:", zero_utc.index.to_list())

A két sorozat naptári napcímkéit akkor tudjuk összehasonlítani, ha a zónainformációt leválasztjuk a napcímkéről; a következő kód a helyi naptári napokat veti össze, így látszik, hány sor került át másik napra:

comparison = pd.DataFrame(
    {
        "utc": utc_daily.values,
        "budapest": budapest_daily.values,
    },
    index=utc_daily.index.tz_localize(None),
)
# Ha a helyi szélső napok átcsúsznak, a pozíció szerinti párosítás
# a naptári napcímkére vonatkozik, amit a fenti módon biztosítunk.
comparison["diff"] = comparison["budapest"] - comparison["utc"]
print(comparison.loc["2025-03-28":"2025-04-01"])

A naphatár-dokumentációhoz tartozik a DST-hatás pontos leírása. Budapest 2025. március 30-án 02:00 helyi időről 03:00-ra ugrik; az átmenet után az UTC offset +1 óráról +2 órára nő. Ezért a helyi március 30-i nap az UTC 2025. március 29. 23:00 és 2025. március 30. 22:00 közötti 23 órás ablak; a 22:00 UTC utáni rendelések már a helyi március 31-re esnek. A comparison tábla diff oszlopa éppen ezt az áthelyeződést mutatja.

Kézi kontrollként válasszunk egy UTC 23 óra utáni rendelést, és nézzük meg a budapesti dátumát:

late = orders[orders["order_ts"].dt.hour >= 23].sort_values("order_ts").iloc[0]
print(late[["order_id", "order_ts"]])
print(
    "Budapest szerint:",
    late["order_ts"].tz_convert("Europe/Budapest"),
)

A második sor napja a szemléltető várakozás szerint eggyel nagyobb, mint az UTC dátum; ez kézzel is ellenőrizhető egyetlen rekordon. Az invariáns itt nem a napok száma, hanem a sorösszeg: a zónakonverzió kizárólag a besorolást változtatja meg.

6.4.5 Eredmény értelmezése, hibák és trükkök

A munkapélda ellenőrző invariánsa: a betöltött orders hossza 2400, az order_ts dtype datetime64[us, UTC], a két napi sorozat összege szintén 2400. A comparison["diff"].abs().sum() azt adja meg, hány rendelést sorolt át a zónakonverzió más naptári napra; ehhez nincs szükség statisztikai toleranciára, mert az időzóna-átszámítás egzakt. A DST-napon a budapesti napi kosár 23 órányi forgalmat tartalmaz, ezért a napi darabszám ott nem hasonlítható közvetlenül a 24 órás UTC-ablakkal; a diff éppen ezt dokumentálja.

Az egyik leggyakoribb típuscsapda az, ha a to_datetime hívás kimarad. A CSV-ből a pandas az order_ts oszlopot object típusú stringekként olvassa be; ezen a .dt accessor nem létezik, és AttributeError-t kapunk. A második csapda az, ha a tz_convert helyett a tz_localize-t használjuk egy már aware oszlopra: az Already tz-aware, use tz_convert to convert. hibaüzenet pontosan jelzi a félreértést.

A kategória típuscsapdája a .cat.codes numerikus értékként való használata. A segment kódjai business=0, consumer=1 lehetnek, de ez nem jelenti azt, hogy a két szegmens között 1 egységnyi távolság van, vagy hogy a business megelőzi a consumer-t. A kategóriát a későbbi modellezésben dummy-változókká vagy rendezetlen szintekké alakítjuk; erről a Formula, interakció és kategorikus változó alfejezetben lesz szó.

6.4.6 Feladatok

6.4.6.1 1. feladat: értelmezés

Magyarázd el saját szavaiddal a különbséget a tz_localize és a tz_convert között, és írj két egysoros példát: az egyik egy naive pd.Timestamp("2025-03-01 12:00") értéken mutatja a localize-t, a másik a Northstar order_ts oszlopán mutatja a convertet. Mi történik, ha a két műveletet felcserélve hívjuk?

6.4.6.2 2. feladat: implementálás

Készíts napi aggregációt a budapesti zóna szerint, amely minden napra megadja a rendelések számát és a basket_minor átlagát:

basket_budapest = (
    orders.assign(local_ts=orders["order_ts"].dt.tz_convert("Europe/Budapest"))
    .set_index("local_ts")
    .resample("D")
    .agg(n_orders=("order_id", "size"), basket_mean=("basket_minor", "mean"))
)

Ellenőrizd, hogy a basket_mean a 2025-03-30-i napon eltér-e az UTC szerinti aggregáció azonos napi átlagától, és magyarázd meg, miért várható eltérés a DST-napon. A feladathoz minden input a lab/orders.csv tiszta fájl.

6.4.6.3 3. feladat: hibadiagnózis

Egy tanuló a következő kódot futtatta közvetlenül a pd.read_csv("lab/orders.csv") után:

daily = orders.groupby(orders["order_ts"].dt.date).size()

A hibát kap: AttributeError: Can only use .dt accessor with datetimelike values. Mi a hiba oka, és mi a minimális javítás? Írd le azt a két sort, amit a csoportosítás elé kell beszúrni.

6.4.6.4 4. feladat: megváltozott feltétel

Az üzleti riport a következő változatban America/New_York zónát kér. Készítsd el a New York-i napi rendelésszám-sorozatot, majd dokumentáld a naphatárt: a zóna 2025. március 9-én 02:00 helyi időről 03:00-ra ugrik, ezért a naphatár UTC szerint előtte 05:00, utána 04:00. A kód írja ki a new_york_daily sorösszegét és a 2025-03-07 és 2025-03-11 közötti sorokat. Ellenőrizd az invariánst, és nevezd meg, miért nem azonos a napok száma a New York-i és a budapesti sorozatban, ha a szélső napok átcsúsznak.

6.4.7 Megoldások és indoklás

6.4.7.1 1. feladat megoldása

A tz_localize egy zóna nélküli (naive) időponthoz rendeli hozzá az első zónát; nem változtatja meg az időtengelyen elfoglalt pillanatot, csak értelmezi az eddig zóna nélküli számot. A tz_convert egy már zónatudatos időpontot átszámol egy másik zónába; az abszolút pillanat itt sem változik, csak a helyi óra- és dátumcímke. Példa:

naive = pd.Timestamp("2025-03-01 12:00")
aware_utc = naive.tz_localize("UTC")
# Type-aware oszlopon:
orders["order_ts"].dt.tz_convert("Europe/Budapest")

Ha egy naive értéken tz_convert-et hívunk, a pandas hibát ad, mert nincs mit konvertálni; ha egy aware értéken tz_localize-t hívunk, a hibaüzenet szerint már van zónája.

6.4.7.2 2. feladat megoldása

A 2025-03-30-i budapesti nap 23 órás, ezért a n_orders itt kevesebb, mint az UTC 24 órás ablaka; a basket_mean eltérése abból fakad, hogy a két ablak más rendeléseket tartalmaz, nem az átlagképlet hibájából. A pontos értékek a helyi futtatásból jönnek; az invariant itt az, hogy a súlyozott átlaggal számított napi basket_mean értéke a kosárösszeg és a darabszám hányadosa, tehát basket_mean = basket_sum / n_orders. Ezt egy kiválasztott napra kézzel ellenőrizhetjük:

day = basket_budapest.loc["2025-03-30"]
assert abs(day["basket_mean"] * day["n_orders"] - basket_sum) < 0.5

Itt a basket_sum az adott napi basket_minor összeg, amit a .agg-be fel kell venni. Egész centekben dolgozunk, ezért a tolerancia 0,5 cent alatti kerekítési szint.

6.4.7.3 3. feladat megoldása

A read_csv az order_ts oszlopot stringként adja vissza, tehát a .dt accessor nem érhető el. A minimális javítás a csoportosítás előtt:

orders["order_ts"] = pd.to_datetime(orders["order_ts"], utc=True)
daily = orders.groupby(orders["order_ts"].dt.date).size()

A utc=True a zónatudatosságot is biztosítja, ezért a későbbi zónakonverzió nem hibázik. A .dt.date az UTC naptári dátumot adja; ha helyi nap kell, előbb tz_convert, utána .dt.date.

6.4.7.4 4. feladat megoldása

new_york_ts = orders["order_ts"].dt.tz_convert("America/New_York")
new_york_daily = (
    orders.assign(local_ts=new_york_ts)
    .set_index("local_ts")
    .resample("D")
    .size()
    .rename("n_orders_new_york")
)

assert new_york_daily.sum() == len(orders)
print(new_york_daily.loc["2025-03-07":"2025-03-11"])

A naphatár-dokumentáció: 2025-03-08-ig a New York-i nap UTC 05:00-kor kezdődik, 2025-03-09-től UTC 04:00-kor, mert a zóna EST-ről EDT-re vált. A new_york_daily sorösszege 2400; a napok száma akkor térhet el a budapesti sorozatétól, ha az adat első vagy utolsó rendelése a zónakonverzió miatt átcsúszik a szomszédos naptári napra. A Northstar tartománya napközbeni rendeléseket tartalmaz, ezért ez nem várható, de az állítást az adott futtatásban a min() és max() ellenőrzésével kell alátámasztani.

6.4.8 Gyakori tévedés és ellenpélda

A legfontosabb félreértés az időbélyeg és a helyi naptári dátum keverése. A Timestamp.date() egy datetime.date objektumot ad, amely nem hordoz zónát; ha az UTC-oszlopon hívjuk, az UTC szerinti naptári napot kapjuk, nem a helyi naptári napot. Vegyük ezt a rendelést: 2025-01-01 23:30:00+00:00. UTC szerint január 1., de Budapesten már 2025-01-02 00:30. Tehát:

row = pd.Timestamp("2025-01-01 23:30:00+00:00")
print(row.date())                                   # 2025-01-01
print(row.tz_convert("Europe/Budapest").date())     # 2025-01-02

A két eredmény különbözik, pedig ugyanarról a pillanatról van szó. A riportban ezért mindig rögzíteni kell, melyik zóna naptári napjáról beszélünk; a date önmagában nem elég. Ez a hiba ugyanaz a fajta kontextusvesztés, mint amit a Másik gép, azonos projekt alfejezetben a környezeti eltéréseknél láttunk: az operációs rendszer órazóna-beállítása sem írja felül az adat saját zónaattribútumát.

Visszautalva a Vektorizálás és mért teljesítmény alfejezetre: a .dt accessor és a resample a pandas vektorizált, tömb alapú működését használja; nem soronkénti Python-ciklussal dolgozunk, ezért a fenti műveletek 2400 soron is gyorsak maradnak.

6.4.9 Önellenőrzés

Időzónák kezelésénél a tz_localize egy naiv időponthoz rendel időzónát, a tz_convert pedig már zónatudatos időbélyeget alakít át. Helyi nap szerinti aggregáció előtt előbb a megfelelő időzónára kell váltani: a nyári időszámítás miatt egy helyi naptári nap nem mindig 24 órás.

6.4.10 Rövid összefoglaló

A order_ts oszlopot pd.to_datetime(..., utc=True) hívással zónatudatos datetime típussá alakítottuk, a segment és region oszlopokat pedig explicit category típussal láttuk el. A resample("D") időindexet és rendezett sorrendet kíván; a zónakonverzió a naptári nap besorolását változtatja, a sorösszeget nem. A budapesti DST-nap 23 órás ablak, ezért az aznapi darabszám nem hasonlítható közvetlenül az UTC 24 órás napjához. A tz_localize és tz_convert felcserélése, valamint a zóna nélküli date használata a leggyakoribb hibák. A következő fejezetben az így megtisztított, típusos táblát SQL-relációs műveletekhez használjuk, ahol a NULL-ok és a join-kardinalitás lesznek a fő kérdések.