Osoba pisząca na laptopie z kolorowymi danymi na ekranie
Źródło: Pexels | Autor: Antoni Shkraba Studio
Rate this post

Nawigacja po artykule:

Dlaczego typy danych w pandas decydują o tym, czy analiza ma sens

Typ danych jako kontrakt między tobą a pandas

Typy danych w pandas (dtypes) działają jak kontrakt: skoro kolumna ma typ liczbowy, wolno na niej wykonywać operacje arytmetyczne, agregacje, grupowania, sortowania numeryczne. Jeśli ma typ tekstowy, biblioteka traktuje ją jak ciąg znaków – będzie je łączyć, porównywać leksykograficznie, a nie liczbowo. Gdy ten kontrakt jest złamany (np. liczby siedzą w typie object), analizy nie muszą się „wywalić” błędem; mogą po prostu dać złe wyniki.

DataFrame potrafi udawać, że wszystko jest w porządku, bo z wierzchu kolumna wygląda poprawnie: same cyfry, ładne formatowanie, brak oczywistych śmieci. Tymczasem w pamięci taka kolumna trzymana jest jak tablica Pythonowych obiektów (typ object), często z pojedynczym nietypowym wpisem, który blokuje konwersję. Pandas nie wie, że te wartości „powinny” być liczbami – wie tylko, że ma stringi, więc operuje na nich zgodnie z tym typem.

Ten kontrakt działa również w drugą stronę. Jeśli typ jest zadeklarowany jako int64 lub float64, pandas realnie korzysta z wektorowych operacji NumPy i maszynowych rejestrów, co daje znaczący zysk wydajności. Gdy zamiast tego dane trzymane są jako object, wiele optymalizacji wypada z gry; operacje stają się wolniejsze i bardziej podatne na niespodzianki (np. porównania z NaN czy dziwnymi stringami).

Ciche błędy: kiedy analiza się „rozjeżdża” bez wyjątku

Najgorszy scenariusz to brak jawnego błędu. Kod się wykonuje, raport się generuje, tylko liczby są absurdalne. Przykładowo, masz kolumnę cena, która wygląda jak liczby, ale przyszła z Excela jako tekst. Jeśli zrobisz:

df["cena"].sum()

i kolumna jest typu object, pandas nie rzuci błędem – spróbuje zinterpretować sumę w kontekście stringów. Przy niektórych wersjach i konfiguracjach skończy się to błędem, przy innych – po prostu zignorowaniem części danych lub nieintuicyjnym wynikiem. Zupełnie innym klasykiem są operacje typu:

df["rok"] + 1

Jeśli rok jest stringiem, dostajesz konkatenację (np. "2023" + "1" → "20231"), a nie zwiększenie roku. Bez kontroli typów takie błędy potrafią prześlizgnąć się niezauważone, zwłaszcza w długich pipeline’ach ETL, gdzie wynik końcowy jest jedynie zgrubnie „na oko” weryfikowany.

To, co widać w DataFrame, a to, co siedzi w pamięci

Wyświetlenie df.head() bywa zdradliwe. Ekran pokazuje reprezentację tekstową wartości, a nie ich rzeczywisty typ. Dwie kolumny mogą wyglądać identycznie, ale jedna będzie int64, a druga object z tekstami typu "1", "2". Podobnie daty: jedne będą prawdziwymi datetime64[ns], inne zwykłymi stringami z myślnikami.

W pamięci przekłada się to na bardzo różne zachowanie:

  • kolumna numeryczna korzysta z ciągłego bloku pamięci typu NumPy, gotowego do szybkich obliczeń wektorowych,
  • kolumna typu object to tablica wskaźników do Pythonowych obiektów; każda operacja musi przejść przez masę warstw pośrednich,
  • kolumna datetime pozwala na różnice czasowe, resampling, przesunięcia, grupowanie po granicach czasu,
  • kolumna string daty wymaga parsowania przy każdej operacji czasowej albo po prostu odmawia współpracy.

Ten rozdźwięk między „jak to wygląda” a „jak jest przechowywane” jest głównym powodem, dla którego analizy „rozjeżdżają się” po kilku krokach – transformacje zmieniają typy, dochodzi do niejawnych konwersji, a końcowy DataFrame ma inny układ typów niż wejściowy.

Krótki przykład: raport sprzedaży z cenami jako stringi

Wyobraź sobie raport sprzedaży z systemu, który generuje CSV. Kolumny: data, id_zamowienia, cena_brutto, waluta. Plik zawiera polskie formaty liczb (przecinek dziesiętny, spacja jako separator tysięcy):

data;id_zamowienia;cena_brutto;waluta
2023-01-01;A001;1 234,50;PLN
2023-01-02;A002;987,00;PLN

Domyślny read_csv (z separatorem średnika) wczyta cena_brutto jako object. Jeśli bezrefleksyjnie policzysz:

df["cena_brutto"].sum()

otrzymasz po prostu zlepienie tekstów (albo błąd), a nie sumę wartości. Z kolei próba grupowania i liczenia średniej:

df.groupby("waluta")["cena_brutto"].mean()

zakończy się komunikatem o niemożliwej konwersji na numeric lub da pusty wynik. Taki raport „działa” w sensie kod się wykonuje, ale biznesowo jest bezużyteczny, dopóki nie naprawisz typu kolumny.

