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

Ćwiczenie 2: Poznaj swoje dane (EDA)

Po co to ćwiczenie?

W ćwiczeniu 01 zbudowaliśmy model, prawie nie patrząc na dane. Zadziałało - bo zbiór diabetes.csv jest wyjątkowo grzeczny: same liczby, żadnej pustej komórki, sensowne zakresy. W prawdziwym projekcie tak nie będzie.

EDA = analiza eksploracyjna danych (ang. exploratory data analysis). Ten skrót wraca w całym ćwiczeniu, więc warto go zapamiętać od razu.

EDA to etap, na którym oglądasz dane, zanim cokolwiek wytrenujesz. Studenci często traktują go jak ozdobnik: „narysuję kilka histogramów, żeby było ładnie w sprawozdaniu". To nieporozumienie. EDA jest etapem diagnostycznym - szukasz problemów, które w przeciwnym razie cicho zniszczą model:

  • kolumna, w której 40% wartości to zakamuflowane braki danych,
  • cecha (ang. feature) skorelowana z etykietą tak silnie, że aż podejrzanie (przeciek danych),
  • wartości odstające, które przeciągną model w swoją stronę,
  • klasa mniejszościowa tak rzadka, że skuteczność przestaje cokolwiek znaczyć.

Żadnego z tych problemów nie zobaczysz w wyniku model.score(). Zobaczysz je tylko, jeśli spojrzysz na dane.

Reguła, którą warto zapamiętać: godzina spędzona na EDA oszczędza dzień debugowania modelu, którego wyników nie da się wytłumaczyć.

Czego się nauczysz

  1. Jak w kilku poleceniach zrobić przegląd nowego zbioru danych (shape, info, describe).
  2. Jak czytać histogram rozkładu cechy i co ci mówi jego kształt.
  3. Jak liczyć i czytać macierz korelacji - oraz dlaczego korelacja to nie przyczynowość.
  4. Jak sprawdzić, czy cecha w ogóle rozróżnia klasy (wykresy pudełkowe według etykiety).
  5. Jak wykrywać wartości odstające regułą rozstępu międzykwartylowego (IQR).
  6. Jak rozpoznać podejrzane zera - czyli braki danych udające poprawne pomiary.

Zanim zaczniesz: uruchamiaj komórki po kolei (Shift+Enter). Późniejsze korzystają ze zmiennych zdefiniowanych wcześniej.

1. Pierwszy rzut oka

Pracujemy na tym samym zbiorze co poprzednio: dane/diabetes.csv - 10 000 kart pacjentów przebadanych pod kątem cukrzycy.

Przy każdym nowym zbiorze danych zaczynaj od tych samych czterech pytań:

Pytanie Polecenie Czego szukasz
Ile mam danych? dane.shape czy 200 wierszy, czy 2 miliony - to zmienia dobór metod
Jak wyglądają? dane.head() czy kolumny są tym, czym się wydają
Jakie mam typy? dane.info() liczba udająca tekst to klasyczna pułapka
Czy są braki? dane.isna().sum() które kolumny są dziurawe i jak bardzo
In [ ]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

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

print("Kształt (wiersze, kolumny):", dane.shape)
print()
dane.head()
In [ ]:
# Typy kolumn i liczba wartosci niepustych
dane.info()

print()
print("Braki danych w poszczególnych kolumnach:")
print(dane.isna().sum().to_string())

Wynik isna().sum() pokazuje same zera - w tym pliku nie ma ani jednej pustej komórki. Zapamiętaj to, bo w sekcji 6 pokażemy, że brak pustych komórek i brak braków danych to dwie zupełnie różne rzeczy.

Zwróć też uwagę, że wszystkie kolumny są liczbowe. Nie ma tu ani jednej kolumny tekstowej - to sytuacja komfortowa i rzadka. Zmiennymi kategorycznymi zajmiemy się w ćwiczeniu 03.

2. Statystyki opisowe: describe()

describe() to najtańszy sposób na wychwycenie rzeczy niemożliwych. Jedno wywołanie i już wiesz, czy w kolumnie „wiek" nie siedzi wartość -3 albo 999.

In [ ]:
# .T (transpozycja) - cechy w wierszach, statystyki w kolumnach. Znacznie czytelniej.
dane.describe().T

Jak to czytać

Wiersz Znaczenie Na co patrzeć
count liczba wartości niepustych wartość mniejsza niż liczba wierszy = są braki
mean / std średnia i odchylenie standardowe std bliskie zeru = cecha prawie stała, czyli bezużyteczna
min / max skrajne wartości tu wychodzą wartości fizycznie niemożliwe
25% / 50% / 75% kwartyle; 50% to mediana duża różnica mediany i średniej = rozkład jest skośny

Dwie obserwacje z tej tabeli, które od razu wpływają na dalsze decyzje:

  1. Skale cech są dramatycznie różne. DiabetesPedigree mieści się poniżej 3, SerumInsulin sięga setek, a PatientID - milionów. Dla drzewa decyzyjnego to bez znaczenia, ale dla regresji logistycznej, k najbliższych sąsiadów czy sieci neuronowej to problem. Dlatego w ćwiczeniu 01 użyliśmy StandardScaler, a w ćwiczeniu 03 zajmiemy się skalowaniem na poważnie.
  2. PatientID ma statystyki, choć nie ma to najmniejszego sensu - średni numer pacjenta to pojęcie puste. To dobre przypomnienie, że pandas policzy średnią ze wszystkiego, co jest liczbą. Myślenie pozostaje po Twojej stronie.

Odseparujmy więc identyfikator i etykietę od właściwych cech.

In [ ]:
ETYKIETA = 'Diabetic'
IDENTYFIKATOR = 'PatientID'

cechy = [k for k in dane.columns if k not in (ETYKIETA, IDENTYFIKATOR)]

print("Cechy (%d):" % len(cechy))
for k in cechy:
    print("  -", k)

3. Rozkład etykiety

Zanim spojrzymy na cechy, sprawdzamy etykietę (ang. label). To pytanie: „czego ja właściwie uczę model?".

Jeśli klasy są mocno niezrównoważone (ang. class imbalance), zmienia się wszystko: dobór metryki, sposób podziału danych, czasem sam algorytm.

In [ ]:
liczebnosc = dane[ETYKIETA].value_counts().sort_index()
udzial = dane[ETYKIETA].value_counts(normalize=True).sort_index()
opisy = {0: 'brak cukrzycy', 1: 'cukrzyca'}

for klasa in liczebnosc.index:
    print(f"{klasa} = {opisy[klasa]:14s}: {liczebnosc[klasa]:5d} pacjentów ({udzial[klasa]:.1%})")

fig, ax = plt.subplots(figsize=(5, 4))
ax.bar([opisy[k] for k in liczebnosc.index], liczebnosc.values, color=['#4C72B0', '#C44E52'])
ax.set_ylabel('liczba pacjentów')
ax.set_title('Rozkład etykiety')
for i, v in enumerate(liczebnosc.values):
    ax.text(i, v, f"{v}", ha='center', va='bottom')
ax.grid(axis='y', alpha=0.3)
plt.tight_layout()
plt.show()

Proporcja mniej więcej dwa do jednego. To umiarkowane niezrównoważenie - nieprzyjemne, ale nie dramatyczne. Dla porównania: w wykrywaniu oszustw kartowych klasa pozytywna to często 0,1% danych i tam problem jest zupełnie innego kalibru.

Praktyczna konsekwencja, którą znasz już z ćwiczenia 01: model zawsze odpowiadający klasą większościową osiąga tu skuteczność równą udziałowi tej klasy. Udział klasy większościowej to Twoja poprzeczka.

4. Rozkłady cech - histogramy

Histogram (ang. histogram) dzieli zakres wartości cechy na przedziały (ang. bins) i pokazuje, ile obserwacji wpada do każdego. Odpowiada na pytania, których describe() nie obejmuje: czy rozkład ma jeden szczyt czy kilka, czy jest symetryczny, czy nie ma dziwnych skupisk przy krańcach.

Rysujemy wszystkie cechy naraz, w siatce podwykresów.

In [ ]:
fig, osie = plt.subplots(2, 4, figsize=(16, 7))

for ax, kolumna in zip(osie.ravel(), cechy):
    ax.hist(dane[kolumna], bins=30, color='#4C72B0', edgecolor='white')
    ax.axvline(dane[kolumna].mean(), color='#C44E52', linestyle='--', linewidth=1.5, label='średnia')
    ax.axvline(dane[kolumna].median(), color='#55A868', linestyle=':', linewidth=1.5, label='mediana')
    ax.set_title(kolumna, fontsize=10)
    ax.grid(alpha=0.3)

osie.ravel()[0].legend(fontsize=8)
fig.suptitle('Rozkłady cech', fontsize=13)
plt.tight_layout()
plt.show()

Co widać i co z tego wynika

Przejdź po wykresach i nazwij kształt każdego rozkładu. Trzy wzorce, które warto umieć rozpoznać:

