Baza wiedzy / Python

pandas crosstab: liczebności, udziały i właściwy mianownik

Skąd pochodzą zwroty i jaka część zamówień jest zwracana? Pokazuję różne mianowniki w crosstab oraz sposób ich odczytania.

Kajo RudzińskiOpublikowano Aktualizacja 5 min czytania

Dwa strumienie klocków trafiające do czterech przegródek tabeli, żółto-granatowy pixel art.

Tabela krzyżowa potrafi odpowiedzieć na kilka podobnie brzmiących pytań. Ile zwrotów pochodzi ze sklepu? Jaka część zamówień sklepu została zwrócona? Jaka część wszystkich zwrotów pochodzi ze sklepu? Jeśli pomylę mianowniki, otrzymam poprawnie policzoną liczbę i błędne wyjaśnienie.

Ja zaczynam od tabeli liczebności, a udziały dokładam później. Poniżej używam ośmiu syntetycznych obserwacji i zasad z dokumentacji pandas 3.1.1, dostępnej w listopadzie 2026. Wyniki wyprowadzam na tej małej tabeli. Nie trzeba losować danych ani chować podstawowego rachunku w rozbudowanym przykładzie.

Co liczy crosstab

pandas.crosstab domyślnie liczy wystąpienia kombinacji czynników. Pierwszy argument określa grupy wierszy, drugi grupy kolumn. Do innej agregacji trzeba podać zarówno values, jak i aggfunc.

import pandas as pd

orders = pd.DataFrame({
    "channel": ["Web", "Web", "Web", "Store", "Store", "Store", "Store", "Store"],
    "status": ["Sent", "Sent", "Returned", "Sent", "Sent", "Sent", "Returned", "Returned"],
    "amount": [10, 20, 30, 40, 50, 60, 70, 80],
})

counts = pd.crosstab(orders["channel"], orders["status"])

W naszym ćwiczeniu każdy wiersz opisuje jedno zamówienie, a status jest jego ustalonym stanem dla analizy. To nie jest historia wszystkich zmian statusu. Gdyby zamówienie pojawiało się raz jako wysłane, a potem jako zwrócone, liczba wierszy nie byłaby liczbą zamówień. Taką różnicę trzeba rozstrzygnąć przed tabelą.

channel Returned Sent
Store 2 3
Web 1 2

Store ma pięć obserwacji, Web trzy, razem osiem. W kolumnie Returned są trzy, a w Sent pięć. Większa liczba zwrotów w Store nie wystarcza do oceny, gdzie zwroty są częstsze względem liczby zamówień. Kanały mają różną wielkość.

Podsumowania pomagają pilnować liczebności

with_totals = pd.crosstab(
    orders["channel"],
    orders["status"],
    margins=True,
)
channel Returned Sent All
Store 2 3 5
Web 1 2 3
All 3 5 8

W przykładzie z kompletnymi kategoriami i jednym wierszem na zamówienie liczba 8 powinna zgadzać się z wejściem. Ja sprawdziłbym też pojedynczą komórkę, bo zgodność całości nie wykryje zamiany kanałów. margins_name pozwala zmienić nazwę podsumowania, jeśli „All” nie pasuje do języka raportu.

Przy łączeniu podsumowań z normalizacją interpretacja zależy od wybranego mianownika. Najpierw wyjaśnij prostą tabelę, dopiero później dodawaj wariant z marginesami. Więcej liczb na ekranie nie musi pomóc odbiorcy, szczególnie gdy część ma inną podstawę niż sąsiednie komórki.

Udział w całym zbiorze

overall = pd.crosstab(
    orders["channel"],
    orders["status"],
    normalize="all",
)

Dzielę każdą komórkę przez 8. Store/Returned to 2 z 8, czyli 0.25. Web/Returned to 1 z 8, czyli 0.125. Suma wszystkich czterech komórek wynosi 1.

channel Returned Sent
Store 0.25 0.375
Web 0.125 0.25

Tak samo wybór podstawy działa przy normalize=True. To udziały od 0 do 1, więc 0.25 oznacza 25%. W podpisie napisałbym „udział wszystkich zamówień”, żeby nie sugerować, że chodzi o procent zwrotów w kanale. Sama obecność znaku procenta nie wyjaśnia mianownika.

Struktura wiersza: zwroty wewnątrz kanału

by_channel = pd.crosstab(
    orders["channel"],
    orders["status"],
    normalize="index",
)

Store dzielę przez 5, Web przez 3. Dla czytelności wartości okresowe przedstawiam poniżej do czterech miejsc po przecinku; to sposób prezentacji, a nie dokładny wydruk typu danych.

channel Returned Sent
Store 0.4000 0.6000
Web 0.3333 0.6667

Teraz odpowiadam na pytanie o strukturę statusów wewnątrz kanału. Każdy wiersz sumuje się do 1 przed zaokrągleniem. Przy tak małej próbce traktuję wynik jako opis tych ośmiu obserwacji. Nie uznaję go za dowód trwałej przewagi kanału ani za wyjaśnienie przyczyny zwrotów.

