Materiały kursów
Materiały / Machine Learning
Pobierz notatnik (.ipynb)

Ćwiczenie 3: Przygotowanie danych

Po co to ćwiczenie?

W ćwiczeniu 02 znaleźliśmy w danych problemy: braki ukryte pod zerami, cechy o zupełnie różnych skalach, rozkłady skośne. Znalezienie problemu to jednak dopiero połowa pracy - teraz trzeba go naprawić, i to tak, żeby naprawa sama nie stała się większym problemem.

Przygotowanie danych (ang. data preprocessing) to w praktyce większość czasu w projekcie uczenia maszynowego. Nie dlatego, że jest trudne technicznie - SimpleImputer ma trzy linijki - tylko dlatego, że łatwo je zrobić w sposób, który psuje ocenę modelu, nie dając o tym znać.

Najważniejszy wątek tego ćwiczenia mieści się w jednym zdaniu:

Każde przekształcenie, które uczy się czegokolwiek z danych, wolno dopasować (fit) wyłącznie na zbiorze uczącym.

Średnia do uzupełnienia braków, średnia i odchylenie do standaryzacji, minimum i maksimum do skalowania, lista kategorii do kodowania - to wszystko są rzeczy wyuczone z danych. Policzone na całym zbiorze, przenoszą informację ze zbioru testowego do modelu. Nazywa się to przeciekiem danych (ang. data leakage) i jest to najczęstszy powód, dla którego model świetny w notatniku okazuje się bezużyteczny w praktyce.

Pokażemy to na eksperymencie, a nie na wykładzie.

Czego się nauczysz

  1. Jak uzupełniać braki danych (SimpleImputer) i jak wybrać strategię uzupełniania.
  2. Czym różni się StandardScaler od MinMaxScaler i kiedy sięgać po który.
  3. Jak kodować zmienne kategoryczne (OneHotEncoder) i dlaczego nie wolno zamieniać kategorii na kolejne liczby.
  4. Czym jest Pipeline i dlaczego jest zabezpieczeniem przed przeciekiem, a nie tylko wygodą.
  5. Jak stosować różne przekształcenia do różnych kolumn (ColumnTransformer).
  6. Dlaczego fit wyłącznie na zbiorze uczącym - i jak wygląda wynik, gdy się o tym zapomni.

Zanim zaczniesz: uruchamiaj komórki po kolei (Shift+Enter).

1. Punkt wyjścia: dane z problemami

Plik dane/diabetes.csv jest wyjątkowo czysty - nie ma w nim braków ani kolumn tekstowych. Dla ćwiczenia to niewygodne, bo nie ma czego naprawiać. Dlatego wytworzymy problemy programowo, dokładnie tak jak w ćwiczeniu 02:

  1. zasymulujemy wadliwy eksport, w którym braki zapisano jako zera, i przywrócimy im status braków (NaN),
  2. dołożymy kolumnę kategoryczną (ang. categorical feature) - grupę wiekową wyliczoną z Age funkcją pd.cut.

Ma to walor dydaktyczny: widzisz dokładnie, skąd problem się wziął, i możesz w każdej chwili porównać wynik z prawdą.

In [ ]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

dane_czyste = pd.read_csv('dane/diabetes.csv')

ETYKIETA = 'Diabetic'
IDENTYFIKATOR = 'PatientID'

# --- krok 1: symulujemy wadliwy eksport (braki zapisane jako 0) ---
losowy = np.random.RandomState(42)
dane = dane_czyste.copy()

podejrzane = ['SerumInsulin', 'TricepsThickness', 'DiastolicBloodPressure']
for kolumna, udzial in zip(podejrzane, [0.35, 0.20, 0.05]):
    maska = losowy.rand(len(dane)) < udzial
    dane.loc[maska, kolumna] = 0

# --- krok 2: przywracamy zerom status brakow danych ---
# Uwaga: TYLKO w kolumnach, w ktorych zero jest fizycznie niemozliwe.
# Pregnancies zostawiamy w spokoju - tam zero jest poprawnym pomiarem.
dane[podejrzane] = dane[podejrzane].replace(0, np.nan)

# --- krok 3: dokladamy ceche kategoryczna ---
dane['GrupaWiekowa'] = pd.cut(
    dane['Age'],
    bins=[0, 30, 45, 60, 200],
    labels=['młody', 'średni', 'starszy', 'senior'],
)

print("Braki danych w kolumnach:")
print(dane.isna().sum().loc[lambda s: s > 0].to_string())
print()
print("Rozkład grupy wiekowej:")
print(dane['GrupaWiekowa'].value_counts().to_string())
print()
dane.head()

Dlaczego grupa wiekowa, skoro mamy dokładny wiek?

To dobre pytanie i odpowiedź brzmi: w tym konkretnym przypadku raczej nie warto. Dzieląc ciągły wiek na cztery kubełki, tracimy informację - pacjent 44-letni i 31-letni wpadają do tego samego pudełka, a 45-letni i 46-letni do różnych.

Robimy to tutaj żeby mieć na czym ćwiczyć kodowanie kategorii, bo w tym zbiorze żadnej kolumny tekstowej nie ma. W prawdziwym projekcie kategorie przychodzą same: płeć, województwo, rodzaj ubezpieczenia, kod oddziału.

Warto jednak wiedzieć, że dyskretyzacja (ang. binning) bywa uzasadniona: gdy zależność jest wyraźnie progowa, gdy dziedzina posługuje się ustalonymi przedziałami (grupy wiekowe w medycynie, klasy BMI) albo gdy model jest liniowy, a zależność - nie.

2. Braki danych: SimpleImputer

Większość algorytmów w scikit-learn nie przyjmuje NaN i kończy się błędem. Mamy dwa wyjścia:

Podejście Jak Koszt
usunąć wiersze z brakami dane.dropna() tracisz dane - tu byłaby to około połowa zbioru
uzupełnić braki (ang. imputation) SimpleImputer wprowadzasz wartości, których nie zmierzono

Usuwanie jest uczciwe, ale kosztowne i niebezpieczne: jeśli braki nie są losowe (a zwykle nie są - pomiaru częściej nie robi się pacjentom w lepszym stanie), to usuwając te wiersze zmieniasz skład badanej populacji.

SimpleImputer uzupełnia braki jedną z czterech strategii:

strategy Czym uzupełnia Kiedy
'mean' średnią kolumny rozkład symetryczny, brak silnych wartości odstających
'median' medianą kolumny rozkład skośny lub z odstającymi - domyślny wybór dla danych medycznych
'most_frequent' wartością najczęstszą kolumny kategoryczne i całkowitoliczbowe
'constant' ustaloną wartością (fill_value) gdy brak sam w sobie coś znaczy
In [ ]:
from sklearn.impute import SimpleImputer

probka = dane[['SerumInsulin']].head(10)
print("Przed uzupełnieniem:")
print(probka.to_string())

imputer_sredni = SimpleImputer(strategy='mean')
imputer_mediana = SimpleImputer(strategy='median')

# fit uczy sie statystyki z danych, transform ja stosuje
imputer_sredni.fit(dane[['SerumInsulin']])
imputer_mediana.fit(dane[['SerumInsulin']])

print()
print(f"Wartość wyuczona przez strategy='mean':   {imputer_sredni.statistics_[0]:.2f}")
print(f"Wartość wyuczona przez strategy='median': {imputer_mediana.statistics_[0]:.2f}")
print()
print("Po uzupełnieniu medianą (pierwsze 10 wierszy):")
print(np.round(imputer_mediana.transform(probka).ravel(), 2))

Zwróć uwagę na atrybut statistics_ - kończąca się podkreśleniem nazwa to w scikit-learn konwencja oznaczająca coś, czego obiekt nauczył się podczas fit. Zapamiętaj ją, bo to najprostszy sposób, żeby rozpoznać, czy dane przekształcenie w ogóle się czegoś uczy (a więc czy grozi przeciekiem).

Zobaczmy teraz, co uzupełnianie robi z rozkładem cechy.

In [ ]:
uzupelnione_srednia = imputer_sredni.transform(dane[['SerumInsulin']]).ravel()
uzupelnione_mediana = imputer_mediana.transform(dane[['SerumInsulin']]).ravel()

fig, osie = plt.subplots(1, 3, figsize=(15, 4.2), sharey=True)
zestawy = [
    (dane_czyste['SerumInsulin'], 'Prawda (czego nigdy nie zobaczysz)', '#55A868'),
    (uzupelnione_srednia, "Uzupełnione strategy='mean'", '#4C72B0'),
    (uzupelnione_mediana, "Uzupełnione strategy='median'", '#C44E52'),
]
for ax, (wartosci, tytul, kolor) in zip(osie, zestawy):
    ax.hist(wartosci, bins=40, color=kolor, edgecolor='white')
    ax.set_title(tytul, fontsize=10)
    ax.set_xlabel('SerumInsulin')
    ax.grid(alpha=0.3)
osie[0].set_ylabel('liczba pacjentów')
plt.tight_layout()
plt.show()

Widać dokładnie to, czego należało się spodziewać: uzupełnianie jedną wartością tworzy sztuczny, bardzo wysoki słupek w miejscu tej wartości. Rozkład przestaje przypominać oryginał, a wariancja cechy spada.

To nie znaczy, że uzupełnianie jest złe - znaczy, że nie jest za darmo. Przy 35% braków w kolumnie deformacja jest poważna i warto rozważyć alternatywy:

  • dodać kolumnę SerumInsulin_brakowało o wartościach 0/1 (SimpleImputer(add_indicator=True)) - model dostaje wtedy jawną informację, że wartość jest zmyślona,
  • użyć uzupełniania modelowego (KNNImputer, IterativeImputer), które przewiduje brak z pozostałych cech,
  • po prostu usunąć kolumnę, jeśli braków jest bardzo dużo, a cecha niesie mało sygnału.

3. Skalowanie cech

Z ćwiczenia 02 wiemy, że skale cech są tu nieporównywalne: DiabetesPedigree poniżej 3, SerumInsulin w setkach. Skalowanie (ang. feature scaling) sprowadza je do wspólnego zakresu.

StandardScaler MinMaxScaler
Wzór (x - średnia) / odchylenie (x - min) / (max - min)
Wynik średnia 0, odchylenie 1 zakres od 0 do 1
Nazwa standaryzacja (ang. standardization) normalizacja min-max (ang. min-max normalization)
Wartości odstające znosi je nieźle - nie ma sztywnych granic bardzo wrażliwy - jedna ekstremalna wartość ściska całą resztę
Zachowuje zakres? nie, wychodzi poza tak, dokładnie [0, 1]
Kiedy domyślny wybór: regresja liniowa i logistyczna, SVM, PCA gdy algorytm wymaga ograniczonego zakresu (niektóre sieci neuronowe, obrazy)

Trzecia opcja, wspomniana dla porządku: RobustScaler korzysta z mediany i rozstępu międzykwartylowego zamiast średniej i odchylenia, więc jest odporny na wartości odstające.

Kiedy skalowanie jest zbędne: drzewa decyzyjne i lasy losowe porównują wartość cechy z progiem wewnątrz tej samej cechy, więc skala nie ma dla nich żadnego znaczenia. Skalowanie im nie zaszkodzi, ale też nic nie da.

In [ ]:
from sklearn.preprocessing import StandardScaler, MinMaxScaler

