Практика · Порівняння моделей¶

Лекція: lecture.html · Тест: quiz.html · Домашнє: homework.md

Сім моделей курсу нарешті стануть поруч — на одній дошці оголошень, з одним поділом на фолди й однією метрикою. Що зробимо:

  1. Згенеруємо дошку: 1 200 оголошень про вживані телефони, частина з них шахрайські.
  2. Порахуємо базову лінію — навмисно дурну модель, з якою порівнюють усе інше.
  3. Складемо таблицю всіх моделей на одній крос-валідації з однією метрикою.
  4. Дамо кожній моделі однаковий бюджет на налаштування й подивимось, як зміниться рейтинг.
  5. Поміряємо час навчання й передбачення — вісь, про яку забувають.
  6. Повторимо все на вдесятеро меншій дошці, де переможець зміниться.
  7. Побачимо, що одна вигадана ознака дає більше, ніж заміна моделі.
In [1]:
import time

import numpy as np
import pandas as pd
from sklearn.dummy import DummyClassifier, DummyRegressor
from sklearn.ensemble import (GradientBoostingClassifier, GradientBoostingRegressor,
                              RandomForestClassifier, RandomForestRegressor)
from sklearn.linear_model import LinearRegression, LogisticRegression
from sklearn.model_selection import GridSearchCV, KFold, StratifiedKFold, cross_validate
from sklearn.naive_bayes import GaussianNB
from sklearn.neighbors import KNeighborsClassifier, KNeighborsRegressor
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor

pd.set_option("display.width", 160)
print("бібліотеки на місці, версія scikit-learn:", __import__("sklearn").__version__)
бібліотеки на місці, версія scikit-learn: 1.7.2

1 · Дошка оголошень¶

Генеруємо ту саму дошку, що й у лекції. Кожне оголошення описують сім ознак, а дві колонки — це відповіді на дві задачі: ціна для регресії й шахрай для класифікації.

Шахрайське оголошення виглядає підозріло дешевим для свого класу телефона — тобто сигнал сидить не в самій ціні, а у відношенні ціни до того, скільки такий телефон коштує зазвичай. Запамʼятай це: наприкінці зошита воно стане головним.

In [2]:
def зробити_дошку(кількість=1200, зерно=42):
    """Синтетична дошка оголошень про вживані телефони."""
    rng = np.random.default_rng(зерно)

    рік = rng.integers(2013, 2025, кількість)
    памʼять = rng.choice([32, 64, 128, 256, 512], кількість, p=[.14, .30, .30, .19, .07])
    стан_батареї = rng.integers(60, 101, кількість)
    бренд = rng.choice([0, 1, 2, 3], кількість, p=[.35, .30, .22, .13])
    фото = rng.integers(1, 11, кількість)
    вік_акаунта = rng.integers(1, 1500, кількість)

    # скільки такий телефон коштує зазвичай: бренд × вік × памʼять × стан батареї
    множник_бренду = np.array([1.00, 0.78, 0.62, 1.45])[бренд]
    типова_ціна = множник_бренду * (
        900 + 8200 * 0.82 ** (2025 - рік) + 9.5 * памʼять + 22 * (стан_батареї - 60)
    )

    # шахрайство залежить від поведінки продавця, а не від ціни
    логіт = (-1.85
             + 0.70 * (вік_акаунта < 90)
             + 0.45 * (фото <= 2)
             + rng.normal(0, 0.5, кількість))
    шахрай = (rng.random(кількість) < 1 / (1 + np.exp(-логіт))).astype(int)

    ціна = типова_ціна * rng.normal(1.0, 0.11, кількість)
    # а ось ціна вже залежить від шахрайства: приманка коштує втричі дешевше
    ціна = np.where(шахрай == 1, ціна * rng.uniform(0.30, 0.55, кількість), ціна)
    ціна = np.round(np.clip(ціна, 300, None)).astype(int)

    return pd.DataFrame({
        "рік": рік, "памʼять": памʼять, "стан_батареї": стан_батареї,
        "бренд": бренд, "фото": фото, "вік_акаунта": вік_акаунта,
        "ціна": ціна, "шахрай": шахрай,
    })