Przegląd podstawowych typów danych w pandas i ich semantyka

Typy numeryczne: int, float i rzadziej complex

Typy numeryczne to fundament większości analiz. W pandas opierają się na typach NumPy: int64, float64 itd. W kontekście biznesowym najczęściej spotkasz:

  • int64 / Int64 – liczby całkowite (np. ilości, identyfikatory, lata),
  • float64 – liczby zmiennoprzecinkowe (np. kwoty, miary ciągłe),
  • complex – praktycznie nieużywane w typowych raportach; pojawia się raczej w analizach sygnałów, fizyce itp.

Istotne są dwie rzeczy: zakres i precyzja. Typ int8 ma inny zakres niż int64, więc przy nieuważnej konwersji możesz doprowadzić do przepełnień lub obcięcia wartości. Z kolei float64 ma ograniczoną precyzję reprezentacji ułamków (typ IEEE 754), co przy bardzo precyzyjnych kwotach finansowych może generować zaokrąglenia.

Nowa rodzina typów nullable (np. Int64 z wielką literą) pozwala przechowywać brakujące wartości NaN w kolumnach całkowitych. Standardowy int64 nie wspiera NaN – jeśli spróbujesz wstawić brak, pandas podniesie błąd lub skonwertuje całą kolumnę na float64, bo tam NaN jest reprezentowalne.

Typy tekstowe: object vs string[python] vs string[pyarrow]

Przez lata typem tekstowym w pandas był po prostu object. To worek, do którego trafiają wszelkie Pythonowe obiekty, ale w praktyce głównie stringi. Problem w tym, że object nie daje żadnych gwarancji: w tej samej kolumnie mogą pojawić się liczby, daty i teksty jednocześnie. To jeden z głównych generatorów „mieszanych typów” i problemów wydajnościowych.

Dlatego wprowadzono dedykowane typy:

  • string[python] – nowszy, oficjalny typ ciągów znaków oparty na klasycznych Pythonowych stringach, ale z lepszą obsługą braków danych i operacji wektorowych,
  • string[pyarrow] – typ oparty na Apache Arrow, znacznie bardziej wydajny pamięciowo i obliczeniowo przy dużych zbiorach; wymaga zainstalowanego pyarrow.

Różnica praktyczna: string[python] i string[pyarrow] dają spójne zachowanie (np. brak to <NA>, a nie NaN albo None), unifikują API i poprawiają wydajność. W nowych projektach lepiej unikać gołego object dla tekstów, chyba że świadomie przechowujesz w nim różne typy obiektów.

Typy logiczne: bool i nullable boolean

Typ logiczny wydaje się prosty, ale i tu pojawiają się niuanse. W pandas istnieją dwa główne warianty:

  • bool – klasyczne wartości True/False bez wsparcia dla braku (NaN),
  • boolean (z małej litery, ale jako pandas extension type) – nullable, obsługuje także <NA> jako brak logiczny.

Jeśli chcesz mieć kolumnę flagową z opcjonalnymi brakami, używaj boolean. W przeciwnym razie pandas będzie kombinował z typem object lub mieszał bool z NaN (który jest typowo float), co potrafi wywołać dziwne efekty w filtrach i agregacjach.

Typy czasowe: datetime64[ns], timedelta64[ns], Period

Czas w danych rzadko jest „zwykłym tekstem”. Najważniejsze typy czasowe w pandas:

  • datetime64[ns] – pojedynczy punkt w czasie (data + godzina + opcjonalnie strefa czasowa),
  • timedelta64[ns] – różnica między dwoma momentami w czasie (np. czas realizacji zamówienia),
  • Period – przedział czasowy o określonej granulacji, np. miesiąc, kwartał, rok.

datetime64[ns] jest typem podstawowym – umożliwia sortowanie chronologiczne, wyliczanie różnic, budowanie okien czasowych, resampling (np. agregowanie dziennych danych do poziomu miesięcznego) i poprawną obsługę stref czasowych. Period przydaje się przy analizach cyklicznych, gdzie interesują cię konkretne jednostki czasu (np. Q1 2023), a nie dokładna data i godzina.

Typ kategoryczny: category i jego semantyka

category to typ dla dyskretnych, powtarzających się wartości tekstowych lub numerycznych. Technicznie to słownik (lista kategorii) plus tablica kodów liczbowych. Semantycznie oznacza: „ta kolumna ma skończony zbiór możliwych wartości, często powtarzających się”. Typowe przykłady:

  • status zamówienia: ["nowe", "w realizacji", "wysłane", "anulowane"],
  • płeć, region, segment klienta, kategoria produktu,
  • dowolne etykiety, które pojawiają się wielokrotnie.

Korzyści są dwie: mniejsze zużycie pamięci (jedna wartość kategorii przechowywana jest raz, a reszta to kody) i szybsze operacje porównawcze (porównywane są liczby, nie stringi). Dodatkowo kategorie mogą mieć zdefiniowany porządek (np. „niski < średni < wysoki”), co przydaje się przy sortowaniu i analizach progowych.

Jak sprawdzać typy danych w istniejącym dataframe i wyłapywać anomalie

Podstawowe narzędzia: dtypes, info, describe, select_dtypes

Kontrola typów powinna być pierwszym krokiem po wczytaniu danych. Kilka komend odpowiada za szybki przegląd:

