pandas — analiza danych tabelarycznych

Materiał przygotowany z pomocą AI

Wymagania wstępne

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
  • PyArrow — alternatywne zaplecze pamięci, obsługa Parquet, szybkie I/O

pandas 3.x — istotne zmiany API

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 pd
import numpy as np
 
print(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):

import pandas as pd
 
s = pd.Series([10, 20, 30], index=["a", "b", "c"], name="przeplyw")
print(s)

Output:

a    10
b    20
c    30
Name: przeplyw, dtype: int64

Series ma cztery podstawowe atrybuty:

print("values:", s.values)
print("index:", s.index)
print("name:", s.name)
print("dtype:", s.dtype)

Output:

values: [10 20 30]
index: Index(['a', 'b', 'c'], dtype='str')
name: przeplyw
dtype: int64
  • values — tablica NumPy z wartościami
  • 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 etykiecie
print("s.iloc[0]:", s.iloc[0])   # po pozycji
print("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:

s2 = pd.Series([100, 200], index=["a", "d"])
print(s + s2)

Output:

a    110.0
b      NaN
c      NaN
d      NaN
dtype: float64

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:

dtypeCo przechowujeUwagi
int64liczby całkowitenie obsługuje NaN
float64liczby zmiennoprzecinkoweNaN dozwolone (jako np.nan)
boolwartości logiczne
str (StringDtype)tekstnowy domyślny typ w pandas 3.x
objectdowolne obiekty Pythonawolniejszy; do uniknięcia, jeśli się da
datetime64[us]znaczniki czasumikrosekundy (pandas 3.x default)
categorywartości kategoryczneoszczędność pamięci dla powtarzających się
Int64int 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.

Konstrukcja

Najczęstszy wariant — słownik kolumn:

df = pd.DataFrame({
    "kod": ["BIA", "POZ", "KRK", "WAR"],
    "Q": [12.5, 80.3, 25.0, 540.1],
    "rzeka": ["Biala", "Warta", "Wisla", "Wisla"],
})
print(df)

Output:

   kod      Q  rzeka
0  BIA   12.5  Biala
1  POZ   80.3  Warta
2  KRK   25.0  Wisla
3  WAR  540.1  Wisla

Z listy słowników (wygodne, gdy budujemy wiersz po wierszu):

records = [
    {"kod": "BIA", "Q": 12.5},
    {"kod": "POZ", "Q": 80.3},
]
df2 = pd.DataFrame(records)
print(df2)

Output:

   kod     Q
0  BIA  12.5
1  POZ  80.3

Z tablicy NumPy:

import numpy as np
 
arr = np.array([[1, 2, 3], [4, 5, 6]])
df3 = pd.DataFrame(arr, columns=["a", "b", "c"])
print(df3)

Output:

   a  b  c
0  1  2  3
1  4  5  6

Podstawowe atrybuty i podgląd

print("shape:", df.shape)
print("columns:", df.columns.tolist())
print("dtypes:")
print(df.dtypes)

Output:

shape: (4, 3)
columns: ['kod', 'Q', 'rzeka']
dtypes:
kod          str
Q        float64
rzeka        str
dtype: object
  • head(n) / tail(n) — pierwsze / ostatnie n wierszy (domyślnie 5)
  • info() — typy kolumn, zajętość pamięci, liczba non-null
  • describe() — statystyki opisowe kolumn numerycznych
print(df.describe())

Output:

                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
df = pd.DataFrame({"kod": ["BIA", "POZ", "KRK"], "Q": [12.5, 80.3, 25.0]})
print("default index:", df.index)

Output:

default index: RangeIndex(start=0, stop=3, step=1)

set_index, reset_index, sort_index

set_index ustawia jedną (lub kilka) kolumn jako indeks:

df_kod = df.set_index("kod")
print(df_kod)

Output:

        Q
kod      
BIA  12.5
POZ  80.3
KRK  25.0

reset_index odwraca tę operację — indeks staje się zwykłą kolumną:

print(df_kod.reset_index())

Output:

   kod     Q
0  BIA  12.5
1  POZ  80.3
2  KRK  25.0

sort_index sortuje wiersze po wartościach indeksu (alfabetycznie/numerycznie/chronologicznie):

print(df_kod.sort_index())

Output:

        Q
kod      
BIA  12.5
KRK  25.0
POZ  80.3

MultiIndex

Dla danych z naturalną hierarchią (stacja × data, dorzecze × rzeka × stacja) używamy MultiIndex:

records = [
    {"station_id": "BIA", "date": "2020-01-01", "Q": 12.5},
    {"station_id": "BIA", "date": "2020-01-02", "Q": 11.8},
    {"station_id": "POZ", "date": "2020-01-01", "Q": 80.3},
    {"station_id": "POZ", "date": "2020-01-02", "Q": 78.1},
]
df = pd.DataFrame(records)
df["date"] = pd.to_datetime(df["date"])
mi = df.set_index(["station_id", "date"])
print(mi)

Output:

                          Q
station_id date            
BIA        2020-01-01  12.5
           2020-01-02  11.8
POZ        2020-01-01  80.3
           2020-01-02  78.1

Wybór “całej stacji” odbywa się przez .xs() lub .loc[]:

print(mi.xs("BIA"))

Output:

               Q
date            
2020-01-01  12.5
2020-01-02  11.8

Wybór konkretnej obserwacji — krotką:

print(mi.loc[("BIA", pd.Timestamp("2020-01-02"))])

Output:

Q    11.8
Name: (BIA, 2020-01-02 00:00:00), dtype: float64

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:

FormatCzytaniePisanieUwagi
CSVread_csvto_csvuniwersalny, ale wolny i bez typów
Excelread_excelto_excelwymaga openpyxl lub xlrd
Parquetread_parquetto_parquetwymaga pyarrow lub fastparquet
JSONread_jsonto_jsondla danych zagnieżdżonych raczej json + pd
SQLread_sqlto_sqlwymaga sterownika (sqlite3, sqlalchemy)
HDF5read_hdfto_hdfduże binarne, wymaga pytables

read_csv — najważniejsze parametry

import io
 
csv = """station,date,Q
BIA,2020-01-01,12.5
BIA,2020-01-02,
POZ,2020-01-01,80.3
"""
df = pd.read_csv(io.StringIO(csv), parse_dates=["date"])
print(df)
print(df.dtypes)

Output:

  station       date     Q
0     BIA 2020-01-01  12.5
1     BIA 2020-01-02   NaN
2     POZ 2020-01-01  80.3
station               str
date       datetime64[us]
Q                 float64
dtype: object

Kluczowe argumenty:

  • sep — separator kolumn, domyślnie ",". Dla TSV: sep="\t"
  • parse_dates — lista kolumn do konwersji na datetime
  • dtype — słownik {nazwa_kolumny: typ}, wymusza typy
  • na_values — dodatkowe stringi traktowane jako NaN (np. ["-", "brak", "N/A"])
  • index_col — kolumna używana jako indeks
  • usecols — lista kolumn do wczytania (ignoruje pozostałe — oszczędność pamięci)
  • nrows — liczba wierszy do wczytania (przydatne przy rekonesansie na dużym pliku)
  • encoding — kodowanie ('utf-8', 'cp1250', 'iso-8859-2')

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:

# df.to_parquet("/tmp/dane.parquet")
# df = pd.read_parquet("/tmp/dane.parquet")

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:

  1. df[...] — wybór kolumn (lub wierszy przez maskę logiczną)
  2. df.loc[...] — selekcja po etykietach (indeks, kolumna)
  3. df.iloc[...] — selekcja po pozycji całkowitej

Wybór kolumn

df = pd.DataFrame({
    "kod": ["BIA", "POZ", "KRK", "WAR"],
    "Q": [12.5, 80.3, 25.0, 540.1],
    "rzeka": ["Biala", "Warta", "Wisla", "Wisla"],
})
 
print(df["Q"])          # jedna kolumna -> Series
print()
print(df[["kod", "Q"]]) # lista kolumn -> DataFrame

Output:

0     12.5
1     80.3
2     25.0
3    540.1
Name: Q, dtype: float64

   kod      Q
0  BIA   12.5
1  POZ   80.3
2  KRK   25.0
3  WAR  540.1

Maska logiczna

Wybór wierszy spełniających warunek — najczęściej używany sposób filtrowania:

print(df[df["Q"] > 30])

Output:

   kod      Q  rzeka
1  POZ   80.3  Warta
3  WAR  540.1  Wisla

Maski można składać operatorami & (AND), | (OR), ~ (NOT). Ważne: nawiasy wokół każdego warunku, bo operatory bitowe mają niższy priorytet od <, >:

print(df[(df["Q"] > 30) & (df["rzeka"] == "Wisla")])

Output:

   kod      Q  rzeka
3  WAR  540.1  Wisla

.loc — etykiety

print(df.loc[1:3, ["kod", "Q"]])

Output:

   kod      Q
1  POZ   80.3
2  KRK   25.0
3  WAR  540.1

Uwaga: w .loc krojenie 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 = 30
print(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 df
print("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:

df = pd.DataFrame({
    "Q_m3s": [10.0, 20.0, 30.0],
    "A_km2": [50.0, 100.0, 200.0],
})
df["q_l_s_km2"] = df["Q_m3s"] * 1000 / df["A_km2"]  # przeplyw jednostkowy [l/s/km^2]
print(df)

Output:

   Q_m3s  A_km2  q_l_s_km2
0   10.0   50.0      200.0
1   20.0  100.0      200.0
2   30.0  200.0      150.0

apply, map, transform

Gdy operacji nie da się wyrazić wektorowo (np. wywołanie funkcji, której pandas nie zna), sięgamy po jedną z następujących metod:

  • Series.map — element po elemencie, dla Series
  • DataFrame.apply — funkcja po osi (kolumnach lub wierszach)
  • groupby.transform — element po elemencie, ale z agregacją grupową (zachowuje rozmiar)
df = pd.DataFrame({"Q": [10, 20, 30, 40]})
 
# map z funkcja
df["kategoria"] = df["Q"].map(lambda x: "wysoki" if x > 25 else "niski")
print(df)

Output:

    Q kategoria
0  10     niski
1  20     niski
2  30    wysoki
3  40    wysoki

apply dla DataFrame przyjmuje argument axis:

df = pd.DataFrame({"a": [1, 2, 3], "b": [10, 20, 30]})
print(df.apply(lambda col: col.max() - col.min(), axis=0))  # po kolumnach
print()
print(df.apply(lambda row: row["a"] + row["b"], axis=1))     # po wierszach

Output:

a     2
b    20
dtype: int64

0    11
1    22
2    33
dtype: int64

apply jest wolny

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
  • pd.NaT (Not a Time) — dla datetime64
  • pd.NA — uniwersalny marker (dla typów nullable: Int64, boolean, str)
  • None — w kolumnach object (rzadziej; pandas zwykle konwertuje na NaN)

Wykrywanie braków

s = pd.Series([1.0, np.nan, 3.0, np.nan])
print("isna:", s.isna().tolist())
print("notna:", s.notna().tolist())
print("sum of NaN:", s.isna().sum())

Output:

isna: [False, True, False, True]
notna: [True, False, True, False]
sum of NaN: 2

Usuwanie braków

df = pd.DataFrame({
    "a": [1, 2, np.nan, 4],
    "b": [np.nan, 20, 30, 40],
})
print("dropna (any):")
print(df.dropna())
print()
print("dropna (all):")
print(df.dropna(how="all"))
print()
print("dropna w 'a':")
print(df.dropna(subset=["a"]))

Output:

dropna (any):
     a     b
1  2.0  20.0
3  4.0  40.0

dropna (all):
     a     b
0  1.0   NaN
1  2.0  20.0
2  NaN  30.0
3  4.0  40.0

dropna w 'a':
     a     b
0  1.0   NaN
1  2.0  20.0
3  4.0  40.0

Argument how: "any" (domyślny) — wystarczy jeden NaN w wierszu, by go usunąć; "all" — usuwa tylko wiersze z samymi wartościami NaN.

Wypełnianie braków

fillna z wartością:

print(df.fillna(0))

Output:

     a     b
0  1.0   0.0
1  2.0  20.0
2  0.0  30.0
3  4.0  40.0

ffill / bfill — propagacja w przód / wstecz (popularne dla szeregów czasowych):

s = pd.Series([1.0, np.nan, np.nan, 4.0, 5.0])
print("ffill:")
print(s.ffill())
print()
print("bfill:")
print(s.bfill())

Output:

ffill:
0    1.0
1    1.0
2    1.0
3    4.0
4    5.0
dtype: float64

bfill:
0    1.0
1    4.0
2    4.0
3    4.0
4    5.0
dtype: float64

Interpolacja

Najbardziej przydatne dla szeregów czasowych:

s = pd.Series([1.0, np.nan, np.nan, 4.0, 5.0])
print(s.interpolate(method="linear"))

Output:

0    1.0
1    2.0
2    3.0
3    4.0
4    5.0
dtype: float64

Interpolacja po czasie (uwzględnia różne odstępy między pomiarami):

ts = pd.Series(
    [1.0, np.nan, np.nan, 4.0],
    index=pd.to_datetime(["2020-01-01", "2020-01-02", "2020-01-05", "2020-01-06"]),
)
print(ts.interpolate(method="time"))

Output:

2020-01-01    1.0
2020-01-02    1.6
2020-01-05    3.4
2020-01-06    4.0
dtype: 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)

Output:

0       1
1       2
2    <NA>
dtype: Int64
Int64

Braki są reprezentowane przez pd.NA, nie np.nan.

Grupowanie — groupby

groupby realizuje wzorzec split-apply-combine (podział-zastosowanie-połączenie):

  1. Split — dzieli dane na grupy według klucza
  2. Apply — stosuje funkcję na każdej grupie
  3. Combine — łączy wyniki z powrotem w DataFrame/Series
df = pd.DataFrame({
    "station": ["A", "A", "A", "B", "B", "B"],
    "Q": [10.0, 12.0, 11.0, 50.0, 55.0, 52.0],
})
print(df.groupby("station")["Q"].mean())

Output:

station
A    11.000000
B    52.333333
Name: Q, dtype: float64

Samo df.groupby("station") zwraca obiekt DataFrameGroupBy — nic jeszcze nie liczy, dopiero wywołanie metody (.mean, .sum, .agg) uruchamia obliczenie.

Wiele agregacji naraz

print(df.groupby("station")["Q"].agg(["min", "mean", "max"]))

Output:

          min       mean   max
station                       
A        10.0  11.000000  12.0
B        50.0  52.333333  55.0

Nazwana agregacja — kontrola nazw kolumn

Dla większej kontroli nad wynikową strukturą używa się nazwanej agregacji (od pandas 0.25):

print(df.groupby("station").agg(
    Q_min=("Q", "min"),
    Q_mean=("Q", "mean"),
    Q_max=("Q", "max"),
))

Output:

         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:

df["Q_centered"] = df["Q"] - df.groupby("station")["Q"].transform("mean")
print(df)

Output:

  station     Q  Q_centered
0       A  10.0   -1.000000
1       A  12.0    1.000000
2       A  11.0    0.000000
3       B  50.0   -2.333333
4       B  55.0    2.666667
5       B  52.0   -0.333333

filter — eliminuje całe grupy

print(df.groupby("station").filter(lambda g: g["Q"].mean() > 20))

Output:

  station     Q  Q_centered
3       B  50.0   -2.333333
4       B  55.0    2.666667
5       B  52.0   -0.333333

filter przyjmuje funkcję zwracającą wartość logiczną — zachowuje całe grupy spełniające warunek.

Groupby po wielu kolumnach

df = pd.DataFrame({
    "rzeka": ["Warta", "Warta", "Wisla", "Wisla", "Wisla"],
    "rok": [2020, 2021, 2020, 2020, 2021],
    "Q": [100.0, 110.0, 500.0, 550.0, 480.0],
})
print(df.groupby(["rzeka", "rok"])["Q"].mean())

Output:

rzeka  rok 
Warta  2020    100.0
       2021    110.0
Wisla  2020    525.0
       2021    480.0
Name: Q, dtype: float64

Wynik ma MultiIndex (rzeka, rok). reset_index() przywróci płaską postać.

Łączenie zbiorów — merge, join, concat

concat — sklejanie

concat skleja obiekty DataFrame wzdłuż osi 0 (wiersze) lub 1 (kolumny):

df1 = pd.DataFrame({"a": [1, 2], "b": [10, 20]})
df2 = pd.DataFrame({"a": [3, 4], "b": [30, 40]})
print(pd.concat([df1, df2], ignore_index=True))

Output:

   a   b
0  1  10
1  2  20
2  3  30
3  4  40

Bez ignore_index=True indeks z obu obiektów DataFrame zostaje zachowany — co daje powtarzające się 0, 1 — zwykle jest to niepożądane.

merge — JOIN jak w SQL

merge łączy dwa obiekty DataFrame po wartości jednej (lub kilku) kolumn — odpowiednik JOIN z SQL (SQL - podstawy):

left = pd.DataFrame({"k": [1, 2, 3], "v": ["a", "b", "c"]})
right = pd.DataFrame({"k": [2, 3, 4], "w": ["x", "y", "z"]})
print("inner:")
print(left.merge(right, on="k"))
print()
print("left:")
print(left.merge(right, on="k", how="left"))
print()
print("outer:")
print(left.merge(right, on="k", how="outer"))

Output:

inner:
   k  v  w
0  2  b  x
1  3  c  y

left:
   k  v    w
0  1  a  NaN
1  2  b    x
2  3  c    y

outer:
   k    v    w
0  1    a  NaN
1  2    b    x
2  3    c    y
3  4  NaN    z

Typy złączeń:

  • "inner" (domyślnie) — tylko klucze obecne w obu
  • "left" — wszystkie z lewego, dopasowane z prawego (NaN, jeśli brak)
  • "right" — odwrotnie
  • "outer" — wszystkie z obu, NaN tam, gdzie brakuje

merge — różne nazwy kolumn kluczowych

left = pd.DataFrame({"station_id": [1, 2], "name": ["BIA", "POZ"]})
right = pd.DataFrame({"sid": [1, 2], "Q": [10.0, 80.0]})
print(left.merge(right, left_on="station_id", right_on="sid"))

Output:

   station_id name  sid     Q
0           1  BIA    1  10.0
1           2  POZ    2  80.0

join — łączenie po indeksie

join to skrócona forma merge w scenariuszu “po indeksie”:

left = pd.DataFrame({"v": [1, 2, 3]}, index=["a", "b", "c"])
right = pd.DataFrame({"w": [10, 20]}, index=["a", "b"])
print(left.join(right))

Output:

   v     w
a  1  10.0
b  2  20.0
c  3   NaN

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.

Tworzenie DatetimeIndex

dates = pd.date_range("2020-01-01", periods=5, freq="D")
print(dates)

Output:

DatetimeIndex(['2020-01-01', '2020-01-02', '2020-01-03', '2020-01-04',
               '2020-01-05'],
              dtype='datetime64[us]', freq='D')

pd.to_datetime parsuje łańcuchy znaków na typ datetime:

ts = pd.to_datetime(["2020-01-01", "2020-01-15", "2020-02-01"])
print(ts)

Output:

DatetimeIndex(['2020-01-01', '2020-01-15', '2020-02-01'], dtype='datetime64[us]', freq=None)

Aliasy częstotliwości

W pandas 3.x używamy:

AliasCo oznacza
DDzień kalendarzowy
BDzień roboczy
hGodzina (od pandas 2.2 małe h)
minMinuta
sSekunda
WTydzień (kończący się w niedzielę)
MEKoniec miesiąca (Month End)
MSPoczątek miesiąca (Month Start)
QEKoniec kwartału
YEKoniec roku

M i Y zostały usunięte

W pandas 1.x i 2.x można było używać 'M' (month-end) i 'Y' (year-end). W 3.x rzucają wyjątek:

pd.date_range("2020-01-01", periods=3, freq="M")
# ValueError: 'M' is no longer supported for offsets. Please use 'ME' instead.

Trzeba używać 'ME' i 'YE' (lub 'MS'/'YS' dla początku okresu).

resample — zmiana częstotliwości

resample agreguje (downsampling) lub interpoluje (upsampling) dane wzdłuż osi czasu:

dates = pd.date_range("2020-01-01", periods=10, freq="D")
df = pd.DataFrame({"v": range(10)}, index=dates)
print("daily:")
print(df)
print()
print("weekly sum:")
print(df.resample("W").sum())
print()
print("month-end sum:")
print(df.resample("ME").sum())

Output:

daily:
            v
2020-01-01  0
2020-01-02  1
2020-01-03  2
2020-01-04  3
2020-01-05  4
2020-01-06  5
2020-01-07  6
2020-01-08  7
2020-01-09  8
2020-01-10  9

weekly sum:
             v
2020-01-05  10
2020-01-12  35

month-end sum:
             v
2020-01-31  45

resample zachowuje się jak groupby po koszyku czasowym — można po nim wywołać .mean(), .sum(), .agg(), .first(), .last(), .min(), .max().

rolling — okna kroczące

rolling to klasyczne okno kroczące — średnia 7-dniowa, suma 30-dniowa:

s = pd.Series([1, 2, 3, 4, 5, 6, 7], name="v")
print(s.rolling(3).mean())

Output:

0    NaN
1    NaN
2    2.0
3    3.0
4    4.0
5    5.0
6    6.0
Name: v, dtype: float64

Domyślnie pierwsze (window-1) wartości to NaN — bo nie ma jeszcze pełnego okna. Argument min_periods to zmienia:

print(s.rolling(3, min_periods=1).mean())

Output:

0    1.0
1    1.5
2    2.0
3    3.0
4    4.0
5    5.0
6    6.0
Name: v, dtype: float64

Okno może być wyrażone w jednostkach czasu, a nie w liczbie obserwacji:

ts = pd.Series([1, 2, 3, 4, 5], index=pd.date_range("2020-01-01", periods=5))
print(ts.rolling("3D").mean())

Output:

2020-01-01    1.0
2020-01-02    1.5
2020-01-03    2.0
2020-01-04    3.0
2020-01-05    4.0
Freq: D, dtype: float64

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()])

