← Terug naar tutorials

Data-analyse met Pandas in Python: Praktische gids voor intermediates

pandaspythondata-analysedataframesgroupbydata-cleaningdata-transformatietijdreeksenmerge-joinperformance

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

  1. Installatie en omgeving
  2. Data inlezen: CSV, Excel, Parquet, SQL
  3. Eerste verkenning: structuur, types, ontbrekende waarden
  4. Datatypes en conversies: numeriek, categorisch, datums
  5. Indexering en selectie: loc, iloc, boolean masks
  6. Opschonen: missende waarden, duplicaten, outliers
  7. Tekstbewerking met str en regex
  8. Feature engineering: nieuwe kolommen, assign, pipe
  9. Samenvoegen: merge, join, concat
  10. Groeperen en aggregeren: groupby diepgaand
  11. Pivot, cross-tab en reshaping: pivot_table, melt
  12. Tijdreeksen: resampling, rolling, time zones
  13. Validatie en data-kwaliteit: checks en asserts
  14. Performance en geheugen: vectorisatie, categoricals, Parquet
  15. Export en rapportage: Excel, CSV, Parquet
  16. 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?

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:

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")

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

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:

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

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


Praktische oefening: van ruwe transacties naar managementoverzicht

Stel: je hebt transacties.csv met kolommen:

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

  1. Stille typefouten: omzet als object/tekst.
    Oplossing: pd.to_numeric(..., errors="coerce") en controleer isna().

  2. Onbedoelde verdubbeling bij merges: many-to-many zonder dat je het doorhebt.
    Oplossing: gebruik validate= in merge.

  3. Verkeerde logica met boolean filters: and in plaats van &.
    Oplossing: altijd &/| met haakjes.

  4. Datums als strings laten staan: resampling en sortering gaan mis.
    Oplossing: pd.to_datetime en eventueel datetime-index.

  5. 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.