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

Ć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 PatientID przez 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_cechy nic 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

  1. Co w kodzie uczenia maszynowego da się testować, a czego nie da się i dlaczego.
  2. Jak wygląda pojedynczy test w pytest i jak go uruchomić.
  3. Dlaczego testy pisze się na małych, sztucznych ramkach danych, a nie na pełnym zbiorze.
  4. Czym jest atrapa (ang. fixture) i po co powtarzać dane w kilku testach tylko raz.
  5. Jak przetestować kształty tablic, brak przecieku danych, zakresy po skalowaniu i determinizm.
  6. Czym różni się assert wewnątrz potoku danych od assert wewnątrz testu.
  7. Jak sprawdzić styl kodu narzędziem flake8 albo ruff.

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.

In [ ]:
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:

  1. robi jedną rzecz - inaczej nie wiadomo, którą z nich test właśnie sprawdził,
  2. dostaje dane przez argumenty, a nie sięga po zmienne globalne z notatnika,
  3. zwraca wynik, zamiast tylko go wypisywać - z print nie da się nic sprawdzić,
  4. 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.

In [ ]:
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.")
In [ ]:
%%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 ...
"""
In [ ]:
%%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:

  1. przygotowanie - budujemy dane wejściowe,
  2. wykonanie - wywołujemy testowaną funkcję,
  3. 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.

In [ ]:
%%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 pytest uruchamia pytest jako 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 czemu from src.potok import ... w teście znajduje nasz pakiet. Samo pytest (bez python -m) tego nie robi i zobaczylibyśmy ModuleNotFoundError.
  • 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.

In [ ]:
!{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.

In [ ]:
%%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)
In [ ]:
!{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 tego numpy.allclose (dla tablic) i pytest.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_transform na 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.
In [ ]:
%%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)
In [ ]:
!{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ń.

In [ ]:
%%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
In [ ]:
!{sys.executable} -m pytest tests/test_celowo_zepsuty.py -v

W raporcie z błędu warto zobaczyć trzy rzeczy:

  1. FAILED tests/test_celowo_zepsuty.py::test_celowo_zepsuty - który test nie przeszedł,
  2. assert 7 == 8 - pytest podstawił wartości obu stron, więc nie trzeba dopisywać print,
  3. E przy 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.

In [ ]:
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ę assert da 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 i raise ValueError(...).

Zobaczmy asercję w działaniu: podsuniemy funkcji plik bez kolumny Diabetic.

In [ ]:
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 ruff albo !pip install flake8 (i pomiń ten krok, gdy na współdzielonej maszynie brakuje uprawnień do instalacji).

In [ ]:
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ą:

  1. testy - czy kod robi to, co trzeba (bez tego reszta nie ma znaczenia),
  2. linter - czy da się go przeczytać i czy nie ma w nim śmieci,
  3. 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:

  1. model trafiający wszystko → skuteczność, czułość i F1 równe 1,0,
  2. model odpowiadający zawsze zerem przy y_test = [0, 1, 1, 0] → skuteczność 0,5 i czułość 0,0,
  3. 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?

In [ ]:
# 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:

  1. atrapa (@pytest.fixture) z 20-wierszową sztuczną ramką (wzoruj się na tests/test_podzial.py),
  2. test: dwa wywołania trenuj_model z tym samym ziarno dają identyczne co do elementu przewidywania (numpy.array_equal),
  3. test: trenuj_model z max_depth=1 daje 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.

In [ ]:
# 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:

  1. w zwróconym X nie ma ani jednej brakującej wartości (X.isna().sum().sum() == 0),
  2. liczba wierszy zmalała dokładnie o liczbę wierszy z brakami,
  3. X i y mają tę samą długość - to najważniejszy z tych testów,
  4. 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.

In [ ]:
# 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.

  1. Dopisz w src/potok.py (w funkcji wczytaj_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.
  2. Napisz tests/test_wczytaj_dane.py, w którym:
    • atrapa tmp_path (wbudowana w pytest, daje tymczasowy katalog) posłuży do zapisania małego pliku CSV z etykietą zawierającą wartość 2,
    • test sprawdzi, że wczytaj_dane podnosi AssertionError:
with pytest.raises(AssertionError):
    wczytaj_dane(sciezka_do_zlego_pliku)
  1. 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.

In [ ]:
# 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.

  1. Napisz plik src/potok_zly.py z funkcją przygotuj_cechy_zle(ramka), która usuwa tylko kolumnę Diabetic, a PatientID zostawia wśród cech. To dokładnie ta pomyłka, którą łatwo popełnić przy pisaniu drop(columns=['Diabetic']) z pamięci.
  2. Napisz tests/test_bez_identyfikatora.py z testem, który sprawdza, że wśród kolumn X nie ma żadnego identyfikatora. Napisz go ogólnie - nie jako assert "PatientID" not in X.columns, tylko jako sprawdzenie, czy w nazwach kolumn nie występuje wzorzec identyfikatora (np. nazwa kończąca się na ID albo Id, bez rozróżniania wielkości liter).
  3. Uruchom ten test na funkcji zepsutej i pokaż, że nie przechodzi.
  4. Uruchom go na przygotuj_cechy z src/potok.py i 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.

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 6: Sprawdzenie stylu własnego kodu

  1. Uruchom ruff albo flake8 na katalogach src/ i tests/ - 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ę".
  2. Wypisz wszystkie zgłoszone uwagi i przy każdej odpowiedz sobie: czy to realny problem, czy tylko kwestia gustu?
  3. Popraw te, które uznasz za realne, i uruchom narzędzie ponownie.
  4. 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.

In [ ]:
# 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.1 część 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:

  1. 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 X i y albo potasuje etykiety?)
  2. 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.

In [ ]:
# TWÓJ KOD TUTAJ

Pytania do przemyślenia

Na te pytania odpowiadasz słowami, nie kodem.

  1. 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.
  2. Cztery sztuczne wiersze zamiast 10 000 prawdziwych - wymień trzy powody. Kiedy pełny zbiór jednak jest w teście potrzebny?
  3. Czym różni się assert w teście od assert w funkcji wczytaj_dane? Który z nich zniknie po uruchomieniu Pythona z przełącznikiem -O i jakie to ma konsekwencje?
  4. 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?
  5. Test test_podzial_jest_deterministyczny przechodzi na jednym komputerze, a na drugim nie. Wymień trzy możliwe przyczyny (przypomnij sobie ćwiczenie 10).
  6. Jak rozpoznać test, który zawsze przechodzi, niezależnie od stanu kodu? Jak się przed takim testem bronić?
  7. 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


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:

  1. 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.
  2. Wynik, którego nie da się odtworzyć, nie jest wynikiem. Ustalone ziarno losowości, kod w pliku, parametry zapisane razem z metrykami.
  3. 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.