Python, de la zero Lecția 32 / 60

Time series: resample, rolling, capcanele de date-time

DatetimeIndex, conversia de frecventa, rolling windows si bug-urile de timezone care musca productia.

Pandas a fost scris inițial la AQR, un hedge fund cantitativ, ca să mânuiască serii temporale financiare. Se vede. API-ul de time-series e una dintre cele mai șlefuite părți din bibliotecă: odată ce îi dai un DataFrame cu un DatetimeIndex, ți se deschide un întreg mecanism: conversie de frecvență, rolling windows, umplerea golurilor, aritmetică de timezone. Astăzi acoperim părțile care contează pentru munca analitică normală și bug-ul de timezone care apare în orice pipeline de producție măcar o dată.

DatetimeIndex: cheia care deblochează

Un DataFrame cu un DatetimeIndex e o serie temporală: pandas știe să lucreze cu el. Un DataFrame cu o coloană created_at de tip datetime e doar un DataFrame cu o coloană datetime; poți filtra pe ea, dar nu primești mecanismul de time-series. Setarea unui index e primul pas:

import pandas as pd

df = pd.read_csv("sales.csv", parse_dates=["ts"], date_format="ISO8601")
df = df.set_index("ts").sort_index()

Două lucruri contează aici. Unu, setează indexul după parsare: parse_dates= face din coloană un datetime real, apoi set_index o promovează la index. Doi, sort_index(): majoritatea operațiilor de time-series presupun că indexul e monoton crescător, iar dacă nu e, primești fie răspunsuri greșite, fie warning-uri. Sortează o dată la încărcare.

Odată ce ai un DatetimeIndex, indexarea pe string parțial pur și simplu funcționează:

df["2026"]                     # toate randurile din 2026
df["2026-03"]                  # toate randurile din martie 2026
df["2026-03-15":"2026-03-20"]  # un slice

E una dintre comoditățile pandas care pare magică prima dată când funcționează.

Stringuri de frecvență

Aproape orice funcție de time-series primește un string de frecvență, prescurtarea pandas pentru „ce mărime de bucket vrei”. Cele pe care le vei folosi de fapt:

  • "D": zilnic
  • "H" sau "h": orar (forma cu literă mică e cea modernă în pandas 2.2+; ambele încă funcționează)
  • "min": minute (vechiul "T" încă acceptat, dar deprecated)
  • "W": săptămânal (implicit săptămâna se termină duminica; folosește "W-MON", "W-FRI" etc. pentru alte ancore)
  • "M" / "ME": sfârșit de lună. În pandas 2.2+, "ME" e aliasul explicit, iar "M" va da warning în cele din urmă.
  • "MS": început de lună
  • "Q" / "QE": sfârșit de trimestru ("QS" pentru început de trimestru)
  • "Y" / "YE": sfârșit de an ("YS" pentru început de an)

Le poți și combina: "5min", "15min", "4H", "2W". Pandas acceptă aproape orice compoziție rezonabilă.

Resampling: resample()

resample e groupby pentru timp. Pune rândurile în bucket-uri după o frecvență și te lasă să agregezi.

Downsampling: trecerea de la frecvență mai mare la mai mică, de exemplu de la date pe minut la totaluri zilnice. E cazul des întâlnit:

daily = df.resample("D").sum()
hourly_avg = df.resample("h").mean()
weekly_max = df.resample("W").max()

Poți aplica mai multe agregări odată cu .agg:

df.resample("D").agg({"sales": "sum", "visitors": "mean", "orders": "count"})

Upsampling: trecerea de la frecvență mai mică la mai mare. Asta creează rânduri care nu există în sursă și trebuie să-i spui pandas cum să le umple:

df.resample("h").ffill()   # forward-fill: duce ultima valoare cunoscuta inainte
df.resample("h").bfill()   # back-fill: aduce urmatoarea valoare cunoscuta inapoi
df.resample("h").interpolate()  # interpolare lineara intre puncte cunoscute