# Bierzemy dwie cechy o skrajnie roznych skalach, bez brakow
do_pokazu = dane_czyste[['SerumInsulin', 'DiabetesPedigree']]

standard = StandardScaler().fit_transform(do_pokazu)
minmax = MinMaxScaler().fit_transform(do_pokazu)

porownanie = pd.DataFrame({
    'oryginał - min': do_pokazu.min(), 'oryginał - max': do_pokazu.max(),
    'oryginał - średnia': do_pokazu.mean(), 'oryginał - odch. std': do_pokazu.std(),
    'Standard - średnia': standard.mean(axis=0), 'Standard - odch. std': standard.std(axis=0),
    'MinMax - min': minmax.min(axis=0), 'MinMax - max': minmax.max(axis=0),
})
print(porownanie.T.round(3).to_string())
In [ ]:
# Wrazliwosc MinMaxScaler na wartosci odstajace - jedna liczba potrafi zepsuc cala kolumne
zwykle = np.array([[10.0], [12.0], [11.0], [13.0], [9.0]])
z_bledem = np.vstack([zwykle, [[10000.0]]])   # np. blad wpisu: 10000 zamiast 100

print("MinMaxScaler bez błędnej wartości:")
print(np.round(MinMaxScaler().fit_transform(zwykle).ravel(), 3))
print()
print("MinMaxScaler z JEDNĄ błędną wartością na końcu:")
print(np.round(MinMaxScaler().fit_transform(z_bledem).ravel(), 4))
print()
print("StandardScaler z tą samą błędną wartością:")
print(np.round(StandardScaler().fit_transform(z_bledem).ravel(), 3))

Popatrz na środkowy wynik. Pięć prawdziwych obserwacji, które wcześniej rozkładały się ładnie w zakresie od 0 do 1, zostało zgniecionych w okolice zera - bo max wynosi teraz 10 000. Dla modelu te pięć wartości stało się praktycznie nierozróżnialnych.

StandardScaler też odczuwa błędną wartość (zawyża odchylenie standardowe), ale nie ma sztywnej granicy, więc nie spłaszcza reszty do jednego punktu.

Wniosek praktyczny: MinMaxScaler wymaga danych oczyszczonych z błędnych wartości. StandardScaler jest bezpieczniejszym wyborem domyślnym - i dlatego pojawia się we wszystkich przykładach tego kursu.

4. Kodowanie zmiennych kategorycznych

Model to matematyka - przyjmuje wyłącznie liczby. Kolumna GrupaWiekowa zawiera tekst, więc trzeba ją przetłumaczyć.

Zła droga, w którą wchodzi prawie każdy początkujący: zamienić kategorie na kolejne liczby (młody→0, średni→1, starszy→2, senior→3). Kod zadziała i błędu nie będzie. Problem w tym, że modelowi właśnie powiedziano, że senior to trzy razy średni i że starszy leży dokładnie w połowie między średni a senior. Model liniowy potraktuje to dosłownie i wyciągnie z tego wnioski, których nikt nie zamierzał mu przekazać.

Dobra droga: kodowanie zero-jedynkowe (ang. one-hot encoding). Każda kategoria dostaje własną kolumnę o wartościach 0/1. Nie ma wtedy żadnego porządku ani odległości między kategoriami - bo żadnego porządku nie ma.

In [ ]:
from sklearn.preprocessing import OneHotEncoder

koder = OneHotEncoder(sparse_output=False, handle_unknown='ignore')
zakodowane = koder.fit_transform(dane[['GrupaWiekowa']])

podglad = pd.DataFrame(zakodowane, columns=koder.get_feature_names_out(['GrupaWiekowa']))
podglad.insert(0, 'GrupaWiekowa (oryginał)', dane['GrupaWiekowa'].values)

print("Kategorie wykryte podczas fit:", koder.categories_[0].tolist())
print("Jedna kolumna tekstowa zamieniona na", zakodowane.shape[1], "kolumny liczbowe.")
print()
print(podglad.head(8).to_string(index=False))

Dwa argumenty, które właśnie ustawiliśmy, są ważniejsze, niż wyglądają:

  • handle_unknown='ignore' - co ma się stać, gdy w danych testowych (albo u prawdziwego pacjenta) pojawi się kategoria, której nie było przy fit? Domyślnie OneHotEncoder rzuca wyjątek i cała predykcja się zatrzymuje. Z 'ignore' nieznana kategoria dostaje same zera. Dla systemu działającego w produkcji to różnica między łagodną degradacją a awarią.
  • sparse_output=False - zwraca zwykłą tablicę numpy zamiast macierzy rzadkiej. Wygodniejsze do oglądania; przy tysiącach kategorii warto wrócić do domyślnej macierzy rzadkiej, żeby nie wyczerpać pamięci.

Uwaga o wersjach: w scikit-learn starszym niż 1.2 argument nazywa się sparse zamiast sparse_output. Jeśli dostaniesz TypeError, to jest właśnie ta różnica.

I najważniejsze zdanie tej sekcji: lista kategorii w categories_ to rzecz wyuczona z danych. Jeśli dopasujesz koder na całym zbiorze, przekażesz modelowi informację o tym, jakie kategorie występują w zbiorze testowym. To ten sam przeciek co przy średniej - tylko subtelniejszy.

5. Pipeline - dlaczego to nie jest tylko wygoda

Mamy już trzy przekształcenia: uzupełnianie braków, skalowanie, kodowanie. Gdyby stosować je ręcznie, kolejność wywołań wyglądałaby tak:

imputer.fit(X_ucz);      X_ucz = imputer.transform(X_ucz);      X_test = imputer.transform(X_test)
skaler.fit(X_ucz);       X_ucz = skaler.transform(X_ucz);       X_test = skaler.transform(X_test)
model.fit(X_ucz, y_ucz)

Ten kod jest poprawny, ale kruchy. Wystarczy jedna literówka - imputer.fit(X_test) zamiast transform - i przeciek gotowy. Bez żadnego komunikatu błędu. Przy walidacji krzyżowej, gdzie podziałów jest kilka, prawdopodobieństwo pomyłki rośnie dramatycznie.

Pipeline łączy przekształcenia i model w jeden obiekt, który zachowuje się jak zwykły model:

  • potok.fit(X_ucz, y_ucz) wywołuje fit_transform na każdym kroku po kolei, a na końcu fit modelu,
  • potok.predict(X_test) wywołuje na każdym kroku wyłącznie transform - z parametrami wyuczonymi na zbiorze uczącym.

Czyli: potok nie pozwala przypadkiem dopasować przekształcenia na danych testowych. To zabezpieczenie, nie kosmetyka.

In [ ]:
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.linear_model import LogisticRegression

cechy_liczbowe = ['Pregnancies', 'PlasmaGlucose', 'DiastolicBloodPressure',
                  'TricepsThickness', 'SerumInsulin', 'BMI', 'DiabetesPedigree', 'Age']

X = dane[cechy_liczbowe]
y = dane[ETYKIETA]

X_ucz, X_test, y_ucz, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42
)

potok = Pipeline([
    ('uzupelnianie', SimpleImputer(strategy='median')),
    ('skalowanie', StandardScaler()),
    ('model', LogisticRegression(max_iter=1000, random_state=42)),
])

potok.fit(X_ucz, y_ucz)

print("Kroki potoku:", [nazwa for nazwa, _ in potok.steps])
print(f"Skuteczność na zbiorze testowym: {potok.score(X_test, y_test):.1%}")
print()
print("Mediany wyuczone przez imputer (TYLKO ze zbioru uczącego):")
print(pd.Series(potok.named_steps['uzupelnianie'].statistics_,
                index=cechy_liczbowe).round(2).to_string())

6. ColumnTransformer - różne przekształcenia do różnych kolumn

Potok z sekcji 5 stosuje to samo do wszystkich kolumn. Nie da się tak potraktować kolumny GrupaWiekowa: SimpleImputer(strategy='median') nie policzy mediany z tekstu, a StandardScaler nie podzieli słowa przez odchylenie standardowe.

ColumnTransformer rozwiązuje ten problem: przypisuje listę kolumn do gałęzi przekształceń, uruchamia każdą gałąź osobno i skleja wyniki w jedną tablicę.

                          ┌─ kolumny liczbowe   → imputer(median) → StandardScaler ─┐
dane wejściowe ──────────┤                                                          ├─→ model
                          └─ kolumny kategoryczne → imputer(most_frequent) → OneHot ─┘
In [ ]:
from sklearn.compose import ColumnTransformer

cechy_kategoryczne = ['GrupaWiekowa']

X = dane[cechy_liczbowe + cechy_kategoryczne]
y = dane[ETYKIETA]

X_ucz, X_test, y_ucz, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42
)

galaz_liczbowa = Pipeline([
    ('uzupelnianie', SimpleImputer(strategy='median')),
    ('skalowanie', StandardScaler()),
])

galaz_kategoryczna = Pipeline([
    ('uzupelnianie', SimpleImputer(strategy='most_frequent')),
    ('kodowanie', OneHotEncoder(handle_unknown='ignore')),
])

przygotowanie = ColumnTransformer([
    ('liczbowe', galaz_liczbowa, cechy_liczbowe),
    ('kategoryczne', galaz_kategoryczna, cechy_kategoryczne),
])

potok_pelny = Pipeline([
    ('przygotowanie', przygotowanie),
    ('model', LogisticRegression(max_iter=1000, random_state=42)),
])

potok_pelny.fit(X_ucz, y_ucz)

print(f"Kolumn na wejściu:  {X_ucz.shape[1]}")
print(f"Kolumn po przygotowaniu: {potok_pelny.named_steps['przygotowanie'].transform(X_ucz).shape[1]}")
print()
print("Nazwy kolumn, które dostaje model:")
print(potok_pelny.named_steps['przygotowanie'].get_feature_names_out().tolist())
print()
print(f"Skuteczność na zbiorze testowym: {potok_pelny.score(X_test, y_test):.1%}")

Zwróć uwagę, że liczba kolumn urosła: jedna kolumna tekstowa zamieniła się w cztery zero-jedynkowe. Metoda get_feature_names_out() pozwala sprawdzić, co dokładnie dostaje model - bardzo się przydaje, gdy potem chcesz obejrzeć ważność cech albo współczynniki modelu.

Przydatny argument remainder: domyślnie ColumnTransformer wyrzuca kolumny niewymienione na żadnej liście (remainder='drop'). To zachowanie bezpieczne, ale bywa zaskakujące. remainder='passthrough' przepuszcza je bez zmian.


7. Sedno: dlaczego fit wyłącznie na zbiorze uczącym

Teraz najważniejsza część ćwiczenia. Rozważmy dwa sposoby uzupełnienia braków:

Poprawnie Z przeciekiem
Skąd bierzemy medianę wyłącznie ze zbioru uczącego z całego zbioru, razem z testowym
Kod imputer.fit(X_ucz) imputer.fit(X)
Co się dzieje model nie wie nic o danych testowych mediana zawiera informację z danych testowych
Czy zadziała tak tak, i to lepiej - na czym polega problem

Dlaczego to jest błąd, skoro mediana to tylko jedna liczba? Bo zbiór testowy ma udawać przyszłość. Ma odpowiadać na pytanie: „jak model poradzi sobie z pacjentem, którego nikt jeszcze nie widział?". W dniu, w którym ten pacjent przyjdzie, jego wyniki nie mogły wpłynąć na medianę policzoną wcześniej - bo jeszcze nie istniały.