Dwie tacki z odpowiednio trzema i pięcioma miejscami, pokazujące różne podstawy udziałów, żółto-granatowy pixel art.

Struktura kolumny: skąd pochodzą zwroty

by_status = pd.crosstab(
    orders["channel"],
    orders["status"],
    normalize="columns",
)

Kolumnę Returned dzielę przez 3, a Sent przez 5. Store ma dwie trzecie wszystkich zwrotów i trzy piąte wysłanych zamówień. Web ma odpowiednio jedną trzecią i dwie piąte. Każda kolumna ma sumę 1 przed zaokrągleniem.

To inne pytanie niż w poprzednim punkcie. „Dwie trzecie zwrotów pochodzi ze sklepu” nie znaczy, że dwie trzecie zamówień sklepu jest zwracane. Ja dodałbym takie zdanie do opisu raportu, jeśli tabela trafia do osób, które nie pracują codziennie z udziałami.

Kierunek normalizacji ustawia normalize. Nie ma osobnego argumentu normalize_axis w opisanej sygnaturze. Przy rozbudowie wywołania korzystaj z nazw argumentów: łatwiej wtedy zobaczyć, czy podajesz etykiety osi, funkcję agregującą, czy mianownik.

Kwoty zamiast liczebności

amounts = pd.crosstab(
    orders["channel"],
    orders["status"],
    values=orders["amount"],
    aggfunc="sum",
)

Dla Store/Returned dodaję 70 i 80, co daje 150. Store/Sent to 40, 50 i 60, również 150. Obie komórki Web mają sumę 30. Całość wynosi 360 w umownej jednostce. Udział kwoty zwrotów byłby inną miarą niż udział liczby zwrotów.

Nie mieszałbym tych tabel pod identycznym tytułem „zwroty”. Opis musi powiedzieć, czy chodzi o obserwacje, kwoty czy unikalne osoby. Jeśli pracujesz nad bardziej rozbudowaną tabelą agregacji, zobacz także pandas pivot_table.

Braki i zgodność indeksów

Przed tabelą ustalam, co zrobić z nieznanym statusem. Dokumentacja braków opisuje ich wykrywanie i uzupełnianie. Mogę jawnie utworzyć kategorię kontrolną, jeśli taką regułę przyjmujemy:

status_for_report = orders["status"].fillna("Unknown")
counts_with_unknown = pd.crosstab(orders["channel"], status_for_report)

W głównym zbiorze nie ma braków, więc ten krok nie zmienia jego klasyfikacji. W danych rzeczywistych „Unknown” oznaczałoby nieznany status, bez udawania, że jest wysłaniem lub zwrotem. Trzeba też zdecydować, czy te obserwacje należą do mianownika publikowanego wskaźnika.

Dokumentacja tabel krzyżowych omawia kategorie, w tym pokazanie niezaobserwowanych poziomów przez dropna=False. Nie utożsamiam tego z uniwersalną naprawą braków. Serie są również dopasowywane po indeksie. Dlatego w przykładzie biorę kolumny z tego samego DataFrame i nie składam przypadkowo niezależnych serii po różnym filtrowaniu.

Ćwiczenie z jednym nowym zamówieniem

Dodaj w myślach jedno wysłane zamówienie Web. Liczba zwrotów tego kanału pozostaje 1, ale mianownik rośnie z 3 do 4. Udział zwrotów w Web zmienia się z jednej trzeciej na jedną czwartą. Udział Web wśród wszystkich zwrotów nadal wynosi jedną trzecią.

Jeśli umiesz wyjaśnić, dlaczego jedna proporcja się zmieniła, a druga została taka sama, masz najważniejszy element tego hasła. Do grupowania liczbowych wartości przed taką tabelą może przydać się pandas cut. Najpierw ustal jednak, co reprezentuje obserwacja i przez co zamierzasz dzielić.

Najważniejsze w pigułce

Czy normalize=True zwraca procenty od 0 do 100?

Zwraca udziały w całości tabeli, więc na przykład 0.25 oznacza 25%. Do wartości procentowej można pomnożyć udział przez 100; w opisie podaj mianownik.

Czym różnią się normalize='index' i normalize='columns'?

index dzieli przez sumę odpowiedniego wiersza, a columns przez sumę odpowiedniej kolumny. Pierwszy wariant opisuje strukturę wewnątrz grup wierszy, drugi strukturę wewnątrz grup kolumn.

Czy crosstab liczy unikalnych klientów?

Domyślnie liczy obserwacje kombinacji czynników. Jeśli jeden klient występuje kilka razy, może być policzony kilka razy. Zdefiniuj jednostkę i przygotuj dane odpowiednio do pytania.

Przy pracy nad artykułem użyto w zautomatyzowany sposób sztucznej inteligencji. Jeśli też chcesz tak umieć, sprawdź ten projekt.

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.