df.dtypes

zwraca serię: nazwa kolumny → dtype. Szybko wyłapiesz tu np. wszystkie kolumny z object lub nieoczekiwanymi typami.

df.info()

rozszerza ten widok o liczbę niepustych wartości w każdej kolumnie, co pomaga powiązać typ z brakami danych. df.describe() generuje statystyki opisowe dla kolumn numerycznych; z kolei:

df.describe(include="object")
df.describe(include="category")

pozwalają spojrzeć na rozkład tekstów i kategorii. Jeśli kolumna spodziewanie numeryczna pojawia się tylko w describe(include="object"), jest to silny sygnał, że dtype jest błędny.

Przydatna jest także funkcja:

df.select_dtypes(include=["object"])

która wyciąga wszystkie kolumny typu object. To często lista priorytetowa do weryfikacji i ewentualnej konwersji.

Rozpoznawanie „podejrzanych” kolumn i mieszanych typów

Kolumna z dtype object nie jest sama w sobie błędem, ale w wielu pipeline’ach jest to czerwone światło. Szczególnie niebezpieczne są kolumny, które z semantyki powinny być numeryczne (np. ceny, ilości) lub czasowe (daty), a występują jako object. Do diagnozy można użyć:

df["kolumna"].apply(type).value_counts()

Jeśli okaże się, że w jednej kolumnie siedzą i str, i int, i float, to masz typowo „mieszaną” kolumnę. Takie miksowanie wymusza dtype object i blokuje wiele optymalizacji. To też typowy znak, że dane przyszły z wielu źródeł lub były ręcznie poprawiane.

Diagnostyka błędnych typów przez próbną konwersję

Samo obejrzenie dtypes to dopiero pierwszy skan. Często trzeba wprost spróbować konwersji i zobaczyć, co się wywali. Przykład dla kolumny, która powinna być numeryczna:

pd.to_numeric(df["cena_brutto"], errors="raise")

ustawienie errors="raise" wymusi błąd przy pierwszej niekonwertowalnej wartości. Stosując:

pd.to_numeric(df["cena_brutto"], errors="coerce")

dostaniesz kolumnę numeryczną, w której wszystkie „podejrzane” wpisy zamienią się na NaN. To dobry sposób na zlokalizowanie problematycznych rekordów:

tmp = pd.to_numeric(df["cena_brutto"], errors="coerce")
df[tmp.isna() & df["cena_brutto"].notna()][["cena_brutto"]].head()

Analogiczny wzorzec działa dla dat:

tmp = pd.to_datetime(df["data_zakupu"], errors="coerce", dayfirst=True)
df[tmp.isna() & df["data_zakupu"].notna()][["data_zakupu"]].head()

W ten sposób da się szybko wychwycić np. literówki, nietypowe formaty dat czy wpisy typu "brak".

Bezpieczne konwersje typów podczas ładowania danych

Ustawianie typów już w momencie wczytywania plików

Najmniej bólu sprawia sytuacja, w której typy są poprawne od pierwszej linijki pipeline’u. Przy read_csv czy read_excel można dużo ustawić z góry.

Przykład dla CSV:

dtype_spec = {
    "id_zamowienia": "Int64",     # nullable int
    "waluta": "string[python]",   # tekst
    "status": "category",         # powtarzające się etykiety
}

df = pd.read_csv(
    "zamowienia.csv",
    dtype=dtype_spec,
)

Dzięki dtype= pandas nie będzie zgadywał typów, tylko od razu użyje zadanych. Dla dat używa się parametru parse_dates:

df = pd.read_csv(
    "zamowienia.csv",
    dtype=dtype_spec,
    parse_dates=["data_zlozenia", "data_realizacji"],
    dayfirst=True,
)

Jeśli nazwy kolumn dat mają wspólny wzorzec, można je wykryć wcześniej i przekazać listę dynamicznie. To ogranicza liczbę ręcznie utrzymywanych konfiguracji.

Konwersja krok po kroku po wczytaniu surowych danych

Gdy źródło jest nieprzewidywalne (pliki użytkowników, eksporty z różnych systemów), lepiej wczytać dane „luźno”, a potem konsekwentnie wymusić typy. Typowy schemat:

  1. Wczytaj dane bez ostrych dtype, ale z minimalną walidacją formatów (np. separatory, encoding).
  2. Sprawdź dtypes i wyłap kluczowe kolumny biznesowe (kwoty, daty, identyfikatory).
  3. Na tych kolumnach zrób jawne konwersje: to_numeric, to_datetime, astype("category"), astype("string[pyarrow]").
  4. Przy każdej konwersji zapisuj liczbę utraconych / niekonwertowalnych rekordów.

Przykładowy fragment „twardej” normalizacji:

# 1. Kwoty
df["cena_brutto"] = (
    df["cena_brutto"]
    .str.replace(" ", "", regex=False)
    .str.replace(",", ".", regex=False)
)
df["cena_brutto"] = pd.to_numeric(df["cena_brutto"], errors="coerce")

# 2. Daty
df["data_zakupu"] = pd.to_datetime(
    df["data_zakupu"],
    errors="coerce",
    format="%Y-%m-%d",  # jeśli format jest stabilny
)

# 3. Teksty
df["waluta"] = df["waluta"].astype("string[python]")

# 4. Kategorie
df["status"] = df["status"].astype("category")

