Baza wiedzy / Python

pandas describe(): statystyki opisowe tabeli i ich pułapki

Pokazuję, jak czytam wynik describe() dla liczb, tekstu, kategorii i dat w pandas 3, dlaczego include='object' zgłasza teraz ostrzeżenie i czego ta mała tabela nigdy mi nie powie.

Kajo RudzińskiOpublikowano Aktualizacja 7 min czytania

Pikselowy rząd złotych kostek różnej wysokości ciągnie się po granatowej równinie, a nad nim unosi się świecący wykres pudełkowy z bloków, który go streszcza.

describe() to pierwsza rzecz, którą uruchamiam po wczytaniu nowej tabeli w pandas, zaraz po info(). info() mówi mi, jakie mam kolumny i typy. describe() w jednej małej tabeli pokazuje, jak wyglądają same wartości: ile ich jest, jaka jest średnia, rozrzut, minimum, maksimum i kwartyle. Dla tekstu liczy różne wartości i podaje najczęstszą.

Wszystkie przykłady uruchomiłem w Pythonie 3.14 z pandas 3.0.6, a wyniki poniżej są skopiowane z tego uruchomienia. Wersja ma tu znaczenie. pandas 3 zmienił domyślny typ tekstu i działanie parametru percentiles, więc część starszych poradników pokazuje rzeczy, które dziś działają inaczej. Szerszy przykład pracy z tabelą, od zmiennej do sprawdzonego raportu, opisałem w artykule Podstawy Pythona: od zmiennej do sprawdzonego raportu w pandas.

Składnia describe()

DataFrame.describe(percentiles=None, include=None, exclude=None)
  • percentiles: lista ułamków od 0 do 1, na przykład [0.1, 0.5, 0.9]. Domyślnie dostaję 25%, 50% i 75%.
  • include: typy kolumn do opisania, na przykład "number", "str", "category", "datetime", lista takich typów albo "all".
  • exclude: typy kolumn do pominięcia, w tym samym formacie.

Bez argumentów DataFrame.describe() opisuje kolumny liczbowe i kolumny dat. Tekst, kategorie i wartości logiczne pomija. Wyjątek: jeśli ramka nie ma żadnej kolumny liczbowej ani kolumny dat, opisuje tekst, kategorie i wartości logiczne. Tę samą metodę ma pojedyncza kolumna (Series). Tam include i exclude są ignorowane, bo wynik zależy od typu tej jednej kolumny.

Liczby: count, mean, std i kwartyle

Pracuję na małej, syntetycznej tabeli zamówień ze sklepu internetowego. Ma wszystkie typy, o których piszę dalej: identyfikator i kanał jako tekst, sposób dostawy jako kategorię, datę zamówienia, liczbę sztuk i kwotę.

# example: data
import pandas as pd

orders = pd.DataFrame({
    "order_id": ["A-101", "A-102", "A-103", "A-104", "A-105", "A-106", "A-107", "A-108"],
    "channel": ["web", "store", "web", "app", "web", None, "store", "web"],
    "delivery": pd.Categorical(
        ["locker", "courier", "locker", "pickup", "locker", "courier", "locker", "locker"],
        categories=["courier", "locker", "pickup", "post"],
    ),
    "ordered_at": pd.to_datetime([
        "2026-03-02", "2026-03-02", "2026-03-04", "2026-03-09",
        "2026-03-11", "2026-03-15", "2026-03-20", "2026-03-31",
    ]),
    "items": [1, 3, 2, 1, 2, 4, 1, 2],
    "amount": [48.0, 120.0, 76.0, 30.0, 88.0, 410.0, 52.0, 64.0],
})

Na ramce z samymi liczbami wystarczyłoby describe(). Moja ma też daty, więc na początek zawężam wynik do liczb. round(2) tylko skraca wydruk.

# example: numbers
print(orders.describe(include="number").round(2))
       items  amount
count   8.00     8.0
mean    2.00   111.0
std     1.07   123.9
min     1.00    30.0
25%     1.00    51.0
50%     2.00    70.0
75%     2.25    96.0
max     4.00   410.0