Output:

shift(1): [nan, 10.0, 12.0, 11.0, 14.0]
diff: [nan, 2.0, -1.0, 3.0, 4.0]
pct_change: ['nan', '0.200', '-0.083', '0.273', '0.286']

Strefy czasowe

Timestamp może być “naiwny” (bez strefy) lub “świadomy” (z określoną strefą):

ts_naive = pd.Timestamp("2020-01-15 12:00")
ts_warsaw = pd.Timestamp("2020-01-15 12:00", tz="Europe/Warsaw")
ts_utc = ts_warsaw.tz_convert("UTC")
print("naive:", ts_naive)
print("warsaw:", ts_warsaw)
print("utc:", ts_utc)

Output:

naive: 2020-01-15 12:00:00
warsaw: 2020-01-15 12:00:00+01:00
utc: 2020-01-15 11:00:00+00:00

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 np
import pandas as pd
 
rng = np.random.default_rng(seed=42)
stations = ["BIA", "POZ", "KRK", "WAR", "WRO"]   # kody fikcyjne
start = 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.005
data.loc[gap_mask, "Q_m3s"] = np.nan
 
print(data.head())
print("shape:", data.shape)
print("luki:", data["Q_m3s"].isna().sum())

Output:

        date station_id      Q_m3s
0 2015-01-01        BIA  15.599721
1 2015-01-02        BIA  76.586195
2 2015-01-03        BIA  11.680631
3 2015-01-04        BIA  30.977637
4 2015-01-05        BIA  18.995250
shape: (18265, 3)
luki: 100