Kształt Jak wygląda Co oznacza dla modelu
symetryczny (zbliżony do normalnego) jeden szczyt pośrodku, średnia ≈ mediana sytuacja komfortowa, standaryzacja działa bez zastrzeżeń
prawoskośny (ang. right-skewed) szczyt po lewej, długi ogon w prawo, średnia > mediana średnia jest zawyżana przez ogon; do uzupełniania braków lepsza jest mediana; czasem pomaga logarytm
ucięty / skupiony przy krańcu słupek przyklejony do zera albo do maksimum podejrzenie, że wartości były przycinane albo że zero to zakamuflowany brak

Zwróć uwagę na Age i SerumInsulin - to wyraźnie rozkłady prawoskośne: większość pacjentów jest młoda, a pojedyncze osoby mają wiek dużo wyższy od reszty. Porównaj w każdym podwykresie położenie czerwonej linii (średnia) i zielonej (mediana): im bardziej się rozjeżdżają, tym rozkład bardziej skośny.

Zwróć też uwagę na Pregnancies: to cecha o wartościach całkowitych z wyraźnym słupkiem w zerze. Zero jest tu w pełni sensowne - to kobieta, która nie była w ciąży. Zapamiętaj ten przypadek, bo w sekcji 6 zestawimy go z zerem, które sensu nie ma.

5. Korelacje

Korelacja (ang. correlation, dokładniej współczynnik Pearsona) mierzy siłę liniowego związku między dwiema zmiennymi. Przyjmuje wartości od -1 do +1:

  • +1 - gdy jedna rośnie, druga rośnie idealnie proporcjonalnie,
  • 0 - brak związku liniowego,
  • -1 - gdy jedna rośnie, druga maleje.

Interesują nas dwie rzeczy naraz:

  1. korelacja cechy z etykietą - czy ta cecha w ogóle niesie informację o chorobie,
  2. korelacja cech między sobą - czy dwie cechy nie mówią tego samego (ang. collinearity, współliniowość).
In [ ]:
# Liczymy korelacje BEZ identyfikatora - korelacja numeru pacjenta z czymkolwiek
# jest przypadkowym szumem i tylko zaciemnia obraz.
macierz = dane[cechy + [ETYKIETA]].corr()

fig, ax = plt.subplots(figsize=(8.5, 7))
obraz = ax.imshow(macierz, cmap='RdBu_r', vmin=-1, vmax=1)

ax.set_xticks(range(len(macierz.columns)))
ax.set_xticklabels(macierz.columns, rotation=45, ha='right', fontsize=9)
ax.set_yticks(range(len(macierz.columns)))
ax.set_yticklabels(macierz.columns, fontsize=9)

# Wpisujemy wartosci w komorki - macierz bez liczb czyta sie duzo gorzej
for i in range(len(macierz.columns)):
    for j in range(len(macierz.columns)):
        w = macierz.iloc[i, j]
        ax.text(j, i, f"{w:.2f}", ha='center', va='center', fontsize=8,
                color='white' if abs(w) > 0.55 else 'black')

fig.colorbar(obraz, ax=ax, shrink=0.8, label='współczynnik korelacji')
ax.set_title('Macierz korelacji')
plt.tight_layout()
plt.show()
In [ ]:
# Sam ranking korelacji z etykieta - czyta sie szybciej niz cala macierz
ranking = macierz[ETYKIETA].drop(ETYKIETA).sort_values(ascending=False)

print("Korelacja cechy z etykietą (od najsilniejszej):")
print(ranking.to_string(float_format=lambda v: f"{v:+.3f}"))

Jak to interpretować

Trzy pułapki, w które łatwo wpaść przy czytaniu macierzy korelacji:

Pułapka 1: korelacja to nie przyczynowość. Zdanie „ta cecha jest najsilniej skorelowana z chorobą" nie znaczy „ta cecha wywołuje chorobę". Może być odwrotnie (choroba wpływa na wynik badania), może istnieć trzeci czynnik wpływający na oba, może to być przypadek.

Pułapka 2: niska korelacja nie znaczy „cecha bezużyteczna". Pearson widzi tylko związki liniowe. Cecha, dla której chorują osoby zarówno bardzo młode, jak i bardzo stare, będzie miała korelację bliską zeru, a mimo to model drzewiasty świetnie ją wykorzysta. Dlatego cech nie usuwamy na podstawie samej korelacji.

Pułapka 3: korelacja bardzo wysoka jest podejrzana, a nie wymarzona. Gdyby któraś cecha miała z etykietą korelację rzędu 0,95, pierwszą reakcją powinno być nie „świetnie, mamy model", tylko „skąd ta kolumna pochodzi?". Zwykle okazuje się, że powstała po postawieniu diagnozy - czyli jest przeciekiem danych (ang. data leakage), o którym mówiliśmy w ćwiczeniu 01.

Popatrz też na korelacje między cechami (poza ostatnim wierszem i kolumną). Jeśli dwie cechy są skorelowane na poziomie powyżej 0,9, niosą praktycznie tę samą informację - dla modelu liniowego to problem, bo współczynniki stają się niestabilne i nie da się ich sensownie interpretować.

6. Czy cecha rozróżnia klasy? Wykresy pudełkowe

Korelacja ściska całą zależność do jednej liczby. Wykres pudełkowy (ang. box plot) pokazuje cały rozkład cechy osobno dla chorych i osobno dla zdrowych - a to znacznie więcej informacji.

Jak czytać pudełko: linia w środku to mediana, krawędzie pudełka to pierwszy i trzeci kwartyl (czyli 25% i 75% obserwacji), wąsy sięgają do 1,5 rozstępu międzykwartylowego, a kropki poza wąsami to kandydaci na wartości odstające.

Kluczowe pytanie przy oglądaniu: czy pudełka dla obu klas leżą wyraźnie w innych miejscach? Jeśli tak - cecha rozróżnia klasy. Jeśli nakładają się niemal całkowicie - ta cecha sama z siebie niewiele wnosi.

In [ ]:
fig, osie = plt.subplots(2, 4, figsize=(16, 7))

for ax, kolumna in zip(osie.ravel(), cechy):
    grupy = [dane.loc[dane[ETYKIETA] == 0, kolumna],
             dane.loc[dane[ETYKIETA] == 1, kolumna]]
    # set_xticklabels zamiast argumentu labels= : ten drugi zmienil nazwe
    # na tick_labels w Matplotlib 3.9 i wypisuje ostrzezenie o wycofaniu
    pudelka = ax.boxplot(grupy, patch_artist=True)
    ax.set_xticklabels(['zdrowi', 'chorzy'])
    for latka, kolor in zip(pudelka['boxes'], ['#4C72B0', '#C44E52']):
        latka.set_facecolor(kolor)
        latka.set_alpha(0.65)
    ax.set_title(kolumna, fontsize=10)
    ax.grid(axis='y', alpha=0.3)

fig.suptitle('Rozkład każdej cechy osobno dla chorych i zdrowych', fontsize=13)
plt.tight_layout()
plt.show()
In [ ]:
# Ta sama informacja liczbowo: srednia w grupie chorych i zdrowych
zestawienie = dane.groupby(ETYKIETA)[cechy].mean().T
zestawienie.columns = ['zdrowi (0)', 'chorzy (1)']
zestawienie['różnica'] = zestawienie['chorzy (1)'] - zestawienie['zdrowi (0)']

# Roznica surowa nie da sie porownywac miedzy cechami o roznych skalach,
# wiec wyrazamy ja w odchyleniach standardowych calej cechy.
zestawienie['różnica w odch. std'] = zestawienie['różnica'] / dane[cechy].std()

zestawienie.sort_values('różnica w odch. std', key=abs, ascending=False).round(3)

Ostatnia kolumna to znormalizowana różnica średnich - różnica wyrażona w odchyleniach standardowych cechy. Dzięki temu da się porównywać cechy o zupełnie różnych skalach: różnica 40 jednostek w SerumInsulin i różnica 3 lata w Age przestają być nieporównywalne.

Porównaj ten ranking z rankingiem korelacji z sekcji 5. Kolejność cech jest identyczna - co do jednej pozycji.

Same liczby jednak się różnią i to jest w porządku: Pregnancies ma korelację +0,405, a znormalizowaną różnicę +0,859, czyli ponad dwa razy większą. To nie jest błąd - to dwie miary tego samego zjawiska wyrażone w różnych skalach.

Dla etykiety zero-jedynkowej łączy je dokładna zależność:

korelacja = znormalizowana różnica × √(p · q)

gdzie p to udział klasy 1, a q udział klasy 0. W naszym zbiorze p = 0,334, q = 0,666, więc √(p·q) = 0,472. Sprawdź na dowolnym wierszu tabeli:

Pregnancies:   0,859 × 0,472 = 0,405   ✔
Age:           0,756 × 0,472 = 0,357   ✔
SerumInsulin:  0,519 × 0,472 = 0,245   ✔

Wniosek praktyczny: do ustalania kolejności cech obie miary są wymienne. Różnią się interpretacją - korelacja mówi „jak silny jest związek w skali od −1 do 1", a znormalizowana różnica mówi „o ile odchyleń standardowych różnią się obie grupy pacjentów". Ta druga jest zwykle czytelniejsza dla lekarza.

7. Wartości odstające - reguła IQR

Wartość odstająca (ang. outlier) to obserwacja wyraźnie oddalona od reszty. Najpopularniejsze kryterium korzysta z rozstępu międzykwartylowego (ang. interquartile range, IQR):

