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.

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:
countto 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 zlen(orders)albo liczę braki przez isna().mean(111) i50%, 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ę.minimaxto skrajne wartości, a25%,50%i75%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
countwchannelwynosi 7 przy ośmiu wierszach.Nonew danych zostało zapisane jako brak i nie jest liczone.uniqueto liczba różnych niepustych wartości.topto najczęstsza wartość, afreqto liczba jej wystąpień. Kanał web pojawia się 4 razy.- W
order_idcount 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

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.