Realizm danych

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.

Eksploracja zbioru

print(data.dtypes)
print()
print(data[["Q_m3s"]].describe())

Output:

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]).

Charakterystyki przepływu per stacja

print(data.groupby("station_id")["Q_m3s"].agg(["min", "mean", "max", "count"]))

Output:

                 min        mean          max  count
station_id                                          
BIA         0.679471  101.353238   594.726816   3631
KRK         2.177734  108.452392   707.810580   3635
POZ         0.750208   99.623069   722.468699   3638
WAR         0.739869   75.212867   486.930682   3630
WRO         1.185458  124.067577  1139.636375   3631

Kolumna count (liczba nie-NaN obserwacji) jest mniejsza od 3653 (liczba dni w 10 latach), bo część obserwacji to luki.

Wykrywanie luk danych

Ile luk na każdej stacji?

luki = data.groupby("station_id")["Q_m3s"].apply(lambda s: s.isna().sum())
print(luki)

Output:

station_id
BIA    22
KRK    18
POZ    15
WAR    23
WRO    22
Name: Q_m3s, dtype: int64

Czy występują ciągłe okresy braków? Najprościej sprawdzić, gdzie są luki — wybierając wiersze z NaN:

gaps = data[data["Q_m3s"].isna()]
print(gaps.head())
print("liczba:", len(gaps))