Model korzystający z mediany policzonej z całości jest niemożliwy do zbudowania w praktyce. Jego wynik nie jest więc oszacowaniem czegokolwiek - to liczba opisująca sytuację, która nie może zajść.

Sprawdźmy to eksperymentalnie.

In [ ]:
def eksperyment_przeciek(n_probek, udzial_brakow, ziarno):
    # Zwraca (skutecznosc_poprawna, skutecznosc_z_przeciekiem) dla jednego losowania.
    prob = dane_czyste.sample(n=n_probek, random_state=ziarno).reset_index(drop=True)
    Xp = prob[cechy_liczbowe].copy()
    yp = prob[ETYKIETA]

    # Wprowadzamy braki w jednej kolumnie
    rng = np.random.RandomState(ziarno)
    Xp.loc[rng.rand(len(Xp)) < udzial_brakow, 'SerumInsulin'] = np.nan

    Xu, Xt, yu, yt = train_test_split(Xp, yp, test_size=0.3, stratify=yp,
                                      random_state=ziarno)

    # --- wariant POPRAWNY: statystyki wylacznie ze zbioru uczacego ---
    p = Pipeline([('u', SimpleImputer(strategy='mean')),
                  ('s', StandardScaler()),
                  ('m', LogisticRegression(max_iter=1000, random_state=42))])
    p.fit(Xu, yu)
    poprawnie = p.score(Xt, yt)

    # --- wariant Z PRZECIEKIEM: statystyki z CALEGO zbioru ---
    imp = SimpleImputer(strategy='mean').fit(Xp)         # <-- fit na calosci!
    sk = StandardScaler().fit(imp.transform(Xp))          # <-- fit na calosci!
    m = LogisticRegression(max_iter=1000, random_state=42)
    m.fit(sk.transform(imp.transform(Xu)), yu)
    z_przeciekiem = m.score(sk.transform(imp.transform(Xt)), yt)

    return poprawnie, z_przeciekiem


# Powtarzamy wielokrotnie - pojedyncze losowanie niczego by nie dowodzilo
wyniki = np.array([eksperyment_przeciek(120, 0.5, z) for z in range(40)])

print("Zbiór 120 wierszy, 50% braków w SerumInsulin, 40 powtórzeń:")
print(f"  poprawnie:      średnia {wyniki[:, 0].mean():.4f}")
print(f"  z przeciekiem:  średnia {wyniki[:, 1].mean():.4f}")
print(f"  różnica średnich: {wyniki[:, 1].mean() - wyniki[:, 0].mean():+.4f}")
print()
przewaga = (wyniki[:, 1] > wyniki[:, 0]).sum()
remis = (wyniki[:, 1] == wyniki[:, 0]).sum()
print(f"Wariant z przeciekiem wypadł lepiej w {przewaga}/40 powtórzeń, "
      f"remis w {remis}, gorzej w {40 - przewaga - remis}.")

Jak czytać ten wynik - uczciwie

Różnica najprawdopodobniej wyszła niewielka. I bardzo dobrze, bo uczy czegoś ważniejszego niż straszenie:

Przeciek przy uzupełnianiu średnią jest subtelny. Jedna liczba (średnia) zabiera ze zbioru testowego bardzo mało informacji. Nie spodziewaj się skoku skuteczności o 20 punktów - i właśnie dlatego to jest groźne. Błąd, który daje spektakularny wynik, zostaje zauważony. Błąd, który poprawia wynik o pół punktu, nie zostanie zauważony nigdy - a wyniku i tak nie da się już nazwać uczciwym oszacowaniem.

Skala efektu rośnie, gdy: danych jest mało, braków dużo, a przekształcenie uczy się więcej niż jednej liczby. Przy MinMaxScaler przeciek jest większy niż przy StandardScaler (minimum i maksimum to konkretne, pojedyncze obserwacje - być może właśnie ze zbioru testowego). Przy wyborze cech albo redukcji wymiarowości na całym zbiorze potrafi być dramatyczny.

Teraz wersja, w której przeciek widać gołym okiem.

In [ ]:
# Wariant z przeciekiem RAZACYM: uzupelniamy braki srednia policzona OSOBNO
# dla chorych i zdrowych. Brzmi madrze ("przeciez to dokladniejsze!"),
# a w rzeczywistosci wpisuje etykiete prosto do cechy.

prob = dane_czyste.sample(n=1500, random_state=0).reset_index(drop=True)
Xp = prob[cechy_liczbowe].copy()
yp = prob[ETYKIETA]

rng = np.random.RandomState(0)
braki = rng.rand(len(Xp)) < 0.6
Xp.loc[braki, 'SerumInsulin'] = np.nan

# "Sprytne" uzupelnianie z uzyciem etykiety
Xp_leak = Xp.copy()
srednie_w_klasach = Xp.groupby(yp)['SerumInsulin'].mean()
Xp_leak['SerumInsulin'] = Xp_leak['SerumInsulin'].fillna(yp.map(srednie_w_klasach))

def ocen(X_dane, opis):
    Xu, Xt, yu, yt = train_test_split(X_dane, yp, test_size=0.3, stratify=yp, random_state=42)
    p = Pipeline([('u', SimpleImputer(strategy='mean')),
                  ('s', StandardScaler()),
                  ('m', LogisticRegression(max_iter=1000, random_state=42))])
    p.fit(Xu, yu)
    print(f"{opis:<42} {p.score(Xt, yt):.1%}")

ocen(Xp, 'Poprawnie (średnia bez patrzenia na etykietę)')
ocen(Xp_leak, 'Z przeciekiem (średnia w obrębie klasy)')

Ta różnica jest już wyraźna. Powód: wpisując w brakujące komórki wartość zależną od etykiety (ang. label), przemyciliśmy odpowiedź do cechy. Model nie musi niczego zgadywać - wystarczy, że odczyta, którą z dwóch wartości wpisano.

Uzasadnienie brzmi przy tym całkiem rozsądnie: „uzupełnię brak średnią w grupie podobnych pacjentów, będzie dokładniej". I byłoby dokładniej - gdyby w momencie predykcji było wiadomo, do której grupy pacjent należy. Ale to jest dokładnie ta informacja, którą model ma przewidzieć.

Reguła do zapamiętania

Wszystko, co uczy się czegokolwiek z danych, dopasowuj wyłącznie na zbiorze uczącym. Nigdy nie używaj etykiety do przekształcania cech.

Praktycznie sprowadza się to do dwóch nawyków:

  1. Podziel dane najpierw, przekształcaj potem. Pierwsze train_test_split w notatniku ma wystąpić przed pierwszym fit czegokolwiek.
  2. Wszystkie przekształcenia trzymaj w Pipeline. Wtedy poprawna kolejność wychodzi sama - nie da się przez pomyłkę wywołać fit na danych testowych.

Do tego wątku wrócimy w ćwiczeniu 06, gdzie zobaczysz, dlaczego przy walidacji krzyżowej potok przestaje być wygodą, a staje się jedynym poprawnym rozwiązaniem: podziałów jest wtedy pięć albo dziesięć i każdy wymaga osobnego dopasowania przekształceń.


Zadania

Korzystaj z gotowych zmiennych: dane (z brakami i kolumną GrupaWiekowa), dane_czyste (bez braków), cechy_liczbowe, cechy_kategoryczne, X_ucz, X_test, y_ucz, y_test, ETYKIETA.

Zadanie 1: Która strategia uzupełniania jest lepsza?

Zbuduj cztery potoki różniące się wyłącznie strategią uzupełniania braków: 'mean', 'median', 'most_frequent' oraz 'constant' z fill_value=0.

Każdy potok: SimpleImputer(...) → StandardScaler() → LogisticRegression(max_iter=1000, random_state=42). Użyj samych cechy_liczbowe.

Wypisz skuteczność każdego na zbiorze testowym. Zastanów się: czy różnice są duże? Czy 'constant' z zerem to dobry pomysł i co właściwie oznacza dla modelu?

Krok po kroku - co masz zrobić (kliknij, żeby rozwinąć)
  1. Przygotuj listę czterech strategii; przy 'constant' zapamiętaj, że potrzebny jest dodatkowy argument fill_value=0.
  2. W pętli po strategiach zbuduj potok: uzupełnianie → skalowanie → regresja logistyczna.
  3. Naucz każdy potok na X_ucz[cechy_liczbowe].
  4. Dla każdego odczytaj skuteczność testową oraz wartość, którą imputer wstawia w miejsce braków.
  5. Zbierz wyniki w tabelę i policz różnicę między najlepszą a najgorszą strategią.

Dlaczego X_ucz[cechy_liczbowe], a nie X_ucz: X_ucz zawiera też kolumnę GrupaWiekowa, która jest tekstem. SimpleImputer i StandardScaler obsługują tylko liczby, więc trzeba wybrać same kolumny liczbowe. Kategoriami zajmiesz się w zadaniu 3.

Co robi każda strategia: 'mean' wstawia średnią kolumny, 'median' medianę, 'most_frequent' wartość najczęstszą, a 'constant' stałą, którą sam podasz.

Jak to napisać - kod z wyjaśnieniem

Krok 1: lista strategii

strategie = [
    ('mean', {}),
    ('median', {}),
    ('most_frequent', {}),
    ('constant', {'fill_value': 0}),
]

Każdy element to para: nazwa strategii i słownik dodatkowych argumentów. Trzy pierwsze nie potrzebują nic więcej, 'constant' wymaga podania wartości. Dzięki tej strukturze pętla obsłuży wszystkie cztery przypadki jednym kodem.

Krok 2-3: potok w pętli

for nazwa, dodatkowe in strategie:
    potok = Pipeline([
        ('uzupelnianie', SimpleImputer(strategy=nazwa, **dodatkowe)),
        ('skalowanie', StandardScaler()),
        ('model', LogisticRegression(max_iter=1000, random_state=42)),
    ])
    potok.fit(X_ucz[cechy_liczbowe], y_ucz)
  • Pipeline przyjmuje listę par (nazwa_kroku, obiekt). Nazwy same wybierasz i przydadzą się za chwilę do zajrzenia do środka.
  • **dodatkowe rozpakowuje słownik na argumenty nazwane. Przy pustym słowniku nie dodaje nic, przy {'fill_value': 0} dokłada fill_value=0.
  • Jedno .fit() uruchamia wszystkie trzy kroki po kolei - imputer uczy się statystyk, skaler swoich, a model trenuje na gotowych danych.

Krok 4: co wstawił imputer

    wstawiona = potok.named_steps['uzupelnianie'].statistics_[
        cechy_liczbowe.index('SerumInsulin')
    ]
  • potok.named_steps['uzupelnianie'] wyciąga z potoku krok o tej nazwie.
  • .statistics_ to tablica wartości wstawianych - po jednej na kolumnę, w kolejności kolumn wejściowych.
  • cechy_liczbowe.index('SerumInsulin') zwraca pozycję tej kolumny na liście, żeby trafić we właściwy element tablicy.

Krok 5: różnica w pacjentach

rozpietosc = tabela['skuteczność testowa'].max() - tabela['skuteczność testowa'].min()
print(f"... {rozpietosc:.4f} ({rozpietosc * len(X_test):.0f} pacjentów na {len(X_test)})")

