Wpis zakłada znajomość podstaw Pythona — patrz Python - podstawy. Sekcje o szeregach czasowych i agregacji mają sens dopiero, gdy rozumiesz, czym jest słownik, lista, NumPy array i pętla for. Przy okazji warto rzucić okiem na SQL - podstawy — wiele operacji w pandas ma niemal jeden do jednego odpowiednik w SQL.
Środowisko do uruchomienia
Bloki kodu w tym artykule uruchamia się w środowisku opisanym w Środowisko Python dla przykładów. Wersje kluczowych pakietów użytych do weryfikacji: pandas 3.0.2, numpy 2.4.4, matplotlib 3.10.9.
W codziennej pracy z danymi hydrologicznymi i meteorologicznymi większość operacji da się sprowadzić do jednego pytania: jak z tabeli pełnej obserwacji wyciągnąć liczbę, wiersz albo wykres, którego potrzebuję? Może to być średnia miesięczna z dziesięciu lat, charakterystyka roczna na pięciu stacjach, luka w pomiarach do uzupełnienia interpolacją albo połączenie dwóch tabel — wodowskazów i opadów — tak, żeby daty i lokalizacje pasowały do siebie. Każda z tych operacji ma w pandas bezpośrednie odzwierciedlenie w jednej lub dwóch linijkach.
Ten wpis jest przewodnikiem po podstawowych strukturach (Series, DataFrame, Index), wejściu/wyjściu (CSV, Excel, Parquet), indeksowaniu, agregacji (groupby), brakujących danych (NaN, pd.NA, interpolate), łączeniu zbiorów (merge, concat) i szeregach czasowych (resample, rolling). Ostatnia sekcja to pełne case study: syntetyczne dane wodowskazowe dla pięciu stacji × dziesięć lat, na których pokazujemy każdą z tych operacji w działaniu.
Czym jest pandas
pandas to biblioteka Pythona do pracy z danymi tabelarycznymi: tablicami, w których każda kolumna ma swoją nazwę i swój typ danych, a każdy wiersz reprezentuje jedną obserwację. Powstała w 2008 r. (Wes McKinney, początkowo na potrzeby analizy finansowej) i z czasem stała się de facto standardem w analizie danych w Pythonie — porównywalnym pod względem ergonomii z data.frame w R.
Pod spodem pandas opiera się na NumPy — większość operacji numerycznych jest delegowana do macierzy ndarray, dzięki czemu otrzymujemy szybkość kodu w C, mimo wygodnego API Pythona. Z drugiej strony jest matplotlib — pandas ma wbudowane metody .plot(), które są cienką nakładką nad matplotlibem (patrz Praca z rastrami w Pythonie dla podobnej relacji rasterio ↔ NumPy).
Ekosystem wokół pandas obejmuje:
NumPy — fundament numeryczny, używany wewnątrz pandas
matplotlib / seaborn — wizualizacje (pandas integruje się przez .plot())
xarray — odpowiednik DataFrame dla danych wielowymiarowych z etykietowanymi osiami (NetCDF, dane klimatyczne); patrz porównanie w dalszej części artykułu
scikit-learn — modele ML konsumują DataFrame bezpośrednio
Wersja 3.0 wprowadza kilka zmian niekompatybilnych ze starszymi wersjami:
Copy-on-Write (CoW) jest zawsze włączone — wycisza ostrzeżenia typu SettingWithCopyWarning, ale niektóre stare wzorce przestają mieć efekt
Domyślnym typem kolumny tekstowej jest str (StringDtype), a nie object — Series(['a','b']) ma dtype: str
Aliasy częstotliwości takie jak 'M' (miesiąc) i 'Y' (rok) zostały usunięte; należy używać 'ME' (month-end) i 'YE' (year-end)
Metoda DataFrame.append() została usunięta na rzecz pd.concat()
Domyślną rozdzielczością datetime jest datetime64[us] (mikrosekundy), a nie [ns]
Wszystkie przykłady w tym wpisie są napisane pod pandas 3.x.
Konwencja importu
Praktycznie zawsze importujemy pandas jako pd:
import pandas as pdimport numpy as npprint(pd.__version__)
Output (w środowisku z requirements.txt):
3.0.2
W dalszej części wpisu zakładamy, że oba importy są wykonane na początku każdego skryptu.
Series — dane 1D z etykietami
Series to jednowymiarowa tablica z etykietowanym indeksem. Można o niej myśleć jak o słowniku, w którym klucze są uporządkowane, a wartości są tego samego typu (lub przynajmniej zgodne):
index — obiekt Index (zwykle też ze swoim typem, tu str)
name — opcjonalna nazwa serii (przydaje się przy łączeniu w DataFrame)
dtype — typ danych, identyczny dla wszystkich wartości
Dostęp do wartości
print("s['a']:", s["a"]) # po etykiecieprint("s.iloc[0]:", s.iloc[0]) # po pozycjiprint("s.loc['a']:", s.loc["a"]) # jawnie po etykiecie
Output:
s['a']: 10
s.iloc[0]: 10
s.loc['a']: 10
.iloc[] używa pozycji całkowitej (jak listy Pythona), .loc[] — etykiety indeksu. Surowy operator [] próbuje zgadnąć: dla Series ze stringowym indeksem s['a'] traktuje argument jak etykietę, dla indeksu liczbowego bywa różnie. Dla jednoznaczności preferuje się .loc / .iloc.
Operacje wektorowe
print(s * 2)
Output:
a 20
b 40
c 60
Name: przeplyw, dtype: int64
Operacje arytmetyczne na Series są wektorowe (jak w NumPy), nie ma potrzeby pisania pętli for. Co ważne, pandas wyrównuje serie po indeksie przy operacji binarnej:
Tylko etykieta a istnieje w obu seriach — tam wartości się sumują. Pozostałe (b, c, d) dają NaN. To kluczowa właściwość pandas: dane są wyrównywane po etykietach, a nie po pozycji. Dla osoby przyzwyczajonej do NumPy może to być pułapką (NumPy używa pozycji).
dtype i konwersje
Typ kolumny wpływa na to, co można z nią zrobić i jak dużo zajmuje w pamięci. Najczęstsze typy:
dtype
Co przechowuje
Uwagi
int64
liczby całkowite
nie obsługuje NaN
float64
liczby zmiennoprzecinkowe
NaN dozwolone (jako np.nan)
bool
wartości logiczne
str (StringDtype)
tekst
nowy domyślny typ w pandas 3.x
object
dowolne obiekty Pythona
wolniejszy; do uniknięcia, jeśli się da
datetime64[us]
znaczniki czasu
mikrosekundy (pandas 3.x default)
category
wartości kategoryczne
oszczędność pamięci dla powtarzających się
Int64
int z obsługą braków (nullable)
duża “I” — uwaga, to inny typ niż int64
Konwersja jawna:
s = pd.Series(["1", "2", "3"])print(s.dtype)s_num = pd.to_numeric(s)print(s_num.dtype, s_num.tolist())
Output:
str
int64 [1, 2, 3]
Funkcje pd.to_numeric(), pd.to_datetime(), pd.to_timedelta() to bezpieczne sposoby konwersji — ich zachowaniem w razie błędu można sterować argumentem errors= ('raise', 'coerce', 'ignore').
DataFrame — tabela z indeksem i kolumnami
DataFrame to dwuwymiarowa, etykietowana struktura: kolekcja obiektów Series współdzielących ten sam indeks (wiersze). Każda kolumna ma swoją nazwę i swój dtype.
Q
count 4.000000
mean 164.475000
std 252.143641
min 12.500000
25% 21.875000
50% 52.650000
75% 195.250000
max 540.100000
describe() domyślnie liczy: liczbę obserwacji (count), średnią, odchylenie standardowe, min, kwartyle (25/50/75%), max. Dla kolumn kategorycznych pokazuje liczbę unikalnych wartości, najczęstszą wartość i jej liczność.
Index — kręgosłup pandas
Wszystko w pandas opiera się na Index — uporządkowanej, etykietowanej kolekcji, która stanowi “kręgosłup” zarówno Series, jak i każdej osi DataFrame. Indeks daje pandas możliwość wyrównywania danych po etykietach (z czego nie korzysta NumPy) i przyspiesza wyszukiwanie po kluczu.
Rodzaje indeksów
RangeIndex — domyślny, sekwencja liczb 0, 1, 2… (leniwy, zajmuje stałą ilość pamięci)
Index — ogólny indeks (string, mieszane typy, kolumny)
DatetimeIndex — znaczniki czasu, krytyczny dla szeregów czasowych
PeriodIndex — okresy (miesiąc, rok), nie punkty w czasie
MultiIndex — wielopoziomowy, hierarchiczny (krotki jako klucze)
CategoricalIndex, IntervalIndex — rzadziej spotykane
MultiIndex pozwala czysto reprezentować zbiory wielowymiarowe, ale wymaga uwagi przy krojeniu (potrzebny pd.IndexSlice dla pełnej elastyczności). W praktyce dla większości operacji wystarczy groupby — patrz dalsza sekcja.
I/O — wejście i wyjście
pandas obsługuje kilkanaście formatów przez funkcje read_* i metody to_*. Najczęściej używane:
Sterowanie typami jest kluczowe dla dużych plików. Bez dtype= pandas ogląda każdą kolumnę i zgaduje typ — co bywa kosztowne i czasem błędne (np. kolumna z numerami stacji rozpoczynającymi się od zera może zostać potraktowana jak int64, gubiąc wiodące zera).
to_csv — zapis
df.to_csv("/tmp/dane.csv", index=False)
Argument index=False często jest pożądany — bez niego pandas zapisze również kolumnę indeksu (zwykle nic nie wnoszący RangeIndex 0, 1, 2…).
Parquet — szybki format kolumnowy
Parquet (binarny, kolumnowy) jest znacznie szybszy i mniejszy od CSV — dla dużych zbiorów warto go używać. Wymaga zainstalowanego pyarrow:
Parquet zachowuje typy kolumn (CSV gubi wszystko poza tekstem — trzeba pamiętać o parse_dates i dtype). W środowisku tego artykułu pyarrow nie jest zainstalowany, więc nie pokazujemy działającego przykładu.
Indeksowanie i selekcja
Indeksowanie w pandas jest jednym z najczęstszych źródeł zamieszania. Trzy główne sposoby:
df[...] — wybór kolumn (lub wierszy przez maskę logiczną)
df.loc[...] — selekcja po etykietach (indeks, kolumna)
Uwaga: w .lockrojenie jest inkluzywne po obu stronach (1:3 zawiera również 3, w przeciwieństwie do Pythona). Powód: indeks może być łańcuchem znaków albo datą — bez inkluzywnego końca trudno byłoby napisać “od 2020-01-01 do 2020-12-31”.
.iloc — pozycje
print(df.iloc[1:3, 0:2])
Output:
kod Q
1 POZ 80.3
2 KRK 25.0
W .iloc krojenie jest półotwarte (jak w Pythonie i NumPy) — 1:3 to wiersze 1 i 2, a nie 3.
query — alternatywa dla maski logicznej
query() przyjmuje wyrażenie jako łańcuch znaków, co bywa czytelniejsze niż złożone maski:
print(df.query("Q > 30 and rzeka == 'Wisla'"))
Output:
kod Q rzeka
3 WAR 540.1 Wisla
Wewnątrz wyrażenia możemy odwoływać się do zmiennych Pythona przez @:
prog = 30print(df.query("Q > @prog"))
Output:
kod Q rzeka
1 POZ 80.3 Warta
3 WAR 540.1 Wisla
Widok vs kopia, Copy-on-Write
Historycznie pandas miał problem z rozróżnieniem, czy wynik selekcji to kopia, czy widok na oryginał. Modyfikacja widoku zmieniała oryginał — czasem niezamierzenie. Ostrzeżenia typu SettingWithCopyWarning były próbą zwrócenia na to uwagi.
W pandas 3.x ten problem został rozwiązany przez Copy-on-Write (CoW): każde przypisanie jest semantycznie kopią, a faktyczna alokacja pamięci następuje tylko wtedy, gdy ktoś rzeczywiście modyfikuje dane. CoW jest zawsze włączone i nie da się go wyłączyć:
df = pd.DataFrame({"a": [1, 2, 3]})sub = df[df["a"] > 1]sub["a"] = 99 # nie wpływa na dfprint("df:")print(df)print("sub:")print(sub)
Output:
df:
a
0 1
1 2
2 3
sub:
a
1 99
2 99
W pandas 1.x ten kod albo rzucał SettingWithCopyWarning, albo (co gorsza) zmieniał df razem z sub. W 3.x semantyka jest jasna: sub to oddzielny obiekt.
Operacje na danych
Arytmetyka kolumnowa
Operacje na kolumnach są wektorowe — pętle for po wierszach prawie nigdy nie są potrzebne:
apply z funkcją Pythona jest często znacznie wolniejszy od operacji wektorowych — dla dużych zbiorów może być wąskim gardłem. Zasada: najpierw sprawdź, czy nie da się napisać wektorowo (df["Q"] * 1000 / df["A"] zamiast df.apply(...)), a apply używaj tylko wtedy, gdy nie ma alternatywy.
Brakujące dane
Brakujące dane w pandas są reprezentowane przez:
np.nan (float NaN) — dla kolumn numerycznych float64
Tutaj odstępy w czasie są nierówne (1 dzień, 3 dni, 1 dzień), więc method="time" daje inne wyniki niż method="linear" (ta byłaby uśrednieniem indeksów pozycyjnych 0..3).
Int64 — int z obsługą braków
Kolumna int64 (mała i) nie obsługuje NaN — jeśli wczytasz CSV z brakami w kolumnie całkowitoliczbowej, pandas skonwertuje ją na float64. Aby zachować semantykę liczby całkowitej, używa się typu Int64 (duża I, nullable):
s = pd.Series([1, 2, None], dtype="Int64")print(s)print(s.dtype)
Q_min Q_mean Q_max
station
A 10.0 11.000000 12.0
B 50.0 52.333333 55.0
Każde nazwane wyrażenie ma formę nowa_kolumna=(istniejaca_kolumna, funkcja).
transform — zachowuje rozmiar
W przeciwieństwie do agg metoda transform zwraca wynik o tej samej długości co wejście — przydatne np. do centrowania danych względem średniej grupowej:
Domyślnie join wykonuje how="left" — często wygodne.
Szeregi czasowe
Szeregi czasowe to obszar, w którym pandas naprawdę błyszczy. DatetimeIndex daje dostęp do bogatej kolekcji metod: resampling (zmiana częstotliwości), okna ruchome (rolling), przesunięcie (shift), różnicowanie, obsługa stref czasowych.
Zaleta okna czasowego: poprawnie obsługuje nierównomierne odstępy (np. luki w pomiarach).
shift, diff, pct_change
shift(n) — przesuwa serię o n pozycji (puste miejsca to NaN)
diff() — różnice między kolejnymi wartościami (s - s.shift(1))
pct_change() — zmiana względna (procentowa)
s = pd.Series([10, 12, 11, 14, 18])print("shift(1):", s.shift(1).tolist())print("diff:", s.diff().tolist())print("pct_change:", [f"{x:.3f}" if pd.notna(x) else "nan" for x in s.pct_change().tolist()])
Mieszanie znaczników strefowych i bezstrefowych zwykle rzuca wyjątek — to celowe, by uniknąć błędów typu “godzina w jednym formacie zinterpretowana w innym”.
Praktyka — syntetyczne dane wodowskazowe
W tej sekcji łączymy poprzednie wątki w jednym, większym przykładzie: budujemy syntetyczny zbiór dziennych przepływów dla pięciu fikcyjnych stacji wodowskazowych w okresie 2015–2024, a następnie wykonujemy operacje typowe dla analizy hydrologicznej: agregacje miesięczne i roczne, średnie kroczące, wykrywanie luk danych, statystyki przepływów charakterystycznych (SNQ, SSQ, SWQ), wizualizację.
Wszystkie wartości są generowane deterministycznie (seed 42) — uruchomienie skryptu daje identyczne wyniki.
Generowanie syntetycznych danych
import numpy as npimport pandas as pdrng = np.random.default_rng(seed=42)stations = ["BIA", "POZ", "KRK", "WAR", "WRO"] # kody fikcyjnestart = pd.Timestamp("2015-01-01")end = pd.Timestamp("2024-12-31")dates = pd.date_range(start, end, freq="D")records = []for sid in stations: base = rng.uniform(20, 200) # bazowy przeplyw m^3/s doy = dates.dayofyear.values seasonal = 0.5 * np.sin(2 * np.pi * (doy - 90) / 365) # max wiosna trend = np.linspace(0, 0.1, len(dates)) # +10% w skali 10 lat noise = rng.gamma(shape=2.0, scale=0.3, size=len(dates)) Q = base * (1 + seasonal + trend) * noise df = pd.DataFrame({"date": dates, "station_id": sid, "Q_m3s": Q}) records.append(df)data = pd.concat(records, ignore_index=True)# Wstrzykujemy sztuczne luki danych dla realizmu (~0.5% wartosci NaN)gap_mask = rng.random(len(data)) < 0.005data.loc[gap_mask, "Q_m3s"] = np.nanprint(data.head())print("shape:", data.shape)print("luki:", data["Q_m3s"].isna().sum())
Wartości przepływów (rzędu kilkudziesięciu do kilkuset m³/s) są wiarygodne dla średnich i dużych rzek polskich. Konkretne liczby są jednak fikcyjne — kody stacji BIA/POZ/KRK/WAR/WRO to skróty nazw miast, a nie kody IMGW. Dla rzeczywistych pomiarów patrz IMGWTools i debever.pl/tools/wodowskazy.csv.
date datetime64[us]
station_id str
Q_m3s float64
dtype: object
Q_m3s
count 18165.000000
mean 101.743950
std 86.604283
min 0.679471
25% 42.088033
50% 77.360755
75% 134.522958
max 1139.636375
describe() od razu pokazuje, że rozkład przepływów jest prawoskośny — mediana (77) poniżej średniej (102), maksimum (1140) wielokrotnie większe od kwartyli. To typowe dla danych hydrologicznych (rzadkie wezbrania o dużych wartościach).
describe() w pandas 3.0
Wywołanie data.describe() bez argumentu w pandas 3.x liczy statystyki dla wszystkich kolumn, w tym dla date (jako daty, z min, max, kwartylami). Dla samej kolumny numerycznej wybieramy ją jawnie: data[["Q_m3s"]].describe() albo data.describe(include=[np.number]).
date station_id Q_m3s
70 2015-03-12 BIA NaN
75 2015-03-17 BIA NaN
377 2016-01-13 BIA NaN
433 2016-03-09 BIA NaN
761 2017-01-31 BIA NaN
liczba: 100
W naszej syntezie luki są losowe i rozproszone — w realnych pomiarach (np. z IMGW) ciągłe luki typu “całe lato 2018 w X” są częstsze i zwykle wynikają z awarii sprzętu.
Wypełnianie luk interpolacją
Dla każdej stacji osobno (luki w jednej nie mogą być uzupełniane wartościami z innej):
Interpolacja liniowa wewnątrz każdej stacji uzupełniła wszystkie 100 braków. Dla luk na początku/końcu serii (gdzie nie ma wartości “po obu stronach”) trzeba dodatkowo zastosować ffill/bfill.
Agregacja miesięczna
Ustawiamy MultiIndex (station_id, date), a potem zmieniamy częstotliwość po date z grupowaniem po station_id:
pd.Grouper(key="date", freq="ME") to sposób na grupowanie po koszyku czasowym wewnątrz zwykłej kolumny (alternatywa: .set_index('date').groupby('station_id').resample('ME')).
Agregacja roczna i charakterystyki SNQ/SSQ/SWQ
Tradycyjne charakterystyki hydrologiczne (z polskiej literatury hydrologicznej, m.in. Ozga-Zielińska & Brzeziński):
SNQ — średnia z minimów rocznych (S — średnia, N — niskie, Q — przepływ)
Argument min_periods=1 pozwala policzyć średnią również na początku szeregu (gdy okno nie jest pełne — np. dla pierwszej obserwacji średnia z jednej wartości to ona sama).
Wizualizacja
pandas ma wbudowaną integrację z matplotlib przez .plot(). Wykres szeregu dla jednej stacji wraz z jego średnią kroczącą:
import matplotlibmatplotlib.use("Agg") # zaplecze nieinteraktywne, do zapisu plikuimport matplotlib.pyplot as pltbia = data_filled[data_filled["station_id"] == "BIA"].set_index("date")fig, ax = plt.subplots(figsize=(10, 4))bia["Q_m3s"].plot(ax=ax, alpha=0.4, label="dobowy")bia["Q_7d"].plot(ax=ax, color="red", label="7-dniowa srednia kroczaca")ax.set_ylabel("Q [m^3/s]")ax.set_title("Przeplyw stacja BIA")ax.legend()plt.tight_layout()plt.savefig("/tmp/bia_przeplyw.png", dpi=100)print("zapisano /tmp/bia_przeplyw.png")
Output:
zapisano /tmp/bia_przeplyw.png
W realnej pracy warto rozważyć seaborn (wykresy statystyczne) lub plotly (interaktywne), ale natywne .plot() z pandas wystarcza w 80% przypadków.
W naszym chars indeks (station_id) ma sens, więc to_csv zapisuje go domyślnie. Dla wyników typu data_filled (gdzie RangeIndex 0..18264 nie ma wartości informacyjnej) używamy index=False.
Symetria z xarray
Dla danych wielowymiarowych z etykietowanymi osiami pandas przestaje być wygodny — to nisza biblioteki xarray. Typowy podział wygląda następująco:
W praktyce: dla danych z osiami (czas, stacja, kolumna) → pandas. Dla danych z osiami (czas, latitude, longitude) → xarray. Dla danych z osiami (czas, ensemble, latitude, longitude, depth) → xarray jednoznacznie.
mógł rzucić SettingWithCopyWarning albo (co gorsza) modyfikować zarówno sub, jak i df_demo. W pandas 3.x problem został rozwiązany przez Copy-on-Write — sub["a"] = 99 zawsze modyfikuje tylko sub. Jeśli czytasz starszy kod z df.copy() wstawianym “na wszelki wypadek”, w 3.x jest on niepotrzebny (choć nie szkodzi).
Object dtype — kiedy unikać
Kolumna object przechowuje dowolne obiekty Pythona — jest wolna w operacjach i zajmuje dużo pamięci. W pandas 3.x typową kolumną tekstową jest str (StringDtype), a nie object. Typ object przyda się tylko do prawdziwie mieszanych danych (np. struktur podobnych do JSON-a).
datetime vs Period
Timestamp (datetime64) — konkretny moment w czasie (“2020-01-15 12:00”)
Period — okres (“styczeń 2020”, “Q1 2020”)
Najczęściej używamy Timestamp. Period przydaje się, gdy mówimy o “wszystkich danych z marca”, a nie o jednym konkretnym momencie:
ts = pd.Timestamp("2020-01-15")p = ts.to_period("M")print("period:", p)print("period back to ts:", p.to_timestamp())
Output:
period: 2020-01
period back to ts: 2020-01-01 00:00:00
inplace=True — co dalej
Wiele metod ma argument inplace=True (np. df.reset_index(inplace=True)). W idealnej wersji pandas powinien zniknąć — pisanie:
df = df.reset_index()
jest bardziej spójne (zawsze przypisanie) i pozwala łańcuchować operacje. Z drugiej strony inplace=True jest jeszcze obecne w 3.x i nie ma stuprocentowego konsensusu, by je usunąć. Rekomendacja: pisz bez inplace, chyba że masz konkretny powód (np. mutacja obiektu DataFrame, do którego istnieją odwołania w wielu miejscach).
Łańcuchowanie metod
pandas zachęca do programowania w stylu funkcyjnym, czyli do łańcuchowania metod:
result = ( data .dropna(subset=["Q_m3s"]) .assign(year=lambda df: df["date"].dt.year) .groupby(["station_id", "year"])["Q_m3s"] .agg(["min", "mean", "max"]) .reset_index())print(result.head())
Output:
station_id year min mean max
0 BIA 2015 2.659256 97.465721 552.422159
1 BIA 2016 2.824858 93.623587 378.688378
2 BIA 2017 2.832809 93.997964 594.726816
3 BIA 2018 1.783456 104.271418 487.088680
4 BIA 2019 3.303906 100.591436 497.734038
Łańcuch jest czytelniejszy niż 5 osobnych przypisań do zmiennej tmp. Metoda .assign() dodaje kolumnę, nie modyfikując oryginału (lambda df: pozwala odnieść się do aktualnej postaci obiektu DataFrame wewnątrz łańcucha).