дошка = зробити_дошку()
print(дошка.head())
print()
print("рядків:", len(дошка))
print("шахрайських:", int(дошка["шахрай"].sum()),
      "· частка:", round(дошка["шахрай"].mean(), 4))
    рік  памʼять  стан_батареї  бренд  фото  вік_акаунта  ціна  шахрай
0  2014      128            78      1     7         1023  2555       0
1  2022      256            85      2     3          308  5291       0
2  2020      256            66      2     3          106  4286       0
3  2018      128            85      3     2         1209  7375       0
4  2018      512            97      2     1         1271  5507       0

рядків: 1200
шахрайських: 200 · частка: 0.1667

2 · Базова лінія: з чим ми взагалі порівнюємо¶

Перше число в будь-якій задачі — не результат моделі, а результат дурної константи. DummyClassifier(strategy="most_frequent") завжди відповідає найчастішим класом, тобто «оголошення чесне». Він не дивиться на ознаки взагалі.

Порахуємо його точність двома способами: руками й бібліотекою. Якщо числа збігаються — значить, ми правильно розуміємо, що саме робить DummyClassifier.

In [3]:
ОЗНАКИ = ["рік", "памʼять", "стан_батареї", "бренд", "фото", "вік_акаунта", "ціна"]
X = дошка[ОЗНАКИ].values
y = дошка["шахрай"].values

# один поділ на фолди для ВСІХ моделей у зошиті — інакше порівняння нечесне
поділ = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

# руками: якщо завжди казати «чесне», ми вгадаємо рівно частку чесних оголошень
наша_базова_точність = 1 - y.mean()

базова = DummyClassifier(strategy="most_frequent")
бібліотечна = cross_validate(базова, X, y, cv=поділ,
                             scoring=["accuracy", "f1"])["test_accuracy"].mean()

assert np.allclose(наша_базова_точність, бібліотечна), "розрахунок розійшовся!"
print("✅ збігається")
print("базова точність:", round(бібліотечна, 4))
print("F1 базової лінії: 0.0 — вона не знаходить жодного шахрая")
✅ збігається
базова точність: 0.8333
F1 базової лінії: 0.0 — вона не знаходить жодного шахрая

Ось перша важлива думка теми: 83 % точності в цій задачі — це нуль корисної роботи. Будь-яка модель мусить спершу перегнати цю константу, і тільки різниця з нею щось означає.

3 · Одна крос-валідація, одна метрика, сім рядків¶

Тепер таблиця. Правила чесного порівняння з лекції:

  • однакові дані — усі беруть ті самі сім ознак;
  • однаковий поділ — та сама змінна поділ для всіх;
  • однакова метрика — вирішуємо заздалегідь: F1, бо класи нерівні й accuracy тут бреше;
  • однакові налаштування — поки що всі зі значеннями за замовчуванням.

Моделям, чутливим до масштабу ознак (логістичній і kNN), даємо StandardScaler — без нього вони працюють у явно гірших умовах, і порівняння знову стане нечесним.

In [4]:
def зібрати_моделі():
    """Свіжий набір моделей — щоб кожен експеримент починався з чистих."""
    return {
        "базова лінія": DummyClassifier(strategy="most_frequent"),
        "логістична": make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)),
        "kNN k=5": make_pipeline(StandardScaler(), KNeighborsClassifier(5)),
        "наївний Баєс": GaussianNB(),
        "дерево": DecisionTreeClassifier(random_state=42),
        "ліс": RandomForestClassifier(n_estimators=100, random_state=42),
        "бустинг": GradientBoostingClassifier(random_state=42),
    }