Output:

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

data_filled = data.copy()
data_filled["Q_m3s"] = data_filled.groupby("station_id", group_keys=False)["Q_m3s"].transform(
    lambda s: s.interpolate(method="linear")
)
print("luki przed:", data["Q_m3s"].isna().sum())
print("luki po:", data_filled["Q_m3s"].isna().sum())

Output:

luki przed: 100
luki po: 0

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:

data_indexed = data_filled.set_index(["station_id", "date"])
print(data_indexed.head())

Output:

                           Q_m3s
station_id date                 
BIA        2015-01-01  15.599721
           2015-01-02  76.586195
           2015-01-03  11.680631
           2015-01-04  30.977637
           2015-01-05  18.995250

Średnia miesięczna per stacja:

monthly = (
    data_filled
    .groupby(["station_id", pd.Grouper(key="date", freq="ME")])["Q_m3s"]
    .mean()
)
print(monthly.head(15))

Output:

station_id  date      
BIA         2015-01-31     46.854297
            2015-02-28     50.412192
            2015-03-31     78.000890
            2015-04-30     94.783093
            2015-05-31    158.462146
            2015-06-30    173.177679
            2015-07-31    145.647039
            2015-08-31    127.894211
            2015-09-30    103.863360
            2015-10-31     75.231519
            2015-11-30     63.427308
            2015-12-31     45.529626
            2016-01-31     51.858579
            2016-02-29     61.562680
            2016-03-31     80.872537