Tak to czytam:

  • count to liczba niepustych wartości. Tu 8 przy 8 wierszach, więc braków nie ma. Liczby braków describe nie wypisuje, dlatego porównuję count z len(orders) albo liczę braki przez isna().
  • mean (111) i 50%, czyli mediana (70), wyraźnie się różnią. Średnia jest wyższa nawet od trzeciego kwartyla (96). To pierwszy sygnał, że jedna duża wartość ciągnie wynik w górę.
  • min i max to skrajne wartości, a 25%, 50% i 75% to kwartyle.

Kwartyle pandas liczy z interpolacją liniową. Dla ośmiu kwot pozycja pierwszego kwartyla to (8 - 1) × 0,25 = 1,75. Wynik leży trzy czwarte drogi między drugą (48) a trzecią (52) posortowaną kwotą: 48 + 0,75 × 4 = 51. Dlatego dla liczb całkowitych, takich jak items, kwartyl może wyjść 2,25.

std to odchylenie standardowe z próby. pandas dzieli przez n - 1 (ddof=1), tak samo jak ODCH.STANDARD.PRÓBKI w Excelu. NumPy w np.std domyślnie dzieli przez n, więc przy porównywaniu wyników z dwóch narzędzi sprawdzam, który wariant stoi za liczbą:

# example: ddof
print(round(orders["amount"].std(), 2))
print(round(orders["amount"].std(ddof=0), 2))
123.9
115.9

Przy jednej wartości w kolumnie std wychodzi NaN, bo n - 1 = 0.

Tekst, kategorie i daty w pandas 3

W pandas 3 tekst dostaje domyślnie typ str, a nie object. Kolumny tekstowe opisuję więc przez include="str":

# example: text
print(orders.describe(include="str"))
       order_id channel
count         8       7
unique        8       3
top       A-101     web
freq          1       4
  • count w channel wynosi 7 przy ośmiu wierszach. None w danych zostało zapisane jako brak i nie jest liczone.
  • unique to liczba różnych niepustych wartości.
  • top to najczęstsza wartość, a freq to liczba jej wystąpień. Kanał web pojawia się 4 razy.
  • W order_id count i unique są równe, a freq wynosi 1. Każdy identyfikator występuje raz, więc w tej kolumnie nie ma duplikatów. top (A-101) nic tu nie znaczy, to jedna z wartości, które wystąpiły raz.

Przy remisie dokumentacja mówi wprost, że top jest wybierany dowolnie spośród najczęstszych wartości. Pełny rozkład pokaże value_counts().

Starsze poradniki używają include="object". W pandas 3.0.6 to nadal zwraca kolumny tekstowe, ale zgłasza ostrzeżenie Pandas4Warning: kolumny str są dobierane do object już tylko dla zgodności wstecznej i ta furtka zniknie. Gdy kod musi działać w pandas 2 i 3, przewodnik migracji poleca listę ["object", "string"]. Sprawdziłem to w pandas 2.3.3: include="str" kończy się tam błędem TypeError, a ta lista działa w obu wersjach.

# example: text-compatible
text_summary = orders.describe(include=["object", "string"])

Kategorie opisuję tymi samymi czterema wierszami:

# example: category
print(orders.describe(include="category"))
       delivery
count         8
unique        3
top      locker
freq          5

unique liczy wartości, które naprawdę występują. Kategoria post jest zdefiniowana, ale nie ma jej żadne zamówienie, więc unique wynosi 3, a nie 4. Kolumny logiczne (True/False) describe opisuje tak samo jak kategorie.

Daty describe traktuje podobnie jak liczby: podaje średnią, minimum, kwartyle i maksimum, ale bez std.

# example: dates
print(orders["ordered_at"].describe())
count                      8
mean     2026-03-11 18:00:00
min      2026-03-02 00:00:00
25%      2026-03-03 12:00:00
50%      2026-03-10 00:00:00
75%      2026-03-16 06:00:00
max      2026-03-31 00:00:00
Name: ordered_at, dtype: object