Po takim bloku masz dataframe z w miarę stabilną semantyką typów. Każde dalsze obliczenie (grupowanie, sumy, joiny) jest mniej podatne na „rozjechanie”.

Dłonie piszące na laptopie z wykresami analizy danych
Źródło: Pexels | Autor: Василь Вовк

Typy nullable i zarządzanie brakami danych

Porównanie klasycznych typów NumPy i typów rozszerzonych pandas

Klasyczne typy int64, float64, bool pochodzą z NumPy i nie mają natywnego pojęcia „braku” poza NaN (dla float). Typy rozszerzone (extension dtypes) w pandas mają własny znacznik braku <NA>, który zachowuje się spójniej między różnymi kolumnami.

Najczęściej używane rozszerzone typy:

  • Int64, Int32 – liczby całkowite z obsługą <NA>,
  • boolean – trójstanowy typ logiczny (True/False/<NA>),
  • string[python], string[pyarrow] – tekstowy z własnym brakiem,
  • category – kategorie z opcjonalną kategorią brakową.

Przy konwersji z „gołych” typów na nullable używa się po prostu astype:

df["ilosc"] = df["ilosc"].astype("Int64")
df["czy_aktywny"] = df["czy_aktywny"].astype("boolean")
df["opis"] = df["opis"].astype("string[python]")

Konsekwencja: operacje takie jak df["ilosc"].isna() czy filtrowanie po brakach działają spójnie między różnymi kolumnami. W mieszance NaN, None i <NA> łatwo się pogubić.

Łapanie i standaryzacja „dziwnych” reprezentacji braków

W danych biznesowych brak nie zawsze wygląda jak brak. Widziane w praktyce warianty:

  • puste stringi "" lub spacje,
  • stałe typu "brak", "n/d", "NULL",
  • konkretne kody (np. -1 lub 999999) używane jako „wartość specjalna”.

Dobry nawyk to szybka standaryzacja tuż po wczytaniu:

null_like = ["", " ", "brak", "BRAK", "n/d", "NULL", "None"]

for col in df.select_dtypes(include=["object", "string"]).columns:
    df[col] = (
        df[col]
        .astype("string[python]")
        .str.strip()
        .replace(null_like, pd.NA)
    )

Przy wczytywaniu CSV można też ustawić na_values=:

df = pd.read_csv(
    "plik.csv",
    na_values=["", " ", "brak", "BRAK", "n/d", "NULL"],
    keep_default_na=True,
)

Po takim kroku wszystkie brakowe patterny zlepiają się w jeden mechanizm braków (NaN lub <NA>), co ułatwia dalszą konwersję typów i filtrowanie.

Konwersje masowe: pipeline przekształceń typów

Strategia „schema first” – deklarowanie oczekiwanej struktury

Dla stabilnych źródeł (hurtownie, API, pliki z jednego systemu) przydaje się koncepcja „schematu”: słownik opisujący, jakich typów oczekujesz po wczytaniu. Przykładowa definicja:

schema = {
    "id_zamowienia": "Int64",
    "id_klienta": "Int64",
    "waluta": "string[python]",
    "status": "category",
    "cena_brutto": "float64",
    "ilosc": "Int64",
    "data_zlozenia": "datetime64[ns]",
}

Następnie można zbudować prostą funkcję wymuszającą taką strukturę:

def enforce_schema(df, schema):
    for col, target_type in schema.items():
        if col not in df.columns:
            # decyzja biznesowa: albo tworzymy kolumnę, albo podnosimy błąd
            df[col] = pd.NA
        if target_type.startswith("datetime64"):
            df[col] = pd.to_datetime(df[col], errors="coerce")
        else:
            df[col] = df[col].astype(target_type)
    return df

df = enforce_schema(df, schema)

Taki krok można potraktować jako „bramkę jakościową”: jeśli konwersja się nie powiedzie (zbyt dużo braków, niekonwertowalne wartości), pipeline się zatrzymuje zamiast generować fałszywe raporty.

Selektor typów i przekształcenia hurtowe

Gdy dataframe ma dziesiątki / setki kolumn, wygodniej jest operować grupami. select_dtypes pomaga odsiać np. wszystkie teksty i wykonać na nich jedną, wektorową operację:

# Czyszczenie wszystkich tekstów: usunięcie białych znaków, konwersja do string[python]
obj_cols = df.select_dtypes(include=["object"]).columns

df[obj_cols] = (
    df[obj_cols]
    .apply(lambda s: s.astype("string[python]").str.strip())
)

Analogicznie można potraktować wszystkie kolumny numeryczne, które mają być nullable:

int_cols = ["ilosc", "rok", "miesiac"]
df[int_cols] = df[int_cols].astype("Int64")

i wszystkie flagi logiczne:

bool_cols = ["czy_aktywny", "czy_vip"]
df[bool_cols] = df[bool_cols].astype("boolean")

Specyficzne pułapki typów a poprawność analiz

Łączenie dataframe’ów z różnymi typami tych samych kolumn

Przy concat lub merge łatwo doprowadzić do cichej zmiany typu. Przykład:

  • w jednym dataframe id_klienta jest Int64,
  • w drugim jest string[python] (bo ktoś poprzednio zrobił .astype(str)).

