Am petrecut douăsprezece lecții construind un vocabular: dtypes, IO, selecție, join-uri, groupby-uri, time series, performanță, Polars. Astăzi facem chiar lucrul pentru care există vocabularul. Luăm un dataset real, îl ducem de la CSV brut la un răspuns curat și scriem genul de script pe care chiar l-ai pune într-un repo. E lecția de încheiere a Modulului 6 și un șablon funcțional pentru aproape orice sarcină de analiză pe care o vei primi în primul tău job.
Dataset-ul pe care îl folosim e fișierul lunar NYC Yellow Taxi Trips, disponibil public, cu vreo 3 milioane de rânduri pe lună, murdar cum e lumea reală în feluri interesante. Întrebarea la care răspundem: care e procentul mediu de bacșiș pe oră din zi și diferă între cei care plătesc cu cardul și cei care plătesc cash? E genul de întrebare pe care un manager o pune efectiv, iar răspunsul cere fiecare pas din pipeline.
Scriptul complet e la finalul lecției. Aici parcurgem cele șase faze.
Forma: scriptul-schelet
Înainte de orice cod, forma. Un script bun de analiză e un singur fișier Python, rulabil de la cap la coadă, cu comentarii pe secțiuni. Cam așa:
"""nyc_taxi_tips.py — average tip % by hour, by payment type."""
# 1. Load + inspect
# 2. Clean
# 3. Explore
# 4. Transform
# 5. Answer
# 6. Save / present
if __name__ == "__main__":
main()
De ce un singur fișier și nu un notebook? Notebook-urile sunt grozave pentru faza de explorare, dar versiunea care intră în repo și care se rerulează pe datele de luna viitoare ar trebui să fie un script: reproductibil, ușor de comparat în diff, rulabil din cron. Pattern-ul pe care îl folosesc: explorez în notebook, apoi, după ce știu pașii, îi copiez într-un script care face doar acei pași în ordine. Notebook-ul rămâne ca jurnal; scriptul e livrabilul.
Faza 1 — load și inspecție
Mereu aceleași trei lucruri: shape, dtypes, sample, nulls. Nu sări peste niciunul, oricât de încrezător ai fi în fișier.
import pandas as pd
df = pd.read_parquet("yellow_tripdata_2025-01.parquet", dtype_backend="pyarrow")
print(df.shape)
print(df.dtypes)
print(df.head())
print(df.tail())
print(df.sample(5))
print(df.isna().sum())
print(df.describe())
Rulând asta pe un fișier NYC taxi din ianuarie obții ceva de genul:
(3_267_104, 19)
VendorID int64[pyarrow]
tpep_pickup_datetime timestamp[us][pyarrow]
tpep_dropoff_datetime timestamp[us][pyarrow]
passenger_count double[pyarrow]
trip_distance double[pyarrow]
...
fare_amount double[pyarrow]
tip_amount double[pyarrow]
total_amount double[pyarrow]
payment_type int64[pyarrow]
Trei observații dintr-un singur dump:
payment_typee un cod întreg, nu o etichetă. O să avem nevoie de o tabelă de lookup ca să-l facem citibil.passenger_counte float, suspect pentru un count. Probabil are nulls și a fost promovat.- Dataset-ul are 3.2 milioane de rânduri și 19 coloane. Încape lejer în memorie; nu e nevoie de chunking sau Polars azi.
describe() îți va spune restul poveștii: distanță minimă a cursei de 0 (curse de zero mile, alea sunt buguri), bacșiș maxim de 400 dolari (cineva a dat 400 dolari bacșiș pe o cursă de 30? probabil eroare la introducerea datelor), fare_amount care intră ușor în negativ (rambursări sau erori de semn). Toate astea vor cere decizii în faza de cleaning.
isna().sum() îți arată ce coloane au nulls. În fișierul ăsta passenger_count și câteva altele au un mic procent; le tratăm.
Faza 2 — clean
Aici își petrec majoritatea analizelor cea mai mare parte a timpului și aici scriptul își câștigă încrederea. Fiecare decizie de cleaning e o judecată pe care o faci asupra datelor, iar scrierea ei explicită e diferența dintre un script reproductibil și unul misterios.
# Parse the payment type codes (from the NYC TLC data dictionary)
payment_map = {
1: "credit_card",
2: "cash",
3: "no_charge",
4: "dispute",
5: "unknown",
6: "voided_trip",
}
df["payment_label"] = df["payment_type"].map(payment_map).astype("category")
# Drop rows where the trip is obviously bogus
df = df[df["trip_distance"] > 0] # zero-distance trips
df = df[df["fare_amount"] > 0] # negative fares
df = df[df["tip_amount"] >= 0] # negative tips
df = df[df["total_amount"] > 0]
df = df[df["tpep_dropoff_datetime"] > df["tpep_pickup_datetime"]] # time travel
# Cap outliers at the 99.5th percentile so one $1000 ride doesn't skew averages
fare_cap = df["fare_amount"].quantile(0.995)
df = df[df["fare_amount"] <= fare_cap]
# Fill passenger_count nulls with 1 (the most common value, and a reasonable assumption)
df["passenger_count"] = df["passenger_count"].fillna(1).astype("Int64")
# Cast known-categorical columns
df["VendorID"] = df["VendorID"].astype("category")
Câteva pattern-uri de remarcat:
- Fiecare filtru e pe linia lui, cu un comentariu. Un cititor viitor (inclusiv tu peste șase luni) trebuie să poată parcurge asta și să vadă ce a fost aruncat.
- Plafonul la percentila 99.5 pe fare e o judecată: spun „orice peste asta e probabil date proaste, iar includerea ar distorsiona răspunsul”. Genul de decizie care merită justificată într-un comentariu.
- Cast la category la final ca fazele de explorare și groupby să fie rapide.
După cleaning, afișează noul shape:
print(f"Cleaned shape: {df.shape}, dropped {3_267_104 - len(df):,} rows")
Ar trebui să știi mereu câte rânduri ai aruncat. Dacă e mai mult de câteva procente, uită-te de ce.
Faza 3 — explore
Înainte să calculezi răspunsul, uită-te la date. E faza în care asistenții AI sunt neobișnuit de utili: pune output-ul lui df.describe() și df.head() în Claude sau ChatGPT și întreabă „ce ar trebui să mă uit mai departe dat fiind întrebarea «tips by hour by payment type»?”. Modelul îți va sugera de încredere derivările potrivite de coloane și verificările standard de bun-simț. Nu e magie, e pattern matching pe zeci de mii de analize similare de pe net, dar accelerează mult bucla „ce-mi scapă?”. Unde AI e mai puțin util e întrebarea ce întrebare să pui și judecățile despre cleaning. Astea rămân ale tale.
Concret, în faza de explorare:
print(df["payment_label"].value_counts(normalize=True))
print(df.groupby("payment_label", observed=True)["tip_amount"].describe())
print(df["tpep_pickup_datetime"].dt.hour.value_counts().sort_index())
Ce aș urmări aici: au plătitorii cu cardul și cei cu cash distribuții suspect de diferite ale bacșișului? (Da, bacșișurile cash sunt de obicei 0 dolari în acest dataset, fiindcă șoferul nu le introduce. E o descoperire uriașă pentru întrebarea noastră și va trebui s-o tratăm.) Sunt ore din zi cu suspect de puține curse? (3-4 dimineața, e de așteptat.) E intervalul de timp corect, sau fișierul a inclus rânduri rătăcite din decembrie?
Faza 4 — transform
Acum coloanele de care întrebarea chiar are nevoie:
df["hour"] = df["tpep_pickup_datetime"].dt.hour
df["tip_pct"] = (df["tip_amount"] / df["fare_amount"]) * 100
# The cash-tip problem: cash trips almost always show tip=0 because of how
# the data is collected. Including them would systematically pull cash tips
# toward zero in a way that doesn't reflect reality. The right call here is
# to either restrict to credit-card trips for the headline number, or to
# show both with a clear caveat. We'll do both.
df_cc = df[df["payment_label"] == "credit_card"].copy()
Al treilea bloc de comentarii e genul de judecată care transformă „un script” în „o analiză”. Un cititor care vede doar cifrele n-ar ști că datele cash sunt structural distorsionate. Un cititor care vede acest script da.
Faza 5 — answer
Calculul propriu-zis, după toată setarea, e câteva linii:
# Headline: tip % by hour, credit-card payers only
hourly = (
df_cc.groupby("hour", observed=True)["tip_pct"]
.agg(["mean", "median", "count"])
.round(2)
)
print(hourly)
# Comparison: tip % by hour, by payment type, both populations
by_pmt = (
df.groupby(["hour", "payment_label"], observed=True)["tip_pct"]
.mean()
.unstack()
.round(2)
)
print(by_pmt)
Uită-te la rezultat și verifică-l cu bun-simț. Procent mediu de bacșiș în jur de 18-22% în timpul zilei, scade cu vreo două puncte noaptea târziu, sare la 4-6 dimineața (eșantioane mici, curse spre LGA/JFK cu fare-uri mai mari și bacșișuri mai rotunde). Coloana cash aproape de zero peste tot, exact cum era de așteptat din problema structurală.
Verifică mereu cu bun-simț. Dacă răspunsul arată dramatic, bug-ul e mai des în codul tău decât în date.
Faza 6 — save și present
Scrie rezultatul undeva durabil, într-un format care e ușor de comparat în diff și de reîncărcat:
hourly.to_parquet("output/tip_pct_by_hour.parquet")
with open("output/summary.md", "w") as f:
f.write("# NYC taxi tips, January 2025\n\n")
f.write(f"Trips analyzed: {len(df_cc):,} (credit-card only)\n")
f.write(f"Overall mean tip %: {df_cc['tip_pct'].mean():.2f}\n\n")
f.write("## Hourly tip percentage\n\n")
f.write(hourly.to_markdown())
ax = hourly["mean"].plot(kind="bar", figsize=(10, 5),
title="Mean tip % by hour of day (credit-card)")
ax.set_xlabel("Hour")
ax.set_ylabel("Tip %")
ax.figure.savefig("output/tip_pct_by_hour.png", dpi=150, bbox_inches="tight")
Trei artefacte: un fișier Parquet (pentru lucru programatic ulterior), un Markdown cu sumarul (pentru cititorul uman) și un PNG cu graficul (pentru slide-uri). Asta e ieșirea durabilă a analizei. Scriptul poate fi rerulat luna viitoare cu un alt fișier de input și produce aceleași trei artefacte.
Scriptul întreg
Lipit împreună, cu boilerplate-ul:
"""nyc_taxi_tips.py — average tip % by hour, by payment type."""
from pathlib import Path
import pandas as pd
INPUT = "data/yellow_tripdata_2025-01.parquet"
OUTPUT_DIR = Path("output")
PAYMENT_MAP = {
1: "credit_card", 2: "cash", 3: "no_charge",
4: "dispute", 5: "unknown", 6: "voided_trip",
}
def load_and_inspect(path: str) -> pd.DataFrame:
df = pd.read_parquet(path, dtype_backend="pyarrow")
print(f"Loaded {df.shape[0]:,} rows, {df.shape[1]} cols")
print(df.dtypes, "\n")
print(df.describe(), "\n")
print("Nulls per column:")
print(df.isna().sum()[df.isna().sum() > 0], "\n")
return df
def clean(df: pd.DataFrame) -> pd.DataFrame:
initial = len(df)
df = df.copy()
df["payment_label"] = df["payment_type"].map(PAYMENT_MAP).astype("category")
df = df[df["trip_distance"] > 0]
df = df[df["fare_amount"] > 0]
df = df[df["tip_amount"] >= 0]
df = df[df["total_amount"] > 0]
df = df[df["tpep_dropoff_datetime"] > df["tpep_pickup_datetime"]]
df = df[df["fare_amount"] <= df["fare_amount"].quantile(0.995)]
df["passenger_count"] = df["passenger_count"].fillna(1).astype("Int64")
df["VendorID"] = df["VendorID"].astype("category")
print(f"Dropped {initial - len(df):,} rows ({(initial - len(df)) / initial:.1%})")
return df
def transform(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df["hour"] = df["tpep_pickup_datetime"].dt.hour
df["tip_pct"] = (df["tip_amount"] / df["fare_amount"]) * 100
return df
def analyze(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
df_cc = df[df["payment_label"] == "credit_card"]
hourly = (
df_cc.groupby("hour", observed=True)["tip_pct"]
.agg(["mean", "median", "count"])
.round(2)
)
by_pmt = (
df.groupby(["hour", "payment_label"], observed=True)["tip_pct"]
.mean().unstack().round(2)
)
return hourly, by_pmt
def save(hourly: pd.DataFrame, by_pmt: pd.DataFrame, df_cc: pd.DataFrame) -> None:
OUTPUT_DIR.mkdir(exist_ok=True)
hourly.to_parquet(OUTPUT_DIR / "tip_pct_by_hour.parquet")
by_pmt.to_parquet(OUTPUT_DIR / "tip_pct_by_hour_by_payment.parquet")
with open(OUTPUT_DIR / "summary.md", "w") as f:
f.write("# NYC taxi tips, January 2025\n\n")
f.write(f"- Credit-card trips analyzed: **{len(df_cc):,}**\n")
f.write(f"- Overall mean tip %: **{df_cc['tip_pct'].mean():.2f}**\n\n")
f.write("## Hourly tip percentage (credit-card)\n\n")
f.write(hourly.to_markdown())
ax = hourly["mean"].plot(
kind="bar", figsize=(10, 5),
title="Mean tip % by hour of day (credit-card)",
)
ax.set_xlabel("Hour"); ax.set_ylabel("Tip %")
ax.figure.savefig(OUTPUT_DIR / "tip_pct_by_hour.png",
dpi=150, bbox_inches="tight")
def main() -> None:
df = load_and_inspect(INPUT)
df = clean(df)
df = transform(df)
hourly, by_pmt = analyze(df)
print("\nHourly (credit-card):"); print(hourly)
print("\nBy payment type:"); print(by_pmt)
save(hourly, by_pmt, df[df["payment_label"] == "credit_card"])
if __name__ == "__main__":
main()
Cam o sută de linii, șase funcții, rulabil de la cap la coadă. Fiecare funcție face o fază. Fiecare fază afișează ceva ca să poți vedea progresul. Output-ul merge într-un director. L-ai putea programa cu cron diseară și să nu te mai uiți la el, sau l-ai putea rerula pe fișierul din martie schimbând o singură constantă.
Pattern-uri care merită duse mai departe
Câteva lucruri din scriptul de mai sus care apar în fiecare analiză pe care am scris-o vreodată:
- Funcții pe fază, apelate dintr-un
main(). Mai ușor de testat, mai ușor de sărit peste o fază când iterezi („rerulez doaranalyzepe frame-ul deja în memorie”). - Constante sus, inclusiv path-uri. Niciodată nu îngropa un path într-o funcție.
- Print după fiecare fază. Scripturile pandas lungi care rulează în tăcere zece minute și apoi crapă sunt demoralizatoare; afișează numărul de rânduri după fiecare pas.
- Comentează judecățile, nu codul. „Plafonez la p99.5 fiindcă coada lungă e date murdare” merită o linie. „Calculez media coloanei” nu.
- Salvează Parquet, nu CSV, pentru output intermediar. Mai rapid, mai mic, sigur la dtypes.
- Un singur grafic per întrebare. Prea multe grafice și nu se uită nimeni la niciunul.
Când să te întorci la Polars
Pentru un dataset de 3 milioane de rânduri, pandas e ok și scriptul de mai sus rulează în 30 de secunde. Dacă același script ar procesa un an de date NYC taxi, 36 de milioane de rânduri în 12 fișiere, aș rescrie fazele de load și clean în Polars (pl.scan_parquet("data/*.parquet") urmat de aceleași filtre în formă de expresii) și aș converti la pandas doar pentru pasul final de groupby și plotting, unde integrarea pandas cu matplotlib e convenabilă. Pattern-ul din lecția 35, Polars pentru munca grea, pandas pentru predare, e exact ăsta.
Încheiere de Modul 6
Cu asta închidem Modulul 6. În zece lecții am ajuns de la „ce e un DataFrame” la „uite un pipeline de analiză complet pe un dataset public real”, cu opriri la fiecare API important din pandas pe drum și un ocol serios prin Polars. Acum știi suficient cât să faci munca de date în roughly orice rol Python: citește fișierul, curăță-l, explorează-l, răspunde la întrebare, salvează răspunsul și ai o opinie despre dacă să folosești pandas sau Polars la următorul.
Modulul 7 e data engineering: a lua analize ca cea de mai sus și a le face de nivel producție. Asta înseamnă a trata corect eșecurile, a le scrie ca job-uri programabile, a le monitoriza când rulează nesupravegheate și întrebarea plictisitoare-dar-esențială cum să-ți dai seama când unul dintre scripturile tale a început în tăcere să producă gunoi. Tranziția e de la „am rulat analiza o dată și am obținut un răspuns” la „chestia asta rulează în fiecare luni la 6 dimineața și business-ul depinde de output-ul ei”. Mentalitate diferită, același Python.
Lectură suplimentară
- pandas user guide — întregul lucru. Merită recitit după ce ai memoria musculară a unui proiect. Consultat 2026-05-01.
- Polars user guide — pattern-urile de migrare folosite în lecția 35, extinse. Consultat 2026-05-01.
- NYC TLC trip record data — dataset-ul, fișiere Parquet lunare, gratis.
Pe luni, la Modulul 7.