Przeliczenie różnicy skuteczności na liczbę pacjentów jest ważniejsze, niż wygląda. Ułamek 0,006 brzmi abstrakcyjnie; „12 osób na 2000" mówi, o czym naprawdę rozmawiamy.

Gotowe rozwiązanie i czego się spodziewać
# Uwaga: X_ucz zawiera takze kolumne GrupaWiekowa (tekst), a te potoki obsluguja
# wylacznie liczby - dlatego wszedzie wybieramy X_ucz[cechy_liczbowe].

strategie = [
    ('mean', {}),
    ('median', {}),
    ('most_frequent', {}),
    ('constant', {'fill_value': 0}),
]

wyniki = []
for nazwa, dodatkowe in strategie:
    potok = Pipeline([
        ('uzupelnianie', SimpleImputer(strategy=nazwa, **dodatkowe)),
        ('skalowanie', StandardScaler()),
        ('model', LogisticRegression(max_iter=1000, random_state=42)),
    ])
    potok.fit(X_ucz[cechy_liczbowe], y_ucz)

    wstawiona = potok.named_steps['uzupelnianie'].statistics_[
        cechy_liczbowe.index('SerumInsulin')
    ]
    wyniki.append({
        'strategia': nazwa,
        'skuteczność testowa': potok.score(X_test[cechy_liczbowe], y_test),
        'wstawiana wartość (SerumInsulin)': wstawiona,
    })

tabela = pd.DataFrame(wyniki)
print(tabela.to_string(index=False, float_format=lambda v: f"{v:.4f}"))
print()
rozpietosc = tabela['skuteczność testowa'].max() - tabela['skuteczność testowa'].min()
print(f"Różnica między najlepszą a najgorszą strategią: {rozpietosc:.4f} "
      f"({rozpietosc * len(X_test):.0f} pacjentów na {len(X_test)})")

Czego się spodziewać:

    strategia  skuteczność testowa  wstawiana wartość (SerumInsulin)
         mean               0.7835                          140.3152
       median               0.7835                           86.0000
most_frequent               0.7795                           16.0000
     constant               0.7775                            0.0000

Różnica między najlepszą a najgorszą strategią: 0.0060 (12 pacjentów na 2000)

Jak to czytać:

Najpierw druga kolumna: cztery strategie wstawiają wartości od 0 do 140 - różnica rzędu stu czterdziestu jednostek insuliny. To ogromny rozrzut, jeśli chodzi o same wstawiane liczby.

A teraz pierwsza kolumna: skuteczność mieści się w przedziale od 0,7775 do 0,7835. Różnica to 12 pacjentów na 2000.

Co z tego wynika praktycznie: dobór strategii uzupełniania rzadko bywa tym, co decyduje o jakości modelu. Studenci potrafią spędzić nad tym wyborem godzinę, podczas gdy zysk jest w granicach szumu. Warto wybrać medianę (odporna na wartości skrajne) i zająć się rzeczami, które naprawdę mają wpływ.

Odpowiedź na pytanie o 'constant' z zerem: to najgorszy wynik z czwórki i nie jest to przypadek. Wstawiając zero, mówisz modelowi: „ten pacjent miał zerowy poziom insuliny" - a to fizjologicznie niemożliwe. Zamiast przyznać się do braku informacji, wpisujesz informację fałszywą. W ćwiczeniu 02 widziałeś dokładnie ten mechanizm od drugiej strony: tam zera udawały dane i trzeba je było wykryć. Tutaj sam byś je tam wstawił.

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 2: StandardScaler kontra MinMaxScaler

Porównaj oba skalery w tym samym potoku (uzupełnianie medianą + skaler + regresja logistyczna).

  1. Wypisz skuteczność testową obu wariantów.
  2. Sprawdź, jakich statystyk nauczył się każdy skaler: skaler.mean_ i skaler.scale_ dla StandardScaler, skaler.data_min_ i skaler.data_max_ dla MinMaxScaler. Dostaniesz się do nich przez potok.named_steps['nazwa_kroku'].
  3. Dołóż trzeci wariant bez żadnego skalowania. Czy regresja logistyczna w ogóle się zbiegnie? Zwróć uwagę na ewentualne ostrzeżenia.
Krok po kroku - co masz zrobić (kliknij, żeby rozwinąć)
  1. Napisz funkcję budującą potok, która przyjmuje skaler (albo None, gdy skalowania ma nie być).
  2. Zbuduj trzy warianty: StandardScaler, MinMaxScaler i bez skalowania.
  3. Naucz każdy, przechwytując ostrzeżenia o zbieżności.
  4. Wypisz skuteczność, liczbę iteracji i liczbę ostrzeżeń dla każdego wariantu.
  5. Wyciągnij z obu skalerów statystyki, których się nauczyły, i zestaw je w tabeli.

Czym różnią się oba skalery: StandardScaler odejmuje średnią i dzieli przez odchylenie standardowe - po przekształceniu cecha ma średnią 0 i odchylenie 1. MinMaxScaler ściska wartości do przedziału od 0 do 1, biorąc pod uwagę wyłącznie wartość najmniejszą i największą.

Dlaczego to ma znaczenie dla MinMaxScaler: skoro patrzy tylko na dwie skrajne wartości, jedna wartość odstająca ustawia całą skalę. StandardScaler jest pod tym względem odporniejszy.

Jak to napisać - kod z wyjaśnieniem

Krok 1: funkcja budująca potok

def zbuduj(skaler):
    kroki = [('uzupelnianie', SimpleImputer(strategy='median'))]
    if skaler is not None:
        kroki.append(('skalowanie', skaler))
    kroki.append(('model', LogisticRegression(max_iter=1000, random_state=42)))
    return Pipeline(kroki)

Budujesz listę kroków stopniowo i dokładasz skalowanie tylko wtedy, gdy jest potrzebne. Dzięki temu jedna funkcja obsługuje wszystkie trzy warianty, łącznie z tym bez skalera.

Krok 3: przechwytywanie ostrzeżeń

with warnings.catch_warnings(record=True) as zlapane:
    warnings.simplefilter('always')
    potok.fit(X_ucz[cechy_liczbowe], y_ucz)
    ostrzezenia = [w for w in zlapane if issubclass(w.category, ConvergenceWarning)]
  • catch_warnings(record=True) zbiera ostrzeżenia do listy zamiast wypisywać je na ekran.
  • simplefilter('always') wyłącza domyślne zachowanie Pythona, który to samo ostrzeżenie pokazuje tylko raz.
  • Filtrowanie po ConvergenceWarning wyłuskuje wyłącznie te dotyczące zbieżności.

Bez tego zabiegu ostrzeżenia z trzech wariantów zlałyby się w jedną kupkę i nie wiedziałbyś, który wariant je wywołał.

Krok 4: liczba iteracji

liczba_iteracji = potok.named_steps['model'].n_iter_[0]

n_iter_ mówi, ile kroków algorytm optymalizacji potrzebował, żeby się zatrzymać. To najciekawsza liczba w tym zadaniu - patrz niżej.

Krok 5: czego nauczyły się skalery

std = warianty['StandardScaler'].named_steps['skalowanie']
mm = warianty['MinMaxScaler'].named_steps['skalowanie']

statystyki = pd.DataFrame({
    'Standard: mean_': std.mean_,
    'Standard: scale_': std.scale_,
    'MinMax: data_min_': mm.data_min_,
    'MinMax: data_max_': mm.data_max_,
}, index=cechy_liczbowe)

Podkreślnik na końcu nazwy (mean_, scale_, data_min_) to konwencja scikit-learn: tak oznacza się to, czego obiekt nauczył się z danych. Atrybuty bez podkreślnika to ustawienia, które sam podałeś.

Gotowe rozwiązanie i czego się spodziewać
import warnings
from sklearn.exceptions import ConvergenceWarning

def zbuduj(skaler):
    kroki = [('uzupelnianie', SimpleImputer(strategy='median'))]
    if skaler is not None:
        kroki.append(('skalowanie', skaler))
    kroki.append(('model', LogisticRegression(max_iter=1000, random_state=42)))
    return Pipeline(kroki)

warianty = {
    'StandardScaler': zbuduj(StandardScaler()),
    'MinMaxScaler': zbuduj(MinMaxScaler()),
    'bez skalowania': zbuduj(None),
}

for nazwa, potok in warianty.items():
    # Przechwytujemy ostrzezenia, zeby bylo widac, KTORY wariant ich nie lubi
    with warnings.catch_warnings(record=True) as zlapane:
        warnings.simplefilter('always')
        potok.fit(X_ucz[cechy_liczbowe], y_ucz)
        ostrzezenia = [w for w in zlapane if issubclass(w.category, ConvergenceWarning)]

    wynik = potok.score(X_test[cechy_liczbowe], y_test)
    liczba_iteracji = potok.named_steps['model'].n_iter_[0]
    print(f"{nazwa:<16} skuteczność {wynik:.4f} | iteracji: {liczba_iteracji:>4} "
          f"| ostrzeżeń o zbieżności: {len(ostrzezenia)}")

# Czego nauczyl sie kazdy ze skalerow
std = warianty['StandardScaler'].named_steps['skalowanie']
mm = warianty['MinMaxScaler'].named_steps['skalowanie']

statystyki = pd.DataFrame({
    'Standard: mean_': std.mean_,
    'Standard: scale_': std.scale_,
    'MinMax: data_min_': mm.data_min_,
    'MinMax: data_max_': mm.data_max_,
}, index=cechy_liczbowe)

print(statystyki.round(3).to_string())
print()
print("Wszystkie te liczby zostały policzone WYŁĄCZNIE ze zbioru uczącego -")
print("potok nie miał okazji zobaczyć X_test podczas fit.")

Czego się spodziewać:

StandardScaler   skuteczność 0.7835 | iteracji:    6 | ostrzeżeń o zbieżności: 0
MinMaxScaler     skuteczność 0.7855 | iteracji:   19 | ostrzeżeń o zbieżności: 0
bez skalowania   skuteczność 0.7835 | iteracji:  134 | ostrzeżeń o zbieżności: 0

Jak to czytać - odpowiedź na pytanie 3 jest inna, niż sugeruje treść zadania:

Regresja logistyczna zbiegła się we wszystkich trzech wariantach i nie wypisała ani jednego ostrzeżenia. Skuteczność bez skalowania (0,7835) jest identyczna jak ze StandardScaler. Gdyby patrzeć tylko na pierwszą kolumnę, można by uznać, że skalowanie jest zbędne.

Prawdziwa różnica siedzi w kolumnie iteracji:

Wariant Iteracji
StandardScaler 6
MinMaxScaler 19
bez skalowania 134

Bez skalowania algorytm potrzebuje ponad dwudziestu razy więcej kroków, żeby dojść do tego samego miejsca. Przy max_iter=1000 starczyło zapasu. Przy domyślnym max_iter=100 nie starczyłoby - i wtedy zobaczyłbyś ostrzeżenie oraz gorszy wynik.

Wniosek: skalowanie przy regresji logistycznej nie tyle poprawia wynik, co sprawia, że model w ogóle daje się wytrenować w rozsądnym czasie. Przy większych zbiorach i trudniejszych danych ta różnica przestaje być akademicka.