Podczas merge pandas będzie próbował sprowadzić typy do wspólnego mianownika, często kończąc na object lub stringu. Efekt: sumaryczna tabela jest trudniejsza do filtrowania, a niektóre joiny nie znajdują dopasowań (np. "00123" vs 123).

Dobry nawyk: przed łączeniem ustal wspólny typ dla wszystkich kluczy:

common_key_type = "Int64"

for df_part in [df1, df2, df3]:
    df_part["id_klienta"] = pd.to_numeric(
        df_part["id_klienta"],
        errors="coerce"
    ).astype(common_key_type)

Jeśli zachowanie z wiodącymi zerami jest ważne (np. numery kont, kody pocztowe), wtedy lepiej konsekwentnie używać tekstu:

common_key_type = "string[python]"

for df_part in [df1, df2]:
    df_part["kod_pocztowy"] = (
        df_part["kod_pocztowy"]
        .astype("string[python]")
        .str.zfill(5)
    )

Agregacje po typach tekstowych zamiast numerycznych

Gdy kolumna kwotowa zostanie odczytana jako tekst, wiele operacji „zadziała”, ale zwróci logicznie bezsensowny wynik. Przykład:

df["cena_brutto"].dtype
# object

df["cena_brutto"].max()

max() w takim przypadku użyje porównania leksykograficznego (alfabetycznie po stringach), nie liczbowego. W rezultacie „największą” ceną będzie np. "999", ale "1000" może być niżej w sortowaniu, jeśli ma inny wzorzec znaków.

Dlatego przy kolumnach krytycznych pod agregacje finansowe rozsądne jest dodanie asercji w kodzie:

assert pd.api.types.is_numeric_dtype(df["cena_brutto"]), 
    "cena_brutto nie jest typem numerycznym"

raport = df.groupby("waluta")["cena_brutto"].sum()

Podobne asercje można dodać dla dat (sprawdzając is_datetime64_any_dtype) czy dla typów kategorycznych.

Operacje na datach zapisanych jako tekst

Daty przechowywane jako stringi potrafią „przechodzić” przez pipeline przez długi czas, bo w wielu miejscach są tylko wyświetlane. Problem pojawia się przy próbie sortowania, wyliczania różnic czy resamplingu. Sortowanie tekstowych dat:

df.sort_values("data_zakupu")

będzie poprawne tylko wtedy, gdy format jest leksykograficznie zgodny z czasem (np. "YYYY-MM-DD"). W formacie "DD.MM.YYYY" kolejność posypie się całkowicie. Różnica między string a datetime64 jest też widoczna przy filtrach:

# To ZADZIAŁA poprawnie tylko dla prawdziwych datetime64
df[df["data_zakupu"] >= "2023-01-01"]

Dlatego daty najrozsądniej jest skonwertować od razu:

df["data_zakupu"] = pd.to_datetime(
    df["data_zakupu"],
    errors="coerce",
    dayfirst=True,
)

Po tej zmianie wszystko, co związane z czasem (okna ruchome, przedziały, różnice między datami) zachowuje się przewidywalnie.

Typy kategoryczne w praktycznych analizach

Definiowanie i porządkowanie kategorii biznesowych

Świadome definiowanie kategorii i pilnowanie spójności

Typ category jest szczególnie przydatny tam, gdzie występuje skończony zestaw wartości biznesowych: status zamówienia, typ klienta, kanał sprzedaży. Daje oszczędność pamięci i szybsze grupowania, ale pod warunkiem, że kategorie są dobrze zdefiniowane.

Prosty przykład porządkowania statusów:

status_order = ["złożone", "opłacone", "wysłane", "zwrócone"]

df["status"] = pd.Categorical(
    df["status"],
    categories=status_order,
    ordered=True,
)

Po takim kroku sortowanie po status czy porównania typu df["status"] >= "opłacone" mają sens biznesowy, a nie tylko leksykograficzny. Dodatkowo od razu widać wszelkie „śmieciowe” wartości, bo lądują jako kategoria brakowa (NaN).

Aktualizacja kategorii przy łączeniu danych

Problem pojawia się, gdy łączone są tabele, w których te same kategorie zdefiniowano z minimalnie innymi słownikami. Przykładowo:

  • w jednym zbiorze: ["A", "B", "C"],
  • w drugim: ["A", "B", "D"].

Po concat kategorie zostaną zmergowane, ale brak synchronizacji słowników może utrudnić filtrowanie czy raportowanie. Dobry wzorzec to jawne ustawienie wspólnego zestawu kategorii przed łączeniem:

all_cats = sorted(
    set(df1["segment"].cat.categories)
    | set(df2["segment"].cat.categories)
)

for d in (df1, df2):
    d["segment"] = d["segment"].cat.set_categories(all_cats)

Po takim manewrze każda tabela ma identyczny słownik kategorii, więc agregacje i porównania działają przewidywalnie.

Konwersja z kategorii na string i z powrotem

Czasem trzeba „roz-kategoryzować” kolumnę, np. do exportu lub przekazania do biblioteki, która nie wspiera category. Najprostsza droga:

df["status_str"] = df["status"].astype("string[python]")

W drugą stronę (z powrotem na category) lepiej nie polegać na domyślnej kolejności kategorii, tylko ją zdefiniować:

status_order = ["złożone", "opłacone", "wysłane", "zwrócone"]