def порівняти(моделі, X, y, поділ, метрики):
    """Проганяє кожну модель по тих самих фолдах і збирає таблицю середніх."""
    рядки = []
    for імʼя, модель in моделі.items():
        результат = cross_validate(модель, X, y, cv=поділ, scoring=метрики)
        рядок = {"модель": імʼя}
        for метрика in метрики:
            рядок[метрика] = round(результат["test_" + метрика].mean(), 4)
        рядки.append(рядок)
    return pd.DataFrame(рядки)


таблиця_з_коробки = порівняти(зібрати_моделі(), X, y, поділ,
                              ["accuracy", "f1", "roc_auc"])
print(таблиця_з_коробки.to_string(index=False))
      модель  accuracy     f1  roc_auc
базова лінія    0.8333 0.0000   0.5000
  логістична    0.9483 0.8248   0.9588
     kNN k=5    0.8733 0.4048   0.8558
наївний Баєс    0.8483 0.4834   0.8510
      дерево    0.9633 0.8867   0.9220
         ліс    0.9417 0.8009   0.9764
     бустинг    0.9750 0.9185   0.9931

Три спостереження, які варто прочитати уважно.

  1. Метрика вирішує рейтинг. За F1 попереду бустинг, а за accuracy — теж бустинг, але друге місце дістається одиночному дереву, а не лісу. За ROC-AUC ліс, навпаки, обходить дерево з великим відривом. Одна й та сама таблиця, три різні відповіді на питання «хто другий».
  2. Ліс програв одиночному дереву за F1 — і це не помилка. Ліс за замовчуванням дає кожному розрізу лише sqrt(7) ≈ 2 випадкові ознаки, а наше шахрайство вимагає узгодити ціну з роком, памʼяттю й брендом одночасно. Далі ми дамо лісу бюджет на налаштування, і він відіграється.
  3. kNN і наївний Баєс ледь тримаються над базовою лінією за accuracy, хоча їхній F1 явно не нульовий. Це нагадування, що дивитись треба на обидва числа.

4 · Однаковий бюджет на налаштування¶

Найчастіша нечесність у порівняннях: одну модель довго налаштовували, іншу взяли з коробки. Виправимо це. Кожна модель отримує рівно чотири кандидати на свій головний гіперпараметр. Кандидата обирає вкладена крос-валідація всередині кожного навчального фолда — тобто тест жодного разу не бачить, як ми підбирали.

Це та сама схема, що в темі 24: зовнішня крос-валідація міряє якість, внутрішня підбирає ручки.

In [5]:
сітки = {
    "логістична": (
        make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000, solver="liblinear")),
        {"logisticregression__C": [0.03, 0.3, 3, 30]}),
    "kNN k=5": (
        make_pipeline(StandardScaler(), KNeighborsClassifier()),
        {"kneighborsclassifier__n_neighbors": [1, 3, 9, 21]}),
    "наївний Баєс": (
        GaussianNB(),
        {"var_smoothing": [1e-9, 1e-6, 1e-3, 1e-1]}),
    "дерево": (
        DecisionTreeClassifier(random_state=42),
        {"max_depth": [3, 5, 8, None]}),
    "ліс": (
        RandomForestClassifier(n_estimators=40, random_state=42),
        {"max_features": [1, 2, 4, 7]}),
    "бустинг": (
        GradientBoostingClassifier(random_state=42, n_estimators=60),
        {"learning_rate": [0.1, 0.3], "max_depth": [2, 3]}),
}

внутрішній_поділ = StratifiedKFold(n_splits=3, shuffle=True, random_state=0)
налаштовані = {
    імʼя: GridSearchCV(модель, сітка, scoring="f1", cv=внутрішній_поділ)
    for імʼя, (модель, сітка) in сітки.items()
}

початок = time.perf_counter()
таблиця_з_бюджетом = порівняти(налаштовані, X, y, поділ, ["accuracy", "f1", "roc_auc"])
print(таблиця_з_бюджетом.to_string(index=False))
print("\nчасу на весь блок:", round(time.perf_counter() - початок, 1), "с")
      модель  accuracy     f1  roc_auc
  логістична    0.9492 0.8351   0.9591
     kNN k=5    0.8883 0.5891   0.7270