Druga rzecz do zauważenia - w tabeli statystyk SerumInsulin ma data_max_ równe 796, przy średniej 121. Jedna skrajna wartość ustawia całą skalę MinMaxScaler: po przekształceniu typowy pacjent wyląduje w okolicach 0,13, a cały dolny zakres zostanie ściśnięty. To jest praktyczny powód, dla którego StandardScaler jest domyślnym wyborem.

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 3: Kodowanie kategorii - dobrze i źle

Porównaj dwa sposoby potraktowania kolumny GrupaWiekowa:

  • A - OneHotEncoder (poprawnie),
  • B - zamiana kategorii na kolejne liczby: młody→0, średni→1, starszy→2, senior→3 (podejście, którego unikamy).

Wskazówki: do wariantu B przyda się dane['GrupaWiekowa'].cat.codes albo map({...}). Zbuduj w obu przypadkach pełny potok i porównaj skuteczność.

Wynik prawdopodobnie Cię zaskoczy. Zanim zobaczysz liczby, odpowiedz: kiedy wariant B jest naprawdę groźny, a kiedy przypadkiem uchodzi na sucho? Podpowiedź: czy kategorie w tym zadaniu mają naturalny porządek?

Krok po kroku - co masz zrobić (kliknij, żeby rozwinąć)
  1. Zbuduj gałąź liczbową: uzupełnianie medianą + StandardScaler.
  2. Wariant A: ColumnTransformer z dwiema gałęziami - liczbową i kategoryczną z OneHotEncoder.
  3. Wariant B: zamień GrupaWiekowa na kolejne liczby przez .cat.codes i potraktuj wszystko jedną gałęzią liczbową.
  4. Dla wariantu B potrzebny jest osobny podział danych, bo X ma inny zestaw kolumn.
  5. Wypisz nazwy kolumn, które widzi model w wariancie A, oraz skuteczność obu wariantów.

Na czym polega problem: OneHotEncoder zamienia jedną kolumnę z czterema kategoriami na cztery kolumny zero-jedynkowe - po jednej na kategorię. Wariant B wpisuje zamiast tego liczby 0, 1, 2, 3, przez co model zakłada, że odległość między „młody" a „senior" wynosi 3, a między „młody" a „średni" - 1.

Jak to napisać - kod z wyjaśnieniem

Krok 2: ColumnTransformer

przygotowanie_A = ColumnTransformer([
    ('liczbowe', galaz_liczbowa, cechy_liczbowe),
    ('kategoryczne', Pipeline([
        ('uzupelnianie', SimpleImputer(strategy='most_frequent')),
        ('kodowanie', OneHotEncoder(handle_unknown='ignore')),
    ]), cechy_kategoryczne),
])
  • ColumnTransformer przyjmuje trójki: (nazwa, co_zrobić, na_których_kolumnach). To jego cała idea - różne kolumny, różne traktowanie.
  • Dla kategorii uzupełniasz 'most_frequent', bo średniej z tekstu policzyć się nie da.
  • handle_unknown='ignore' zabezpiecza przed kategorią, która pojawi się dopiero w danych testowych. Bez tego OneHotEncoder rzuciłby wyjątkiem w najmniej odpowiednim momencie - już po wdrożeniu.

Krok 3: wariant B

X_B = dane[cechy_liczbowe].copy()
X_B['GrupaWiekowa_kod'] = dane['GrupaWiekowa'].cat.codes

.cat.codes działa na kolumnach typu kategorycznego i zwraca numer każdej kategorii zgodnie z kolejnością ustaloną przy jej tworzeniu. Tutaj kolejność pochodzi z pd.cut, więc kody to młody=0, średni=1, starszy=2, senior=3.

.copy() jest istotne: bez niego dopisywałbyś kolumnę do wycinka oryginalnej ramki, co kończy się ostrzeżeniem SettingWithCopyWarning.

Krok 4: osobny podział

X_B_ucz, X_B_test, y_B_ucz, y_B_test = train_test_split(
    X_B, y, test_size=0.2, stratify=y, random_state=42
)

X_B ma inny zestaw kolumn niż X, więc potrzebuje własnego podziału. Te same stratify i random_state gwarantują, że do zbioru testowego trafią ci sami pacjenci - inaczej porównywałbyś dwie rzeczy naraz.

Krok 5: nazwy kolumn

print(potok_A.named_steps['przygotowanie'].get_feature_names_out().tolist())

get_feature_names_out() pokazuje, co naprawdę dostaje model po wszystkich przekształceniach. Warto to wywołać za każdym razem, gdy budujesz ColumnTransformer - to jedyny sposób, żeby zobaczyć efekt zamiast go sobie wyobrażać.

Gotowe rozwiązanie i czego się spodziewać
galaz_liczbowa = Pipeline([
    ('uzupelnianie', SimpleImputer(strategy='median')),
    ('skalowanie', StandardScaler()),
])

# --- WARIANT A: kodowanie zero-jedynkowe (poprawnie) ---
przygotowanie_A = ColumnTransformer([
    ('liczbowe', galaz_liczbowa, cechy_liczbowe),
    ('kategoryczne', Pipeline([
        ('uzupelnianie', SimpleImputer(strategy='most_frequent')),
        ('kodowanie', OneHotEncoder(handle_unknown='ignore')),
    ]), cechy_kategoryczne),
])

potok_A = Pipeline([('przygotowanie', przygotowanie_A),
                    ('model', LogisticRegression(max_iter=1000, random_state=42))])
potok_A.fit(X_ucz, y_ucz)

# --- WARIANT B: kategorie zamienione na kolejne liczby (podejscie, ktorego unikamy) ---
# .cat.codes nadaje kody zgodnie z kolejnoscia kategorii ustalona w pd.cut,
# czyli mlody=0, sredni=1, starszy=2, senior=3. To mapowanie jest STALE -
# nie jest niczego uczone z danych, wiec nie tworzy przecieku.
X_B = dane[cechy_liczbowe].copy()
X_B['GrupaWiekowa_kod'] = dane['GrupaWiekowa'].cat.codes

X_B_ucz, X_B_test, y_B_ucz, y_B_test = train_test_split(
    X_B, y, test_size=0.2, stratify=y, random_state=42
)

potok_B = Pipeline([
    ('uzupelnianie', SimpleImputer(strategy='median')),
    ('skalowanie', StandardScaler()),
    ('model', LogisticRegression(max_iter=1000, random_state=42)),
])
potok_B.fit(X_B_ucz, y_B_ucz)

print("Kolumny, które widzi model w wariancie A:")
print(" ", potok_A.named_steps['przygotowanie'].get_feature_names_out().tolist())
print()
print(f"A - OneHotEncoder (poprawnie):        {potok_A.score(X_test, y_test):.4f}")
print(f"B - kategorie jako 0/1/2/3 (unikamy): {potok_B.score(X_B_test, y_B_test):.4f}")
print()
print("Mapowanie użyte w wariancie B:")
print(dict(enumerate(dane['GrupaWiekowa'].cat.categories)))

Czego się spodziewać:

A - OneHotEncoder (poprawnie):        0.7920
B - kategorie jako 0/1/2/3 (unikamy): 0.7860

Mapowanie użyte w wariancie B:
{0: 'młody', 1: 'średni', 2: 'starszy', 3: 'senior'}

Kolumn po kodowaniu jest dwanaście: osiem liczbowych plus cztery zero-jedynkowe, po jednej na grupę wiekową.

Jak to czytać - i dlaczego wynik zaskakuje:

Wariant B wypadł gorzej, ale tylko o 0,6 punktu procentowego. Skoro robimy coś „źle", czemu kara jest tak mała?

Odpowiedź tkwi w pytaniu zadanym w treści: czy te kategorie mają naturalny porządek? Mają. Grupy wiekowe układają się w ciąg: młody < średni < starszy < senior. Kodując je jako 0, 1, 2, 3, przypadkiem powiedziałeś modelowi prawdę - że senior jest „dalej" od młodego niż średni.

Kiedy ten sam zabieg jest naprawdę groźny: gdy kategorie porządku nie mają. Zakoduj województwo jako 0-15, a model uzna, że dolnośląskie (0) i kujawsko-pomorskie (1) są sobie bliskie, a dolnośląskie i zachodniopomorskie (15) - odległe. To czysty wymysł, którego w danych nie ma.

Praktyczna zasada: OneHotEncoder stosuj domyślnie. Kodowanie numeryczne wolno zastosować tylko wtedy, gdy porządek istnieje naprawdę i gdy odstępy między kolejnymi kategoriami są sensownie porównywalne. W tym zadaniu uszło na sucho - ale uszło, a nie było poprawne.

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 4: Własny ColumnTransformer

Zbuduj potok, w którym różne kolumny liczbowe traktowane są inaczej:

  • SerumInsulin i TricepsThickness (dużo braków, rozkłady skośne): uzupełnianie medianą + StandardScaler,
  • pozostałe cechy liczbowe: uzupełnianie średnią + MinMaxScaler,
  • GrupaWiekowa: uzupełnianie most_frequent + OneHotEncoder(handle_unknown='ignore').

Trzy gałęzie w jednym ColumnTransformer. Wypisz skuteczność oraz - przez get_feature_names_out() - nazwy kolumn trafiających do modelu.

Krok po kroku - co masz zrobić (kliknij, żeby rozwinąć)
  1. Podziel cechy liczbowe na dwie grupy: skośne (SerumInsulin, TricepsThickness) i pozostałe.
  2. Zbuduj trzy gałęzie: skośną (mediana + StandardScaler), pozostałą (średnia + MinMaxScaler) i kategoryczną (most_frequent + OneHotEncoder).
  3. Złóż je w jeden ColumnTransformer.
  4. Dołóż model i naucz cały potok.
  5. Wypisz liczbę kolumn przed i po przygotowaniu, ich nazwy oraz skuteczność - i porównaj ją z wynikiem z zadania 3.

Dlaczego skośne osobno: SerumInsulin i TricepsThickness mają najwięcej braków i rozkłady z długim ogonem w prawo. Przy takim rozkładzie mediana jest uczciwszą „wartością typową" niż średnia, którą ogon zawyża.

Jak to napisać - kod z wyjaśnieniem

Krok 1: podział cech na grupy

skosne = ['SerumInsulin', 'TricepsThickness']
pozostale = [k for k in cechy_liczbowe if k not in skosne]

Drugą listę wyliczasz, zamiast wypisywać ręcznie. Gdyby lista cech się zmieniła, kod nadal będzie poprawny - a wypisana ręcznie lista po cichu przestałaby się zgadzać.

Krok 2-3: trzy gałęzie w jednym transformatorze

przygotowanie = ColumnTransformer([
    ('skosne', galaz_skosna, skosne),
    ('pozostale', galaz_pozostala, pozostale),
    ('kategoryczne', galaz_kategoryczna, cechy_kategoryczne),
])

Każda gałąź to osobny Pipeline, a ColumnTransformer kieruje do niej wskazane kolumny. Wyniki wszystkich gałęzi zostają potem sklejone obok siebie w jedną tablicę - w kolejności, w jakiej wymieniłeś gałęzie.

Kolumny niewymienione w żadnej gałęzi są odrzucane. To domyślne zachowanie (remainder='drop') i warto o nim pamiętać: łatwo zgubić kolumnę, o której się zapomniało.