df["status"] = (
    df["status_str"]
    .astype("string[python]")
    .str.strip()
    .pipe(
        lambda s: pd.Categorical(
            s,
            categories=status_order,
            ordered=True,
        )
    )
)

Kategorie a brakujące wartości i „inne”

Czasami sensowne jest połączenie kilku rzadkich kategorii w jedną, np. „inne”. Mechanicznie wygląda to tak:

top_kraje = ["PL", "DE", "CZ"]

df["kraj_sprzedazy"] = df["kraj_sprzedazy"].astype("string[python]")

df.loc[~df["kraj_sprzedazy"].isin(top_kraje) & df["kraj_sprzedazy"].notna(),
       "kraj_sprzedazy"] = "inne"

df["kraj_sprzedazy"] = pd.Categorical(
    df["kraj_sprzedazy"],
    categories=top_kraje + ["inne"],
)

Uwaga: brak (<NA>) i kategoria „inne” to dwie różne rzeczy semantycznie – nie powinny być mieszane. Brak oznacza „nie wiemy”, „inne” oznacza „wiemy, że to coś spoza listy”.

Drewniane kostki z literami DATA na drewnianym tle
Źródło: Pexels | Autor: Markus Winkler

Walidacja typów w pipeline ETL

Proste testy typów jako „bezpiecznik”

W dłuższych pipeline’ach ETL dobrze działają proste, szybkie testy pilnujące typów. Przykładowy moduł walidacyjny:

from pandas.api.types import (
    is_integer_dtype,
    is_float_dtype,
    is_datetime64_any_dtype,
)

def assert_schema(df, expected):
    for col, kind in expected.items():
        assert col in df.columns, f"Brak kolumny: {col}"

        if kind == "int":
            assert is_integer_dtype(df[col]), f"{col} nie jest int"
        elif kind == "float":
            assert is_float_dtype(df[col]), f"{col} nie jest float"
        elif kind == "datetime":
            assert is_datetime64_any_dtype(df[col]), f"{col} nie jest datetime"
        elif kind == "string":
            assert df[col].dtype == "string[python]", f"{col} nie jest string[python]"
        elif kind == "category":
            assert str(df[col].dtype).startswith("category"), f"{col} nie jest category"

Uproszczony słownik oczekiwanego schematu:

expected_schema = {
    "id_zamowienia": "int",
    "cena_brutto": "float",
    "data_zlozenia": "datetime",
    "waluta": "string",
    "status": "category",
}

assert_schema(df, expected_schema)

Złamany typ skutkuje szybkim, czytelnym błędem, zamiast cichego generowania niepoprawnych KPI.

Raportowanie nietypowych typów i heurystyczne naprawy

Przy integracji dzikich źródeł (arkusze użytkowników, CSV z eksportów) przydaje się mała diagnostyka. Przykład prostego „lintera typów”:

def profile_dtypes(df):
    summary = []
    for col in df.columns:
        dt = df[col].dtype
        non_null = df[col].notna().sum()
        nunique = df[col].nunique(dropna=True)

        summary.append(
            {
                "col": col,
                "dtype": str(dt),
                "non_null": non_null,
                "nunique": nunique,
            }
        )
    return pd.DataFrame(summary).sort_values("dtype")

profil = profile_dtypes(df)

Na podstawie takiej tabeli widać np. kolumny object z małą liczbą unikalnych wartości (kandydat na category) albo potencjalne numery trzymane w stringach. Szybka heurystyka dla prostych intów w stringu:

def try_cast_int(s):
    return pd.to_numeric(s, errors="ignore").astype("Int64")

mask_obj = df.dtypes.eq("object")
for col in df.columns[mask_obj]:
    df[col] = try_cast_int(df[col])

Typy a wydajność i pamięć

Porównanie rozmiaru dla różnych reprezentacji

Ten sam zestaw danych można przechowywać bardzo różnie pod względem pamięci. Przykład dla prostego dataframe’u:

df = pd.DataFrame(
    {
        "id": range(1, 100_001),
        "kraj": np.random.choice(["PL", "DE", "CZ", "FR"], size=100_000),
    }
)

df_obj = df.copy()
df_obj["kraj"] = df_obj["kraj"].astype("object")

df_cat = df.copy()
df_cat["kraj"] = df_cat["kraj"].astype("category")

df_str = df.copy()
df_str["kraj"] = df_str["kraj"].astype("string[python]")

print(df_obj.memory_usage(deep=True))
print(df_cat.memory_usage(deep=True))
print(df_str.memory_usage(deep=True))

Zwykle category wygrywa przy małej liczbie unikalnych wartości, a string[python] jest kompromisem między wygodą operacji tekstowych a rozsądnym zużyciem pamięci. object to wariant „legacy”, który często jest najcięższy i najmniej przewidywalny.

Wymuszanie oszczędnych typów liczbowych

Domyślnie pandas chętnie tworzy int64/float64. Dla wielu danych biznesowych to overkill. Przykład „downcastu” (zbijania rozmiaru typu):

df["rok"] = pd.to_numeric(df["rok"], errors="coerce")

df["rok"] = pd.to_numeric(df["rok"], downcast="integer")

Dla danych bez braków otrzymamy np. int16. Przy nullable można użyć konkretnych rozszerzonych typów:

df["rok"] = df["rok"].astype("Int16")
df["miesiac"] = df["miesiac"].astype("Int8")