наївний Баєс    0.8617 0.5235   0.8620
      дерево    0.9633 0.8857   0.9209
         ліс    0.9700 0.9034   0.9857
     бустинг    0.9808 0.9395   0.9965

часу на весь блок: 15.8 с

Тепер зведімо обидві таблиці разом і подивимось не на числа, а на місця.

In [6]:
рейтинг = таблиця_з_коробки[таблиця_з_коробки["модель"] != "базова лінія"][["модель", "f1"]]
рейтинг = рейтинг.rename(columns={"f1": "F1 з коробки"})
рейтинг = рейтинг.merge(таблиця_з_бюджетом[["модель", "f1"]], on="модель")
рейтинг = рейтинг.rename(columns={"f1": "F1 з бюджетом"})
рейтинг["зміна"] = (рейтинг["F1 з бюджетом"] - рейтинг["F1 з коробки"]).round(4)

# місце = позиція в рейтингу за спаданням метрики
рейтинг["місце було"] = рейтинг["F1 з коробки"].rank(ascending=False).astype(int)
рейтинг["місце стало"] = рейтинг["F1 з бюджетом"].rank(ascending=False).astype(int)

print(рейтинг.sort_values("F1 з бюджетом", ascending=False).to_string(index=False))
      модель  F1 з коробки  F1 з бюджетом   зміна  місце було  місце стало
     бустинг        0.9185         0.9395  0.0210           1            1
         ліс        0.8009         0.9034  0.1025           4            2
      дерево        0.8867         0.8857 -0.0010           2            3
  логістична        0.8248         0.8351  0.0103           3            4
     kNN k=5        0.4048         0.5891  0.1843           6            5
наївний Баєс        0.4834         0.5235  0.0401           5            6

Чотири спроби на модель — мізерний бюджет, а рейтинг уже інший. Найбільше додали ті, у кого значення за замовчуванням найгірше пасували задачі. Висновок практичний: фраза «модель A краща за модель B» без згадки бюджету не означає нічого.

5 · Час навчання й передбачення¶

Точність — не єдина вісь. Навчимо кожну модель один раз на 960 оголошеннях і попросимо передбачити 24 000 — стільки запитів дошка отримує за годину пік.

Перед вимірюванням робимо один «холостий» виклик: перший запуск завжди повільніший через розігрів бібліотеки, і без нього числа брехатимуть.

In [7]:
from sklearn.base import clone

навчальні_X, навчальні_y = X[:960], y[:960]
багато_запитів = np.tile(X, (20, 1))   # 24 000 рядків

виміри = []
for імʼя, модель in зібрати_моделі().items():
    модель = clone(модель)
    модель.fit(навчальні_X, навчальні_y)
    модель.predict(багато_запитів[:100])          # розігрів, у вимір не входить

    модель = clone(модель)
    старт = time.perf_counter()
    модель.fit(навчальні_X, навчальні_y)
    час_навчання = (time.perf_counter() - старт) * 1000

    старт = time.perf_counter()
    модель.predict(багато_запитів)
    час_передбачення = (time.perf_counter() - старт) * 1000

    виміри.append({"модель": імʼя,
                   "навчання, мс": round(час_навчання, 1),
                   "передбачення 24k, мс": round(час_передбачення, 1)})

print(pd.DataFrame(виміри).to_string(index=False))
      модель  навчання, мс  передбачення 24k, мс
базова лінія           0.2                   0.1
  логістична          30.9                   7.5
     kNN k=5          14.8                 260.8
наївний Баєс           1.8                   8.6
      дерево           3.0                   1.6
         ліс         240.5                 109.6
     бустинг         361.6                  26.5

kNN нічого не рахує при навчанні — і платить за це на кожному запиті: щоб відповісти, йому треба перебрати всю навчальну вибірку. Ліс повільний з обох боків. Логістична регресія й дерево відповідають майже миттєво.