IQR = Q3 - Q1
dolna granica = Q1 - 1,5 * IQR
górna granica = Q3 + 1,5 * IQR

Wszystko poza granicami uznajemy za odstające. Dokładnie tę regułę rysują wąsy wykresu pudełkowego, który właśnie oglądałeś.

Zanim sięgniesz po usuwanie: wartość odstająca to nie to samo co błąd. Odstający wynik insuliny u ciężko chorego pacjenta jest prawdziwy i niesie najwięcej informacji w całym zbiorze. Usuwanie odstających „dla porządku" potrafi wyciąć dokładnie ten sygnał, którego model potrzebuje.

In [ ]:
def granice_iqr(seria, mnoznik=1.5):
    # Zwraca (dolna, gorna) granice reguly IQR dla podanej kolumny.
    q1 = seria.quantile(0.25)
    q3 = seria.quantile(0.75)
    iqr = q3 - q1
    return q1 - mnoznik * iqr, q3 + mnoznik * iqr


raport = []
for kolumna in cechy:
    dol, gora = granice_iqr(dane[kolumna])
    odstajace = (dane[kolumna] < dol) | (dane[kolumna] > gora)
    raport.append({
        'cecha': kolumna,
        'dolna granica': round(dol, 2),
        'górna granica': round(gora, 2),
        'liczba odstających': int(odstajace.sum()),
        'udział': f"{odstajace.mean():.2%}",
    })

pd.DataFrame(raport).set_index('cecha')

Teraz najciekawsze pytanie, jakie można zadać o wartości odstające: kim są ci pacjenci? Jeśli okaże się, że odstający to w większości osoby chore, usunięcie ich byłoby usunięciem sygnału, a nie szumu.

In [ ]:
dol, gora = granice_iqr(dane['SerumInsulin'])
odstajace = (dane['SerumInsulin'] < dol) | (dane['SerumInsulin'] > gora)

print(f"SerumInsulin - granice reguły IQR: [{dol:.1f}, {gora:.1f}]")
print(f"Pacjentów odstających: {odstajace.sum()} ({odstajace.mean():.2%} zbioru)")
print()
print(f"Udział chorych wśród odstających:     {dane.loc[odstajace, ETYKIETA].mean():.1%}")
print(f"Udział chorych wśród pozostałych:     {dane.loc[~odstajace, ETYKIETA].mean():.1%}")
print(f"Udział chorych w całym zbiorze:       {dane[ETYKIETA].mean():.1%}")

Porównaj te trzy liczby. Jeśli udział chorych wśród odstających wyraźnie różni się od udziału w całym zbiorze, to znaczy, że fakt bycia wartością odstającą sam w sobie niesie informację o chorobie. W takiej sytuacji usunięcie odstających to usunięcie sygnału.

Wniosek praktyczny: zanim usuniesz wartości odstające, sprawdź, kim są. Sensowne strategie, w kolejności od najbezpieczniejszej:

Strategia Kiedy stosować
zostawić bez zmian wartości są prawdziwe, a model jest odporny (drzewa, lasy)
przyciąć do granic (ang. winsorization) wartości są prawdziwe, ale model jest wrażliwy (regresja liniowa)
oznaczyć dodatkową kolumną 0/1 sama „nietypowość" niesie informację
usunąć wiersz wartość jest fizycznie niemożliwa, czyli to błąd pomiaru lub zapisu

8. Podejrzane zera - braki danych w przebraniu

To najważniejsza sekcja tego ćwiczenia.

Wywołanie isna().sum() z sekcji 1 pokazało same zera - „brak braków danych". Problem w tym, że isna() widzi wyłącznie puste komórki. Nie widzi braku, który ktoś po drodze zapisał jako liczbę.

Tak dzieje się wyjątkowo często. Systemy eksportujące dane do CSV nagminnie wstawiają 0 w miejsce wartości nieznanej, bo kolumna jest zadeklarowana jako liczbowa i nie przyjmuje pustej wartości. Efekt: „grubość fałdu skórnego = 0 mm" albo „poziom insuliny = 0" - wartości fizycznie niemożliwe u żywego pacjenta.

Nasz plik jest akurat czysty. Żeby móc ten problem zobaczyć i przećwiczyć, wytworzymy go sami: zrobimy kopię danych i zasymulujemy taki wadliwy eksport. To nie jest sztuczka na potrzeby zajęć - dokładnie tak wygląda oryginalny, powszechnie używany zbiór Pima Indians Diabetes, na którym uczą się tysiące osób, często nie zauważając problemu.

In [ ]:
# Symulujemy wadliwy eksport: system zapisal brak wartosci jako 0.
losowy = np.random.RandomState(42)
dane_surowe = dane.copy()

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

print("Czy pandas widzi tu jakiekolwiek braki danych?")
print(dane_surowe.isna().sum().to_string())
print()
print("Suma braków według pandas:", dane_surowe.isna().sum().sum())

Zero braków. Dane wyglądają na nienaganne - a jedna trzecia kolumny SerumInsulin została właśnie zniszczona.

Jak to wykryć? Policz zera w każdej kolumnie i zastanów się, czy zero jest tam fizycznie możliwe. To pytanie do dziedziny, nie do kodu - i dlatego jest tak często pomijane.

In [ ]:
print("Liczba zer w każdej kolumnie:")
print()
print(f"{'kolumna':<26} {'liczba zer':>11} {'udział':>9}   zero jest sensowne?")
print("-" * 78)

komentarze = {
    'Pregnancies': 'TAK - kobieta, która nie była w ciąży',
    'PlasmaGlucose': 'NIE - pacjent bez glukozy we krwi nie żyje',
    'DiastolicBloodPressure': 'NIE - ciśnienie rozkurczowe 0 oznacza brak krążenia',
    'TricepsThickness': 'NIE - fałd skórny zawsze ma jakąś grubość',
    'SerumInsulin': 'NIE - zerowa insulina to wartość niespotykana',
    'BMI': 'NIE - masa ciała nie może być zerowa',
    'DiabetesPedigree': 'NIE - wskaźnik z definicji dodatni',
    'Age': 'NIE - w tym zbiorze są sami dorośli',
}

for kolumna in cechy:
    zera = (dane_surowe[kolumna] == 0).sum()
    print(f"{kolumna:<26} {zera:>11} {zera / len(dane_surowe):>8.1%}   {komentarze[kolumna]}")

Teraz obraz jest jednoznaczny. Pregnancies ma mnóstwo zer i tak ma być. Ale zera w SerumInsulin, TricepsThickness i DiastolicBloodPressure nie mogą być prawdziwymi pomiarami - to braki danych w przebraniu.

Zobaczmy, co takie zera robią ze statystykami cechy.

In [ ]:
kolumna = 'SerumInsulin'
prawdziwe = dane[kolumna]
zepsute = dane_surowe[kolumna]
bez_zer = zepsute.replace(0, np.nan)

porownanie = pd.DataFrame({
    'dane prawdziwe': prawdziwe.describe(),
    'z zerami (tak jak wczytano)': zepsute.describe(),
    'po zamianie 0 na NaN': bez_zer.describe(),
})
print(porownanie.round(2).to_string())

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 4.5), sharey=True)
ax1.hist(zepsute, bins=40, color='#C44E52', edgecolor='white')
ax1.set_title('Z zerami - zwróć uwagę na słupek przy 0')
ax2.hist(bez_zer.dropna(), bins=40, color='#4C72B0', edgecolor='white')
ax2.set_title('Po zamianie zer na NaN')
for ax in (ax1, ax2):
    ax.set_xlabel(kolumna)
    ax.grid(alpha=0.3)
ax1.set_ylabel('liczba pacjentów')
plt.tight_layout()
plt.show()

Dlaczego to jest groźne

Popatrz na tabelę i na lewy histogram. Sztuczne zera:

  1. zaniżają średnią - im więcej zer, tym bardziej średnia odjeżdża od prawdy,
  2. zawyżają odchylenie standardowe - rozkład ma teraz dwa skupiska zamiast jednego,
  3. rozbijają rozkład na dwa garby - a wiele metod statystycznych zakłada jeden,
  4. psują skalowanie - StandardScaler liczy średnią i odchylenie, czyli obie zatrute statystyki,
  5. uczą model fałszywej reguły - „niska insulina" zaczyna oznaczać „nie zmierzono", a nie stan pacjenta.

Punkt 5 jest najgorszy, bo model nadal osiąga niezłe wyniki. Jeśli brak pomiaru nie jest przypadkowy (a rzadko bywa - pomiaru często nie robi się pacjentom w lepszym stanie), zero staje się użyteczną cechą i model będzie się go trzymał. Model uczy się wtedy procedury szpitalnej, a nie fizjologii. W innym szpitalu, z inną procedurą, przestanie działać - a nikt nie będzie wiedział dlaczego.

Właściwa reakcja to zamiana takich zer na NaN, czyli przywrócenie im statusu braku danych:

dane_surowe[kolumna] = dane_surowe[kolumna].replace(0, np.nan)

Co zrobić z brakami dalej - uzupełnić je i czym - to już temat ćwiczenia 03.


Puenta

EDA nie jest etapem „na ładne obrazki do sprawozdania". Każda z sekcji tego notatnika odpowiadała na pytanie, którego model za Ciebie nie zada:

