Ć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¶
- Jak w kilku poleceniach zrobić przegląd nowego zbioru danych (
shape,info,describe). - Jak czytać histogram rozkładu cechy i co ci mówi jego kształt.
- Jak liczyć i czytać macierz korelacji - oraz dlaczego korelacja to nie przyczynowość.
- Jak sprawdzić, czy cecha w ogóle rozróżnia klasy (wykresy pudełkowe według etykiety).
- Jak wykrywać wartości odstające regułą rozstępu międzykwartylowego (IQR).
- 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 |
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()
# 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.
# .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:
- Skale cech są dramatycznie różne.
DiabetesPedigreemieści się poniżej 3,SerumInsulinsięga setek, aPatientID- 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śmyStandardScaler, a w ćwiczeniu 03 zajmiemy się skalowaniem na poważnie. PatientIDma 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.
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.
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.
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:
- korelacja cechy z etykietą - czy ta cecha w ogóle niesie informację o chorobie,
- korelacja cech między sobą - czy dwie cechy nie mówią tego samego (ang. collinearity, współliniowość).
# 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()
# 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.
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()
# 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.
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.
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.
# 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.
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.
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:
- zaniżają średnią - im więcej zer, tym bardziej średnia odjeżdża od prawdy,
- zawyżają odchylenie standardowe - rozkład ma teraz dwa skupiska zamiast jednego,
- rozbijają rozkład na dwa garby - a wiele metod statystycznych zakłada jeden,
- psują skalowanie -
StandardScalerliczy średnią i odchylenie, czyli obie zatrute statystyki, - 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ąć)
- Pogrupuj wiersze według kolumny
Diabetic- chcesz osobno zdrowych (0) i chorych (1). - Dla każdej grupy policz medianę wszystkich cech.
- Odwróć tabelę, żeby cechy były w wierszach, a grupy w kolumnach - tak się to czyta o wiele łatwiej.
- Dodaj kolumnę z różnicą median między grupami.
- Dodaj kolumnę z różnicą względną, czyli różnicą podzieloną przez medianę zdrowych.
- 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:
SerumInsulinróżni się o 101 jednostek, aDiabetesPedigreeo 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 kolumnieDiabetic- powstaną dwie grupy: 0 i 1.[cechy]ogranicza działanie do kolumn z cechami.cechyto lista zdefiniowana wcześniej w notatniku, bezPatientIDi bezDiabetic..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)']
.Tto 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 zamiast0i1.
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=abssortuje 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=Falseukł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. TricepsThicknessma różnicę ujemną - u chorych jest niższa. Właśnie dlatego sortujesz przezkey=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.
# 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=30ilabel=..., dodajax.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ąć)
- Utwórz figurę z jedną osią (
fig, ax = plt.subplots(...)). - Wybierz wiersze zdrowych pacjentów i narysuj z nich histogram na tej osi.
- Wybierz wiersze chorych i narysuj drugi histogram na tej samej osi.
- Dodaj legendę, podpis osi i tytuł.
- 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] == 0daje kolumnę wartościTrue/False-Truetam, gdzie pacjent jest zdrowy.dane.loc[maska, 'PlasmaGlucose']wybiera te wiersze, w których maska jestTrue, i z nich tylko kolumnęPlasmaGlucose.bins=30dzieli zakres wartości na 30 przedziałów. Za mało przedziałów ukryje kształt, za dużo zamieni wykres w grzebień.alpha=0.6to 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
PlasmaGlucoseoba rozkłady są przesunięte względem siebie. Widać, że chorzy mają wyższe wartości - nakładają się, ale nie pokrywają. - Przy
DiastolicBloodPressureoba 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.
# 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
macierzpoliczonej 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 < jalbomacierz.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ąć)
- Policz macierz korelacji samych cech, bez etykiety.
- Zostaw tylko górny trójkąt macierzy, bez przekątnej.
- Zamień macierz na listę par (cecha A, cecha B, korelacja).
- Dodaj kolumnę z wartością bezwzględną korelacji.
- Posortuj i wypisz trzy najsilniejsze oraz trzy najsłabsze pary.
Dlaczego górny trójkąt: macierz korelacji jest symetryczna - korelacja
BMIzAgejest tą samą liczbą co korelacjaAgezBMI. 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 robik=1). Zostaje górny trójkąt..astype(bool)zamienia 1 naTrue, a 0 naFalse..where(maska)zostawia wartości tam, gdzie maska jestTrue, a w pozostałych miejscach wstawiaNaN.
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 tymNaN, więc dolny trójkąt znika sam..reset_index()zamienia ten indeks w zwykłe kolumny, domyślnie nazwanelevel_0ilevel_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ć.
# 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ąć)
- Przygotuj listę mnożników do sprawdzenia: 1,0, 1,5, 2,0 i 3,0.
- Dla każdego mnożnika wywołaj gotową funkcję
granice_iqrna kolumnieSerumInsulin. - Policz, ile wartości wypada poza wyznaczone granice.
- Policz, jaki to procent wszystkich wierszy.
- 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 * IQRiQ3 + 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 kolumnieTrue/FalseliczyTruejako 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.
# TWÓJ KOD TUTAJ
Zadanie 5: Wykryj podejrzane zera samodzielnie¶
Napisz funkcję raport_zer(ramka, kolumny, prog=0.01), która:
- dla każdej kolumny liczy udział zer,
- wypisuje tylko te kolumny, w których udział zer przekracza
prog, - 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ąć)
- Zdefiniuj funkcję o trzech parametrach: ramka danych, lista kolumn i próg (domyślnie 0,01).
- W środku przejdź po kolumnach i dla każdej policz udział zer.
- Zatrzymaj tylko te kolumny, w których udział przekracza próg.
- Posortuj wynik malejąco i zwróć go.
- Uruchom funkcję na
dane_surowe, potem nadane, 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 zlambdaznaczy „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ę.
# TWÓJ KOD TUTAJ
Zadanie 6: Ile kosztuje jedno przeoczone zero?¶
Na kolumnie TricepsThickness z ramki dane_surowe:
- policz średnią i odchylenie standardowe razem z zerami,
- policz te same statystyki po zamianie zer na
NaN(.replace(0, np.nan)), - porównaj obie wersje z wartościami z ramki
dane(dane prawdziwe), - 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ąć)
- Weź kolumnę
TricepsThicknessz ramkidane_surowei policz jej średnią oraz odchylenie standardowe. - Zrób kopię tej kolumny z zerami zamienionymi na
NaNi policz te same dwie statystyki. - Policz je również dla prawdziwej kolumny z ramki
dane. - Policz, o ile procent średnia „z zerami" różni się od prawdziwej.
- Narysuj wykres pudełkowy trzech wersji obok siebie.
Czym jest
NaN: skrót od not a number, czyli „tu nie ma wartości". Pandas traktujeNaNinaczej 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 -boxplotnie poradzi sobie zNaNw danych wejściowych.- Etykiety ustawiaj przez
ax.set_xticklabels(...), a nie argumentemlabels=. 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ścipokazuje 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.
# 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
NaNi użyjdropna()).
Dla każdego wariantu:
- zbuduj
X(bezPatientIDiDiabetic) orazy, - podziel dane (
test_size=0.2,stratify=y,random_state=42), - wytrenuj
make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000, random_state=42)), - 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ąć)
- Napisz funkcję, która dostaje ramkę danych i opis, a zwraca skuteczność modelu i liczbę wierszy uczących.
- W środku: wydziel
X(bezPatientIDiDiabetic) orazy(sama kolumnaDiabetic). - Podziel dane na część uczącą i testową.
- Zbuduj potok: skalowanie + regresja logistyczna, i naucz go na części uczącej.
- Policz skuteczność na części testowej.
- Przygotuj trzy warianty danych (A, B, C) i przepuść każdy przez tę funkcję.
- Zestaw wyniki w jednej tabeli.
Po co potok (
make_pipeline): regresja logistyczna działa źle, gdy cechy mają bardzo różne skale -SerumInsulinsięga setek, aDiabetesPedigreejest poniżej jedynki.StandardScalersprowadza 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]
Xto cechy, czyli wszystko poza identyfikatorem i etykietą.PatientIDmusi wypaść - to numer z rejestracji, a model potrafiłby się go nauczyć na pamięć.yto 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.2odkłada 20% danych na test.stratify=ypilnuje, ż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=42sprawia, ż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.
# 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.
- 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?
- 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?
- 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?
- Cecha okazuje się skorelowana z etykietą na poziomie 0,97. Jaka powinna być Twoja pierwsza reakcja i dlaczego nie jest nią radość?
- 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.
- 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¶
pandas.DataFrame.describe- zwróć uwagę na argumentinclude, który pozwala objąć też kolumny tekstowe.pandas.DataFrame.corr- poza Pearsonem dostępne sąmethod='spearman'imethod='kendall', które wychwytują zależności nieliniowe, ale monotoniczne.- Wizualizacja w pandas - skrótowe
dane.hist()idane.plot.box()rysują matplotlibem pod spodem. - Galeria matplotliba - gotowe przykłady wykresów wraz z kodem.
- Praca z brakami danych w pandas -
isna,fillna,dropna,replace.
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.