Krok 5: kontrola, co weszło i co wyszło

nazwy = potok_trzy_galezie.named_steps['przygotowanie'].get_feature_names_out()

print(f"Kolumn na wejściu:       {X_ucz.shape[1]}")
print(f"Kolumn po przygotowaniu: {len(nazwy)}")

Ta para liczb to najtańsza możliwa kontrola poprawności. Wchodzi 9 kolumn, wychodzi 12 - bo jedna kolumna kategoryczna zamieniła się w cztery. Gdyby wyszło mniej, znaczyłoby to, że któraś kolumna wypadła po drodze.

Prefiksy w nazwach (skosne__, pozostale__, kategoryczne__) pochodzą od nazw gałęzi i pozwalają sprawdzić, że każda kolumna trafiła tam, gdzie miała.

Gotowe rozwiązanie i czego się spodziewać
skosne = ['SerumInsulin', 'TricepsThickness']
pozostale = [k for k in cechy_liczbowe if k not in skosne]

galaz_skosna = Pipeline([
    ('uzupelnianie', SimpleImputer(strategy='median')),
    ('skalowanie', StandardScaler()),
])

galaz_pozostala = Pipeline([
    ('uzupelnianie', SimpleImputer(strategy='mean')),
    ('skalowanie', MinMaxScaler()),
])

galaz_kategoryczna = Pipeline([
    ('uzupelnianie', SimpleImputer(strategy='most_frequent')),
    ('kodowanie', OneHotEncoder(handle_unknown='ignore')),
])

przygotowanie = ColumnTransformer([
    ('skosne', galaz_skosna, skosne),
    ('pozostale', galaz_pozostala, pozostale),
    ('kategoryczne', galaz_kategoryczna, cechy_kategoryczne),
])

potok_trzy_galezie = Pipeline([
    ('przygotowanie', przygotowanie),
    ('model', LogisticRegression(max_iter=1000, random_state=42)),
])

potok_trzy_galezie.fit(X_ucz, y_ucz)

nazwy = potok_trzy_galezie.named_steps['przygotowanie'].get_feature_names_out()

print(f"Kolumn na wejściu:       {X_ucz.shape[1]}")
print(f"Kolumn po przygotowaniu: {len(nazwy)}")
print()
print("Kolumny trafiające do modelu (w kolejności gałęzi):")
for n in nazwy:
    print("  -", n)
print()
print(f"Skuteczność testowa (trzy gałęzie): {potok_trzy_galezie.score(X_test, y_test):.4f}")
print(f"Dla porównania - jedna gałąź liczbowa (zadanie 3, wariant A): "
      f"{potok_A.score(X_test, y_test):.4f}")

Czego się spodziewać:

Kolumn na wejściu:       9
Kolumn po przygotowaniu: 12

Skuteczność testowa (trzy gałęzie): 0.7925
Dla porównania - jedna gałąź liczbowa (zadanie 3, wariant A): 0.7920

Jak to czytać:

Trzy starannie dobrane gałęzie dały 0,0005 przewagi - czyli jednego pacjenta na dwa tysiące. Praktycznie nic.

To nie jest porażka zadania, tylko jego pointa. Warto z niej wyciągnąć dwie rzeczy:

1. Umiesz już zbudować dowolnie złożone przygotowanie danych. ColumnTransformer z trzema gałęziami, z których każda jest osobnym potokiem, to konstrukcja, która w prawdziwym projekcie obsłuży dane mieszane: liczby, kategorie, daty, tekst.

2. Złożoność nie jest sama w sobie zaletą. Ten potok jest trudniejszy do napisania, trudniejszy do przeczytania i trudniejszy do naprawienia, gdy coś przestanie działać - a daje tyle samo. Zanim dołożysz kolejną gałąź, warto sprawdzić prostszą wersję i mieć liczbę do porównania.

Są dane, przy których taki podział daje realną przewagę - zwykle wtedy, gdy kolumny różnią się naprawdę zasadniczo (ceny obok ocen w skali 1-5 obok znaczników zero-jedynkowych). Tutaj wszystkie cechy to pomiary medyczne o zbliżonym charakterze, więc jednolite traktowanie im wystarcza.

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 5: Znajdź przeciek w cudzym kodzie

Poniższy kod działa bez błędu i daje przyzwoity wynik. Zawiera dwa osobne przecieki danych.

X_zle = dane[cechy_liczbowe]
y_zle = dane[ETYKIETA]

imputer = SimpleImputer(strategy='median')
X_zle = pd.DataFrame(imputer.fit_transform(X_zle), columns=cechy_liczbowe)

skaler = StandardScaler()
X_zle = pd.DataFrame(skaler.fit_transform(X_zle), columns=cechy_liczbowe)

X_u, X_t, y_u, y_t = train_test_split(X_zle, y_zle, test_size=0.2,
                                      stratify=y_zle, random_state=42)

model = LogisticRegression(max_iter=1000, random_state=42)
model.fit(X_u, y_u)
print(model.score(X_t, y_t))
  1. Wskaż oba przecieki - podaj numery linii i wyjaśnij, jaka informacja przecieka w każdym z nich.
  2. Przepisz kod poprawnie, używając Pipeline.
  3. Uruchom obie wersje i porównaj wyniki. Czy różnica jest na tyle duża, że ktoś by ją zauważył podczas przeglądu kodu?
Krok po kroku - co masz zrobić (kliknij, żeby rozwinąć)
  1. Przepisz kod z zadania bez zmian - tak, żeby dało się go uruchomić i zmierzyć.
  2. Napisz wersję poprawną: najpierw podział, potem Pipeline z tymi samymi krokami.
  3. Uruchom obie i wypisz wyniki oraz różnicę.
  4. Wyciągnij z obu imputerów wartość, której się nauczyły dla SerumInsulin, i porównaj je.

Gdzie są te dwa przecieki: imputer.fit_transform(X_zle) i skaler.fit_transform(X_zle) wykonują się przed train_test_split. Każde fit uczy się czegoś z danych - imputer uczy się mediany, skaler średniej i odchylenia. Skoro liczą je z całego zbioru, to do przekształcenia danych uczących trafia informacja pochodząca z wierszy, które później znajdą się w zbiorze testowym.

Dlaczego to nazywamy przeciekiem, a nie błędem obliczeniowym: kod wykonuje się poprawnie i daje sensowny wynik. Problem polega na tym, że ten wynik jest zawyżony w sposób niemożliwy do odtworzenia na prawdziwie nowych danych - bo tam nikt nie da Ci wcześniej zajrzeć do przyszłych pomiarów.

Jak to napisać - kod z wyjaśnieniem

Krok 2: wersja poprawna

X_ok_u, X_ok_t, y_ok_u, y_ok_t = train_test_split(X_ok, y_ok, test_size=0.2,
                                                  stratify=y_ok, random_state=42)

potok_ok = Pipeline([
    ('uzupelnianie', SimpleImputer(strategy='median')),
    ('skalowanie', StandardScaler()),
    ('model', LogisticRegression(max_iter=1000, random_state=42)),
])
potok_ok.fit(X_ok_u, y_ok_u)

Zwróć uwagę na kolejność: podział, potem potok. Pipeline sam zadba o resztę - podczas .fit() uczy imputer i skaler wyłącznie na danych uczących, a podczas .score() stosuje te same wyuczone wartości do danych testowych, bez ponownego uczenia.

Na tym polega cała wartość potoku. Nie chodzi o skrócenie zapisu, tylko o to, że nie da się w nim przypadkiem nauczyć czegoś na danych testowych.

Krok 4: porównanie statystyk

i_zly = imputer.statistics_[cechy_liczbowe.index('SerumInsulin')]
i_ok = potok_ok.named_steps['uzupelnianie'].statistics_[cechy_liczbowe.index('SerumInsulin')]

To jest właściwy dowód w tym zadaniu. Porównujesz nie wyniki modeli, tylko liczby, których nauczyły się imputery - jeden z całego zbioru, drugi tylko z części uczącej.

Gotowe rozwiązanie i czego się spodziewać
# --- WERSJA Z PRZECIEKIEM (kod z zadania, przepisany bez zmian) ---
X_zle = dane[cechy_liczbowe]
y_zle = dane[ETYKIETA]

imputer = SimpleImputer(strategy='median')
X_zle = pd.DataFrame(imputer.fit_transform(X_zle), columns=cechy_liczbowe)

skaler = StandardScaler()
X_zle = pd.DataFrame(skaler.fit_transform(X_zle), columns=cechy_liczbowe)

X_u, X_t, y_u, y_t = train_test_split(X_zle, y_zle, test_size=0.2,
                                      stratify=y_zle, random_state=42)

model_zly = LogisticRegression(max_iter=1000, random_state=42)
model_zly.fit(X_u, y_u)
wynik_zly = model_zly.score(X_t, y_t)

# --- WERSJA POPRAWNA: najpierw podzial, potem Pipeline ---
X_ok = dane[cechy_liczbowe]
y_ok = dane[ETYKIETA]

X_ok_u, X_ok_t, y_ok_u, y_ok_t = train_test_split(X_ok, y_ok, test_size=0.2,
                                                  stratify=y_ok, random_state=42)

potok_ok = Pipeline([
    ('uzupelnianie', SimpleImputer(strategy='median')),
    ('skalowanie', StandardScaler()),
    ('model', LogisticRegression(max_iter=1000, random_state=42)),
])
potok_ok.fit(X_ok_u, y_ok_u)
wynik_ok = potok_ok.score(X_ok_t, y_ok_t)

print(f"Z przeciekiem: {wynik_zly:.4f}")
print(f"Poprawnie:     {wynik_ok:.4f}")
print(f"Różnica:       {wynik_zly - wynik_ok:+.4f}")
print()
print("Statystyki wyuczone przez oba imputery (SerumInsulin):")
i_zly = imputer.statistics_[cechy_liczbowe.index('SerumInsulin')]
i_ok = potok_ok.named_steps['uzupelnianie'].statistics_[cechy_liczbowe.index('SerumInsulin')]
print(f"  z całego zbioru (przeciek):  {i_zly:.4f}")
print(f"  tylko ze zbioru uczącego:    {i_ok:.4f}")
print(f"  różnica:                     {abs(i_zly - i_ok):.4f}")

Czego się spodziewać:

Z przeciekiem: 0.7835
Poprawnie:     0.7835
Różnica:       +0.0000

Statystyki wyuczone przez oba imputery (SerumInsulin):
  z całego zbioru (przeciek):  86.0000
  tylko ze zbioru uczącego:    86.0000
  różnica:                     0.0000

Jak to czytać - i uwaga, to jest najtrudniejszy wniosek w tym notatniku:

Przeciek nie zmienił absolutnie nic. Ani jednej cyfry. Mediana policzona z 10 000 wierszy i mediana policzona z 8000 wierszy wyszły identyczne - obie równe 86.