Sekcja Pytanie diagnostyczne Konsekwencja dla modelu
describe() czy są wartości niemożliwe? czy skale są porównywalne? potrzeba skalowania, czyszczenia
rozkład etykiety jaka jest poprzeczka? dobór metryki i stratyfikacji
histogramy czy rozkład jest skośny? czy ucięty? średnia czy mediana przy uzupełnianiu braków
korelacje co niesie sygnał? co jest podejrzanie silne? wykrycie przecieku danych
wykresy pudełkowe czy cecha rozróżnia klasy? oczekiwania wobec wyniku
reguła IQR ile jest odstających i kim oni są? decyzja o przycięciu lub zostawieniu
podejrzane zera czy „brak braków" to prawda? uratowanie modelu przed nauką bzdur

Model nie powie Ci, że jedna trzecia kolumny to zakamuflowane braki. Po prostu nauczy się na tym i zwróci wynik, który będzie wyglądał wiarygodnie.


Zadania

Wszystko, czego potrzebujesz, pojawiło się w przykładzie powyżej. Korzystaj z gotowych zmiennych: dane, dane_surowe, cechy, ETYKIETA, funkcji granice_iqr.

Zadanie 1: Portret statystyczny chorego i zdrowego pacjenta

Policz describe() osobno dla pacjentów chorych i zdrowych, a następnie porównaj mediany obu grup dla wszystkich cech.

Wskazówka: dane[dane[ETYKIETA] == 1][cechy].describe() albo krócej dane.groupby(ETYKIETA)[cechy].median().

Odpowiedz sobie: dla której cechy różnica median jest największa proporcjonalnie do jej wartości?

Krok po kroku - co masz zrobić (kliknij, żeby rozwinąć)
  1. Pogrupuj wiersze według kolumny Diabetic - chcesz osobno zdrowych (0) i chorych (1).
  2. Dla każdej grupy policz medianę wszystkich cech.
  3. Odwróć tabelę, żeby cechy były w wierszach, a grupy w kolumnach - tak się to czyta o wiele łatwiej.
  4. Dodaj kolumnę z różnicą median między grupami.
  5. Dodaj kolumnę z różnicą względną, czyli różnicą podzieloną przez medianę zdrowych.
  6. Posortuj tabelę malejąco według różnicy względnej.

Dlaczego mediana, a nie średnia: mediana to wartość środkowa - połowa pacjentów ma mniej, połowa więcej. W odróżnieniu od średniej nie daje się zaburzyć pojedynczym skrajnym wynikiem. Przy danych medycznych, gdzie zdarzają się bardzo wysokie pomiary, to istotna różnica.

Dlaczego różnica względna, a nie zwykła: SerumInsulin różni się o 101 jednostek, a DiabetesPedigree o 0,04. Porównywanie tych liczb wprost nie ma sensu, bo są w zupełnie innych skalach. Dzielenie przez wartość wyjściową sprowadza je do wspólnego mianownika: „o ile procent więcej".

Jak to napisać - kod z wyjaśnieniem

Krok 1-2: grupowanie i mediana

mediany = dane.groupby(ETYKIETA)[cechy].median()
  • dane.groupby(ETYKIETA) dzieli wiersze na grupy według wartości w kolumnie Diabetic - powstaną dwie grupy: 0 i 1.
  • [cechy] ogranicza działanie do kolumn z cechami. cechy to lista zdefiniowana wcześniej w notatniku, bez PatientID i bez Diabetic.
  • .median() liczy medianę każdej kolumny osobno w każdej grupie.

Krok 3: odwrócenie tabeli

mediany = dane.groupby(ETYKIETA)[cechy].median().T
mediany.columns = ['zdrowi (0)', 'chorzy (1)']
  • .T to transpozycja - zamienia wiersze z kolumnami. Bez niej masz 2 wiersze i 8 kolumn, po niej 8 wierszy i 2 kolumny.
  • mediany.columns = [...] nadaje kolumnom czytelne nazwy zamiast 0 i 1.

Krok 4-5: obie różnice

mediany['różnica'] = mediany['chorzy (1)'] - mediany['zdrowi (0)']
mediany['różnica względna'] = mediany['różnica'] / mediany['zdrowi (0)']

Przypisanie do nieistniejącej kolumny tworzy ją. Odejmowanie i dzielenie działają tu na całych kolumnach naraz, wiersz po wierszu - nie potrzebujesz pętli.

Krok 6: sortowanie

mediany.sort_values('różnica względna', key=abs, ascending=False)
  • key=abs sortuje według wartości bezwzględnej. Bez tego cecha, która u chorych jest niższa (różnica ujemna), wylądowałaby na końcu, choć różnica może być duża.
  • ascending=False układa od największej do najmniejszej.
Gotowe rozwiązanie i czego się spodziewać
mediany = dane.groupby(ETYKIETA)[cechy].median().T
mediany.columns = ['zdrowi (0)', 'chorzy (1)']
mediany['różnica'] = mediany['chorzy (1)'] - mediany['zdrowi (0)']
mediany['różnica względna'] = mediany['różnica'] / mediany['zdrowi (0)']

print(mediany.sort_values('różnica względna', key=abs, ascending=False)
             .to_string(float_format=lambda v: f"{v:.3f}"))

print()
print("Pełne describe() dla grupy chorych:")
dane[dane[ETYKIETA] == 1][cechy].describe().T.round(2)

Czego się spodziewać - tabela uszeregowana mniej więcej tak:

                        zdrowi (0)  chorzy (1)  różnica  różnica względna
Pregnancies                  1.000       5.000    4.000             4.000
SerumInsulin                53.000     154.000  101.000             1.906
Age                         23.000      37.000   14.000             0.609
DiabetesPedigree             0.193       0.231    0.038             0.198
BMI                         28.623      33.883    5.260             0.184
PlasmaGlucose               96.000     108.000   12.000             0.125
TricepsThickness            31.000      29.000   -2.000            -0.065
DiastolicBloodPressure      69.000      73.000    4.000             0.058

Jak to czytać:

  • Odpowiedź na pytanie z zadania to Pregnancies: mediana rośnie z 1 do 5, czyli o 400%. Żadna inna cecha nie zbliża się do tego.
  • TricepsThickness ma różnicę ujemną - u chorych jest niższa. Właśnie dlatego sortujesz przez key=abs.
  • Porównaj ten ranking z korelacjami z sekcji 5. Kolejność jest podobna, ale nie identyczna - korelacja i różnica median to dwie różne miary i nie muszą się zgadzać co do joty.
In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 2: Histogram z podziałem na klasy

Narysuj histogram cechy PlasmaGlucose z dwoma nałożonymi rozkładami: osobno dla chorych, osobno dla zdrowych.

Wskazówki:

  • narysuj dwa razy ax.hist(...) na tej samej osi,
  • użyj alpha=0.6, żeby oba były widoczne,
  • ustaw bins=30 i label=..., dodaj ax.legend().

Następnie powtórz to dla cechy, która według rankingu z sekcji 6 rozróżnia klasy najsłabiej. Porównaj oba rysunki - co widzisz?

Krok po kroku - co masz zrobić (kliknij, żeby rozwinąć)
  1. Utwórz figurę z jedną osią (fig, ax = plt.subplots(...)).
  2. Wybierz wiersze zdrowych pacjentów i narysuj z nich histogram na tej osi.
  3. Wybierz wiersze chorych i narysuj drugi histogram na tej samej osi.
  4. Dodaj legendę, podpis osi i tytuł.
  5. Powtórz całość dla cechy, która rozróżnia klasy najsłabiej. Nie wpisuj jej nazwy z pamięci - wylicz ją z danych.

Co to jest histogram: wykres pokazujący, ile obserwacji wpada w kolejne przedziały wartości. Słupek przy wartości 100 o wysokości 400 znaczy „400 pacjentów miało wynik w okolicy 100". Dwa nałożone histogramy pozwalają zobaczyć, czy chorzy i zdrowi mają różne rozkłady - a to jest dokładnie to, co model będzie próbował wykorzystać.

Jak to napisać - kod z wyjaśnieniem

Krok 1: figura i oś

fig, ax = plt.subplots(figsize=(8, 5))

plt.subplots() zwraca dwie rzeczy: figurę (całe płótno) i oś (jeden układ współrzędnych). Rysujesz zawsze na osi, czyli na ax.

Krok 2-3: dwa histogramy na jednej osi

ax.hist(dane.loc[dane[ETYKIETA] == 0, 'PlasmaGlucose'],
        bins=30, alpha=0.6, label='zdrowi')
ax.hist(dane.loc[dane[ETYKIETA] == 1, 'PlasmaGlucose'],
        bins=30, alpha=0.6, label='chorzy')

Rozłóżmy pierwszą linię na części:

  • dane[ETYKIETA] == 0 daje kolumnę wartości True/False - True tam, gdzie pacjent jest zdrowy.
  • dane.loc[maska, 'PlasmaGlucose'] wybiera te wiersze, w których maska jest True, i z nich tylko kolumnę PlasmaGlucose.
  • bins=30 dzieli zakres wartości na 30 przedziałów. Za mało przedziałów ukryje kształt, za dużo zamieni wykres w grzebień.
  • alpha=0.6 to przezroczystość. Bez niej drugi histogram całkowicie zasłoni pierwszy - to najczęstszy błąd w tym zadaniu.
  • label= to podpis, który pojawi się w legendzie.

