Ćwiczenie 11: Jakość kodu w uczeniu maszynowym¶
Po co to ćwiczenie?¶
W ćwiczeniu 10 kod przeniósł się z notatnika do pliku .py: powstały funkcje wczytaj_dane, przygotuj_cechy, trenuj_model i ocen_model, parametry trafiły do wiersza poleceń, a model - na dysk. Skrypt uruchamia się w świeżym procesie i daje ten sam wynik za każdym razem.
Zostało jednak pytanie, na które ćwiczenie 10 nie odpowiada: skąd wiadomo, że ten kod robi to, co obiecuje?
Dziś jedyną odpowiedzią jest: „bo kod się uruchomił, a wynik wyglądał sensownie". To znaczy „działa u mnie w notatniku" - i to jest za mało, bo:
- skuteczność 78% wygląda sensownie także wtedy, gdy
PatientIDprzez pomyłkę został wśród cech, - skuteczność 99% wygląda podejrzanie dobrze - ale trzeba to najpierw zauważyć, a nikt nie zauważa wszystkiego,
- po zmianie jednej linijki w
przygotuj_cechynic nie krzyczy; wynik po prostu cicho staje się inny.
Lekarstwem są testy jednostkowe (ang. unit tests): mały kod, który sprawdza inny kod i mówi „przeszło" albo „nie przeszło" - bez oglądania wyników na oko.
Zanim je napiszemy, trzeba rozstrzygnąć najważniejszą kwestię tego ćwiczenia: co właściwie da się testować w kodzie uczenia maszynowego. Bo na pewno nie skuteczność modelu.
Dlaczego to ćwiczenie jest ostatnie, a nie pierwsze. Tak jak w ćwiczeniu 10: ból musi być pierwszy, lekarstwo drugie. Zdanie „pisz testy" na pierwszych zajęciach brzmi jak niepotrzebna biurokracja. Po dziesięciu ćwiczeniach - po pomyłkowo zostawionym identyfikatorze, po przecieku danych z ćwiczenia 06 i po wynikach, których nie dało się odtworzyć - widać, przed czym dokładnie testy chronią. Każdy test w tym notatniku jest odpowiedzią na konkretny błąd popełniony wcześniej w tym kursie.
Czego się nauczysz¶
- Co w kodzie uczenia maszynowego da się testować, a czego nie da się i dlaczego.
- Jak wygląda pojedynczy test w
pytesti jak go uruchomić. - Dlaczego testy pisze się na małych, sztucznych ramkach danych, a nie na pełnym zbiorze.
- Czym jest atrapa (ang. fixture) i po co powtarzać dane w kilku testach tylko raz.
- Jak przetestować kształty tablic, brak przecieku danych, zakresy po skalowaniu i determinizm.
- Czym różni się
assertwewnątrz potoku danych odassertwewnątrz testu. - Jak sprawdzić styl kodu narzędziem
flake8alboruff.
Zanim zaczniesz: uruchamiaj komórki po kolei (Shift+Enter). Notatnik zakłada, że katalogiem roboczym jest
cwiczenia-ml/.
1. Co właściwie da się testować w kodzie ML¶
To jest sedno całego ćwiczenia, więc warto zatrzymać się tu na dłużej.
W zwykłym programie test wygląda tak: „funkcja licząca podatek dla kwoty 1000 ma zwrócić 170". Wejście znane, wyjście znane, sprawdzenie oczywiste.
W uczeniu maszynowym pojawia się pokusa napisania testu: „model ma mieć co najmniej 85% skuteczności". To nie jest test jednostkowy. To pomiar, i to pomiar czegoś, co nie zależy wyłącznie od kodu:
- skuteczność zależy od danych - zmienią się dane, zmieni się wynik, a kod pozostanie bez zarzutu,
- zależy od hiperparametrów, czyli od decyzji, a nie od poprawności,
- gdy taki test nie przechodzi, nie wiadomo, co naprawić: dane, model, próg czy sam test.
Testujemy więc nie skuteczność, tylko poprawność przekształceń danych - czyli te fragmenty, w których istnieje jedna prawidłowa odpowiedź, niezależna od tego, jaki model zostanie potem użyty.
| Da się testować (jedna poprawna odpowiedź) | Nie nadaje się na test jednostkowy |
|---|---|
przygotuj_cechy usuwa PatientID |
„skuteczność ≥ 85%" |
| suma wierszy po podziale = liczba wierszy przed | „las losowy bije drzewo" |
| kolumna z etykietą nie trafia do cech | „ta cecha jest najważniejsza" |
| po skalowaniu średnia ≈ 0, odchylenie ≈ 1 | „krzywa ROC ładnie wygląda" |
| dwa wywołania z tym samym ziarnem dają ten sam wynik | „model jest wystarczająco dobry" |
| wiersze z brakami zostały obsłużone zgodnie z umową | „predykcja dla pacjenta 1001 to 1" |
Zasada jednym zdaniem:
Testujemy kod, nie model. Kod ma być poprawny, model ma być skuteczny - to dwie różne sprawy, mierzone w zupełnie inny sposób.
Skuteczność modelu też się monitoruje, ale służą do tego metryki, walidacja krzyżowa i raporty z ćwiczeń 05 i 06 - a nie zestaw testów, który ma przejść na zielono przed każdym wysłaniem zmian.
2. Czy pytest jest zainstalowany?¶
pytest to osobny pakiet - nie wchodzi w skład standardowej biblioteki Pythona. Na instancji obliczeniowej Azure ML zwykle jest, na własnym komputerze bywa różnie.
Poniższa komórka tylko sprawdza, czy pakiet da się zaimportować. Niczego nie instaluje i niczym nie rzuca, gdy pakietu brakuje.
import importlib.util
import sys
if importlib.util.find_spec("pytest") is None:
print("pytest NIE jest zainstalowany.")
print("Uruchom w osobnej komórce: !pip install pytest")
else:
import pytest
print("pytest jest dostępny, wersja:", pytest.__version__)
print("Python:", sys.version.split()[0])
Gdy w wyniku pojawiło się „NIE jest zainstalowany", wykonaj w nowej komórce:
!pip install pytest
a potem uruchom komórkę sprawdzającą jeszcze raz. Instalacja jest jednorazowa - pakiet jest wymieniony w requirements.txt tego kursu właśnie z myślą o tym ćwiczeniu.
3. Kod, który da się przetestować¶
Testowalność nie jest cechą testu - jest cechą kodu. Funkcja nadaje się do testowania, gdy:
- robi jedną rzecz - inaczej nie wiadomo, którą z nich test właśnie sprawdził,
- dostaje dane przez argumenty, a nie sięga po zmienne globalne z notatnika,
- zwraca wynik, zamiast tylko go wypisywać - z
printnie da się nic sprawdzić, - nie miesza wczytywania danych z liczeniem - funkcja, która sama czyta plik z dysku, wymaga pliku przy każdym teście.
Zaczynamy od kodu z ćwiczenia 10 i rozbijamy go na funkcje spełniające te warunki. Dochodzą dwie nowe: podziel_dane i skaluj_cechy - w ćwiczeniu 10 obie były wplecione w main(), a przez to niedostępne dla testów.
Zwróć też uwagę na assert wewnątrz wczytaj_dane. To asercja sanity-check (ang. sanity check): warunek, który musi być spełniony, żeby dalsza praca miała sens. Wrócimy do niej w sekcji 8.
from pathlib import Path
# %%writefile nie tworzy katalogów - zakładamy je sami
Path('src').mkdir(exist_ok=True)
Path('tests').mkdir(exist_ok=True)
print("Katalogi src/ i tests/ gotowe.")
%%writefile src/__init__.py
"""Pakiet z kodem zrodlowym cwiczenia 11.
Ten plik moze byc pusty - jego obecnosc sprawia, ze katalog src/ jest
pakietem Pythona, wiec z testow da sie napisac: from src.potok import ...
"""
%%writefile src/potok.py
"""Potok danych i treningu dla klasyfikatora cukrzycy.
Kazda funkcja robi jedna rzecz i ma jasne wejscie oraz wyjscie.
Dzieki temu da sie ja wywolac z testu, bez uruchamiania calego treningu.
"""
import pandas as pd
from sklearn.metrics import accuracy_score, f1_score, recall_score
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier
KOLUMNA_ID = "PatientID"
KOLUMNA_ETYKIETY = "Diabetic"
ZIARNO = 42
SCIEZKA_DOMYSLNA = "dane/diabetes.csv"
def wczytaj_dane(sciezka=SCIEZKA_DOMYSLNA):
"""Wczytuje plik CSV i sprawdza, czy w ogole nadaje sie do dalszej pracy."""
ramka = pd.read_csv(sciezka)
# Asercje sanity-check: warunki, ktore MUSZA byc spelnione, zeby
# reszta potoku miala sens. Lepiej zatrzymac sie tutaj niz liczyc
# przez godzine na danych, ktore sa nie te.
assert len(ramka) > 0, f"Plik {sciezka} nie zawiera ani jednego wiersza"
assert KOLUMNA_ETYKIETY in ramka.columns, (
f"Brak kolumny z etykieta: {KOLUMNA_ETYKIETY}"
)
return ramka
def przygotuj_cechy(ramka, usun_braki=True):
"""Dzieli ramke na cechy X i etykiete y.
Identyfikator pacjenta NIE jest cecha - to etykietka z systemu
rejestracji, a nie wynik badania.
"""
ramka = ramka.copy()
if usun_braki:
ramka = ramka.dropna()
do_usuniecia = [k for k in (KOLUMNA_ID, KOLUMNA_ETYKIETY) if k in ramka.columns]
X = ramka.drop(columns=do_usuniecia)
y = ramka[KOLUMNA_ETYKIETY]
return X, y
def podziel_dane(X, y, test_size=0.2, ziarno=ZIARNO):
"""Dzieli dane na czesc uczaca i testowa. Zwraca (X_ucz, X_test, y_ucz, y_test)."""
return train_test_split(
X, y, test_size=test_size, stratify=y, random_state=ziarno
)
def skaluj_cechy(X_ucz, X_test):
"""Skaluje cechy. Skaler uczy sie WYLACZNIE na zbiorze uczacym."""
skaler = StandardScaler()
X_ucz_skalowane = skaler.fit_transform(X_ucz)
X_test_skalowane = skaler.transform(X_test)
return X_ucz_skalowane, X_test_skalowane, skaler
def trenuj_model(X_ucz, y_ucz, max_depth=5, ziarno=ZIARNO):
"""Trenuje drzewo decyzyjne i zwraca wytrenowany model."""
model = DecisionTreeClassifier(max_depth=max_depth, random_state=ziarno)
model.fit(X_ucz, y_ucz)
return model
def ocen_model(model, X_test, y_test):
"""Zwraca slownik metryk. Slownik, bo latwo go zapisac do JSON-a."""
przewidywania = model.predict(X_test)
return {
"skutecznosc": float(accuracy_score(y_test, przewidywania)),
"czulosc": float(recall_score(y_test, przewidywania)),
"f1": float(f1_score(y_test, przewidywania)),
}
4. Pierwszy test¶
Test w pytest to zwykła funkcja, bez klas i bez dziedziczenia. Obowiązują tylko trzy konwencje:
| Konwencja | Po co |
|---|---|
plik nazywa się test_*.py |
pytest sam znajduje pliki z testami - nie trzeba nigdzie ich wymieniać |
funkcja nazywa się test_* |
j.w., dla funkcji |
sprawdzenie przez zwykłe assert |
gdy warunek jest fałszywy, pytest sam pokazuje wartości obu stron |
Dobry test ma trzy części, choć nic ich formalnie nie wymusza:
- przygotowanie - budujemy dane wejściowe,
- wykonanie - wywołujemy testowaną funkcję,
- sprawdzenie - asercja.
Dlaczego testy piszemy na małych, sztucznych ramkach¶
W pierwszym teście pojawiają się cztery wiersze wymyślone na miejscu zamiast dane/diabetes.csv. To nie jest lenistwo, tylko reguła:
- szybkość: 10 000 wierszy razy kilkadziesiąt testów to sekundy czekania przy każdej zmianie. Testy, na które trzeba czekać, przestają być uruchamiane - a wtedy równie dobrze mogłoby ich nie być.
- powtarzalność: prawdziwy plik można podmienić, rozszerzyć albo przenieść. Cztery wiersze wpisane w test są zawsze takie same i nie zależą od niczego na dysku.
- czytelność przyczyny błędu: gdy test na czterech wierszach nie przechodzi, widać dokładnie, na którym wierszu i dlaczego. Gdy nie przechodzi na 10 000 wierszy, zaczyna się śledztwo.
- kontrola nad przypadkiem szczególnym: braki danych, jedna klasa, jeden wiersz, wartość ujemna - takie sytuacje w sztucznej ramce tworzy się w jednej linii, a w prawdziwym zbiorze trzeba ich szukać albo wcale ich nie ma.
Pełny zbiór też się przydaje - ale w teście innego rodzaju, o czym w zadaniu 7.
%%writefile tests/test_przygotuj_cechy.py
"""Testy funkcji przygotuj_cechy."""
import pandas as pd
from src.potok import przygotuj_cechy
def przykladowa_ramka():
"""Cztery wiersze, ktore w calosci widac na ekranie.
To NIE sa prawdziwe dane - to najmniejszy przyklad, na ktorym
da sie sprawdzic, czy funkcja robi to, co obiecuje.
"""
return pd.DataFrame({
"PatientID": [1001, 1002, 1003, 1004],
"PlasmaGlucose": [90, 150, 120, 85],
"BMI": [22.0, 33.5, 28.1, 24.4],
"Age": [21, 55, 40, 33],
"Diabetic": [0, 1, 1, 0],
})
def test_przygotuj_cechy_usuwa_identyfikator():
# Przygotowanie
ramka = przykladowa_ramka()
# Wykonanie
X, y = przygotuj_cechy(ramka)
# Sprawdzenie
assert "PatientID" not in X.columns
def test_przygotuj_cechy_nie_przepuszcza_etykiety():
X, y = przygotuj_cechy(przykladowa_ramka())
assert "Diabetic" not in X.columns
def test_przygotuj_cechy_zostawia_dokladnie_cechy():
X, y = przygotuj_cechy(przykladowa_ramka())
assert list(X.columns) == ["PlasmaGlucose", "BMI", "Age"]
def test_przygotuj_cechy_zgadza_sie_liczba_wierszy():
ramka = przykladowa_ramka()
X, y = przygotuj_cechy(ramka)
assert len(X) == len(ramka)
assert len(y) == len(ramka)
def test_przygotuj_cechy_nie_psuje_ramki_wejsciowej():
"""Funkcja nie ma prawa modyfikowac tego, co dostala."""
ramka = przykladowa_ramka()
kolumny_przed = list(ramka.columns)
przygotuj_cechy(ramka)
assert list(ramka.columns) == kolumny_przed
Uruchomienie testów¶
Polecenie to python -m pytest tests/ -v:
-m pytesturuchamiapytestjako moduł bieżącego interpretera. Ma to skutek uboczny, bez którego nic by nie zadziałało: bieżący katalog trafia do ścieżki wyszukiwania modułów, dzięki czemufrom src.potok import ...w teście znajduje nasz pakiet. Samopytest(bezpython -m) tego nie robi i zobaczylibyśmyModuleNotFoundError.tests/to katalog do przeszukania,-v(ang. verbose) wypisuje każdy test z osobna zamiast ciągu kropek.
W wyniku szukaj linii PASSED przy każdym teście i podsumowania na zielono na samym dole.
!{sys.executable} -m pytest tests/ -v
Pięć testów, pięć razy PASSED. Warto zauważyć, czego te testy nie sprawdzają: ani jednego słowa o skuteczności, drzewie czy metrykach. Sprawdzają wyłącznie, czy przekształcenie danych robi to, co obiecuje jego nazwa.
Test test_przygotuj_cechy_usuwa_identyfikator to tak zwany test regresyjny (ang. regression test): powstał po realnym błędzie - tym samym, który w ćwiczeniu 01 dawał drzewo z nieprzyzwoicie dobrym wynikiem na danych uczących. Test regresyjny nie zapobiega pierwszemu wystąpieniu błędu. Zapobiega drugiemu.
5. Atrapa, czyli wspólne dane dla wielu testów¶
W pliku powyżej dane budowała zwykła funkcja przykladowa_ramka(), wywoływana w każdym teście osobno. Działa, ale pytest ma na to mechanizm wygodniejszy: atrapę (ang. fixture).
Funkcję oznaczoną @pytest.fixture traktuje się jak dostawcę danych: test, który prosi o argument o tej samej nazwie, dostaje świeży wynik jej wywołania. Zalety są dwie:
- dane przygotowuje się raz, a korzysta z nich wiele testów,
- każdy test dostaje własną, świeżą kopię - jeden test nie jest w stanie zepsuć drugiemu danych wejściowych.
Kolejny plik testuje podziel_dane i pokazuje cztery rzeczy, które warto sprawdzać po każdym podziale danych: sumę kształtów, zestaw kolumn, brak wycieku etykiety i determinizm.
%%writefile tests/test_podzial.py
"""Testy funkcji podziel_dane - ksztalty, przeciek i determinizm."""
import numpy as np
import pandas as pd
import pytest
from src.potok import podziel_dane, przygotuj_cechy
@pytest.fixture
def dane_sztuczne():
"""Atrapa danych: 20 wierszy, 12 zdrowych i 8 chorych.
Dekorator @pytest.fixture sprawia, ze kazdy test, ktory poprosi
o argument o tej nazwie, dostanie SWIEZA kopie tych danych.
"""
liczba = 20
ramka = pd.DataFrame({
"PatientID": range(1, liczba + 1),
"PlasmaGlucose": np.arange(80, 80 + liczba),
"BMI": np.arange(20.0, 20.0 + liczba),
"Age": np.arange(25, 25 + liczba),
"Diabetic": [0] * 12 + [1] * 8,
})
return ramka
def test_podzial_sumuje_sie_do_wejscia(dane_sztuczne):
X, y = przygotuj_cechy(dane_sztuczne)
X_ucz, X_test, y_ucz, y_test = podziel_dane(X, y, test_size=0.25)
assert len(X_ucz) + len(X_test) == len(X)
assert len(y_ucz) + len(y_test) == len(y)
def test_podzial_nie_gubi_ani_nie_dodaje_kolumn(dane_sztuczne):
X, y = przygotuj_cechy(dane_sztuczne)
X_ucz, X_test, _, _ = podziel_dane(X, y, test_size=0.25)
assert list(X_ucz.columns) == list(X.columns)
assert list(X_test.columns) == list(X.columns)
def test_etykieta_nie_wycieka_do_cech(dane_sztuczne):
"""Najczestszy przeciek danych: kolumna z odpowiedzia wsrod cech."""
X, y = przygotuj_cechy(dane_sztuczne)
X_ucz, X_test, _, _ = podziel_dane(X, y, test_size=0.25)
assert "Diabetic" not in X_ucz.columns
assert "Diabetic" not in X_test.columns
def test_cechy_i_etykieta_opisuja_tych_samych_pacjentow(dane_sztuczne):
"""X i y musza byc ustawione w tej samej kolejnosci - inaczej model
uczy sie odpowiedzi doklejonych do losowych pacjentow."""
X, y = przygotuj_cechy(dane_sztuczne)
X_ucz, X_test, y_ucz, y_test = podziel_dane(X, y, test_size=0.25)
assert list(X_ucz.index) == list(y_ucz.index)
assert list(X_test.index) == list(y_test.index)
def test_podzial_jest_deterministyczny(dane_sztuczne):
"""To samo ziarno = ten sam podzial. Za kazdym razem."""
X, y = przygotuj_cechy(dane_sztuczne)
pierwszy = podziel_dane(X, y, test_size=0.25, ziarno=42)
drugi = podziel_dane(X, y, test_size=0.25, ziarno=42)
assert list(pierwszy[1].index) == list(drugi[1].index)
def test_inne_ziarno_daje_inny_podzial(dane_sztuczne):
"""Dopelnienie poprzedniego testu: sprawdzamy, ze ziarno w ogole dziala.
Bez tego testu funkcja, ktora ignoruje argument ziarno i zawsze
zwraca to samo, przeszlaby test determinizmu celujaco.
"""
X, y = przygotuj_cechy(dane_sztuczne)
a = podziel_dane(X, y, test_size=0.25, ziarno=0)
b = podziel_dane(X, y, test_size=0.25, ziarno=7)
assert list(a[1].index) != list(b[1].index)
!{sys.executable} -m pytest tests/test_podzial.py -v
Zwróć uwagę na parę testów test_podzial_jest_deterministyczny i test_inne_ziarno_daje_inny_podzial. Same się uzupełniają:
- pierwszy sprawdza, że to samo ziarno daje ten sam wynik - czyli odtwarzalność (ang. reproducibility) z ćwiczenia 10,
- drugi sprawdza, że ziarno w ogóle ma jakiś wpływ.
Bez drugiego funkcja, która ignoruje argument ziarno i zawsze zwraca ten sam podział, przeszłaby pierwszy test bez zarzutu. To ogólna pułapka: test, który przechodzi zawsze, niczego nie testuje - a wygląda dokładnie tak samo jak test prawdziwy.
6. Zakresy wartości po skalowaniu¶
Skalowanie to przekształcenie czysto arytmetyczne, więc daje się sprawdzić dokładnie: po StandardScaler zbiór uczący ma średnią 0 i odchylenie standardowe 1 w każdej kolumnie.
Dwie rzeczy techniczne, które tu zobaczysz:
- liczb zmiennoprzecinkowych nie porównuje się przez
==. Średnia wyjdzie-1.48e-17, a nie idealne zero. Służą do tegonumpy.allclose(dla tablic) ipytest.approx(dla pojedynczych liczb) - z jawnie podaną tolerancją. - ostatnie dwa testy sprawdzają coś ważniejszego niż arytmetyka: że skaler uczy się wyłącznie na zbiorze uczącym. Użycie
fit_transformna danych testowych to przeciek danych (ang. data leakage) z ćwiczenia 06 - błąd, którego w wynikach nie widać, bo one po prostu wychodzą odrobinę lepsze, niż powinny.
%%writefile tests/test_skalowanie.py
"""Testy skalowania - zakresy wartosci i szczelnosc zbioru testowego."""
import numpy as np
import pandas as pd
import pytest
from src.potok import skaluj_cechy
@pytest.fixture
def cechy_uczace():
return pd.DataFrame({
"PlasmaGlucose": [80.0, 100.0, 120.0, 140.0, 160.0, 180.0],
"BMI": [18.0, 22.0, 26.0, 30.0, 34.0, 38.0],
})
def test_po_skalowaniu_srednia_wynosi_zero(cechy_uczace):
X_ucz_s, _, _ = skaluj_cechy(cechy_uczace, cechy_uczace)
# np.allclose zamiast ==, bo liczby zmiennoprzecinkowe nigdy nie sa
# rowne co do bitu; 1e-9 to tolerancja, a nie niedbalstwo.
assert np.allclose(X_ucz_s.mean(axis=0), 0.0, atol=1e-9)
def test_po_skalowaniu_odchylenie_wynosi_jeden(cechy_uczace):
X_ucz_s, _, _ = skaluj_cechy(cechy_uczace, cechy_uczace)
assert np.allclose(X_ucz_s.std(axis=0), 1.0, atol=1e-9)
def test_skalowanie_nie_zmienia_ksztaltu(cechy_uczace):
X_ucz_s, X_test_s, _ = skaluj_cechy(cechy_uczace, cechy_uczace.head(2))
assert X_ucz_s.shape == cechy_uczace.shape
assert X_test_s.shape == (2, cechy_uczace.shape[1])
def test_skaler_uczy_sie_tylko_na_zbiorze_uczacym(cechy_uczace):
"""Gdyby skaler zobaczyl dane testowe, jego srednia bylaby inna.
To jest test na przeciek danych (ang. data leakage) - sprawdza
MECHANIZM, a nie wynik modelu.
"""
X_test = cechy_uczace + 1000.0
_, _, skaler = skaluj_cechy(cechy_uczace, X_test)
assert np.allclose(skaler.mean_, cechy_uczace.mean(axis=0).to_numpy())
def test_zbior_testowy_nie_jest_wysrodkowany_na_sile(cechy_uczace):
"""Dane testowe przesuniete o 1000 MUSZA po skalowaniu odstawac.
Gdyby test wyszedl na zero, znaczyloby to, ze uzyto fit_transform
zamiast transform - czyli klasyczny blad.
"""
X_test = cechy_uczace + 1000.0
_, X_test_s, _ = skaluj_cechy(cechy_uczace, X_test)
assert X_test_s.mean() > 10.0
def test_pojedyncza_wartosc_z_tolerancja(cechy_uczace):
"""pytest.approx dla pojedynczej liczby - czytelniej niz abs(a - b) < eps."""
X_ucz_s, _, _ = skaluj_cechy(cechy_uczace, cechy_uczace)
assert float(X_ucz_s[:, 0].mean()) == pytest.approx(0.0, abs=1e-9)
!{sys.executable} -m pytest tests/ -v
7. Jak wygląda test, który nie przechodzi¶
Testy przechodzące na zielono są przyjemne, ale nauczyć się trzeba czytania tych czerwonych - bo to one niosą informację.
Zapiszemy test celowo zepsuty, uruchomimy go osobno i zaraz potem skasujemy, żeby nie psuł kolejnych uruchomień.
%%writefile tests/test_celowo_zepsuty.py
"""Test, ktory ma nie przejsc. Pokazuje, jak wyglada kolor czerwony."""
def test_celowo_zepsuty():
oczekiwano = 8
otrzymano = 3 + 4
assert otrzymano == oczekiwano
!{sys.executable} -m pytest tests/test_celowo_zepsuty.py -v
W raporcie z błędu warto zobaczyć trzy rzeczy:
FAILED tests/test_celowo_zepsuty.py::test_celowo_zepsuty- który test nie przeszedł,assert 7 == 8-pytestpodstawił wartości obu stron, więc nie trzeba dopisywaćprint,Eprzy linii i strzałki>- miejsce w kodzie.
To jest powód, dla którego w pytest używa się zwykłego assert, a nie metod w rodzaju assertEqual: narzędzie samo rozbiera wyrażenie i pokazuje, co z czym się nie zgodziło.
Sprzątamy plik.
from pathlib import Path
Path('tests/test_celowo_zepsuty.py').unlink(missing_ok=True)
print("Usunięto celowo zepsuty test.")
8. Asercje sanity-check wewnątrz potoku danych¶
assert w teście i assert w kodzie potoku wyglądają identycznie, ale odpowiadają na różne pytania:
assert w teście |
assert w potoku (sanity-check) |
|
|---|---|---|
| Pytanie | czy kod jest poprawny? | czy dane są takie, jak zakładam? |
| Kiedy działa | przy uruchomieniu testów | przy każdym przetwarzaniu danych |
| Na czym | na sztucznej, małej ramce | na prawdziwych danych |
| Gdy nie przechodzi | trzeba poprawić kod | trzeba przyjrzeć się danym (albo założeniom) |
W wczytaj_dane są dwie takie asercje: plik ma mieć co najmniej jeden wiersz i ma zawierać kolumnę z etykietą. Kosztują mikrosekundę, a ratują przed liczeniem godzinami na danych, które są nie te.
Jedno ostrzeżenie. Instrukcję
assertda się wyłączyć - Python uruchomiony z przełącznikiem-O(optymalizacja) całkowicie ją pomija. Dlatego asercje nadają się do wychwytywania własnych pomyłek („tu nie miało prawa być pustej ramki"), a nie do sprawdzania danych od użytkownika ani do obsługi sytuacji, które w normalnej pracy mogą się zdarzyć. Do tego drugiego służy jawny warunek iraise ValueError(...).
Zobaczmy asercję w działaniu: podsuniemy funkcji plik bez kolumny Diabetic.
import pandas as pd
from pathlib import Path
from src.potok import wczytaj_dane
# Plik z poprawnymi kolumnami, ale bez etykiety - typowa pomyłka
# przy podmianie źródła danych
Path('wyniki').mkdir(exist_ok=True)
pd.DataFrame({
'PatientID': [1, 2, 3],
'PlasmaGlucose': [90, 150, 120],
}).to_csv('wyniki/dane_bez_etykiety.csv', index=False)
try:
wczytaj_dane('wyniki/dane_bez_etykiety.csv')
except AssertionError as blad:
print("Potok zatrzymał się od razu. Komunikat:")
print(" ", blad)
Bez tej asercji błąd wyszedłby na jaw dopiero w przygotuj_cechy - albo, w gorszym wariancie, jeszcze później i pod postacią komunikatu, który z prawdziwą przyczyną nie ma nic wspólnego. Im wcześniej potok się zatrzyma, tym taniej.
9. Sprawdzanie stylu kodu¶
Testy odpowiadają na pytanie „czy kod robi to, co trzeba". Zostaje drugie: „czy da się go przeczytać". Zajmują się nim narzędzia zwane linterami (ang. linter), które czytają kod bez uruchamiania go i zgłaszają: nieużywane importy, zmienne, których nikt nie czyta, zbyt długie linie, niespójne odstępy, przesłonięte nazwy wbudowane.
Dwa najpopularniejsze:
| Narzędzie | Uwagi |
|---|---|
flake8 |
klasyk, sprawdza zgodność z PEP 8 - oficjalnym przewodnikiem stylu Pythona |
ruff |
nowszy i wielokrotnie szybszy, obejmuje reguły flake8 i wielu wtyczek |
Oba to osobne pakiety i najprawdopodobniej nie są zainstalowane. Nie są potrzebne do niczego wcześniej w tym kursie. Poniższa komórka tylko sprawdza ich dostępność - jeśli żadnego nie ma, zainstaluj wybrane poleceniem
!pip install ruffalbo!pip install flake8(i pomiń ten krok, gdy na współdzielonej maszynie brakuje uprawnień do instalacji).
import importlib.util
import subprocess
import sys
dostepne = [n for n in ("ruff", "flake8") if importlib.util.find_spec(n) is not None]
for narzedzie in ("ruff", "flake8"):
print(f"{narzedzie:8s}: {'dostępne' if narzedzie in dostepne else 'BRAK'}")
if not dostepne:
print()
print("Żadne narzędzie nie jest zainstalowane - pomiń tę sekcję")
print("albo uruchom: !pip install ruff")
else:
narzedzie = dostepne[0]
print()
print(f"Uruchamiam {narzedzie} na katalogach src/ i tests/:")
print()
polecenie = [sys.executable, "-m", narzedzie]
polecenie += ["check", "src", "tests"] if narzedzie == "ruff" else ["src", "tests"]
wynik = subprocess.run(polecenie, capture_output=True, text=True)
print(wynik.stdout or "(brak uwag)")
print(wynik.stderr)
Uwagi lintera nie są błędami - kod z nieużywanym importem uruchomi się bez problemu. Są sygnałem, że coś jest niepotrzebne albo mylące dla czytelnika, a nieużywany import to często ślad po funkcji, która została skasowana w połowie.
Warto znać kolejność, w jakiej te narzędzia się opłacają:
- testy - czy kod robi to, co trzeba (bez tego reszta nie ma znaczenia),
- linter - czy da się go przeczytać i czy nie ma w nim śmieci,
- formatter (
black,ruff format) - jednolite formatowanie, żeby przestać o nim dyskutować.
Zadania¶
Wszystko, czego potrzebujesz, pojawiło się w przykładzie powyżej. Pliki twórz przez %%writefile (ta linia musi być pierwszą linią komórki), a testy uruchamiaj przez !python -m pytest ... -v.
Uwaga praktyczna: pytest przy każdym uruchomieniu tests/ wykonuje wszystkie testy z tego katalogu - także te z przykładu prowadzonego. To jest zaleta, nie wada: od razu widać, czy nowa zmiana czegoś nie popsuła.
Zadanie 1: Test funkcji ocen_model bez trenowania czegokolwiek¶
ocen_model liczy metryki - to zwykła arytmetyka, więc da się ją sprawdzić dokładnie. Do testu nie jest potrzebny wytrenowany model; wystarczy obiekt, który udaje model i zwraca z góry ustalone przewidywania:
class ModelAtrapa:
"""Udaje model: metoda predict zwraca to, co dostala w konstruktorze."""
def __init__(self, przewidywania):
self._przewidywania = np.array(przewidywania)
def predict(self, X):
return self._przewidywania
Napisz plik tests/test_ocen_model.py z testami:
- model trafiający wszystko → skuteczność, czułość i F1 równe 1,0,
- model odpowiadający zawsze zerem przy
y_test = [0, 1, 1, 0]→ skuteczność 0,5 i czułość 0,0, - zwrócony słownik zawiera dokładnie klucze
skutecznosc,czulosc,f1.
Uruchom testy. Zastanów się przy okazji: dlaczego test metryk jest sensowny, a test „skuteczność ≥ 85%" nie jest?
# TWÓJ KOD TUTAJ
# Podpowiedź: pierwsza linia komórki musi brzmieć dokładnie:
# %%writefile tests/test_ocen_model.py
Zadanie 2: Test determinizmu treningu¶
W ćwiczeniu 10 odtwarzalność sprawdzało się, uruchamiając skrypt dwa razy i porównując pliki. Teraz zapiszemy to samo jako test, który wykonuje się w ułamku sekundy.
Napisz tests/test_determinizm.py:
- atrapa (
@pytest.fixture) z 20-wierszową sztuczną ramką (wzoruj się natests/test_podzial.py), - test: dwa wywołania
trenuj_modelz tym samymziarnodają identyczne co do elementu przewidywania (numpy.array_equal), - test:
trenuj_modelzmax_depth=1daje drzewo o głębokości 1 (model.get_depth()).
Punkt 3 jest ciekawszy, niż wygląda: sprawdza, że argument funkcji faktycznie dociera tam, gdzie powinien - a nie ginie po drodze.
# TWÓJ KOD TUTAJ
Zadanie 3: Test obsługi braków danych¶
przygotuj_cechy ma argument usun_braki=True i wywołuje dropna(). Sprawdź, że rzeczywiście robi to, co obiecuje.
Napisz tests/test_braki.py z ramką, w której celowo brakuje wartości (numpy.nan w dwóch różnych wierszach i różnych kolumnach), i przetestuj:
- w zwróconym
Xnie ma ani jednej brakującej wartości (X.isna().sum().sum() == 0), - liczba wierszy zmalała dokładnie o liczbę wierszy z brakami,
Xiymają tę samą długość - to najważniejszy z tych testów,- przy
usun_braki=Falseżaden wiersz nie znika.
Punkt 3 wygląda trywialnie, a odpowiada na realny, bardzo kosztowny błąd: odfiltrowanie wierszy w X bez odfiltrowania ich w y. Kod nadal działa, tablice nadal mają sensowne kształty, a model uczy się odpowiedzi doklejonych do niewłaściwych pacjentów.
# TWÓJ KOD TUTAJ
Zadanie 4: Nowa asercja sanity-check i test, że działa¶
Etykieta Diabetic jest binarna - dopuszczalne wartości to wyłącznie 0 i 1. Wartość 2 albo -1 oznacza, że coś jest nie tak z danymi.
- Dopisz w
src/potok.py(w funkcjiwczytaj_dane) asercję sprawdzającą, że zbiór unikalnych wartości etykiety zawiera się w{0, 1}. Zadbaj o czytelny komunikat - taki, z którego wynika, co znaleziono. - Napisz
tests/test_wczytaj_dane.py, w którym:- atrapa
tmp_path(wbudowana wpytest, daje tymczasowy katalog) posłuży do zapisania małego pliku CSV z etykietą zawierającą wartość 2, - test sprawdzi, że
wczytaj_danepodnosiAssertionError:
- atrapa
with pytest.raises(AssertionError):
wczytaj_dane(sciezka_do_zlego_pliku)
- Dodaj drugi test: poprawny plik wczytuje się bez wyjątku i ma oczekiwaną liczbę wierszy.
To jest test sprawdzający, że kod odrzuca złe dane. Testowanie ścieżek błędu jest równie ważne jak testowanie ścieżki poprawnej - a wypada z pola widzenia znacznie częściej.
# TWÓJ KOD TUTAJ
# Najpierw komórka z %%writefile src/potok.py (cały plik z dopisaną asercją),
# potem osobna komórka z %%writefile tests/test_wczytaj_dane.py.
Zadanie 5: Test, który wykrywa zostawiony PatientID¶
To zadanie odtwarza realny błąd z ćwiczenia 01 i pokazuje najważniejszą właściwość testu: zanim uwierzysz, że test coś sprawdza, zobacz go czerwonego.
- Napisz plik
src/potok_zly.pyz funkcjąprzygotuj_cechy_zle(ramka), która usuwa tylko kolumnęDiabetic, aPatientIDzostawia wśród cech. To dokładnie ta pomyłka, którą łatwo popełnić przy pisaniudrop(columns=['Diabetic'])z pamięci. - Napisz
tests/test_bez_identyfikatora.pyz testem, który sprawdza, że wśród kolumnXnie ma żadnego identyfikatora. Napisz go ogólnie - nie jakoassert "PatientID" not in X.columns, tylko jako sprawdzenie, czy w nazwach kolumn nie występuje wzorzec identyfikatora (np. nazwa kończąca się naIDalboId, bez rozróżniania wielkości liter). - Uruchom ten test na funkcji zepsutej i pokaż, że nie przechodzi.
- Uruchom go na
przygotuj_cechyzsrc/potok.pyi pokaż, że przechodzi.
Podpowiedź do punktu 2: @pytest.mark.parametrize pozwala uruchomić ten sam test dla obu funkcji naraz - ale wolno też napisać po prostu dwa testy.
Pytanie na koniec: dlaczego test napisany ogólnie (wzorzec nazwy) jest lepszy od wymienienia PatientID wprost? Pomyśl o tym, co się stanie, gdy do zbioru dojdzie kolumna VisitID.
# TWÓJ KOD TUTAJ
Zadanie 6: Sprawdzenie stylu własnego kodu¶
- Uruchom
ruffalboflake8na katalogachsrc/itests/- tak jak w sekcji 9. Gdy żadnego nie ma, zainstaluj wybrane (!pip install ruff); przy braku uprawnień do instalacji zrób punkt 4 „na piechotę". - Wypisz wszystkie zgłoszone uwagi i przy każdej odpowiedz sobie: czy to realny problem, czy tylko kwestia gustu?
- Popraw te, które uznasz za realne, i uruchom narzędzie ponownie.
- Do jednego z plików testowych dopisz celowo: nieużywany import (
import os), zmienną, której nikt nie czyta, i linię dłuższą niż 100 znaków. Sprawdź, czy narzędzie je znajdzie - i czy testy nadal przechodzą (przechodzą; to jest właśnie różnica między poprawnością a czytelnością).
Na koniec usuń celowo dopisane brzydactwa.
# TWÓJ KOD TUTAJ
Zadanie 7 (trudniejsze): Testy parametryzowane i test całego potoku¶
Dwie części - pierwsza o testach jednostkowych, druga o teście innego rodzaju.
Część A: jeden test, wiele przypadków. Napisz tests/test_parametry_podzialu.py z testem oznaczonym:
@pytest.mark.parametrize("test_size", [0.1, 0.2, 0.25, 0.5])
sprawdzającym dla każdej z tych wartości, że:
- suma długości obu części równa się długości wejścia,
- część testowa ma oczekiwaną liczbę wierszy (
round(len(X) * test_size)), - proporcja klas w obu częściach jest zbliżona do wyjściowej - z tolerancją (
pytest.approx(..., abs=0.05)), bo przy małej próbce idealnie wyjść nie może.
pytest policzy to jako cztery osobne testy, więc w raporcie widać, która wartość zawiodła.
Uwaga na rozmiar atrapy. Nie użyj tu 20-wierszowej ramki z przykładu prowadzonego - przy
test_size=0.1część testowa ma wtedy 2 wiersze, po jednym z każdej klasy, więc proporcja wychodzi 0,5 zamiast 0,4 i test na proporcje nie ma prawa przejść, choćby kod był bez zarzutu. Zbuduj atrapę na co najmniej 200 wierszy. To nie jest obejście problemu, tylko sama lekcja: test musi dostać dane, na których sprawdzana własność w ogóle może zajść. Test, który zawodzi przy poprawnym kodzie, jest gorszy niż brak testu - uczy zespół ignorować czerwone wyniki.
Część B: test całego potoku (ang. integration test). Napisz tests/test_potok_calosc.py, który na prawdziwym pliku dane/diabetes.csv przejdzie całą ścieżkę: wczytanie → przygotowanie cech → podział → trening → ocena, i sprawdzi, że:
- liczba cech wynosi 8 (czyli identyfikator ani etykieta nie przeciekły),
- metryki są liczbami z przedziału od 0 do 1,
- skuteczność jest wyższa niż 0,666 - czyli wyższa niż model odniesienia z ćwiczenia 01.
Ostatni warunek jest sporny i właśnie dlatego jest tu umieszczony. Odpowiedz na dwa pytania:
- Czy to jest test kodu, czy pomiar jakości modelu? A jeśli jedno i drugie - co konkretnie ten test wykryje? (Wskazówka: jak wypadnie, gdy ktoś pomyłkowo przestawi
Xiyalbo potasuje etykiety?) - Dlaczego próg ustawiony na 0,666 jest znacznie rozsądniejszy od progu 0,85?
Uruchom oba pliki i porównaj czas wykonania (pytest pokazuje go na dole raportu) z czasem testów jednostkowych na czterowierszowych ramkach.
# TWÓJ KOD TUTAJ
Pytania do przemyślenia¶
Na te pytania odpowiadasz słowami, nie kodem.
- Dlaczego „model ma mieć co najmniej 85% skuteczności" nie jest dobrym testem jednostkowym? Podaj co najmniej dwa powody i zaproponuj, gdzie taki warunek pasuje lepiej.
- Cztery sztuczne wiersze zamiast 10 000 prawdziwych - wymień trzy powody. Kiedy pełny zbiór jednak jest w teście potrzebny?
- Czym różni się
assertw teście odassertw funkcjiwczytaj_dane? Który z nich zniknie po uruchomieniu Pythona z przełącznikiem-Oi jakie to ma konsekwencje? - Wszystkie testy przechodzą, a model na nowych pacjentach daje bzdury. Czy testy zawiodły? Co w ogóle są w stanie wykryć, a czego nie wykryją nigdy?
- Test
test_podzial_jest_deterministycznyprzechodzi na jednym komputerze, a na drugim nie. Wymień trzy możliwe przyczyny (przypomnij sobie ćwiczenie 10). - Jak rozpoznać test, który zawsze przechodzi, niezależnie od stanu kodu? Jak się przed takim testem bronić?
- Ile testów wystarczy? Czy sensowne jest testowanie każdej funkcji w projekcie - a jeśli nie, to które testować w pierwszej kolejności?
Chcesz wiedzieć więcej¶
- Dokumentacja
pytest- zacznij od rozdziałów o atrapach (fixtures) i oparametrize. pytest.approx- porównywanie liczb zmiennoprzecinkowych bez wymyślania własnych tolerancji.- PEP 8 - przewodnik stylu Pythona - źródło reguł, których pilnują
flake8iruff. - Dokumentacja
ruff- lista reguł i konfiguracja w plikupyproject.toml. sklearn.utils.estimator_checks.check_estimator- gotowy zestaw testów dla własnego estymatora zgodnego z API scikit-learn.
Podsumowanie kursu: co potrafisz po ćwiczeniach 01-11¶
To ostatnie ćwiczenie, więc warto zebrać całość w jednym miejscu.
| Nr | Temat | Co zostaje na stałe |
|---|---|---|
| 01 | Pierwszy model | pełny cykl od danych do oceny; nigdy nie oceniać na danych uczących; zacząć od modelu odniesienia |
| 02 | Poznaj swoje dane | rozkłady, korelacje, wartości odstające i podejrzane zera - zanim padnie słowo „model" |
| 03 | Przygotowanie danych | braki, skalowanie, kodowanie zmiennych kategorycznych, Pipeline i ColumnTransformer |
| 04 | Regresja | regresja liniowa, regularyzacja, napięcie między niedouczeniem a przeuczeniem |
| 05 | Klasyfikacja i metryki | skuteczność to nie wszystko: macierz pomyłek, precyzja, czułość, F1, próg decyzyjny |
| 06 | Walidacja i dobór modelu | walidacja krzyżowa, GridSearchCV, przeciek danych, uczciwe użycie zbioru testowego |
| 07 | Drzewa i lasy | drzewa, lasy losowe, boosting, ważność cech, cena za utratę zrozumiałości |
| 08 | Uczenie nienadzorowane | grupowanie i redukcja wymiarowości, gdy etykiet nie ma |
| 09 | Projekt końcowy | samodzielne przejście całej ścieżki na nowym zbiorze |
| 10 | Od notatnika do skryptu | kod w pliku .py, parametry z wiersza poleceń, model na dysku, odtwarzalność |
| 11 | Jakość kodu w ML | funkcje z jasnym kontraktem, testy jednostkowe, asercje sanity-check, styl kodu |
Trzy rzeczy, które warto zapamiętać ponad wszystkie pozostałe:
- Najtrudniejsza w uczeniu maszynowym jest uczciwa ocena, a nie wybór algorytmu. Algorytm to jedna linia kodu. Ustalenie, czy wynik jest prawdziwy, zajmuje resztę projektu.
- Wynik, którego nie da się odtworzyć, nie jest wynikiem. Ustalone ziarno losowości, kod w pliku, parametry zapisane razem z metrykami.
- Testujemy kod, nie model. Kod ma być poprawny, model ma być skuteczny - i mierzy się to zupełnie inaczej.
Czego ten kurs nie obejmował, a warto wiedzieć, że istnieje: sieci neuronowe i uczenie głębokie, przetwarzanie tekstu i obrazu, szeregi czasowe, systemy rekomendacyjne, wdrażanie modeli jako usług i ich monitorowanie w czasie (temu ostatniemu poświęcony jest osobny kurs Azure Machine Learning prowadzony w tym samym repozytorium - warto do niego zajrzeć, bo pokazuje te same dane od strony produkcyjnej).
Ostatnia uwaga. Wszystko, co tu powstało, opierało się na jednym zbiorze 10 000 kart pacjentów, w którym nie brakowało żadnej wartości i nikt niczego nie pomylił przy wpisywaniu. Prawdziwe dane tak nie wyglądają. Umiejętność, która przyda się najbardziej, to nie znajomość kolejnego algorytmu, tylko podejrzliwość wobec zbyt dobrych wyników - i nawyk sprawdzania, skąd się wzięły.