Мільйон запитів на день перетворює ці мілісекунди на години процесорного часу — тому на проді вісь швидкості іноді важить більше за третій знак метрики.

6 · Вдесятеро менша дошка¶

Тепер найцікавіше. Візьмемо 120 оголошень замість 1 200 — і повторимо ту саму таблицю. Щоб результат не залежав від того, які саме 120 рядків нам випали, усереднимо по пʼятьох різних підвибірках.

In [8]:
підсумок = {імʼя: {"accuracy": [], "f1": []} for імʼя in зібрати_моделі()}
скільки_разів_гірше_за_базову = {імʼя: 0 for імʼя in зібрати_моделі()}

for зерно in range(5):
    мала_дошка = дошка.sample(120, random_state=зерно)
    Xм, yм = мала_дошка[ОЗНАКИ].values, мала_дошка["шахрай"].values
    поділ_малої = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

    точність_базової = None
    for імʼя, модель in зібрати_моделі().items():
        результат = cross_validate(модель, Xм, yм, cv=поділ_малої, scoring=["accuracy", "f1"])
        точність = результат["test_accuracy"].mean()
        підсумок[імʼя]["accuracy"].append(точність)
        підсумок[імʼя]["f1"].append(результат["test_f1"].mean())
        if імʼя == "базова лінія":
            точність_базової = точність
        elif точність < точність_базової:
            скільки_разів_гірше_за_базову[імʼя] += 1

таблиця_малої = pd.DataFrame([
    {"модель": імʼя,
     "accuracy": round(float(np.mean(v["accuracy"])), 4),
     "f1": round(float(np.mean(v["f1"])), 4),
     "разів гірше за базову": скільки_разів_гірше_за_базову[імʼя]}
    for імʼя, v in підсумок.items()
])
print(таблиця_малої.to_string(index=False))
      модель  accuracy     f1  разів гірше за базову
базова лінія    0.8200 0.0000                      0
  логістична    0.8983 0.5576                      0
     kNN k=5    0.8233 0.1646                      1
наївний Баєс    0.8133 0.3804                      3
      дерево    0.8650 0.5843                      1
         ліс    0.8883 0.5665                      0
     бустинг    0.8900 0.6063                      0

Порівняй із таблицею з розділу 3 — і поглянь окремо на колонку разів гірше за базову. На 120 оголошеннях частина моделей регулярно програє константі, яка нічого не вміє, а F1 усіх без винятку падає приблизно вдвічі.

Зверни увагу й на те, як змінився порядок: на великій дошці логістична регресія була посередині, на малій вона піднімається нагору за accuracy. Проста модель має мало вільних величин — їй досить мало даних, щоб їх оцінити.

7 · Регресія: та сама вправа на другій задачі¶

Класифікація — не єдина задача на дошці. Спробуймо передбачити ціну чесних оголошень. Тут наївний Баєс і логістична регресія не беруть участі (вони тільки для класів), натомість зʼявляється лінійна регресія. Метрика — MAE в гривнях, базова лінія — «завжди середня ціна».

In [9]:
чесні = дошка[дошка["шахрай"] == 0]
Xр = чесні[["рік", "памʼять", "стан_батареї", "бренд", "фото"]].values
yр = чесні["ціна"].values

регресійні = {
    "базова лінія": DummyRegressor(strategy="mean"),
    "лінійна": LinearRegression(),
    "kNN k=5": make_pipeline(StandardScaler(), KNeighborsRegressor(5)),
    "kNN без масштабу": KNeighborsRegressor(5),
    "дерево": DecisionTreeRegressor(random_state=42),
    "ліс": RandomForestRegressor(n_estimators=100, random_state=42),
    "бустинг": GradientBoostingRegressor(random_state=42),
}

таблиця_регресії = порівняти(регресійні, Xр, yр, KFold(5, shuffle=True, random_state=42),
                             ["neg_mean_absolute_error", "r2"])