Średnia data, 11 marca o 18:00, jest policzona poprawnie, ale rzadko mi się przydaje. Przy datach patrzę głównie na min i max, czyli na zakres, który faktycznie dostałem. Ponieważ daty liczą się tu jak liczby, zwykłe orders.describe() pokazuje (od pandas 2.0) liczby i daty razem. Wiersz std trafia wtedy na koniec tabeli, z NaN w kolumnie dat.

include="all" skleja wszystkie typy w jedną tabelę. Wiersze, które nie pasują do typu kolumny, dostają NaN:

# example: all
print(orders[["channel", "amount"]].describe(include="all").round(2))
       channel  amount
count        7     8.0
unique       3     NaN
top        web     NaN
freq         4     NaN
mean       NaN   111.0
std        NaN   123.9
min        NaN    30.0
25%        NaN    51.0
50%        NaN    70.0
75%        NaN    96.0
max        NaN   410.0

exclude działa odwrotnie. orders.describe(exclude=["number", "datetime"]) opisze identyfikator, kanał i dostawę, czyli wszystko poza liczbami i datami.

Własne percentyle

Domyślne kwartyle mogę zamienić na dowolne percentyle. Podaję je jako ułamki: 0.9 oznacza 90%, a wpisanie 90 kończy się ValueError.

# example: percentiles
print(orders["amount"].describe(percentiles=[0.1, 0.5, 0.9]).round(2))
count      8.0
mean     111.0
std      123.9
min       30.0
10%       42.6
50%       70.0
90%      207.0
max      410.0
Name: amount, dtype: float64

W pandas 3 dostaję dokładnie te percentyle, o które poproszę. Do wersji 2.x describe zawsze dokładał medianę. Od pandas 3.0 percentiles=[0.1, 0.9] zwraca tylko 10% i 90%, więc jeśli potrzebuję mediany, wpisuję 0.5 sam. Przy okazji widać kolejny ślad dużego zamówienia: 90% leży przy 207, a maksimum to 410.

Czego describe nie powie

Dwa pikselowe stosy złotych kostek: jeden z dwoma garbami, drugi z jednym wzgórzem i samotną wysoką wieżą obok. Przed każdym stoi identyczny świecący postument.

describe() streszcza każdą kolumnę kilkoma liczbami. To wygodne, ale streszczenie gubi szczegóły i warto wiedzieć, które.

Kształt rozkładu. Dwa różne zbiory mogą mieć zbliżoną średnią i kwartyle, choć jeden ma dwa skupiska wartości, a drugi jedno. Tabela tego nie odróżni. Przy ważnej kolumnie oglądam histogram albo przedziały z value_counts(bins=...).

Wartości odstające. describe pokazuje objawy: średnią powyżej trzeciego kwartyla i maksimum daleko od 75%. Nie wskazuje jednak wiersza. Ten znajduję sam, na przykład przez nlargest():

# example: outlier
print(orders.nlargest(2, "amount")[["order_id", "amount"]])
  order_id  amount
5    A-106   410.0
1    A-102   120.0

Czy 410 to błąd, czy duże zamówienie firmowe, describe nie rozstrzygnie. Tu muszę wrócić do źródła danych.

Braki zapisane tekstem. Ten przypadek uważam za najgroźniejszy. Wystarczy jedno słowo w kolumnie kwot, żeby cała kolumna stała się tekstem. describe nie zgłasza wtedy błędu. Po cichu podaje statystyki dla tekstu. W syntetycznym eksporcie poniżej ktoś wpisał „brak” zamiast zostawić pustą komórkę:

# example: hidden-missing
import io

csv = io.StringIO(
    "order_id,amount\n"
    "A-201,48\n"
    "A-202,120\n"
    "A-203,brak\n"
    "A-204,\n"
    "A-205,88\n"
)
raw = pd.read_csv(csv)
print(raw.dtypes)
print(raw.describe())
order_id    str
amount      str
dtype: object
       order_id amount
count         5      4
unique        5      4
top       A-201     48
freq          1      1