Forward-fill e implicitul sigur pentru lucruri ca citirile de senzori, unde „ultima valoare e încă adevărată până când sosește una nouă”. Interpolarea e potrivită pentru cantități cu adevărat continue (temperatură, prețuri). Nu interpola counts.

asfreq: pentru schimbarea frecvenței indexului fără agregare. Util când vrei o grilă regulată, dar fără agregare fistichie:

df.asfreq("D", method="ffill")   # un rand pe zi, forward-filled

E și modul în care aliniezi o serie rară și neregulată pe un calendar regulat, des întâlnit când ai un rând pe eveniment și vrei un rând pe zi.

Rolling windows: rolling()

O fereastră rulantă e vărul mai mare al mediei mobile: îți permite să calculezi orice agregare peste o fereastră glisantă a datelor.

df["sales"].rolling(window=7).mean()        # media trailing pe 7 randuri
df["sales"].rolling(window=7).std()         # deviatia standard trailing pe 7 randuri
df["sales"].rolling(window="7D").mean()     # ultimele 7 zile, indiferent de numarul de randuri

Argumentul window e funcționalitatea-cheie. Trimite un întreg și obții o fereastră bazată pe numărul de rânduri; trimite un string de frecvență și obții o fereastră bazată pe timp. A doua e robustă la goluri în date: o medie rulantă cu window="7D" peste o serie temporală rară îți dă „media tuturor datelor din ultimele 7 zile”, oricare ar fi numărul de rânduri. Ferestrele bazate pe rânduri îți dau ceva fără sens când există goluri.

min_periods: implicit, rolling produce NaN până când fereastra e plină. Primele șase zile dintr-o medie mobilă pe 7 zile sunt NaN. Ca să permiți ferestre parțiale:

df["sales"].rolling(window=7, min_periods=1).mean()

Acum ziua 1 returnează media cu sine însăși, ziua 2 cu primele două și așa mai departe. Util când vrei o serie fără NaN la început, dar fii conștient că valorile timpurii se bazează pe mai puține date: nu le compara cu cele mature fără context.

rolling are meniul complet de agregare: mean, sum, std, min, max, median, count, quantile, plus apply pentru funcții arbitrare. Calea cu funcție arbitrară e mai lentă; dacă poți exprima ce vrei cu cele built-in, fă-o.

Expanding windows

expanding() e rolling cu o fereastră care crește de la început în loc să gliseze. Valoarea zilei 1 e doar ziua 1; a zilei 2 e agregarea peste zilele 1-2; a zilei 100 e agregarea peste zilele 1-100.

df["sales"].expanding().mean()   # media cumulativa de la inceput
df["sales"].expanding().sum()    # echivalent cu .cumsum()
df["sales"].expanding().max()    # maximul curent

E unealta potrivită pentru „total curent de la începutul perioadei”: venit curent, recordul absolut, media pe viață, oriunde ai apuca altfel cumsum sau cummax și ai vrea un agregator mai flexibil.

Ponderat exponențial: ewm()

ewm (exponentially weighted) e vărul mai isteț al lui rolling. În loc să trateze fiecare punct din fereastră la fel, dă mai multă greutate punctelor recente și mai puțină celor mai vechi, lin:

df["sales"].ewm(span=7).mean()      # span de 7 perioade (analog cu o medie pe 7 zile)
df["sales"].ewm(halflife=7).mean()  # greutatea se injumatateste la fiecare 7 perioade
df["sales"].ewm(alpha=0.3).mean()   # factor de smoothing explicit

Folosește ewm când vrei reactivitate: un EWMA reacționează la o schimbare de nivel mai repede decât o medie mobilă simplă cu același span. E standardul pentru volatilitate financiară, detecție de anomalii și orice dashboard unde „ce se întâmplă recent” contează mai mult decât „ce s-a întâmplat peste toată fereastra”.