Name: Q_m3s, dtype: float64

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)
  • SSQ — średnia ze średnich rocznych
  • SWQ — średnia z maksimów rocznych (W — wysokie)
data_filled["year"] = data_filled["date"].dt.year
yearly = data_filled.groupby(["station_id", "year"])["Q_m3s"].agg(["min", "mean", "max"])
print(yearly.head())

Output:

                      min        mean         max
station_id year                                  
BIA        2015  2.659256   97.192592  552.422159
           2016  2.824858   93.419021  378.688378
           2017  2.832809   93.738304  594.726816
           2018  1.783456  104.406931  487.088680
           2019  3.303906  100.591436  497.734038

Charakterystyki wieloletnie — średnie z rocznych:

chars = yearly.groupby("station_id").agg({"min": "mean", "mean": "mean", "max": "mean"})
chars.columns = ["SNQ", "SSQ", "SWQ"]
print(chars)

Output:

                 SNQ         SSQ         SWQ
station_id                                  
BIA         2.672306  101.200456  494.174850
KRK         3.317280  108.473900  554.838848
POZ         2.735738   99.646958  556.035225
WAR         2.236901   75.150384  412.170451
WRO         3.656867  123.912338  744.541539

Średnia krocząca 7-dniowa

Do wygładzenia szeregu i wyróżnienia długoterminowych zmian:

