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 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.
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
intervallum, ahol
a helyi éjfél,
a következő helyi éjfél; a hossza
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.
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, categoryAz 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.
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.
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ó.
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?
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.
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.
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.
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.
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.5Itt 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.
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.
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.
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-02A 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.
Timestamp és egy
Timedelta között, és melyik az order_ts,
melyik a delivery_days?set_index, mielőtt
resample("D")-t hívunk, és miért számít az index
rendezettsége?segment.cat.codes értékeit numerikus
rendezéshez vagy távolsághoz használni?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.
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.