Odpowiedź na pytanie z treści („czy różnica jest na tyle duża, żeby ją zauważyć?") brzmi więc: nie, i to jest dokładnie powód, dla którego przecieki są groźne.

Trzy rzeczy, które trzeba stąd wynieść:

1. To, że nie widać skutku, nie znaczy, że kod jest poprawny. Kod jest błędny metodologicznie niezależnie od tego, jaką liczbę wypisał. Przy 10 000 wierszy i medianie zabrakło po prostu miejsca, żeby błąd się ujawnił.

2. Nie da się wykryć przecieku przez porównanie wyników. Gdybyś nie miał wersji poprawnej obok, nie miałbyś żadnego sygnału. Przecieki wykrywa się czytając kod, a konkretnie: szukając każdego fit wykonanego przed podziałem danych.

3. Ta sama pomyłka przy innych danych kosztuje naprawdę. Zmień medianę na średnią, zmniejsz zbiór do dwustu wierszy albo dołóż selekcję cech przed podziałem, a różnica zrobi się wyraźna. Zadanie 7 pokazuje dokładnie, od czego to zależy.

Zasada praktyczna: każde fit, fit_transform albo fit_predict wykonane przed train_test_split to przeciek - niezależnie od tego, czy widać go w wynikach.

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 6: Ile kosztuje uzupełnianie? Porównanie z prawdą

Mamy rzadki komfort: znamy prawdziwe wartości, które udajemy, że zgubiliśmy (są w dane_czyste).

Dla kolumny SerumInsulin:

  1. wybierz wiersze, w których dane['SerumInsulin'] jest brakiem,
  2. odczytaj prawdziwe wartości tych wierszy z dane_czyste,
  3. porównaj je z wartościami wstawionymi przez SimpleImputer przy strategiach 'mean' i 'median',
  4. policz średni błąd bezwzględny (np.abs(prawda - wstawione).mean()) dla obu strategii,
  5. dla porównania policz odchylenie standardowe prawdziwych wartości.

Pytanie końcowe: czy błąd uzupełniania jest mały czy duży w stosunku do naturalnej zmienności tej cechy? Co to mówi o wartości informacyjnej uzupełnionych komórek?

Krok po kroku - co masz zrobić (kliknij, żeby rozwinąć)
  1. Znajdź wiersze, w których SerumInsulin jest brakiem.
  2. Odczytaj prawdziwe wartości tych wierszy z dane_czyste.
  3. Dla strategii 'mean' i 'median' sprawdź, jaką wartość wstawia imputer.
  4. Policz błąd bezwzględny każdej wstawionej wartości względem prawdy: średni, medianę i największy.
  5. Policz odchylenie standardowe prawdziwych wartości i wyraź w nim średni błąd.
  6. Narysuj histogram prawdy z zaznaczonymi wartościami wstawianymi przez obie strategie.

Uwaga metodologiczna: tutaj dopasowujesz imputer na całej kolumnie, co w zwykłym potoku byłoby przeciekiem. Tu jest w porządku, bo nie budujesz modelu - prowadzisz analizę diagnostyczną jakości samego uzupełniania. Gdyby wynik miał trafić do modelu, obowiązywałby fit wyłącznie na zbiorze uczącym.

Dlaczego to rzadka okazja: normalnie nie znasz wartości, których brakuje - gdybyś je znał, nie byłyby brakami. Tutaj zostały usunięte sztucznie, więc możesz sprawdzić, jak daleko od prawdy jest uzupełnienie.

Jak to napisać - kod z wyjaśnieniem

Krok 1-2: braki i prawda

maska_brakow = dane['SerumInsulin'].isna()
prawda = dane_czyste.loc[maska_brakow, 'SerumInsulin'].to_numpy()
  • .isna() daje kolumnę True/False - True tam, gdzie jest brak.
  • Ta sama maska zastosowana do dane_czyste wybiera te same wiersze, ale z wersji sprzed uszkodzenia. Obie ramki mają identyczny indeks, więc wiersze odpowiadają sobie co do jednego.
  • .to_numpy() zamienia wynik na tablicę, żeby dalsze odejmowanie odbywało się po pozycjach, bez dopasowywania po indeksie.

Krok 3-4: błędy

for strategia in ['mean', 'median']:
    imp = SimpleImputer(strategy=strategia).fit(dane[['SerumInsulin']])
    wstawiona = imp.statistics_[0]
    blad = np.abs(prawda - wstawiona)
  • dane[['SerumInsulin']] z podwójnym nawiasem daje ramkę z jedną kolumną, a nie pojedynczą kolumnę. SimpleImputer oczekuje dwuwymiarowego wejścia.
  • prawda - wstawiona odejmuje jedną liczbę od całej tablicy naraz. np.abs bierze wartość bezwzględną, bo interesuje nas wielkość pomyłki, nie jej kierunek.
  • Poza średnią warto policzyć medianę błędu (odporna na skrajne przypadki) i największy błąd (pokazuje najgorszy scenariusz).

Krok 5: błąd w jednostkach naturalnej zmienności

odchylenie = prawda.std()
print(f"średni błąd to {w['średni błąd bezwzględny'] / odchylenie:.2f} odchylenia standardowego cechy")

To najważniejsze przeliczenie w zadaniu. Sam błąd „96 jednostek" nic nie mówi, dopóki nie wiesz, jak bardzo ta cecha się waha. Podzielenie przez odchylenie standardowe daje liczbę, którą da się zinterpretować.

Gotowe rozwiązanie i czego się spodziewać
# Uwaga metodologiczna: tutaj NIE budujemy modelu, tylko oceniamy jakosc samego
# uzupelniania. Dlatego wolno dopasowac imputer na calej kolumnie - to analiza
# diagnostyczna, a nie czesc procedury uczenia. Gdyby wynik mial trafic do modelu,
# obowiazywalby fit wylacznie na zbiorze uczacym.

maska_brakow = dane['SerumInsulin'].isna()
prawda = dane_czyste.loc[maska_brakow, 'SerumInsulin'].to_numpy()

print(f"Braków w SerumInsulin: {maska_brakow.sum()} z {len(dane)} "
      f"({maska_brakow.mean():.1%})")
print()

wiersze = []
for strategia in ['mean', 'median']:
    imp = SimpleImputer(strategy=strategia).fit(dane[['SerumInsulin']])
    wstawiona = imp.statistics_[0]
    blad = np.abs(prawda - wstawiona)
    wiersze.append({
        'strategia': strategia,
        'wstawiana wartość': wstawiona,
        'średni błąd bezwzględny': blad.mean(),
        'mediana błędu': np.median(blad),
        'największy błąd': blad.max(),
    })

odchylenie = prawda.std()
srednia_prawdy = prawda.mean()

tabela_bledow = pd.DataFrame(wiersze)
print(tabela_bledow.to_string(index=False, float_format=lambda v: f"{v:.2f}"))
print()
print(f"Prawdziwe wartości w brakujących komórkach: średnia {srednia_prawdy:.2f}, "
      f"odchylenie standardowe {odchylenie:.2f}")
print(f"Zakres: od {prawda.min():.2f} do {prawda.max():.2f}")
print()
for w in wiersze:
    print(f"  strategia '{w['strategia']}': średni błąd to "
          f"{w['średni błąd bezwzględny'] / odchylenie:.2f} odchylenia standardowego cechy")

# Obrazek, ktory domyka to zadanie: prawda kontra to, co wstawilismy
fig, ax = plt.subplots(figsize=(9, 5))
ax.hist(prawda, bins=40, color='#55A868', edgecolor='white',
        label='prawdziwe wartości brakujących komórek')
for w, kolor in zip(wiersze, ['#4C72B0', '#C44E52']):
    ax.axvline(w['wstawiana wartość'], color=kolor, linestyle='--', linewidth=2,
               label=f"wstawiane przez strategy='{w['strategia']}'")
ax.set_xlabel('SerumInsulin')
ax.set_ylabel('liczba pacjentów')
ax.set_title('Jedna liczba zamiast całego rozkładu')
ax.legend()
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()

Czego się spodziewać:

Braków w SerumInsulin: 3555 z 10000 (35.5%)

strategia  wstawiana wartość  średni błąd bezwzględny  mediana błędu  największy błąd
     mean             140.11                   101.85          93.89           654.89
   median              86.00                    96.38          62.00           709.00

Prawdziwe wartości w brakujących komórkach: średnia 137.67, odchylenie standardowe 133.55
Zakres: od 14.00 do 795.00

  strategia 'mean': średni błąd to 0.76 odchylenia standardowego cechy
  strategia 'median': średni błąd to 0.72 odchylenia standardowego cechy

Jak to czytać - odpowiedź na pytanie końcowe:

Średni błąd wynosi około 0,72-0,76 odchylenia standardowego cechy. To bardzo dużo. Dla porównania: gdybyś zamiast uzupełniać, losował wartość z rozkładu tej cechy, błąd wyniósłby około 1,0 odchylenia. Uzupełnianie jedną liczbą jest więc lepsze od losowania o jakieś 25% - i to wszystko.

Największy pojedynczy błąd to 709 jednostek, przy prawdziwym zakresie od 14 do 795. Dla tego jednego pacjenta wstawiona wartość nie ma nic wspólnego z rzeczywistością.

Na wykresie widać to jeszcze wyraźniej: prawdziwe wartości rozkładają się szeroko od 14 do 795, a obie strategie zastępują cały ten rozkład jedną pionową kreską.

Co to mówi o wartości informacyjnej uzupełnionych komórek:

Uzupełnianie nie odzyskuje informacji. Ono tylko pozwala modelowi dalej działać - wypełnia dziurę czymś, co nie wywróci obliczeń. Po uzupełnieniu 35,5% wierszy w tej kolumnie jedna trzecia wartości jest zmyślona, choć wygląda dokładnie jak reszta.

Praktyczne konsekwencje:

  • Przy tak dużym udziale braków warto rozważyć dodatkową kolumnę zero-jedynkową „ta wartość była brakiem" (SimpleImputer(add_indicator=True)). Sam fakt braku bywa informacją: badania nie zlecono, bo pacjent nie budził podejrzeń.
  • Mediana wypada nieco lepiej od średniej (0,72 wobec 0,76 odchylenia), co zgadza się z tym, co wiesz o rozkładzie skośnym.
  • Najlepszym rozwiązaniem problemu braków jest ich niepowstanie. Rozmowa z osobą zbierającą dane bywa skuteczniejsza od każdej strategii uzupełniania.
In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 7 (trudniejsze): Kiedy przeciek staje się widoczny?

W sekcji 7 przeciek przy uzupełnianiu średnią był ledwie zauważalny. Sprawdź, od czego zależy jego skala.

Skorzystaj z funkcji eksperyment_przeciek(n_probek, udzial_brakow, ziarno) (albo napisz własną) i zbadaj różnicę „z przeciekiem minus poprawnie" dla siatki ustawień:

  • n_probek ∈ {60, 120, 400, 2000},
  • udzial_brakow ∈ {0,1, 0,3, 0,6},
  • dla każdego ustawienia co najmniej 40 powtórzeń z różnymi ziarnami (pojedynczy pomiar to szum, nie wynik).

Zbuduj z wyników tabelę (pd.DataFrame) i narysuj wykres: na osi X liczba próbek, po jednej linii na każdy udział braków, na osi Y średnia różnica.

Odpowiedz na trzy pytania:

  1. Czy przeciek rośnie, gdy danych jest mniej? Dlaczego tak powinno być?
  2. Czy rośnie wraz z udziałem braków?
  3. Przy jakiej wielkości zbioru różnica przestaje być odróżnialna od szumu? Porównaj ją z odchyleniem standardowym różnic między powtórzeniami - jeśli średnia różnica jest mniejsza niż ten rozrzut, nie masz prawa twierdzić, że cokolwiek zmierzyłeś.
Krok po kroku - co masz zrobić (kliknij, żeby rozwinąć)
  1. Ustal siatkę ustawień: cztery rozmiary zbioru i trzy udziały braków.
  2. Dla każdej kombinacji wykonaj co najmniej 40 powtórzeń z różnymi ziarnami.
  3. Dla każdego powtórzenia policz różnicę: wynik z przeciekiem minus wynik poprawny.
  4. Zapisz średnią różnicę, jej odchylenie standardowe i błąd standardowy średniej.
  5. Policz, ile błędów standardowych ma średnia różnica - to mówi, czy w ogóle coś zmierzyłeś.
  6. Narysuj wykres ze słupkami błędu.

Czym jest błąd standardowy średniej: odchylenie standardowe podzielone przez pierwiastek z liczby powtórzeń. Mówi, jak bardzo sama średnia mogłaby się zmienić, gdybyś powtórzył cały eksperyment. Jeśli średnia różnica jest mniejsza niż jeden-dwa błędy standardowe, nie masz podstaw twierdzić, że różnica istnieje.

Uwaga na czas: 4 × 3 × 40 = 480 eksperymentów, każdy trenuje dwa modele. Na typowym laptopie to od jednej do trzech minut.

Jak to napisać - kod z wyjaśnieniem

Krok 2-3: powtórzenia i różnice

pary = np.array([eksperyment_przeciek(n, udzial, z) for z in range(POWTORZENIA)])
roznice = pary[:, 1] - pary[:, 0]          # z przeciekiem minus poprawnie
  • Funkcja zwraca parę wyników, więc pary jest tablicą o kształcie (40, 2).
  • pary[:, 0] to kolumna wyników poprawnych, pary[:, 1] - z przeciekiem. Odejmowanie działa wiersz po wierszu, więc dostajesz 40 różnic z tego samego podziału danych.
  • Porównywanie w parach jest tu kluczowe: obie wersje dostają identyczne dane, więc różnica pochodzi wyłącznie z przecieku, a nie z innego losowania.

Krok 4: trzy miary rozrzutu

'srednia_roznica': roznice.mean(),
'odchylenie_roznic': roznice.std(ddof=1),
'blad_standardowy': roznice.std(ddof=1) / np.sqrt(POWTORZENIA),

ddof=1 to poprawka na to, że liczysz odchylenie z próbki, a nie z całej populacji. Przy 40 powtórzeniach różnica jest niewielka, ale to poprawny zapis.

Krok 5: czy to w ogóle pomiar

siatka['ile_bledow_std'] = siatka['srednia_roznica'] / siatka['blad_standardowy']

Ta jedna kolumna decyduje o wnioskach z całego zadania. Wartość poniżej 2 oznacza, że zmierzona różnica mieści się w zwykłym rozrzucie - czyli nie masz prawa twierdzić, że cokolwiek wykryłeś.

Krok 6: słupki błędu

ax.errorbar(czesc['n_probek'], czesc['srednia_roznica'],
            yerr=czesc['blad_standardowy'], marker='o', capsize=4, ...)
ax.axhline(0, color='gray', linewidth=1)

errorbar rysuje punkt wraz z pionowym odcinkiem niepewności. Pozioma linia w zerze jest tu niezbędna: słupek przecinający zero znaczy „brak dowodu na różnicę".

Gotowe rozwiązanie i czego się spodziewać
# UWAGA NA CZAS: 4 rozmiary x 3 udzialy brakow x 40 powtorzen = 480 eksperymentow,
# a kazdy trenuje dwa modele. Na typowym laptopie to okolo 1-3 minut.
# Gdyby bylo za wolno, ogranicz POWTORZENIA do 40 (minimum z tresci zadania)
# albo usun 2000 z listy rozmiarow.

ROZMIARY = [60, 120, 400, 2000]
UDZIALY = [0.1, 0.3, 0.6]
POWTORZENIA = 40

wiersze = []
for n in ROZMIARY:
    for udzial in UDZIALY:
        pary = np.array([eksperyment_przeciek(n, udzial, z) for z in range(POWTORZENIA)])
        roznice = pary[:, 1] - pary[:, 0]          # z przeciekiem minus poprawnie
        wiersze.append({
            'n_probek': n,
            'udzial_brakow': udzial,
            'poprawnie': pary[:, 0].mean(),
            'z_przeciekiem': pary[:, 1].mean(),
            'srednia_roznica': roznice.mean(),
            'odchylenie_roznic': roznice.std(ddof=1),
            'blad_standardowy': roznice.std(ddof=1) / np.sqrt(POWTORZENIA),
        })

siatka = pd.DataFrame(wiersze)

# Prosta miara "czy to w ogole pomiar": ile bledow standardowych ma srednia roznica
siatka['ile_bledow_std'] = siatka['srednia_roznica'] / siatka['blad_standardowy']

print(siatka.to_string(index=False, float_format=lambda v: f"{v:.4f}"))

fig, ax = plt.subplots(figsize=(9, 5.5))
for udzial in UDZIALY:
    czesc = siatka[siatka['udzial_brakow'] == udzial]
    ax.errorbar(czesc['n_probek'], czesc['srednia_roznica'],
                yerr=czesc['blad_standardowy'], marker='o', capsize=4,
                label=f'braki: {udzial:.0%}')

ax.axhline(0, color='gray', linewidth=1)
ax.set_xscale('log')
ax.set_xlabel('liczba próbek (skala logarytmiczna)')
ax.set_ylabel('średnia różnica: z przeciekiem minus poprawnie')
ax.set_title('Skala przecieku maleje wraz z wielkością zbioru')
ax.legend()
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()

print("Słupki błędu to błąd standardowy średniej. Tam, gdzie słupek przecina zero,")
print("nie ma podstaw, żeby twierdzić, że cokolwiek zmierzono.")

Czego się spodziewać:

 n_probek  udzial_brakow  poprawnie  z_przeciekiem  srednia_roznica  odchylenie_roznic  blad_standardowy  ile_bledow_std
       60         0.1000     0.7556         0.7583           0.0028             0.0176            0.0028          1.0000
       60         0.3000     0.7583         0.7611           0.0028             0.0216            0.0034          0.8130
       60         0.6000     0.7556         0.7639           0.0083             0.0268            0.0042          1.9640
      120         0.1000     0.7444         0.7438          -0.0007             0.0117            0.0019         -0.3739
      120         0.3000     0.7389         0.7410           0.0021             0.0097            0.0015          1.3556
      120         0.6000     0.7326         0.7312          -0.0014             0.0153            0.0024         -0.5725
      400         0.1000     0.7656         0.7650          -0.0006             0.0029            0.0005         -1.3556
      400         0.3000     0.7640         0.7635          -0.0004             0.0032            0.0005         -0.8130
      400         0.6000     0.7629         0.7637           0.0008             0.0032            0.0005          1.6690
     2000         0.1000     0.7769         0.7769           0.0000             0.0008            0.0001          0.0000
     2000         0.3000     0.7745         0.7744          -0.0001             0.0008            0.0001         -0.6276
     2000         0.6000     0.7707         0.7703          -0.0004             0.0014            0.0002         -1.8829

Jak to czytać - i uwaga, wnioski są ostrożniejsze, niż sugeruje treść zadania:

Pytanie 1 - czy przeciek rośnie, gdy danych jest mniej? Najsilniejszy efekt wystąpił przy n=60 i 60% braków: różnica +0,0083 przy ile_bledow_std = 1,96. To jedyny wiersz zbliżający się do progu wiarygodności. Kierunek zgadza się z oczekiwaniem, ale jeden wiersz na dwanaście to za mało, żeby ogłosić prawidłowość.

Pytanie 2 - czy rośnie z udziałem braków? Przy n=60 owszem: 0,0028 → 0,0028 → 0,0083. Przy pozostałych rozmiarach nie widać żadnego porządku, a połowa różnic jest ujemna - czyli wersja z przeciekiem wypadła gorzej, co nie ma sensu merytorycznego i jest po prostu szumem.

Pytanie 3 - kiedy różnica przestaje być odróżnialna od szumu? Praktycznie od razu. Tylko jeden z dwunastu wierszy przekracza 1,9 błędu standardowego; w pozostałych jedenastu słupek błędu przecina zero.

Najważniejszy wniosek - i nie jest to wniosek o przecieku:

Uczciwa odpowiedź na to zadanie brzmi: przy tych ustawieniach nie udało się wiarygodnie zmierzyć skutku przecieku. Widać jedynie słabą przesłankę, że przy bardzo małych zbiorach i dużym udziale braków efekt istnieje.

To jest prawdziwy wynik, a nie porażka. Kolumna odchylenie_roznic pokazuje, dlaczego: przy n=60 pojedyncze pomiary rozrzucone są o 0,027, czyli trzy razy mocniej niż mierzony efekt. Przy 40 powtórzeniach szum nie zdąży się uśrednić.

Gdybyś wypisał samą kolumnę srednia_roznica i ogłosił „przeciek zawyża wynik o 0,8 punktu procentowego przy małych zbiorach", popełniłbyś błąd, który w nauce zdarza się nagminnie: podanie średniej bez informacji o jej niepewności.

I nie zmienia to niczego w ocenie samego przecieku. Nie unikamy go dlatego, że zawyża wynik o mierzalną wartość - tylko dlatego, że procedura pomiaru przestaje być uczciwa. Zadanie 5 pokazało przeciek o efekcie dokładnie zerowym i nadal był to błąd.

In [ ]:
# TWÓJ KOD TUTAJ

Pytania do przemyślenia

Na te pytania odpowiadasz słowami, nie kodem.

  1. Dlaczego uzupełnienie braków średnią policzoną z całego zbioru jest błędem, skoro to tylko jedna liczba i skuteczność zmienia się minimalnie? Czym w ogóle ma być zbiór testowy?
  2. Kolumna ma 60% braków. Wymień trzy różne strategie postępowania i podaj warunek, przy którym każda z nich jest właściwym wyborem.
  3. Drzewo decyzyjne nie potrzebuje skalowania. Czy to znaczy, że przy drzewie można w ogóle zrezygnować z Pipeline? (Zastanów się, co jeszcze potok w sobie mieści i co się stanie przy walidacji krzyżowej.)
  4. MinMaxScaler dopasowany na zbiorze uczącym dostaje w danych testowych wartość większą niż jakakolwiek widziana wcześniej. Co zwróci? Czy to jest błąd i co można z tym zrobić?
  5. W danych produkcyjnych pojawia się kategoria, której nie było przy uczeniu - nowe województwo, nowy rodzaj ubezpieczenia. Co zrobi OneHotEncoder z handle_unknown='ignore', a co bez tego argumentu? Które zachowanie jest lepsze i czy odpowiedź zależy od zastosowania?
  6. Kolejność ma znaczenie: najpierw uzupełnianie braków, potem skalowanie - czy odwrotnie? Uzasadnij, co poszłoby nie tak przy odwróconej kolejności.

Chcesz wiedzieć więcej

W kolejnym ćwiczeniu (04 - Regresja) przechodzimy od przygotowania danych do modelowania: regresja liniowa, regularyzacja Ridge i Lasso oraz napięcie między niedouczeniem a przeuczeniem. Potok, który zbudowałeś tutaj, będzie tam punktem wyjścia.