Krok 4: opisy

ax.set_xlabel('PlasmaGlucose')
ax.set_ylabel('liczba pacjentów')
ax.legend()

ax.legend() musi być wywołane, inaczej podpisy z label= nigdzie się nie pojawią.

Krok 5: najsłabsza cecha - wyliczona, nie zgadnięta

najslabsza = macierz[ETYKIETA].drop(ETYKIETA).abs().idxmin()
  • macierz[ETYKIETA] bierze kolumnę korelacji z etykietą.
  • .drop(ETYKIETA) usuwa korelację etykiety z samą sobą (zawsze równą 1).
  • .abs() bierze wartości bezwzględne - interesuje nas siła związku, nie jego kierunek.
  • .idxmin() zwraca nazwę cechy o najmniejszej wartości, a nie samą wartość.

Żeby nie powtarzać kodu dwa razy, opłaca się zamknąć rysowanie w funkcji przyjmującej nazwę kolumny i oś.

Gotowe rozwiązanie i czego się spodziewać
def histogram_wg_klas(kolumna, ax):
    ax.hist(dane.loc[dane[ETYKIETA] == 0, kolumna], bins=30, alpha=0.6,
            color='#4C72B0', label='zdrowi', edgecolor='white')
    ax.hist(dane.loc[dane[ETYKIETA] == 1, kolumna], bins=30, alpha=0.6,
            color='#C44E52', label='chorzy', edgecolor='white')
    ax.set_xlabel(kolumna)
    ax.set_ylabel('liczba pacjentów')
    ax.set_title(f"{kolumna} (korelacja z etykietą: {macierz.loc[kolumna, ETYKIETA]:+.3f})")
    ax.legend()
    ax.grid(alpha=0.3)


# Cecha najslabiej rozrozniajaca klasy - wybieramy ja z danych, nie z pamieci
najslabsza = macierz[ETYKIETA].drop(ETYKIETA).abs().idxmin()
print("Najsłabiej skorelowana z etykietą:", najslabsza)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 4.5))
histogram_wg_klas('PlasmaGlucose', ax1)
histogram_wg_klas(najslabsza, ax2)
plt.tight_layout()
plt.show()

Czego się spodziewać:

  • Wydruk: Najsłabiej skorelowana z etykietą: DiastolicBloodPressure (korelacja +0,087).
  • Dwa wykresy obok siebie.

Jak to czytać - to jest pointa zadania:

  • Przy PlasmaGlucose oba rozkłady są przesunięte względem siebie. Widać, że chorzy mają wyższe wartości - nakładają się, ale nie pokrywają.
  • Przy DiastolicBloodPressure oba rozkłady leżą niemal jeden na drugim. Gdybyś dostał sam pomiar ciśnienia, nie potrafiłbyś zgadnąć, czy pacjent jest chory.
  • Dokładnie to oznacza „cecha rozróżnia klasy słabo". Korelacja 0,087 to ta sama informacja podana liczbą - wykres pokazuje ją tak, że się ją rozumie.
In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 3: Najsilniej skorelowane pary cech

Znajdź trzy pary cech (nie licząc etykiety) o najwyższej korelacji co do wartości bezwzględnej.

Wskazówki:

  • korzystaj z macierz policzonej w sekcji 5,
  • każda para występuje w macierzy dwa razy, a na przekątnej są same jedynki - trzeba je pominąć; przyda się podwójna pętla po indeksach i < j albo macierz.where(np.triu(np.ones(macierz.shape), k=1).astype(bool)).

Zastanów się: czy któraś z tych par jest na tyle silnie skorelowana, że warto rozważyć usunięcie jednej z cech?

Krok po kroku - co masz zrobić (kliknij, żeby rozwinąć)
  1. Policz macierz korelacji samych cech, bez etykiety.
  2. Zostaw tylko górny trójkąt macierzy, bez przekątnej.
  3. Zamień macierz na listę par (cecha A, cecha B, korelacja).
  4. Dodaj kolumnę z wartością bezwzględną korelacji.
  5. Posortuj i wypisz trzy najsilniejsze oraz trzy najsłabsze pary.

Dlaczego górny trójkąt: macierz korelacji jest symetryczna - korelacja BMI z Age jest tą samą liczbą co korelacja Age z BMI. Gdybyś wziął całą macierz, każda para pojawiłaby się dwa razy. Na przekątnej są same jedynki (korelacja cechy z samą sobą), które zawsze wygrałyby ranking i zaśmieciły wynik.

Jak to napisać - kod z wyjaśnieniem

Krok 1: korelacje bez etykiety

tylko_cechy = dane[cechy].corr()

Zwróć uwagę: dane[cechy], a nie dane[cechy + [ETYKIETA]]. Zadanie pyta o korelacje między cechami, nie o ich związek z etykietą.

Krok 2: górny trójkąt

import numpy as np
gorny = tylko_cechy.where(np.triu(np.ones(tylko_cechy.shape), k=1).astype(bool))

To jedna gęsta linia, więc od środka:

  • np.ones(tylko_cechy.shape) tworzy tablicę 8x8 wypełnioną jedynkami.
  • np.triu(..., k=1) zeruje wszystko poniżej przekątnej oraz samą przekątną (to robi k=1). Zostaje górny trójkąt.
  • .astype(bool) zamienia 1 na True, a 0 na False.
  • .where(maska) zostawia wartości tam, gdzie maska jest True, a w pozostałych miejscach wstawia NaN.

Krok 3: z macierzy na listę par

pary = (gorny.stack()
             .rename('korelacja')
             .reset_index()
             .rename(columns={'level_0': 'cecha A', 'level_1': 'cecha B'}))
  • .stack() „rozkłada" tabelę na jedną długą kolumnę, w której indeks to para (wiersz, kolumna). Pomija przy tym NaN, więc dolny trójkąt znika sam.
  • .reset_index() zamienia ten indeks w zwykłe kolumny, domyślnie nazwane level_0 i level_1.
  • .rename(columns=...) nadaje im czytelne nazwy.

Krok 4-5: sortowanie

pary['|korelacja|'] = pary['korelacja'].abs()
pary.sort_values('|korelacja|', ascending=False).head(3)
pary.sort_values('|korelacja|').head(3)

Osobna kolumna z wartością bezwzględną jest wygodniejsza od key=abs, bo chcesz ją też zobaczyć w wyniku.

Gotowe rozwiązanie i czego się spodziewać
tylko_cechy = dane[cechy].corr()

# Bierzemy gorny trojkat bez przekatnej - kazda para dokladnie raz
gorny = tylko_cechy.where(np.triu(np.ones(tylko_cechy.shape), k=1).astype(bool))

pary = (gorny.stack()
             .rename('korelacja')
             .reset_index()
             .rename(columns={'level_0': 'cecha A', 'level_1': 'cecha B'}))
pary['|korelacja|'] = pary['korelacja'].abs()

print("Trzy najsilniej skorelowane pary cech:")
print(pary.sort_values('|korelacja|', ascending=False).head(3).to_string(index=False))
print()
print("Trzy najsłabiej skorelowane pary cech:")
print(pary.sort_values('|korelacja|').head(3).to_string(index=False))

Czego się spodziewać:

Trzy najsilniej skorelowane pary cech:
    cecha A      cecha B  korelacja  |korelacja|
Pregnancies          Age   0.144361     0.144361
Pregnancies SerumInsulin   0.115830     0.115830
Pregnancies          BMI   0.098109     0.098109

Odpowiedź na pytanie z zadania jest przecząca - i to jest najważniejsze w tym zadaniu.

Najsilniejsza para w całym zbiorze ma korelację 0,144. To bardzo mało. Za próg, przy którym w ogóle rozważa się usunięcie jednej z cech, przyjmuje się zwykle 0,8-0,9. Żadnej cechy nie należy tu usuwać.

Gdyby któraś para miała korelację rzędu 0,95, obie kolumny niosłyby praktycznie tę samą informację. Model dostawałby ją podwójnie, a jego współczynniki stawałyby się niestabilne - to zjawisko nazywa się współliniowością (ang. multicollinearity).

Warto zapamiętać, że negatywny wynik też jest wynikiem. Sprawdziłeś realne ryzyko i wykluczyłeś je pomiarem, zamiast zakładać.

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 4: Reguła IQR z innym mnożnikiem

Funkcja granice_iqr ma parametr mnoznik, domyślnie 1,5. Sprawdź, jak liczba wartości odstających w cesze SerumInsulin zmienia się dla mnożników 1,0, 1,5, 2,0 i 3,0.

Wypisz tabelkę: mnożnik, granice, liczba odstających, udział procentowy.

Zastanów się: skąd w ogóle wzięła się wartość 1,5? Czy jest w niej coś matematycznie koniecznego?

Krok po kroku - co masz zrobić (kliknij, żeby rozwinąć)
  1. Przygotuj listę mnożników do sprawdzenia: 1,0, 1,5, 2,0 i 3,0.
  2. Dla każdego mnożnika wywołaj gotową funkcję granice_iqr na kolumnie SerumInsulin.
  3. Policz, ile wartości wypada poza wyznaczone granice.
  4. Policz, jaki to procent wszystkich wierszy.
  5. Zbierz wyniki w tabelę i ją wypisz.

