Data-analyse met Pandas in Python: Praktische gids voor intermediates
Deze tutorial is bedoeld voor mensen die al basiskennis hebben van Python en Pandas, en nu willen leren hoe je Pandas in de praktijk inzet voor robuuste data-analyse. We gaan verder dan “CSV inlezen en een paar kolommen selecteren”: je leert hoe je data valideert, opschoont, verrijkt, samenvoegt, aggregeert, modelleert met groupby, tijdreeksen behandelt, performance verbetert en je workflow reproduceerbaar maakt.
Inhoud
- Installatie en omgeving
- Data inlezen: CSV, Excel, Parquet, SQL
- Eerste verkenning: structuur, types, ontbrekende waarden
- Datatypes en conversies: numeriek, categorisch, datums
- Indexering en selectie: loc, iloc, boolean masks
- Opschonen: missende waarden, duplicaten, outliers
- Tekstbewerking met
stren regex - Feature engineering: nieuwe kolommen,
assign,pipe - Samenvoegen:
merge,join,concat - Groeperen en aggregeren:
groupbydiepgaand - Pivot, cross-tab en reshaping:
pivot_table,melt - Tijdreeksen: resampling, rolling, time zones
- Validatie en data-kwaliteit: checks en asserts
- Performance en geheugen: vectorisatie, categoricals, Parquet
- Export en rapportage: Excel, CSV, Parquet
- Reproduceerbare analyse: functies, notebooks, logging
Installatie en omgeving
Zorg voor een recente Python-versie en installeer Pandas met bijbehorende pakketten. In een virtuele omgeving:
python -m venv .venv
# Windows:
.venv\Scripts\activate
# macOS/Linux:
source .venv/bin/activate
python -m pip install -U pip
pip install pandas numpy pyarrow openpyxl sqlalchemy matplotlib
Waarom deze extra’s?
- NumPy: onderliggende array-structuren, snel rekenen.
- PyArrow: Parquet lezen/schrijven, vaak sneller en compacter dan CSV.
- OpenPyXL: Excel inlezen/schrijven.
- SQLAlchemy: databaseconnecties.
- Matplotlib: snelle visualisaties vanuit Pandas.
Controleer versies:
python -c "import pandas as pd; import numpy as np; print(pd.__version__, np.__version__)"
Data inlezen: CSV, Excel, Parquet, SQL
CSV: veelvoorkomende valkuilen
import pandas as pd
df = pd.read_csv(
"data/verkopen.csv",
sep=",",
encoding="utf-8",
na_values=["", "NA", "N/A", "null", "None"],
dtype={"klant_id": "string"},
)
Belangrijke opties:
na_values: definieer welke tokens als missend tellen.dtype: voorkom dat IDs als getal worden geïnterpreteerd (leading zeros verdwijnen anders).parse_dates: datums direct parsen (zie verderop).
Voor grote bestanden is usecols nuttig:
df = pd.read_csv("data/verkopen.csv", usecols=["datum", "klant_id", "omzet", "land"])
Excel
df_xls = pd.read_excel(
"data/verkopen.xlsx",
sheet_name="Sheet1",
engine="openpyxl"
)
Meerdere tabbladen:
sheets = pd.read_excel("data/verkopen.xlsx", sheet_name=None)
df_sheet1 = sheets["Sheet1"]
Parquet (aanrader voor performance)
df_pq = pd.read_parquet("data/verkopen.parquet")
SQL
from sqlalchemy import create_engine
engine = create_engine("sqlite:///data/bedrijf.db")
df_sql = pd.read_sql_query("SELECT * FROM verkopen", con=engine)
Tip: haal alleen op wat je nodig hebt (filter in SQL), zeker bij grote tabellen.
Eerste verkenning: structuur, types, ontbrekende waarden
Start altijd met een snelle “gezondheidscheck”:
df.head(10)
df.tail(5)
df.shape
df.columns
df.dtypes
df.info()
Samenvattende statistiek:
df.describe(include="all")
Ontbrekende waarden tellen:
df.isna().sum().sort_values(ascending=False)
Unieke waarden (voor categorische velden):
df["land"].value_counts(dropna=False).head(20)
Waarom dit belangrijk is: veel analyseproblemen komen door stille typefouten (bijv. omzet als tekst), onverwachte missings, of categorieën met spelfouten.
Datatypes en conversies: numeriek, categorisch, datums
Numeriek maken met to_numeric
Als een kolom “getal” eigenlijk tekst is:
df["omzet"] = pd.to_numeric(df["omzet"], errors="coerce")
errors="coerce"zet ongeldige waarden om naarNaN, zodat je ze kunt opsporen.
Controle:
df["omzet"].isna().mean()
Categorische data
Voor velden met herhaalde labels (zoals land, productcategorie) is category vaak efficiënter:
df["land"] = df["land"].astype("category")
Datums en tijden
Datums parsen:
df["datum"] = pd.to_datetime(df["datum"], errors="coerce", dayfirst=True)
Als je al bij het inlezen wilt parsen:
df = pd.read_csv("data/verkopen.csv", parse_dates=["datum"], dayfirst=True)
Tijdzone toevoegen (bijv. Europa/Amsterdam):
df["datum"] = df["datum"].dt.tz_localize("Europe/Amsterdam", nonexistent="shift_forward", ambiguous="NaT")
Let op: tz_localize gebruik je als de tijden “naïef” zijn (zonder tijdzone). Als er al een tijdzone in zit, gebruik je tz_convert.
Indexering en selectie: loc, iloc, boolean masks
loc en iloc
loc: label-gebaseerd (kolomnamen, indexlabels)iloc: positie-gebaseerd (0..n)
df.loc[0:5, ["datum", "omzet"]]
df.iloc[0:5, 0:3]
Boolean filtering
df_nl = df[df["land"] == "NL"]
df_groot = df[df["omzet"] > 1000]
Meerdere voorwaarden:
mask = (df["land"].isin(["NL", "BE"])) & (df["omzet"].between(100, 1000))
df_select = df[mask]
Valkuil: gebruik & en | met haakjes, niet and/or.
Query-syntax
Voor leesbaarheid:
df.query("land == 'NL' and omzet > 1000")
Let op: kolomnamen met spaties werken minder prettig; hernoem ze liever.
Opschonen: missende waarden, duplicaten, outliers
Missende waarden behandelen
Eerst: begrijp waarom waarden missen. Is het “niet van toepassing”, “onbekend”, of een fout?
Verwijderen (alleen als je het verantwoord vindt):
df_clean = df.dropna(subset=["datum", "omzet"])
Imputeren met mediaan per groep:
df["omzet"] = df["omzet"].fillna(
df.groupby("land")["omzet"].transform("median")
)
Voor categorische velden:
df["land"] = df["land"].cat.add_categories(["ONBEKEND"]).fillna("ONBEKEND")
Duplicaten
Controleer duplicaten op sleutelkolommen:
dups = df.duplicated(subset=["datum", "klant_id", "product_id"], keep=False)
df[dups].sort_values(["klant_id", "datum"])
Verwijderen:
df = df.drop_duplicates(subset=["datum", "klant_id", "product_id"], keep="last")
Outliers (eenvoudige aanpak)
Een robuuste methode is werken met quantielen:
q1 = df["omzet"].quantile(0.25)
q3 = df["omzet"].quantile(0.75)
iqr = q3 - q1
lower = q1 - 1.5 * iqr
upper = q3 + 1.5 * iqr
df_out = df[(df["omzet"] < lower) | (df["omzet"] > upper)]
Outliers verwijderen is niet altijd correct; soms zijn het juist belangrijke signalen. Overweeg winsoriseren:
df["omzet_w"] = df["omzet"].clip(lower=lower, upper=upper)
Tekstbewerking met str en regex
Normaliseer tekst om categorieën consistent te maken:
df["land_norm"] = (
df["land"]
.astype("string")
.str.strip()
.str.upper()
)
Regex vervangen:
df["postcode"] = (
df["postcode"]
.astype("string")
.str.replace(r"\s+", "", regex=True)
)
Extract uit tekst:
df["product_code"] = df["product_naam"].str.extract(r"CODE-(\d+)", expand=False)
Meerdere velden splitsen:
df[["voornaam", "achternaam"]] = df["naam"].str.split(" ", n=1, expand=True)
Feature engineering: nieuwe kolommen, assign, pipe
Nieuwe kolommen
df["jaar"] = df["datum"].dt.year
df["maand"] = df["datum"].dt.to_period("M").astype("string")
df["omzet_eur"] = df["omzet"] * 1.0
assign voor ketens
df2 = (
df
.assign(
jaar=lambda x: x["datum"].dt.year,
maand=lambda x: x["datum"].dt.month,
omzet_log=lambda x: (x["omzet"].clip(lower=0)).pipe(lambda s: (s + 1).apply(__import__("math").log))
)
)
Bovenstaand is functioneel, maar let op: apply is vaak trager. Voor log kun je beter NumPy gebruiken:
import numpy as np
df2 = df.assign(omzet_log=lambda x: np.log1p(x["omzet"].clip(lower=0)))
pipe voor herbruikbare stappen
def standaardiseer_land(d):
return d.assign(land=lambda x: x["land"].astype("string").str.strip().str.upper())
def filter_actief(d):
return d.query("omzet > 0")
df3 = (df.pipe(standaardiseer_land).pipe(filter_actief))
Dit helpt om analyses leesbaar en testbaar te houden.
Samenvoegen: merge, join, concat
Stel je hebt verkopen en klanten:
verkopen = pd.read_parquet("data/verkopen.parquet")
klanten = pd.read_parquet("data/klanten.parquet")
merge (SQL-achtig)
dfm = verkopen.merge(
klanten,
how="left",
on="klant_id",
validate="many_to_one"
)
validate is belangrijk: het dwingt af dat je relatie klopt. Als er meerdere klantrecords per klant_id zijn, krijg je een fout in plaats van stille verdubbeling.
Samenvoegen op verschillende kolomnamen:
dfm = verkopen.merge(klanten, how="left", left_on="klant_id", right_on="id")
concat (onder elkaar plakken)
df_all = pd.concat([df_jan, df_feb, df_mar], axis=0, ignore_index=True)
join op index
klanten_idx = klanten.set_index("klant_id")
dfj = verkopen.join(klanten_idx, on="klant_id", how="left")
Groeperen en aggregeren: groupby diepgaand
groupby is het werkpaard van Pandas. Belangrijk is het onderscheid tussen:
- aggregatie: reduceren naar één regel per groep
- transformatie: resultaat met dezelfde lengte als input (handig voor normalisatie)
- filter: groepen selecteren op basis van groepscriteria
Basisaggregatie
omzet_per_land = (
df.groupby("land", dropna=False)["omzet"]
.sum(min_count=1)
.sort_values(ascending=False)
)
Meerdere aggregaties:
stats = (
df.groupby("land")
.agg(
omzet_sum=("omzet", "sum"),
omzet_avg=("omzet", "mean"),
n=("omzet", "size"),
n_nonnull=("omzet", "count"),
)
.reset_index()
)
Groeperen op meerdere sleutels
omzet_land_maand = (
df.groupby(["land", "maand"], as_index=False)
.agg(omzet=("omzet", "sum"))
.sort_values(["land", "maand"])
)
transform voor normalisatie binnen groep
Voor aandeel van elke regel binnen land:
df["omzet_land_totaal"] = df.groupby("land")["omzet"].transform("sum")
df["omzet_aandeel"] = df["omzet"] / df["omzet_land_totaal"]
Z-score per groep:
g = df.groupby("land")["omzet"]
df["omzet_z"] = (df["omzet"] - g.transform("mean")) / g.transform("std")
filter op groepsniveau
Alleen landen met minimaal 100 transacties:
df_big = df.groupby("land").filter(lambda d: len(d) >= 100)
groupby met tijd
Als datum datetime is:
df["datum_dag"] = df["datum"].dt.floor("D")
dagomzet = (
df.groupby("datum_dag", as_index=False)
.agg(omzet=("omzet", "sum"))
.sort_values("datum_dag")
)
Pivot, cross-tab en reshaping: pivot_table, melt
pivot_table
Omzet per land per maand:
pivot = pd.pivot_table(
df,
index="land",
columns="maand",
values="omzet",
aggfunc="sum",
fill_value=0
)
Meerdere waardes:
pivot2 = pd.pivot_table(
df,
index="land",
columns="maand",
values=["omzet", "aantal"],
aggfunc={"omzet": "sum", "aantal": "sum"},
fill_value=0
)
crosstab
Frequenties:
freq = pd.crosstab(df["land"], df["kanaal"], dropna=False)
melt (wide naar long)
Stel je hebt kolommen omzet_2024_01, omzet_2024_02, enz.:
long = df.melt(
id_vars=["klant_id"],
value_vars=["omzet_2024_01", "omzet_2024_02"],
var_name="periode",
value_name="omzet"
)
Tijdreeksen: resampling, rolling, time zones
Tijdreeksanalyse wordt veel makkelijker als je een datetime-index gebruikt:
ts = df.set_index("datum").sort_index()
Resampling
Dag naar week:
week = ts["omzet"].resample("W-MON").sum(min_count=1)
Maand:
maand = ts["omzet"].resample("MS").sum(min_count=1) # MS = maandstart
Rolling gemiddelden
7-daags voortschrijdend gemiddelde:
roll7 = ts["omzet"].rolling("7D").mean()
Of op basis van aantal observaties:
roll10 = ts["omzet"].rolling(window=10, min_periods=3).mean()
Verschillen en groeipercentages
Dag-op-dag verschil:
ts["omzet_diff"] = ts["omzet"].diff()
Procentuele groei:
ts["omzet_pct"] = ts["omzet"].pct_change()
Time zones: consistentie
Als je data uit meerdere bronnen komt, dwing één tijdzone af:
ts = ts.tz_convert("Europe/Amsterdam")
Als sommige rijen geen tijdzone hebben, los dat eerst op vóór je gaat combineren.
Validatie en data-kwaliteit: checks en asserts
Intermediates maken vaak de stap naar “data-contracten”: expliciet vastleggen wat je verwacht.
Eenvoudige asserts
assert df["klant_id"].notna().all()
assert (df["omzet"] >= 0).all()
Uniekheid van sleutels
assert not klanten["klant_id"].duplicated().any()
Domeinchecks
toegestane_landen = {"NL", "BE", "DE"}
fout = ~df["land"].isin(toegestane_landen)
df.loc[fout, ["land"]].value_counts()
Controle op onverwachte nulls na merge
dfm = verkopen.merge(klanten, how="left", on="klant_id", validate="many_to_one")
missing_klant = dfm["klant_naam"].isna().mean()
missing_klant
Als dit percentage hoog is, klopt je sleutel of brondata waarschijnlijk niet.
Performance en geheugen: vectorisatie, categoricals, Parquet
Vermijd apply waar mogelijk
Slecht (rij-voor-rij):
df["omzet_met_btw"] = df["omzet"].apply(lambda x: x * 1.21)
Goed (vectorisatie):
df["omzet_met_btw"] = df["omzet"] * 1.21
Slimme datatypes
- IDs als
string(of somsintals je zeker bent) - Categorieën als
category - Kleine integers als
Int32ofInt16indien passend
Voorbeeld:
df["aantal"] = pd.to_numeric(df["aantal"], errors="coerce").astype("Int32")
df["kanaal"] = df["kanaal"].astype("category")
Geheugengebruik inspecteren
df.memory_usage(deep=True).sort_values(ascending=False).head(20)
Parquet als opslagformaat
CSV is groot en traag; Parquet is kolom-gebaseerd en comprimeert goed:
df.to_parquet("output/verkopen_clean.parquet", index=False)
Later:
df = pd.read_parquet("output/verkopen_clean.parquet")
Export en rapportage: Excel, CSV, Parquet
CSV export
df.to_csv("output/resultaat.csv", index=False, encoding="utf-8")
Excel export met meerdere tabbladen
with pd.ExcelWriter("output/rapport.xlsx", engine="openpyxl") as writer:
stats.to_excel(writer, sheet_name="Stats", index=False)
pivot.reset_index().to_excel(writer, sheet_name="Pivot", index=False)
Snelle grafiek voor sanity check
import matplotlib.pyplot as plt
dagomzet.plot(x="datum_dag", y="omzet", kind="line")
plt.tight_layout()
plt.show()
Reproduceerbare analyse: functies, notebooks, logging
Een analyse wordt pas echt bruikbaar als iemand (jij over 3 maanden) hem kan herhalen.
Maak een duidelijke pipeline
import pandas as pd
def laad_data(pad: str) -> pd.DataFrame:
return pd.read_csv(pad, parse_dates=["datum"], dayfirst=True)
def schoonmaak(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df["omzet"] = pd.to_numeric(df["omzet"], errors="coerce")
df = df.dropna(subset=["datum", "omzet", "klant_id"])
df["land"] = df["land"].astype("string").str.strip().str.upper().astype("category")
return df
def maak_features(df: pd.DataFrame) -> pd.DataFrame:
return df.assign(
maand=lambda x: x["datum"].dt.to_period("M").astype("string"),
jaar=lambda x: x["datum"].dt.year
)
def analyse(df: pd.DataFrame) -> pd.DataFrame:
return (
df.groupby(["land", "maand"], as_index=False)
.agg(omzet=("omzet", "sum"), n=("omzet", "size"))
.sort_values(["land", "maand"])
)
df_raw = laad_data("data/verkopen.csv")
df_clean = maak_features(schoonmaak(df_raw))
resultaat = analyse(df_clean)
resultaat.to_parquet("output/omzet_land_maand.parquet", index=False)
Logging (basis)
import logging
logging.basicConfig(level=logging.INFO)
logging.info("Rijen raw: %s", len(df_raw))
logging.info("Rijen clean: %s", len(df_clean))
Notebook-hygiëne
- Zet parameters bovenaan (paden, filters, datumbereik).
- Gebruik vaste “secties”: inlezen → opschonen → analyse → output.
- Schrijf tussentijdse resultaten weg (bijv. Parquet), zodat je niet steeds opnieuw hoeft te scrapen/inlezen.
Praktische oefening: van ruwe transacties naar managementoverzicht
Stel: je hebt transacties.csv met kolommen:
datum(tekst)klant_id(soms leeg)land(inconsistente schrijfwijze)omzet(tekst, soms met komma’s)kanaal(online/winkel/partner)
Stap 1: Inlezen en basisopschoning
import pandas as pd
import numpy as np
df = pd.read_csv("data/transacties.csv", na_values=["", "NA", "null"])
df["datum"] = pd.to_datetime(df["datum"], errors="coerce", dayfirst=True)
df["omzet"] = (
df["omzet"]
.astype("string")
.str.replace(".", "", regex=False) # duizendtalseparator
.str.replace(",", ".", regex=False) # decimaal
)
df["omzet"] = pd.to_numeric(df["omzet"], errors="coerce")
df["land"] = (
df["land"]
.astype("string")
.str.strip()
.str.upper()
)
Stap 2: Validatie
assert df["datum"].notna().mean() > 0.99
assert df["omzet"].notna().mean() > 0.95
Stap 3: Filter en features
df = df.dropna(subset=["klant_id", "datum", "omzet"])
df = df.query("omzet >= 0")
df["maand"] = df["datum"].dt.to_period("M").astype("string")
df["week"] = df["datum"].dt.to_period("W").astype("string")
df["kanaal"] = df["kanaal"].astype("string").str.strip().str.lower().astype("category")
Stap 4: Overzichten
Omzet per land per maand:
omzet_lm = (
df.groupby(["land", "maand"], as_index=False)
.agg(omzet=("omzet", "sum"), transacties=("omzet", "size"))
)
Top 10 klanten per maand:
topklanten = (
df.groupby(["maand", "klant_id"], as_index=False)
.agg(omzet=("omzet", "sum"))
.sort_values(["maand", "omzet"], ascending=[True, False])
.groupby("maand")
.head(10)
)
Kanaalmix per land:
mix = (
df.groupby(["land", "kanaal"], as_index=False)
.agg(omzet=("omzet", "sum"))
)
mix["omzet_land"] = mix.groupby("land")["omzet"].transform("sum")
mix["aandeel"] = mix["omzet"] / mix["omzet_land"]
Stap 5: Export
with pd.ExcelWriter("output/management_rapport.xlsx", engine="openpyxl") as writer:
omzet_lm.to_excel(writer, sheet_name="Omzet_land_maand", index=False)
topklanten.to_excel(writer, sheet_name="Topklanten", index=False)
mix.to_excel(writer, sheet_name="Kanaalmix", index=False)
Veelgemaakte fouten en hoe je ze voorkomt
-
Stille typefouten: omzet als
object/tekst.
Oplossing:pd.to_numeric(..., errors="coerce")en controleerisna(). -
Onbedoelde verdubbeling bij merges: many-to-many zonder dat je het doorhebt.
Oplossing: gebruikvalidate=inmerge. -
Verkeerde logica met boolean filters:
andin plaats van&.
Oplossing: altijd&/|met haakjes. -
Datums als strings laten staan: resampling en sortering gaan mis.
Oplossing:pd.to_datetimeen eventueel datetime-index. -
Te veel
apply: traag en lastig te optimaliseren.
Oplossing: vectorisatie,np.where,clip,str-methodes.
Afsluiting
Met de technieken in deze gids kun je Pandas inzetten als een volwassen analyse-instrument: je leest data betrouwbaar in, maakt types expliciet, schoonmaakt met duidelijke regels, combineert tabellen zonder verrassingen, bouwt aggregaties die managementvragen beantwoorden, en houdt je workflow reproduceerbaar en performant.
Als je wilt, kan ik op basis van jouw eigen dataset (kolomnamen + een paar voorbeeldregels) een concrete analyse-pipeline voorstellen met validatieregels, merges en een set kern-KPI’s.