data_filled["Q_7d"] = (
    data_filled
    .groupby("station_id", group_keys=False)["Q_m3s"]
    .transform(lambda s: s.rolling(7, min_periods=1).mean())
)
print(data_filled[data_filled["station_id"] == "BIA"].head(10))

Output:

        date station_id      Q_m3s  year       Q_7d
0 2015-01-01        BIA  15.599721  2015  15.599721
1 2015-01-02        BIA  76.586195  2015  46.092958
2 2015-01-03        BIA  11.680631  2015  34.622182
3 2015-01-04        BIA  30.977637  2015  33.711046
4 2015-01-05        BIA  18.995250  2015  30.767887
5 2015-01-06        BIA  69.520148  2015  37.226597
6 2015-01-07        BIA  86.598832  2015  44.279773
7 2015-01-08        BIA  19.009555  2015  44.766893
8 2015-01-09        BIA  17.236298  2015  36.288336
9 2015-01-10        BIA  39.042529  2015  40.197178

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 matplotlib
matplotlib.use("Agg")  # zaplecze nieinteraktywne, do zapisu pliku
import matplotlib.pyplot as plt
 
bia = 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.

Eksport wyników do CSV

chars.to_csv("/tmp/charakterystyki.csv")
print(chars.head())

Output:

                 SNQ         SSQ         SWQ