Przypomnienie reguły IQR: bierzesz pierwszy kwartyl Q1 (25% danych jest poniżej) i trzeci kwartyl Q3 (75% poniżej). Ich różnica to rozstęp międzykwartylowy IQR. Granice to Q1 - mnożnik * IQR i Q3 + mnożnik * IQR. Im większy mnożnik, tym szersze granice i tym mniej wartości uznanych za odstające.

Jak to napisać - kod z wyjaśnieniem

Krok 1-2: pętla po mnożnikach

wiersze = []
for mnoznik in [1.0, 1.5, 2.0, 3.0]:
    dol, gora = granice_iqr(dane['SerumInsulin'], mnoznik)

granice_iqr zwraca dwie wartości naraz, więc możesz je od razu rozpakować do dwóch zmiennych.

Krok 3: liczenie odstających

    odstajace = (dane['SerumInsulin'] < dol) | (dane['SerumInsulin'] > gora)
    liczba = odstajace.sum()
  • Każde porównanie daje kolumnę True/False.
  • | to „lub" działające element po elemencie. Musi być w nawiasach, bo | ma w Pythonie wyższy priorytet niż < i bez nawiasów dostaniesz błąd.
  • .sum() na kolumnie True/False liczy True jako 1 - dostajesz liczbę odstających.

Krok 4: procent

    udzial = odstajace.mean()

.mean() na tej samej kolumnie daje od razu udział (średnia z zer i jedynek to właśnie udział jedynek). Nie musisz dzielić przez len(dane).

Krok 5: tabela

    wiersze.append({
        'mnożnik': mnoznik,
        'dolna granica': round(dol, 1),
        'górna granica': round(gora, 1),
        'liczba odstających': int(odstajace.sum()),
        'udział': f"{odstajace.mean():.2%}",
    })

print(pd.DataFrame(wiersze).to_string(index=False))

Budowanie listy słowników, a na końcu jedno pd.DataFrame(...), jest szybsze i czytelniejsze niż doklejanie wierszy do tabeli w pętli.

to_string(index=False) ukrywa numery wierszy 0-3, które niczego tu nie wnoszą.

Gotowe rozwiązanie i czego się spodziewać
wiersze = []
for mnoznik in [1.0, 1.5, 2.0, 3.0]:
    dol, gora = granice_iqr(dane['SerumInsulin'], mnoznik)
    odstajace = (dane['SerumInsulin'] < dol) | (dane['SerumInsulin'] > gora)
    wiersze.append({
        'mnożnik': mnoznik,
        'dolna granica': round(dol, 1),
        'górna granica': round(gora, 1),
        'liczba odstających': int(odstajace.sum()),
        'udział': f"{odstajace.mean():.2%}",
    })

print(pd.DataFrame(wiersze).to_string(index=False))

Czego się spodziewać:

 mnożnik  dolna granica  górna granica  liczba odstających udział
     1.0         -119.0          355.0                 699  6.99%
     1.5         -198.0          434.0                 448  4.48%
     2.0         -277.0          513.0                 267  2.67%
     3.0         -435.0          671.0                  77  0.77%

Jak to czytać:

  • Liczba odstających spada z 699 do 77 - dziewięciokrotnie - a dane się nie zmieniły. Zmienił się wyłącznie próg, który sam sobie ustawiłeś.
  • Dolna granica jest ujemna w każdym wariancie. Insulina nie może być ujemna, więc reguła IQR nie wykryje tu żadnej wartości „za niskiej". Wszystkie odstające to wartości za wysokie.

Odpowiedź na pytanie „skąd 1,5": to konwencja, a nie wynik matematyczny. Zaproponował ją John Tukey przy okazji wykresu pudełkowego, bo dla rozkładu normalnego daje około 0,7% odstających, co uznał za rozsądny próg. Nie ma tu żadnej konieczności - liczba 1,5 jest wyborem, nie prawem.

To ważniejsze, niż wygląda: „wartość odstająca" nie jest własnością danych, tylko skutkiem progu, który ktoś przyjął. Zmiana progu zmienia odpowiedź, a dane zostają te same.

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 5: Wykryj podejrzane zera samodzielnie

Napisz funkcję raport_zer(ramka, kolumny, prog=0.01), która:

  1. dla każdej kolumny liczy udział zer,
  2. wypisuje tylko te kolumny, w których udział zer przekracza prog,
  3. sortuje wynik malejąco według udziału zer.

Uruchom ją na dane_surowe i na dane. Porównaj wyniki.

Następnie odpowiedz: funkcja wskaże także Pregnancies. Dlaczego to nie jest błąd funkcji i czego to dowodzi o automatycznym wykrywaniu problemów z danymi?

Krok po kroku - co masz zrobić (kliknij, żeby rozwinąć)
  1. Zdefiniuj funkcję o trzech parametrach: ramka danych, lista kolumn i próg (domyślnie 0,01).
  2. W środku przejdź po kolumnach i dla każdej policz udział zer.
  3. Zatrzymaj tylko te kolumny, w których udział przekracza próg.
  4. Posortuj wynik malejąco i zwróć go.
  5. Uruchom funkcję na dane_surowe, potem na dane, i porównaj wyniki.

Po co pisać funkcję, a nie jednorazowy kod: bo ten test będziesz chciał uruchomić na każdym nowym zbiorze danych, jaki dostaniesz. Kod wklejony raz do notatnika ginie; funkcja zostaje i daje się przenieść.

Jak to napisać - kod z wyjaśnieniem

Krok 1: nagłówek funkcji

def raport_zer(ramka, kolumny, prog=0.01):

prog=0.01 to wartość domyślna. Możesz wywołać raport_zer(dane, cechy) i próg wyniesie 0,01, albo raport_zer(dane, cechy, prog=0.05) i użyty zostanie 0,05.

Krok 2: udział zer w kolumnie

    udzialy = {}
    for kolumna in kolumny:
        udzialy[kolumna] = (ramka[kolumna] == 0).mean()

(ramka[kolumna] == 0) daje kolumnę True/False, a .mean() zamienia ją od razu na udział - ten sam chwyt co w zadaniu 4.

Krok 3-4: filtr, sortowanie i druga kolumna

    wynik = (pd.Series(udzialy)
               .loc[lambda s: s > prog]
               .sort_values(ascending=False)
               .rename('udział zer')
               .to_frame())
    wynik['liczba zer'] = [(ramka[k] == 0).sum() for k in wynik.index]
    return wynik
  • pd.Series(słownik) robi z niego kolumnę z nazwami kolumn jako indeksem.
  • .loc[lambda s: s > prog] zostawia tylko pozycje powyżej progu. Zapis z lambda znaczy „weź to, co właśnie powstało w łańcuchu, i zastosuj do tego warunek" - dzięki temu nie trzeba przerywać łańcucha i zapisywać wyniku do zmiennej pomocniczej.
  • .rename('udział zer') nadaje kolumnie nazwę, a .to_frame() zamienia pojedynczą kolumnę w tabelę - bez tego nie dałoby się dołożyć drugiej kolumny.
  • Ostatnia linia dokłada liczby bezwzględne. Sam udział bywa mylący: 5% z dziesięciu wierszy to zupełnie inna sytuacja niż 5% z dziesięciu tysięcy.
  • Funkcja zwraca wynik zamiast go drukować - dzięki temu da się go dalej użyć w kodzie, a nie tylko obejrzeć.

Krok 5: dwa uruchomienia

r_surowe = raport_zer(dane_surowe, cechy)
print(r_surowe.to_string(float_format=lambda v: f"{v:.3f}") if len(r_surowe)
      else "brak kolumn powyżej progu")

Przekazujesz cechy, a nie wszystkie kolumny - inaczej funkcja zgłosi też Diabetic, gdzie 66% zer to poprawna wartość etykiety, a nie brak danych.

Sprawdzenie if len(r_surowe) jest potrzebne, bo to_string() na pustej tabeli daje mylący wydruk samych nagłówków. Funkcja, która nic nie znalazła, powinna powiedzieć to wprost.

Gotowe rozwiązanie i czego się spodziewać
def raport_zer(ramka, kolumny, prog=0.01):
    # Zwraca kolumny, w ktorych udzial zer przekracza prog, posortowane malejaco.
    udzialy = {k: (ramka[k] == 0).mean() for k in kolumny}
    wynik = (pd.Series(udzialy)
               .loc[lambda s: s > prog]
               .sort_values(ascending=False)
               .rename('udział zer')
               .to_frame())
    wynik['liczba zer'] = [(ramka[k] == 0).sum() for k in wynik.index]
    return wynik


print("=== dane_surowe (symulowany wadliwy eksport) ===")
r_surowe = raport_zer(dane_surowe, cechy)
print(r_surowe.to_string(float_format=lambda v: f"{v:.3f}") if len(r_surowe)
      else "brak kolumn powyżej progu")

print()
print("=== dane (plik oryginalny) ===")
r_czyste = raport_zer(dane, cechy)
print(r_czyste.to_string(float_format=lambda v: f"{v:.3f}") if len(r_czyste)
      else "brak kolumn powyżej progu")

Czego się spodziewać:

