Ć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¶
- Jak uzupełniać braki danych (
SimpleImputer) i jak wybrać strategię uzupełniania. - Czym różni się
StandardScalerodMinMaxScaleri kiedy sięgać po który. - Jak kodować zmienne kategoryczne (
OneHotEncoder) i dlaczego nie wolno zamieniać kategorii na kolejne liczby. - Czym jest
Pipelinei dlaczego jest zabezpieczeniem przed przeciekiem, a nie tylko wygodą. - Jak stosować różne przekształcenia do różnych kolumn (
ColumnTransformer). - Dlaczego
fitwyłą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:
- zasymulujemy wadliwy eksport, w którym braki zapisano jako zera, i przywrócimy im status braków (
NaN), - dołożymy kolumnę kategoryczną (ang. categorical feature) - grupę wiekową wyliczoną z
Agefunkcją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ą.
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 |
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.
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łoo 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.
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())
# 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.
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 przyfit? DomyślnieOneHotEncoderrzuca 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ę
sparsezamiastsparse_output. Jeśli dostanieszTypeError, 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łujefit_transformna każdym kroku po kolei, a na końcufitmodelu,potok.predict(X_test)wywołuje na każdym kroku wyłącznietransform- z parametrami wyuczonymi na zbiorze uczącym.
Czyli: potok nie pozwala przypadkiem dopasować przekształcenia na danych testowych. To zabezpieczenie, nie kosmetyka.
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 ─┘
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.
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.
# 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:
- Podziel dane najpierw, przekształcaj potem. Pierwsze
train_test_splitw notatniku ma wystąpić przed pierwszymfitczegokolwiek. - Wszystkie przekształcenia trzymaj w
Pipeline. Wtedy poprawna kolejność wychodzi sama - nie da się przez pomyłkę wywołaćfitna 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ąć)
- Przygotuj listę czterech strategii; przy
'constant'zapamiętaj, że potrzebny jest dodatkowy argumentfill_value=0. - W pętli po strategiach zbuduj potok: uzupełnianie → skalowanie → regresja logistyczna.
- Naucz każdy potok na
X_ucz[cechy_liczbowe]. - Dla każdego odczytaj skuteczność testową oraz wartość, którą imputer wstawia w miejsce braków.
- Zbierz wyniki w tabelę i policz różnicę między najlepszą a najgorszą strategią.
Dlaczego
X_ucz[cechy_liczbowe], a nieX_ucz:X_uczzawiera też kolumnęGrupaWiekowa, która jest tekstem.SimpleImputeriStandardScalerobsł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)
Pipelineprzyjmuje listę par(nazwa_kroku, obiekt). Nazwy same wybierasz i przydadzą się za chwilę do zajrzenia do środka.**dodatkowerozpakowuje słownik na argumenty nazwane. Przy pustym słowniku nie dodaje nic, przy{'fill_value': 0}dokładafill_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ł.
# TWÓJ KOD TUTAJ
Zadanie 2: StandardScaler kontra MinMaxScaler¶
Porównaj oba skalery w tym samym potoku (uzupełnianie medianą + skaler + regresja logistyczna).
- Wypisz skuteczność testową obu wariantów.
- Sprawdź, jakich statystyk nauczył się każdy skaler:
skaler.mean_iskaler.scale_dlaStandardScaler,skaler.data_min_iskaler.data_max_dlaMinMaxScaler. Dostaniesz się do nich przezpotok.named_steps['nazwa_kroku']. - 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ąć)
- Napisz funkcję budującą potok, która przyjmuje skaler (albo
None, gdy skalowania ma nie być). - Zbuduj trzy warianty:
StandardScaler,MinMaxScaleri bez skalowania. - Naucz każdy, przechwytując ostrzeżenia o zbieżności.
- Wypisz skuteczność, liczbę iteracji i liczbę ostrzeżeń dla każdego wariantu.
- Wyciągnij z obu skalerów statystyki, których się nauczyły, i zestaw je w tabeli.
Czym różnią się oba skalery:
StandardScalerodejmuje ś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ę.StandardScalerjest 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
ConvergenceWarningwył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.
# 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ąć)
- Zbuduj gałąź liczbową: uzupełnianie medianą +
StandardScaler. - Wariant A:
ColumnTransformerz dwiema gałęziami - liczbową i kategoryczną zOneHotEncoder. - Wariant B: zamień
GrupaWiekowana kolejne liczby przez.cat.codesi potraktuj wszystko jedną gałęzią liczbową. - Dla wariantu B potrzebny jest osobny podział danych, bo
Xma inny zestaw kolumn. - Wypisz nazwy kolumn, które widzi model w wariancie A, oraz skuteczność obu wariantów.
Na czym polega problem:
OneHotEncoderzamienia 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),
])
ColumnTransformerprzyjmuje 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 tegoOneHotEncoderrzucił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.
# TWÓJ KOD TUTAJ
Zadanie 4: Własny ColumnTransformer¶
Zbuduj potok, w którym różne kolumny liczbowe traktowane są inaczej:
SerumInsuliniTricepsThickness(dużo braków, rozkłady skośne): uzupełnianie medianą +StandardScaler,- pozostałe cechy liczbowe: uzupełnianie średnią +
MinMaxScaler, GrupaWiekowa: uzupełnianiemost_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ąć)
- Podziel cechy liczbowe na dwie grupy: skośne (
SerumInsulin,TricepsThickness) i pozostałe. - Zbuduj trzy gałęzie: skośną (mediana +
StandardScaler), pozostałą (średnia +MinMaxScaler) i kategoryczną (most_frequent+OneHotEncoder). - Złóż je w jeden
ColumnTransformer. - Dołóż model i naucz cały potok.
- Wypisz liczbę kolumn przed i po przygotowaniu, ich nazwy oraz skuteczność - i porównaj ją z wynikiem z zadania 3.
Dlaczego skośne osobno:
SerumInsuliniTricepsThicknessmają 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.
# 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))
- Wskaż oba przecieki - podaj numery linii i wyjaśnij, jaka informacja przecieka w każdym z nich.
- Przepisz kod poprawnie, używając
Pipeline. - 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ąć)
- Przepisz kod z zadania bez zmian - tak, żeby dało się go uruchomić i zmierzyć.
- Napisz wersję poprawną: najpierw podział, potem
Pipelinez tymi samymi krokami. - Uruchom obie i wypisz wyniki oraz różnicę.
- 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)iskaler.fit_transform(X_zle)wykonują się przedtrain_test_split. Każdefituczy 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.
# 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:
- wybierz wiersze, w których
dane['SerumInsulin']jest brakiem, - odczytaj prawdziwe wartości tych wierszy z
dane_czyste, - porównaj je z wartościami wstawionymi przez
SimpleImputerprzy strategiach'mean'i'median', - policz średni błąd bezwzględny (
np.abs(prawda - wstawione).mean()) dla obu strategii, - 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ąć)
- Znajdź wiersze, w których
SerumInsulinjest brakiem. - Odczytaj prawdziwe wartości tych wierszy z
dane_czyste. - Dla strategii
'mean'i'median'sprawdź, jaką wartość wstawia imputer. - Policz błąd bezwzględny każdej wstawionej wartości względem prawdy: średni, medianę i największy.
- Policz odchylenie standardowe prawdziwych wartości i wyraź w nim średni błąd.
- 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
fitwyłą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-Truetam, gdzie jest brak.- Ta sama maska zastosowana do
dane_czystewybiera 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ę.SimpleImputeroczekuje dwuwymiarowego wejścia.prawda - wstawionaodejmuje jedną liczbę od całej tablicy naraz.np.absbierze 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.
# 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:
- Czy przeciek rośnie, gdy danych jest mniej? Dlaczego tak powinno być?
- Czy rośnie wraz z udziałem braków?
- 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ąć)
- Ustal siatkę ustawień: cztery rozmiary zbioru i trzy udziały braków.
- Dla każdej kombinacji wykonaj co najmniej 40 powtórzeń z różnymi ziarnami.
- Dla każdego powtórzenia policz różnicę: wynik z przeciekiem minus wynik poprawny.
- Zapisz średnią różnicę, jej odchylenie standardowe i błąd standardowy średniej.
- Policz, ile błędów standardowych ma średnia różnica - to mówi, czy w ogóle coś zmierzyłeś.
- 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
paryjest 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.
# TWÓJ KOD TUTAJ
Pytania do przemyślenia¶
Na te pytania odpowiadasz słowami, nie kodem.
- 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?
- 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.
- 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.) MinMaxScalerdopasowany 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ć?- W danych produkcyjnych pojawia się kategoria, której nie było przy uczeniu - nowe województwo, nowy rodzaj ubezpieczenia. Co zrobi
OneHotEncoderzhandle_unknown='ignore', a co bez tego argumentu? Które zachowanie jest lepsze i czy odpowiedź zależy od zastosowania? - 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¶
- Przewodnik po przekształcaniu danych - pełny przegląd skalerów i koderów w scikit-learn.
- Uzupełnianie braków danych - poza
SimpleImputerznajdziesz tamKNNImputeriIterativeImputer. PipelineiColumnTransformer- między innymi o tym, jak dostać się do parametrów kroków potoku przeznazwa_kroku__parametr(przyda się w ćwiczeniu 06 przyGridSearchCV).- Częste pułapki: przeciek danych - krótki, bardzo konkretny tekst; warto przeczytać w całości.
pandas.cut- dyskretyzacja cechy ciągłej; zobacz teżpandas.qcut, który dzieli według kwantyli.
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.