station_id                                  
BIA         2.672306  101.200456  494.174850
KRK         3.317280  108.473900  554.838848
POZ         2.735738   99.646958  556.035225
WAR         2.236901   75.150384  412.170451
WRO         3.656867  123.912338  744.541539

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:

Cechapandasxarray
Liczba wymiarów1 (Series) lub 2 (DataFrame)dowolna (DataArray, Dataset)
Osieindeks (wiersze) + nazwy kolumndowolna liczba osi z nazwami
Typowy format I/OCSV, Excel, Parquet, SQLNetCDF, Zarr
Domenadane tabelaryczne (obserwacje)dane gridowe (klimat, rastry, modele)
Główna zależnośćNumPyNumPy + pandas

Przejście między bibliotekami jest płynne:

# pandas -> xarray (DataFrame -> Dataset)
# ds = data_indexed.to_xarray()
# xarray -> pandas (Dataset -> DataFrame)
# df = ds.to_dataframe()

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.

Pułapki i dobre praktyki

SettingWithCopyWarning (już nieaktualne w 3.x)

W pandas 1.x i we wczesnym 2.x kod typu:

df_demo = pd.DataFrame({"a": [1, 2, 3]})
sub = df_demo[df_demo["a"] > 1]
sub["a"] = 99

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

Zobacz także