=== dane_surowe (symulowany wadliwy eksport) ===
                        udział zer  liczba zer
SerumInsulin                 0.355        3555
Pregnancies                  0.288        2879
TricepsThickness             0.197        1967
DiastolicBloodPressure       0.048         482

=== dane (plik oryginalny) ===
             udział zer  liczba zer
Pregnancies       0.288        2879

Jak to czytać:

  • W zepsutych danych funkcja wskazała cztery kolumny, w prawdziwych - jedną.
  • Trzy kolumny, które zniknęły z drugiej listy (SerumInsulin, TricepsThickness, DiastolicBloodPressure), to dokładnie te, które wcześniej celowo zepsuto. Funkcja znalazła je nie wiedząc o tym.

Odpowiedź na pytanie o Pregnancies: 28,79% zer występuje w obu wersjach danych, także w nieuszkodzonej. I słusznie - zero przebytych ciąż to prawdziwa, sensowna wartość.

Czego to dowodzi o automatycznym wykrywaniu problemów: narzędzie wskazuje podejrzanych, ale nie wydaje wyroku. Żeby rozstrzygnąć, czy zero jest wartością, czy zakamuflowanym brakiem, trzeba wiedzieć, co dana kolumna znaczy w rzeczywistości. Tej wiedzy nie ma w pliku CSV - masz ją Ty albo lekarz, z którym rozmawiasz. Automat, który sam usuwałby wszystkie kolumny z zerami, wyrzuciłby tu poprawną cechę.

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 6: Ile kosztuje jedno przeoczone zero?

Na kolumnie TricepsThickness z ramki dane_surowe:

  1. policz średnią i odchylenie standardowe razem z zerami,
  2. policz te same statystyki po zamianie zer na NaN (.replace(0, np.nan)),
  3. porównaj obie wersje z wartościami z ramki dane (dane prawdziwe),
  4. wyraź błąd średniej w procentach wartości prawdziwej.

Na koniec narysuj wykres pudełkowy tej cechy w trzech wersjach obok siebie: prawdziwej, z zerami i po zamianie na NaN.

Krok po kroku - co masz zrobić (kliknij, żeby rozwinąć)
  1. Weź kolumnę TricepsThickness z ramki dane_surowe i policz jej średnią oraz odchylenie standardowe.
  2. Zrób kopię tej kolumny z zerami zamienionymi na NaN i policz te same dwie statystyki.
  3. Policz je również dla prawdziwej kolumny z ramki dane.
  4. Policz, o ile procent średnia „z zerami" różni się od prawdziwej.
  5. Narysuj wykres pudełkowy trzech wersji obok siebie.

Czym jest NaN: skrót od not a number, czyli „tu nie ma wartości". Pandas traktuje NaN inaczej niż zero - pomija go przy liczeniu średniej zamiast wliczać jako 0. Na tym polega cała różnica w tym zadaniu.

Jak to napisać - kod z wyjaśnieniem

Krok 1: statystyki z zerami

kolumna = 'TricepsThickness'
z_zerami = dane_surowe[kolumna]
print(z_zerami.mean(), z_zerami.std())

Krok 2: zamiana zer na NaN

po_naprawie = dane_surowe[kolumna].replace(0, np.nan)

.replace(0, np.nan) zwraca nową kolumnę i nie zmienia dane_surowe. To dobrze - oryginał zostaje nienaruszony do porównania.

Krok 3: wartości prawdziwe

prawdziwe = dane[kolumna]

Krok 4: błąd względny

blad_z_zerami = (z_zerami.mean() - prawdziwe.mean()) / prawdziwe.mean()
print(f"Błąd średniej Z ZERAMI: {blad_z_zerami:+.1%}")

:+.1% w f-stringu robi dwie rzeczy naraz: mnoży przez 100 i dokleja znak procenta, a + wymusza wypisanie znaku. Od razu widać, czy błąd zaniża, czy zawyża.

Do tabeli warto dołożyć jeszcze mediana i liczba wartości - ta druga jest tu najciekawsza. .count() liczy wartości pomijając NaN, więc pokaże, ilu pomiarów naprawdę brakuje.

Krok 5: wykres pudełkowy

fig, ax = plt.subplots(figsize=(8, 5))
ax.boxplot([prawdziwe, z_zerami, po_naprawie.dropna()], patch_artist=True)
ax.set_xticklabels(['prawdziwe', 'z zerami', 'po zamianie\nna NaN'])
  • .dropna() jest konieczne - boxplot nie poradzi sobie z NaN w danych wejściowych.
  • Etykiety ustawiaj przez ax.set_xticklabels(...), a nie argumentem labels=. Ten drugi został wycofany w Matplotlib 3.9 i wypisuje ostrzeżenie.
Gotowe rozwiązanie i czego się spodziewać
kolumna = 'TricepsThickness'

prawdziwe = dane[kolumna]
z_zerami = dane_surowe[kolumna]
po_naprawie = dane_surowe[kolumna].replace(0, np.nan)

zestawienie = pd.DataFrame({
    'średnia': [prawdziwe.mean(), z_zerami.mean(), po_naprawie.mean()],
    'odch. std': [prawdziwe.std(), z_zerami.std(), po_naprawie.std()],
    'mediana': [prawdziwe.median(), z_zerami.median(), po_naprawie.median()],
    'liczba wartości': [prawdziwe.count(), z_zerami.count(), po_naprawie.count()],
}, index=['dane prawdziwe', 'z zerami', 'po zamianie 0 na NaN'])

print(zestawienie.round(2).to_string())
print()
blad_z_zerami = (z_zerami.mean() - prawdziwe.mean()) / prawdziwe.mean()
blad_po_naprawie = (po_naprawie.mean() - prawdziwe.mean()) / prawdziwe.mean()
print(f"Błąd średniej Z ZERAMI:          {blad_z_zerami:+.1%}")
print(f"Błąd średniej PO ZAMIANIE NA NaN: {blad_po_naprawie:+.1%}")

fig, ax = plt.subplots(figsize=(8, 5))
ax.boxplot([prawdziwe, z_zerami, po_naprawie.dropna()],
           patch_artist=True)
ax.set_xticklabels(['prawdziwe', 'z zerami', 'po zamianie\nna NaN'])
ax.set_ylabel(kolumna)
ax.set_title('Skutek nierozpoznanych zer - ten sam pomiar, trzy wersje')
ax.grid(axis='y', alpha=0.3)
plt.tight_layout()
plt.show()

Czego się spodziewać:

                      średnia  odch. std  mediana  liczba wartości
dane prawdziwe          28.82      14.51     31.0            10000
z zerami                23.15      17.32     23.0            10000
po zamianie 0 na NaN    28.82      14.50     31.0             8033

Błąd średniej Z ZERAMI:          -19.7%
Błąd średniej PO ZAMIANIE NA NaN: -0.0%

Jak to czytać - i dlaczego to jest groźne:

  • Średnia „z zerami" jest zaniżona o 19,7%. Gdyby to była grubość fałdu skórnego w badaniu klinicznym, pomyliłbyś się o jedną piątą - i nic by o tym nie krzyknęło.
  • Odchylenie standardowe rośnie z 14,5 do 17,3. Zera dorzucają sztuczne skupisko przy zerze, więc dane wyglądają na bardziej rozrzucone, niż są.
  • Po zamianie zer na NaN średnia wraca do wartości prawdziwej, a błąd spada do -0,0% - nie dlatego, że coś zgadliśmy, tylko dlatego, że przestaliśmy wliczać nieistniejące pomiary.
  • Kolumna liczba wartości pokazuje cenę tej naprawy: z 10 000 pomiarów zostaje 8033. Prawie 2000 wierszy nie ma tej cechy w ogóle - wcześniej udawały zera i wyglądały jak dane.
  • Mediana spada z 31 do 23 i wraca do 31. Zera przesuwają nawet miarę, która z założenia jest odporna na wartości skrajne - bo tu nie chodzi o pojedynczy odstający wynik, tylko o jedną piątą zbioru.

Na wykresie pudełkowym środkowe pudełko jest rozciągnięte w dół, a jego wąs sięga zera. Dwa pozostałe wyglądają praktycznie identycznie.

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 7 (trudniejsze): Czy model zauważy różnicę?

Sprawdź empirycznie, czy podejrzane zera faktycznie szkodzą modelowi. Porównaj trzy warianty tych samych danych:

  • A - dane prawdziwe (dane),
  • B - dane zepsute (dane_surowe), wrzucone do modelu tak jak są, razem z zerami,
  • C - dane zepsute, ale z wierszami zawierającymi zera usuniętymi (zamień zera na NaN i użyj dropna()).

Dla każdego wariantu:

  1. zbuduj X (bez PatientID i Diabetic) oraz y,
  2. podziel dane (test_size=0.2, stratify=y, random_state=42),
  3. wytrenuj make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000, random_state=42)),
  4. wypisz skuteczność na zbiorze testowym oraz liczbę wierszy, na których model się uczył.

Pytania, na które odpowiadasz wynikiem:

  • Czy wariant B wypada zauważalnie gorzej od A? Czy różnica jest tak duża, że ktoś by ją zauważył bez porównania?
  • Ile danych kosztowało nas podejście C? Czy to była dobra cena?
  • Który wariant jest najbardziej niebezpieczny w praktyce - i dlaczego to nie jest ten o najniższej skuteczności?