Uwaga: zbyt agresywny downcast dla kolumn, gdzie pojawiają się duże wartości (np. identyfikatory generowane jako hash) może doprowadzić do przepełnień; tutaj przydają się testy zakresu przed zmianą typu.

Przesiadka na pyarrow backend dla dużych tabel

Nowsze wersje pandas oferują typy oparte o Apache Arrow, które potrafią być lżejsze i szybsze przy operacjach kolumnowych. Przykład:

df = pd.read_csv("plik.csv", dtype_backend="pyarrow")

df["opis"] = df["opis"].astype("string[pyarrow]")
df["czy_aktywny"] = df["czy_aktywny"].astype("boolean[pyarrow]")

Dla niektórych workloadów (np. duże złączenia, proste agregacje) taki backend potrafi wyraźnie przyspieszyć pracę. Trzeba jednak liczyć się z tym, że nie wszystkie funkcje pandas są w pełni wspierane dla typów pyarrow, więc czasem konieczne są konwersje z powrotem na „klasyczne” typy.

Typy a eksport / integracja z innymi narzędziami

Eksport do CSV i utrata informacji o typach

CSV nie przechowuje typów, więc przy eksporcie cała inteligencja związana z Int64, category czy datetime64 znika – zostaje czysty tekst. Dlatego przy imporcie trzeba powtórzyć konwersje lub trzymać osobny opis schematu.

Prosty wzorzec: do pliku .csv dokładany jest mały .json z opisem typów:

schema = {col: str(dt) for col, dt in df.dtypes.items()}

df.to_csv("dane.csv", index=False)
pd.Series(schema).to_json("dane_schema.json", indent=2)

Przy wczytywaniu można ten schemat wykorzystać do ustawienia typów (z modyfikacjami, np. narzucając nullable):

df = pd.read_csv("dane.csv")

schema = pd.read_json("dane_schema.json", typ="series").to_dict()

for col, dt in schema.items():
    if dt.startswith("Int") or dt == "int64":
        df[col] = pd.to_numeric(df[col], errors="coerce").astype("Int64")
    elif dt.startswith("float"):
        df[col] = pd.to_numeric(df[col], errors="coerce")
    elif "datetime64" in dt:
        df[col] = pd.to_datetime(df[col], errors="coerce")

Eksport do Parquet / Feather z zachowaniem typów

Formaty kolumnowe (Parquet, Feather) przechowują informacje o typach, więc przy odczycie dostajemy je „za darmo”. Przykład:

df.to_parquet("dane.parquet", index=False)

df2 = pd.read_parquet("dane.parquet")
df2.dtypes

Przy backendzie pyarrow mapowanie typów jest jeszcze bardziej naturalne; takie podejście sprawdza się przy wymianie danych z narzędziami analitycznymi (Spark, DuckDB, BigQuery).

Współpraca z bazą SQL: mapowanie typów

Przy zapisie do baz danych pojawia się dodatkowa warstwa: typy SQL. Szybki szkic mapowania dla sqlalchemy:

from sqlalchemy import Integer, String, DateTime, Boolean, Numeric

dtype_map = {
    "Int64": Integer(),
    "int64": Integer(),
    "string[python]": String(),
    "category": String(),
    "boolean": Boolean(),
    "datetime64[ns]": DateTime(),
    "float64": Numeric(),
}

sql_types = {
    col: dtype_map.get(str(dt), String())
    for col, dt in df.dtypes.items()
}

df.to_sql(
    "tabela_docelowa",
    engine,
    if_exists="replace",
    index=False,
    dtype=sql_types,
)

Przy odczycie z SQL warto od razu ustawić typy pandasowe, zamiast polegać na domyślnym mapowaniu:

query = "SELECT * FROM tabela_docelowa"
df = pd.read_sql(query, engine)

df["data_zlozenia"] = pd.to_datetime(df["data_zlozenia"], errors="coerce")
df["id_zamowienia"] = df["id_zamowienia"].astype("Int64")

Typy danych w kontekście testów jednostkowych i regresyjnych

Sprawdzanie typów obok wartości

W testach jednostkowych na dataframe’y często weryfikowane są tylko wartości, a typy są pomijane. To ślepa plamka: zmiana typu może nie wysypać testu, a radykalnie zmienić zachowanie produkcji. Przykładowy test z asercją typów:

def assert_frame_equal_strict(left, right):
    pd.testing.assert_frame_equal(left, right, check_dtype=True)

    # alternatywnie: wyraźne porównanie dtypes
    assert left.dtypes.to_dict() == right.dtypes.to_dict()

Budując „golden master” (oczekiwany wynik) dobrze jest ustabilizować typy, np. wszędzie używać Int64 zamiast mieszanek z float64 i NaN.

Snapshoty schematu jako wczesny alarm

Przy długowiecznych pipeline’ach pomocne są snapshoty schematu, przechowywane np. jako prosty JSON. Przykładowo:

def export_schema(df, path):
    schema = {
        col: {
            "dtype": str(dt),
            "nullable": df[col].isna().any(),
        }
        for col, dt in df.dtypes.items()
    }
    with open(path, "w", encoding="utf-8") as f:
        json.dump(schema, f, indent=2, ensure_ascii=False)

def load_schema(path):
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)