Kolumna amount ma typ str. count 4 obejmuje słowo „brak”, a top 48 jest tekstem. Pustą komórkę read_csv() rozpoznał jako brak, podobnie zrobiłby z „n/a”, „NA” czy „NULL”. Słowa „brak” na tej liście nie ma. Wolę więc powiedzieć pandas wprost, co oznacza brak:

# example: hidden-missing-fix
csv.seek(0)
clean = pd.read_csv(csv, na_values=["brak"])
print(clean["amount"].describe().round(2))
print(clean["amount"].isna().sum())
count      3.00
mean      85.33
std       36.07
min       48.00
25%       68.00
50%       88.00
75%      104.00
max      120.00
Name: amount, dtype: float64
2

Teraz count wynosi 3, a isna() pokazuje dwa braki. Inna droga to to_numeric(errors=“coerce”). Zamienia ona na NaN każdą wartość, która nie jest liczbą, także kwotę zapisaną z przecinkiem. Przy niej zawsze sprawdzam, ile wartości zniknęło.

Częste pomyłki

Wybieranie statystyk jak kolumn. orders.describe()[["mean", "std"]] kończy się KeyError. W wyniku describe statystyki są w wierszach, a kolumny tabeli zostają kolumnami. Wiersze wybieram przez .loc:

# example: select-rows
summary = orders.describe(include="number")
print(summary.loc[["mean", "50%", "std"]].round(2))
      items  amount
mean   2.00   111.0
50%    2.00    70.0
std    1.07   123.9

Przy wielu kolumnach czytelniejsze bywa summary.T, które zamienia wiersze z kolumnami.

Średnia identyfikatorów. Jeśli numer zamówienia albo kod pocztowy jest zapisany jako liczba, describe policzy jego średnią i odchylenie. Takie kolumny zamieniam na tekst przez astype() albo pomijam w opisie.

Kopiowanie include="object" do nowego kodu. W pandas 3 piszę include="str", a w kodzie dla obu wersji include=["object", "string"].

Szukanie mediany po samych percentylach. Jeśli przekazuję percentiles, w pandas 3 dopisuję 0.5, inaczej wiersza 50% nie będzie.

Krótkie ćwiczenie

Dopisz do orders dziewiąte zamówienie, w którym kwota jest tekstem „brak”, na przykład przez pd.concat. Uruchom orders.describe(include="number") i sprawdź, co stało się z kolumną amount. Potem zamień „brak” na prawdziwy brak i przywróć typ liczbowy przez pd.to_numeric(orders["amount"], errors="coerce"), bo sama zamiana zostawia kolumnę tekstową. Porównaj count z liczbą wierszy. Na koniec policz percentyle 0.05 i 0.95 i zobacz, czy zamówienie A-106 nadal wyraźnie odstaje.

Najważniejsze w pigułce

Dlaczego describe() pomija moje kolumny tekstowe?

Gdy ramka ma kolumny liczbowe, describe() bez argumentów opisuje tylko liczby i daty. Tekst dostanę przez include='str' w pandas 3, kategorie przez include='category', a wszystkie kolumny naraz przez include='all'.

Czy std w describe() to odchylenie z próby czy z populacji?

Z próby. pandas dzieli przez n - 1 (ddof=1), tak jak ODCH.STANDARD.PRÓBKI w Excelu. Funkcja np.std z NumPy domyślnie dzieli przez n, więc zwraca mniejszą liczbę.

Jak wybrać z wyniku describe() tylko średnią i medianę?

Statystyki są w wierszach wyniku, więc wybieram je przez .loc, na przykład df.describe().loc[['mean', '50%']]. Zapis df.describe()[['mean']] szuka kolumny o tej nazwie i kończy się KeyError.

Kajo Rudziński

Newsletter KajoData

Zostańmy w kontakcie.

Piszę o analizie danych, uczeniu się i zmianie pracy. Dzielę się tym, co pomaga mi ogarniać technologię i zawodowe decyzje. Jeśli lubisz ten sposób myślenia, zapraszam do mojego newslettera.