Krok po kroku - co masz zrobić (kliknij, żeby rozwinąć)
  1. Napisz funkcję, która dostaje ramkę danych i opis, a zwraca skuteczność modelu i liczbę wierszy uczących.
  2. W środku: wydziel X (bez PatientID i Diabetic) oraz y (sama kolumna Diabetic).
  3. Podziel dane na część uczącą i testową.
  4. Zbuduj potok: skalowanie + regresja logistyczna, i naucz go na części uczącej.
  5. Policz skuteczność na części testowej.
  6. Przygotuj trzy warianty danych (A, B, C) i przepuść każdy przez tę funkcję.
  7. Zestaw wyniki w jednej tabeli.

Po co potok (make_pipeline): regresja logistyczna działa źle, gdy cechy mają bardzo różne skale - SerumInsulin sięga setek, a DiabetesPedigree jest poniżej jedynki. StandardScaler sprowadza je do wspólnej skali. Potok łączy skalowanie i model w jeden obiekt, dzięki czemu skalowanie jest uczone tylko na danych uczących - inaczej doszłoby do przecieku informacji ze zbioru testowego.

Jak to napisać - kod z wyjaśnieniem

Krok 1-2: funkcja i podział na X oraz y

def ocen(ramka, opis):
    X = ramka.drop(columns=[IDENTYFIKATOR, ETYKIETA])
    y = ramka[ETYKIETA]
  • X to cechy, czyli wszystko poza identyfikatorem i etykietą. PatientID musi wypaść - to numer z rejestracji, a model potrafiłby się go nauczyć na pamięć.
  • y to kolumna, którą przewidujemy.

Krok 3: podział

    X_ucz, X_test, y_ucz, y_test = train_test_split(
        X, y, test_size=0.2, stratify=y, random_state=42
    )
  • test_size=0.2 odkłada 20% danych na test.
  • stratify=y pilnuje, żeby proporcja chorych do zdrowych była taka sama w obu częściach. Bez tego mógłbyś dostać zbiór testowy o innym składzie niż uczący i porównanie straciłoby sens.
  • random_state=42 sprawia, że losowanie jest powtarzalne. Przy porównywaniu trzech wariantów jest to konieczne - inaczej nie wiedziałbyś, czy różnica bierze się z danych, czy z innego losowania.

Krok 4: potok i trenowanie

    model = make_pipeline(
        StandardScaler(),
        LogisticRegression(max_iter=1000, random_state=42)
    )
    model.fit(X_ucz, y_ucz)

max_iter=1000 podnosi limit iteracji - przy wartości domyślnej algorytm często nie zdąży się zbiec i wypisze ostrzeżenie.

Krok 5: ocena i zwrócenie wyniku

    return {
        'wariant': opis,
        'wierszy razem': len(ramka),
        'wierszy uczących': len(X_ucz),
        'skuteczność testowa': m.score(X_test, y_test),
    }

m.score(...) dla klasyfikatora zwraca skuteczność, czyli udział poprawnych predykcji.

Kolumna wierszy razem jest tu równie ważna jak skuteczność. Bez niej porównanie wariantów wygląda na remis - a to właśnie ona pokazuje, ile wariant C zapłacił za swój wynik.

Krok 6: trzy warianty

dane_c = dane_surowe.copy()
podejrzane = ['SerumInsulin', 'TricepsThickness', 'DiastolicBloodPressure']
dane_c[podejrzane] = dane_c[podejrzane].replace(0, np.nan)
dane_c = dane_c.dropna()

.dropna() usuwa cały wiersz, jeśli choć jedna z tych trzech kolumn jest pusta. Zaraz zobaczysz, ile to kosztuje.

Gotowe rozwiązanie i czego się spodziewać
def ocen(ramka, opis):
    X = ramka.drop(columns=[IDENTYFIKATOR, ETYKIETA])
    y = ramka[ETYKIETA]
    X_ucz, X_test, y_ucz, y_test = train_test_split(
        X, y, test_size=0.2, stratify=y, random_state=42
    )
    m = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000, random_state=42))
    m.fit(X_ucz, y_ucz)
    return {
        'wariant': opis,
        'wierszy razem': len(ramka),
        'wierszy uczących': len(X_ucz),
        'skuteczność testowa': m.score(X_test, y_test),
    }


# C: zamieniamy podejrzane zera na NaN i usuwamy takie wiersze
podejrzane = ['SerumInsulin', 'TricepsThickness', 'DiastolicBloodPressure']
dane_c = dane_surowe.copy()
dane_c[podejrzane] = dane_c[podejrzane].replace(0, np.nan)
dane_c = dane_c.dropna()

wyniki = pd.DataFrame([
    ocen(dane, 'A - dane prawdziwe'),
    ocen(dane_surowe, 'B - z zerami, wrzucone jak leci'),
    ocen(dane_c, 'C - zera na NaN, wiersze usunięte'),
])

print(wyniki.to_string(index=False, float_format=lambda v: f"{v:.4f}"))
print()
print(f"Wariant C stracił {len(dane) - len(dane_c)} wierszy "
      f"({1 - len(dane_c) / len(dane):.1%} zbioru).")

Czego się spodziewać:

                          wariant  wierszy razem  wierszy uczących  skuteczność testowa
               A - dane prawdziwe          10000              8000               0.7880
  B - z zerami, wrzucone jak leci          10000              8000               0.7775
C - zera na NaN, wiersze usunięte           4925              3940               0.7787

Wariant C stracił 5075 wierszy (50.8% zbioru).

Odpowiedzi na trzy pytania z zadania:

1. Czy B wypada zauważalnie gorzej od A? Różnica to 1 punkt procentowy (0,7880 wobec 0,7775). Bez wariantu A obok nikt by jej nie zauważył. Wynik 77,75% wygląda całkowicie normalnie - i to jest sedno sprawy.

2. Ile kosztowało podejście C? 50,8% danych - z 10 000 wierszy zostało 4925. W zamian skuteczność wzrosła o 0,12 punktu procentowego względem B, czyli praktycznie o nic. To zła cena. Przy takim udziale braków sensowniejsze jest uzupełnianie wartości (ang. imputation) niż wyrzucanie wierszy - wrócisz do tego w ćwiczeniu 03.

3. Który wariant jest najbardziej niebezpieczny? Wariant B - mimo że to nie on ma najniższą skuteczność.

Powód: B jest jedynym wariantem, w którym nie wiesz, że masz problem. Model działa, wyniki wyglądają zdrowo, nic się nie wysypuje. C jest jawnie kosztowny - od razu widzisz, że straciłeś połowę danych, i możesz o tym zdecydować świadomie. B po cichu uczy model, że jedna trzecia pacjentów miała zerową insulinę.

To jest pointa całego ćwiczenia: model nie zgłasza problemów z danymi. On się na nich uczy i zwraca liczbę, która wygląda wiarygodnie. Jedyny moment, w którym takie rzeczy da się złapać, to spojrzenie na dane własnymi oczami - czyli EDA.

In [ ]:
# Zadanie 7 jako jedyne w tym notatniku trenuje model,
# więc potrzebuje scikit-learn - wcześniejsze sekcje korzystały
# wyłącznie z pandas i matplotlib.
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

# TWÓJ KOD TUTAJ

Pytania do przemyślenia

Na te pytania odpowiadasz słowami, nie kodem.

  1. Cecha ma korelację z etykietą równą 0,02. Czy to wystarczający powód, żeby ją usunąć ze zbioru? Co jeszcze sprawdzisz, zanim zdecydujesz?
  2. W kolumnie „wiek pacjenta" znajdujesz wartość 0. W kolumnie „liczba przebytych ciąż" też znajdujesz 0. Dlaczego pierwsza jest alarmem, a druga nie? Jakiej wiedzy potrzebujesz, żeby to rozstrzygnąć - i skąd ją bierzesz?
  3. Model wytrenowany na danych z podejrzanymi zerami osiąga przyzwoitą skuteczność na zbiorze testowym. Dlaczego to nie dowodzi, że zera nie szkodzą? Co takiego dzieli zbiór testowy z uczącym, czego nie podzieli prawdziwy pacjent?
  4. Cecha okazuje się skorelowana z etykietą na poziomie 0,97. Jaka powinna być Twoja pierwsza reakcja i dlaczego nie jest nią radość?
  5. Reguła IQR oznaczyła 3% pacjentów jako odstających. Podaj dwie sytuacje, w których należy ich usunąć, i dwie, w których byłby to poważny błąd.
  6. EDA robi się przed podziałem na zbiór uczący i testowy czy po? Uzasadnij. (Podpowiedź: rozróżnij oglądanie danych od podejmowania decyzji, które trafią do modelu - do tego wątku wrócimy w ćwiczeniu 03.)

Chcesz wiedzieć więcej

W kolejnym ćwiczeniu (03 - Przygotowanie danych) zajmiemy się tym, co zrobić z problemami, które właśnie wykryliśmy: jak uzupełniać braki, jak skalować cechy, jak kodować zmienne kategoryczne - i przede wszystkim dlaczego wszystkie te operacje wolno dopasowywać wyłącznie na zbiorze uczącym.