таблиця_регресії["MAE, грн"] = (-таблиця_регресії["neg_mean_absolute_error"]).round(0)
print(таблиця_регресії[["модель", "MAE, грн", "r2"]].to_string(index=False))
          модель  MAE, грн      r2
    базова лінія    1996.0 -0.0011
         лінійна    1250.0  0.5881
         kNN k=5     732.0  0.7944
kNN без масштабу    1290.0  0.5021
          дерево     721.0  0.8267
             ліс     548.0  0.9009
         бустинг     474.0  0.9245

Два рядки kNN стоять поруч не випадково: це та сама модель, різниця лише в тому, чи привели ознаки до спільного масштабу. Без масштабування «вік акаунта» в днях розтоптує «памʼять» у гігабайтах, і відстань перестає означати схожість. Для дерев масштаб не важить узагалі — вони працюють із порогами, а не з відстанями.

8 · Одна ознака проти складної моделі¶

І головне. Наше шахрайство означає «ціна підозріло низька для такого телефона». Серед семи ознак такої величини немає — є ціна окремо, рік окремо, бренд окремо. Дерева здатні зібрати відношення самотужки, послідовністю розрізів; лінійна модель — ні.

Додамо одну колонку: ціна оголошення, поділена на медіанну ціну для того самого бренду й року. Порахувати її можна прямо з даних — цільова змінна для цього не потрібна.

In [10]:
типова_ціна_групи = дошка.groupby(["бренд", "рік"])["ціна"].transform("median")
Xплюс = np.column_stack([X, дошка["ціна"].values / типова_ціна_групи.values])

три_моделі = {
    "логістична": make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)),
    "дерево": DecisionTreeClassifier(random_state=42),
    "бустинг": GradientBoostingClassifier(random_state=42),
}

до = порівняти(три_моделі, X, y, поділ, ["f1"]).rename(columns={"f1": "F1 на 7 ознаках"})
після = порівняти(три_моделі, Xплюс, y, поділ, ["f1"]).rename(columns={"f1": "F1 з новою ознакою"})
print(до.merge(після, on="модель").to_string(index=False))
    модель  F1 на 7 ознаках  F1 з новою ознакою
логістична           0.8248              0.9851
    дерево           0.8867              0.9702
   бустинг           0.9185              0.9644

Логістична регресія з новою ознакою обходить налаштований бустинг без неї — і робить це трьома рядками коду замість годин підбору гіперпараметрів.

Це і є головний практичний висновок теми: робота з ознаками майже завжди дає більший приріст, ніж заміна моделі. Вибір алгоритму — останні кілька відсотків, а не перші.


Завдання¶

🟢 Рівень 1 — База¶

Додай до таблиці з розділу 3 ще одну модель: RandomForestClassifier(n_estimators=100, max_features=None, random_state=42) — тобто ліс, який на кожному розрізі бачить усі сім ознак. Прожени її на тому самому поділ і тій самій метриці.

Зроблено, якщо: у таблиці новий рядок і ти можеш сказати одним реченням, чому цей ліс поводиться інакше, ніж ліс за замовчуванням.

🟡 Рівень 2 — Плюс¶

Побудуй криву навчання для трьох моделей на вибір: для обсягів [60, 120, 250, 500, 1000] навчи модель на випадковій підвибірці дошки й поміряй F1 крос-валідацією. Намалюй три криві на одному графіку matplotlib.

Зроблено, якщо: на графіку видно принаймні одне перетинання кривих і ти назвав обсяг, після якого рейтинг моделей перестає мінятись.

🔴 Рівень 3 — Виклик¶

Напиши функцію чесне_порівняння(моделі, сітки, X, y, бюджет), яка приймає однакове число кандидатів для кожної моделі й повертає таблицю з колонками F1, найкращі параметри, секунд на модель. Перевір її на бюджетах 2, 4 і 8 кандидатів і побудуй графік «місце в рейтингу залежно від бюджету».

Зроблено, якщо: графік показує, що місце принаймні однієї моделі змінюється зі зростанням бюджету, і ти пояснив, чому саме ця модель найчутливіша до налаштування.