Porównanie starego i nowego schematu pozwala wychwycić np. cichą zamianę Int64 na float64 czy pojawienie się braków w kolumnie, która dotąd była kompletna.

Najczęściej zadawane pytania (FAQ)

Jak sprawdzić typy danych kolumn w pandas?

Najprostszy sposób to użycie właściwości df.dtypes, która zwraca serię z typem każdej kolumny. Dla szybkiego podglądu struktury przydaje się też df.info() – oprócz typów pokazuje liczbę niepustych rekordów.

Przykład:

  • df.dtypes – lista typów (int64, float64, object, datetime64[ns] itd.).
  • df["cena"].dtype – typ pojedynczej kolumny.

Dlaczego moje liczby w pandas mają typ object zamiast int/float?

Najczęstszy powód to sposób wczytania danych z CSV/Excela: obecność przecinka dziesiętnego, spacji tysięcznych, pustych stringów lub „dziwnych” wartości (np. "- ", "brak") zmusza pandas do potraktowania całej kolumny jako tekstu (object).

Aby to naprawić, trzeba wyczyścić format i jawnie skonwertować kolumnę, np. pd.to_numeric(df["cena"], errors="coerce") po wcześniejszym usunięciu spacji/znaków waluty. Tip: sprawdź kilka podejrzanych rekordów df["cena"].unique()[:20], często jeden „śmieć” blokuje całą kolumnę.

Jak bezpiecznie zamienić kolumnę na typ liczbowy w pandas?

Użyj pd.to_numeric() z parametrem errors="coerce", który zamieni nieparsowalne wartości na NaN zamiast rzucać wyjątkiem. Przykład: df["cena"] = pd.to_numeric(df["cena"], errors="coerce").

Przy danych z formatem europejskim (przecinek dziesiętny, spacje tysięczne) zrób najpierw prostą normalizację tekstu, np. df["cena"] = df["cena"].str.replace(" ", "").str.replace(",", "."), a dopiero potem konwersję do typu numerycznego.

Czym się różni typ object od string w pandas i którego używać?

object to ogólny „worek” na dowolne obiekty Pythona – w praktyce głównie stringi, ale mogą się tam mieszać liczby, daty i inne typy. To rodzi problemy wydajnościowe i niespodzianki przy operacjach.

Nowe typy string[python] i string[pyarrow] to dedykowane kolumny tekstowe: spójnie obsługują braki (<NA>) i mają lepsze wsparcie wektorowych operacji na łańcuchach. W nowych projektach lepiej jawnie wymuszać df["kol"] = df["kol"].astype("string") niż zostawiać typ object dla tekstów.

Jak odróżnić daty jako tekst od prawdziwych datetime w pandas?

Po pierwsze: sprawdź df.dtypes. Jeśli widzisz object przy kolumnie dat, to są to tylko stringi. Prawdziwe daty mają typ datetime64[ns] (ew. z informacją o strefie czasowej).

Aby skonwertować tekst na daty, użyj pd.to_datetime(), np. df["data"] = pd.to_datetime(df["data"], format="%Y-%m-%d", errors="coerce"). Po takiej konwersji możesz bezpośrednio sortować chronologicznie, liczyć różnice czasowe, robić resampling czy grupowania po roku/miesiącu.

Jak wymusić określone typy danych już przy wczytywaniu pliku CSV?

Do pd.read_csv() można podać słownik w parametrze dtype, np. pd.read_csv("plik.csv", dtype={"id": "Int64", "waluta": "string"}). Dzięki temu od razu dostajesz poprawne typy bez dodatkowych konwersji.

Dla dat skorzystaj z parse_dates=["data"]. Uwaga: przy liczbach z przecinkami i spacjami często trzeba najpierw wczytać jako tekst, wyczyścić format, a dopiero potem rzutować na typ numeryczny – samo dtype=float nie poradzi sobie z lokalnymi formatami.

Jak wykryć i naprawić „rozjechane” typy po kilku krokach ETL w pandas?

Najprostsza praktyka to wstawienie kontroli typów po kluczowych etapach pipeline’u, np. wypisanie df.dtypes lub porównanie ich z oczekiwanym słownikiem. Dla większych projektów sprawdza się prosty „schema check”: słownik {"cena": "float64", "rok": "Int64", "data": "datetime64[ns]"} i asercja, że df.dtypes jest z nim zgodne.

Jeśli widzisz niepokojące object albo niespodziewane float64 tam, gdzie miały być całkowite liczby, prześledź ostatnie operacje (łączenia, uzupełnianie braków, casty). Wiele funkcji pandas przy braku kompatybilności typów robi niejawne konwersje, które cicho zmieniają semantykę danych.

1 KOMENTARZ

  1. Bardzo wartościowy artykuł! Podoba mi się sposób, w jaki autor szczegółowo opisał kwestię ustawiania typów danych w pandas i w jaki sposób może to wpłynąć na analizy danych. Praktyczne wskazówki oraz przykłady zastosowań sprawiły, że temat stał się bardziej zrozumiały dla mnie jako początkującego. Jednakże, brakuje mi bardziej zaawansowanych technik, które mogłyby być przydatne dla osób z większym doświadczeniem w analizie danych. Może warto rozszerzyć artykuł o bardziej zaawansowane przykłady ustawienia typów danych w pandas?

Komentarze są aktywne tylko po zalogowaniu.