shift(): lag și lead

shift(n) îți mută datele cu n rânduri în jos (n pozitiv) sau în sus (n negativ). Cu un DatetimeIndex regulat și o frecvență, decalează cu acel număr de perioade:

df["sales_yesterday"] = df["sales"].shift(1)
df["sales_next_week"] = df["sales"].shift(-7)
df["wow_growth"] = df["sales"] / df["sales"].shift(7) - 1   # week-on-week

E cărămida de bază pentru orice calcul de tip „comparat cu perioada precedentă”. Primele n valori (sau ultimele, pentru shift-uri negative) sunt NaN: nu există ieri pentru prima zi.

Timezones: capcana de producție

Acum partea care îi mușcă pe toți. Datetimes în pandas vin în două variante: naive (fără timezone atașat, doar un string de wall-clock) și aware (cu timezone atașat). Nu se amestecă: nu poți compara un timestamp naive cu unul aware fără eroare.

A fost teritoriul lecției 8 în limbaj; aici apare în indexele DataFrame. Cele două operații:

# indexul e naive; stii ca e de fapt UTC
df.index = df.index.tz_localize("UTC")

# acum converteste la o zona de afisare
df.index = df.index.tz_convert("Europe/Rome")

tz_localize atașează un timezone (fără schimbare de valoare). tz_convert schimbă timezone-ul (valoarea de wall-clock se actualizează). Localizarea în zona greșită, apoi conversia, e una dintre modalitățile clasice prin care livrezi „timestamps decalate cu 6 ore” în producție.

Capcana mai profundă: DST și resample. Să zicem că indexul tău e Europe/Rome și faci resample("D").sum(). În ziua de spring-forward ai 23 de ore în „ziua” ta; în autumn-back, 25. Pandas tratează asta corect dacă indexul e timezone-aware, și o încurcă subtil dacă ai stocat localtime ca index naive. Regula: stochează datele în UTC, convertește în local doar pentru afișare. Setează tz_localize("UTC") la ingestie dacă sursa e UTC, tz_convert("UTC") dacă e altă zonă, și apoi nu mai raționa în localtime în interiorul pipeline-ului. Convertește la margine.

Un exemplu real: vânzări zilnice e-commerce cu rolling și view-uri săptămânale

Punând totul împreună, analiza tipică de prim-pas pe un tabel de comenzi de e-commerce:

import pandas as pd

orders = pd.read_parquet("orders.parquet")
orders["created_at"] = pd.to_datetime(orders["created_at"], utc=True)
orders = orders.set_index("created_at").sort_index()

# Totaluri zilnice de vanzari in UTC
daily = orders["amount"].resample("D").sum().rename("daily_sales")

# Medie mobila pe 7 zile pentru dashboard
daily_smooth = daily.rolling("7D", min_periods=1).mean().rename("rolling_7d")

# Totaluri saptamanale pentru email
weekly = daily.resample("W-MON").sum().rename("weekly_sales")

# Crestere week-on-week
weekly_growth = (weekly / weekly.shift(1) - 1).rename("wow_growth")

report = pd.concat([daily, daily_smooth], axis=1)
report.tail(30).to_csv("daily_dashboard.csv")
weekly_growth.to_csv("weekly_growth.csv")

Trei view-uri ale acelorași date, fiecare potrivit pentru un alt consumator: un dashboard zilnic pe 30 de zile cu o linie netezită, un rezumat săptămânal, o serie de creștere week-on-week. Niciuna nu are mai mult de câteva linii pentru că indexul face treaba.

Ce urmează

Lecția 33 e optimizare de dtype: category și string[pyarrow], cele două dtype-uri care transformă o problemă de „dataset-ul ăsta nu încape în memorie” în „dataset-ul ăsta e ok”. Dacă ai văzut vreodată un job pandas să dea swap pe disc și să moară, vei vrea să o citești.

Lectură suplimentară

Ne vedem marți.

Caută