Метрики регресії · практика> Лекція: lecture.html · Домашнє завдання: homework.html · Тест: quiz.htmlНаскрізний приклад той самий, що й у лекції — передбачення ціни вживаного телефоназа оголошенням. Колонки: модель, рік, стан, обсяг пам'яті, ціна.Що зробимо:1. порахуємо MAE, MSE, RMSE, R², MAPE і SMAPE вручну на NumPy — рядок за рядком;2. звіримо кожне число зі sklearn.metrics — вони мають зійтися до останнього знака;3. зіпсуємо дані одним викидом і подивимось, яка метрика як зреагувала;4. побудуємо дві моделі, на яких MAE і RMSE дають протилежні вердикти;5. проженемо все на більшій вибірці зі справжньою лінійною регресією.¶
In [1]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.metrics import (mean_absolute_error, mean_squared_error,
r2_score, mean_absolute_percentage_error)
# фіксуємо генератор випадкових чисел, щоб результат був однаковий у всіх
генератор = np.random.default_rng(42)
print("numpy", np.__version__)
print("pandas", pd.__version__)
print("готово — усі бібліотеки на місці")
numpy 2.5.1 pandas 2.3.3 готово — усі бібліотеки на місці
1 · Шість оголошень із лекціїПочинаємо з крихітної таблиці, яку можна перевірити калькулятором. Це важливо:поки числа маленькі, кожну формулу видно наскрізь.Колонка ціна — це факт (за скільки телефон реально продали), колонкапрогноз — те, що назвала модель.¶
In [2]:
оголошення = pd.DataFrame({
"модель": ["Redmi Note 10", "Samsung A52", "Poco X3",
"iPhone SE 2020", "Realme 8", "iPhone 11"],
"рік": [2021, 2021, 2020, 2020, 2021, 2019],
"стан": ["добрий", "добрий", "задовільний", "добрий", "відмінний", "відмінний"],
"пам'ять, ГБ": [128, 128, 64, 128, 128, 64],
"ціна": [5200, 7300, 3900, 8100, 6100, 12000],
"прогноз": [5600, 6700, 4800, 8500, 6200, 10800],
})
print(оголошення.to_string(index=False))
модель рік стан пам'ять, ГБ ціна прогноз
Redmi Note 10 2021 добрий 128 5200 5600
Samsung A52 2021 добрий 128 7300 6700
Poco X3 2020 задовільний 64 3900 4800
iPhone SE 2020 2020 добрий 128 8100 8500
Realme 8 2021 відмінний 128 6100 6200
iPhone 11 2019 відмінний 64 12000 10800
2 · Помилка одного рядка й чому суму брати не можнаПомилка (residual) — це факт − прогноз. Знак має сенс: мінус означає, що модельпереоцінила телефон, плюс — що недооцінила.Зараз побачимо головну пастку: сума помилок дорівнює нулю, хоча модель промахнуласьна кожному рядку.¶
In [3]:
факт = оголошення["ціна"].to_numpy(dtype=float)
прогноз = оголошення["прогноз"].to_numpy(dtype=float)
# помилка окремо для кожного оголошення — щоб побачити не одне число, а розподіл
помилка = факт - прогноз
оголошення["помилка"] = помилка.astype(int)
print(оголошення[["модель", "ціна", "прогноз", "помилка"]].to_string(index=False))
print()
print("сума помилок :", помилка.sum())
print("сума модулів :", np.abs(помилка).sum())
print()
print("Сума нульова — але жоден прогноз не влучив. Плюси й мінуси знищили одне одного.")
модель ціна прогноз помилка
Redmi Note 10 5200 5600 -400
Samsung A52 7300 6700 600
Poco X3 3900 4800 -900
iPhone SE 2020 8100 8500 -400
Realme 8 6100 6200 -100
iPhone 11 12000 10800 1200
сума помилок : 0.0
сума модулів : 3600.0
Сума нульова — але жоден прогноз не влучив. Плюси й мінуси знищили одне одного.
3 · MAE — середня абсолютна помилкаФормула: MAE = (1/n) · Σ |факт − прогноз|.Прибираємо знак модулем, складаємо, ділимо на кількість оголошень. Найпростішаметрика з усіх — і найлегша для пояснення замовникові.¶
In [4]:
# рахуємо руками, крок за кроком — саме так, як у лекції
модулі_помилок = np.abs(помилка)
сума_модулів = модулі_помилок.sum()
наш_mae = сума_модулів / len(факт)
бібліотечний_mae = mean_absolute_error(факт, прогноз)
print("модулі помилок :", модулі_помилок.astype(int))
print("сума модулів :", сума_модулів)
print("поділити на", len(факт), " :", наш_mae)
print()
print("наш MAE :", наш_mae)
print("sklearn MAE :", бібліотечний_mae)
assert np.allclose(наш_mae, бібліотечний_mae), "розрахунок розійшовся!"
print("✅ збігається — модель помиляється в середньому на 600 грн")
модулі помилок : [ 400 600 900 400 100 1200] сума модулів : 3600.0 поділити на 6 : 600.0 наш MAE : 600.0 sklearn MAE : 600.0 ✅ збігається — модель помиляється в середньому на 600 грн
4 · MSE і RMSE — те саме, але через квадратMSE = (1/n) · Σ (факт − прогноз)² — середній квадрат помилки. Число виходить у«гривнях у квадраті», тому його не можна прочитати вголос.RMSE = √MSE повертає нас у гривні. Зверни увагу: RMSE вийде більшою за MAE,бо квадрат непропорційно роздуває великі промахи.¶
In [5]:
квадрати_помилок = помилка ** 2
наш_mse = квадрати_помилок.mean()
наш_rmse = np.sqrt(наш_mse)
бібліотечний_mse = mean_squared_error(факт, прогноз)
print("квадрати помилок :", квадрати_помилок.astype(int))
print("сума квадратів :", квадрати_помилок.sum())
print()
print("наш MSE :", наш_mse, "(гривень у квадраті — не читається)")
print("sklearn MSE :", бібліотечний_mse)
print("наш RMSE :", наш_rmse, "грн")
print()
# найбільший квадрат показує, скільки важить один-єдиний поганий прогноз
частка_найбільшого = квадрати_помилок.max() / квадрати_помилок.sum()
print(f"найгірший рядок дає {частка_найбільшого:.1%} усієї суми квадратів")
assert np.allclose(наш_mse, бібліотечний_mse), "розрахунок розійшовся!"
print("✅ збігається")
квадрати помилок : [ 160000 360000 810000 160000 10000 1440000] сума квадратів : 2940000.0 наш MSE : 490000.0 (гривень у квадраті — не читається) sklearn MSE : 490000.0 наш RMSE : 700.0 грн найгірший рядок дає 49.0% усієї суми квадратів ✅ збігається
5 · R² — порівняння з «завжди називаю середнє»MAE в гривнях не каже, добре це чи погано: усе залежить від масштабу цін. R² прибираєодиниці, порівнюючи нашу модель із найдурнішою можливою — тією, що завжди називаєсередню ціну.R² = 1 − SSres / SStot, де SSres = Σ(факт − прогноз)², а SStot = Σ(факт − середнє)².¶
In [6]:
середня_ціна = факт.mean()
ss_res = ((факт - прогноз) ** 2).sum() # помилка нашої моделі
ss_tot = ((факт - середня_ціна) ** 2).sum() # помилка моделі «завжди середнє»
наш_r2 = 1 - ss_res / ss_tot
бібліотечний_r2 = r2_score(факт, прогноз)
print("середня ціна :", середня_ціна)
print("SSres (наша модель):", ss_res)
print("SStot (середнє) :", ss_tot)
print()
print("наш R² :", наш_r2)
print("sklearn R² :", бібліотечний_r2)
assert np.allclose(наш_r2, бібліотечний_r2), "розрахунок розійшовся!"
print(f"✅ збігається — модель пояснює {наш_r2:.1%} розкиду цін")
середня ціна : 7100.0 SSres (наша модель): 2940000.0 SStot (середнє) : 39900000.0 наш R² : 0.9263157894736842 sklearn R² : 0.9263157894736842 ✅ збігається — модель пояснює 92.6% розкиду цін
R² буває від'ємнимЦе не помилка й не рідкість. Якщо модель гірша за просте середнє, SSres > SStot, ідріб перевищує одиницю. Перевіримо на моделі, яка вперто називає 10 000 грн для всіх.¶
In [7]:
уперта_модель = np.full(len(факт), 10000.0) # завжди 10 000 грн, хай там що
print("R² моделі «завжди середнє»:", round(r2_score(факт, np.full(len(факт), середня_ціна)), 4))
print("R² моделі «завжди 10 000» :", round(r2_score(факт, уперта_модель), 4))
print()
print("Нуль означає «не краще за середнє», від'ємне число — «гірше за середнє».")
R² моделі «завжди середнє»: 0.0 R² моделі «завжди 10 000» : -1.2647 Нуль означає «не краще за середнє», від'ємне число — «гірше за середнє».
6 · MAPE і SMAPE — відсотки замість гривеньMAPE = (1/n) · Σ |факт − прогноз| / факт — помилку кожного рядка ділимо на йоговласну ціну. Так промах у 600 грн на телефоні за 12 000 і на телефоні за 1 500перестають бути однаковими.SMAPE ділить не на факт, а на середнє між фактом і прогнозом. У sklearn її немає —напишемо самі.¶
In [8]:
частки = np.abs(помилка) / факт # відносна помилка кожного рядка
наш_mape = частки.mean()
бібліотечний_mape = mean_absolute_percentage_error(факт, прогноз)
# SMAPE: у знаменнику середнє між фактом і прогнозом, тому нуль у факті її не ламає
наш_smape = (np.abs(помилка) / ((факт + прогноз) / 2)).mean()
таблиця_відсотків = оголошення[["модель", "ціна", "помилка"]].copy()
таблиця_відсотків["частка"] = (частки * 100).round(2)
print(таблиця_відсотків.to_string(index=False))
print()
print(f"наш MAPE : {наш_mape:.4%}")
print(f"sklearn MAPE : {бібліотечний_mape:.4%}")
print(f"наш SMAPE : {наш_smape:.4%}")
assert np.allclose(наш_mape, бібліотечний_mape), "розрахунок розійшовся!"
print("✅ збігається")
print()
print("Найгірший рядок за MAPE — Poco X3 (23%), хоча за модулем помилки")
print("найгірший був iPhone 11. Метрики питають різне.")
модель ціна помилка частка
Redmi Note 10 5200 -400 7.69
Samsung A52 7300 600 8.22
Poco X3 3900 -900 23.08
iPhone SE 2020 8100 -400 4.94
Realme 8 6100 -100 1.64
iPhone 11 12000 1200 10.00
наш MAPE : 9.2610%
sklearn MAPE : 9.2610%
наш SMAPE : 8.9400%
✅ збігається
Найгірший рядок за MAPE — Poco X3 (23%), хоча за модулем помилки
найгірший був iPhone 11. Метрики питають різне.
7 · Одна функція, яка рахує всеДалі метрики знадобляться нам багато разів, тому зберемо їх в одну функцію.Вона повертає звичайний словник — його зручно класти в DataFrame.¶
In [9]:
def усі_метрики(факт, прогноз):
# Рахує п'ять метрик регресії й повертає їх словником.
# MAPE ставимо None, якщо серед фактів є нуль: ділення на нуль означає,
# що метрика просто не існує, а не що вона дорівнює нулю.
помилка = факт - прогноз
результат = {
"MAE": np.abs(помилка).mean(),
"MSE": (помилка ** 2).mean(),
"RMSE": np.sqrt((помилка ** 2).mean()),
"R2": r2_score(факт, прогноз),
"SMAPE, %": (np.abs(помилка) / ((np.abs(факт) + np.abs(прогноз)) / 2)).mean() * 100,
}
if np.any(факт == 0):
результат["MAPE, %"] = None
else:
результат["MAPE, %"] = (np.abs(помилка) / факт).mean() * 100
return результат
базові_метрики = усі_метрики(факт, прогноз)
for назва, значення in базові_метрики.items():
print(f"{назва:<10} {значення:>12.4f}")
MAE 600.0000 MSE 490000.0000 RMSE 700.0000 R2 0.9263 SMAPE, % 8.9400 MAPE, % 9.2610
8 · Одне шахрайське оголошення за 1 грнТепер найцікавіше. Додамо сьомий рядок: телефон, виставлений за 1 грн — типовешахрайське оголошення «пишіть у месенджер». Модель, дивлячись на характеристики,оцінила його у 6 000 грн.Модель ми не переучуємо. Змінюються лише дані — і подивимось, що станеться з кожною метрикою.¶
In [10]:
факт_із_викидом = np.append(факт, 1.0) # ціна оголошення — 1 гривня
прогноз_із_викидом = np.append(прогноз, 6000.0) # модель нічого не запідозрила
метрики_з_викидом = усі_метрики(факт_із_викидом, прогноз_із_викидом)
порівняння = pd.DataFrame({
"шість чесних": базові_метрики,
"плюс 1 грн": метрики_з_викидом,
})
порівняння["у скільки разів"] = порівняння["плюс 1 грн"] / порівняння["шість чесних"]
print(порівняння.round(3).to_string())
print()
print("Один рядок із семи. MAE зросла у 2,3 раза, RMSE — у 3,4 раза,")
print("а MAPE злетіла в тисячі разів — бо ділиться на ціну в 1 гривню.")
шість чесних плюс 1 грн у скільки разів MAE 600.000 1371.286 2.285 MSE 490000.000 5561143.000 11.349 RMSE 700.000 2358.208 3.369 R2 0.926 0.532 0.574 SMAPE, % 8.940 36.225 4.052 MAPE, % 9.261 85707.938 9254.713 Один рядок із семи. MAE зросла у 2,3 раза, RMSE — у 3,4 раза, а MAPE злетіла в тисячі разів — бо ділиться на ціну в 1 гривню.
In [11]:
# а що буде, якщо ціна взагалі нульова? MAPE перестає існувати
факт_із_нулем = np.append(факт, 0.0)
прогноз_із_нулем = np.append(прогноз, 6000.0)
метрики_з_нулем = усі_метрики(факт_із_нулем, прогноз_із_нулем)
print("MAPE при нульовій ціні :", метрики_з_нулем["MAPE, %"], "← ділення на нуль")
print("RMSE при нульовій ціні :", round(метрики_з_нулем["RMSE"], 1), "грн — рахується спокійно")
MAPE при нульовій ціні : None ← ділення на нуль RMSE при нульовій ціні : 2358.6 грн — рахується спокійно
9 · Дві моделі, два протилежні вердиктиНайважливіший експеримент теми. Візьмемо ті самі шість оголошень і дві різні моделі:* Обережна помиляється на кожному оголошенні рівно на 650 грн;* Ризикова влучає майже точно на п'яти (промах 200 грн), але на iPhone 11 промахується на 2 000 грн.MAE і RMSE порахують чесно — і назвуть різних переможців.¶
In [12]:
обережна = np.array([4550, 7950, 3250, 8750, 5450, 12650], dtype=float)
ризикова = np.array([5000, 7500, 3700, 8300, 5900, 14000], dtype=float)
дві_моделі = pd.DataFrame({
"Обережна": усі_метрики(факт, обережна),
"Ризикова": усі_метрики(факт, ризикова),
})
print(дві_моделі.round(3).to_string())
print()
краща_за_mae = дві_моделі.loc["MAE"].idxmin()
краща_за_rmse = дві_моделі.loc["RMSE"].idxmin()
print(f"MAE обирає : {краща_за_mae}")
print(f"RMSE обирає : {краща_за_rmse}")
print()
if краща_за_mae != краща_за_rmse:
print("Вердикти протилежні. Метрика не знаходить «кращу модель» —")
print("вона виконує те визначення кращого, яке ти в неї заклав.")
Обережна Ризикова MAE 650.000 500.000 MSE 422500.000 700000.000 RMSE 650.000 836.660 R2 0.936 0.895 SMAPE, % 10.714 5.507 MAPE, % 10.361 5.688 MAE обирає : Ризикова RMSE обирає : Обережна Вердикти протилежні. Метрика не знаходить «кращу модель» — вона виконує те визначення кращого, яке ти в неї заклав.
10 · Велика вибірка й справжня модельШість рядків добре для розуміння, але метрики міряють на сотнях. Згенеруємо 300оголошень із тими самими колонками, навчимо лінійну регресію й порахуємо метрикина тестовій частині — тобто на даних, яких модель не бачила.¶
In [13]:
кількість = 300
назви_моделей = генератор.choice(
["Redmi Note 10", "Samsung A52", "Poco X3", "iPhone SE 2020", "Realme 8", "iPhone 11"],
size=кількість)
роки = генератор.integers(2016, 2024, size=кількість)
стани = генератор.choice(["задовільний", "добрий", "відмінний"],
size=кількість, p=[0.25, 0.5, 0.25])
обсяги = генератор.choice([64, 128, 256], size=кількість)
# стан переводимо в число: моделі потрібні числа, а не слова
бал_стану = pd.Series(стани).map({"задовільний": 0, "добрий": 1, "відмінний": 2}).to_numpy()
# «справжня» залежність, яку модель має відшукати, плюс шум ринку
ціни = (900
+ 1400 * (роки - 2016)
+ 11 * обсяги
+ 700 * бал_стану
+ генератор.normal(0, 900, size=кількість))
ціни = np.clip(ціни, 500, None).round(-2) # ціни в оголошеннях круглі
база = pd.DataFrame({
"модель": назви_моделей, "рік": роки, "стан": стани,
"пам'ять, ГБ": обсяги, "ціна": ціни,
})
print(база.head(8).to_string(index=False))
print()
print("рядків:", len(база), "· ціна від", int(ціни.min()), "до", int(ціни.max()), "грн")
модель рік стан пам'ять, ГБ ціна
Redmi Note 10 2018 відмінний 256 7800.0
Realme 8 2022 задовільний 128 11700.0
iPhone SE 2020 2016 добрий 256 2400.0
Poco X3 2023 добрий 256 12900.0
Poco X3 2020 відмінний 128 8500.0
iPhone 11 2017 добрий 256 7100.0
Redmi Note 10 2018 добрий 128 6100.0
Realme 8 2020 добрий 256 10700.0
рядків: 300 · ціна від 500 до 15400 грн
In [14]:
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
ознаки = np.column_stack([роки - 2016, обсяги, бал_стану])
ознаки_навч, ознаки_тест, ціни_навч, ціни_тест = train_test_split(
ознаки, ціни, test_size=0.3, random_state=42)
модель = LinearRegression().fit(ознаки_навч, ціни_навч)
прогноз_тест = модель.predict(ознаки_тест)
print("коефіцієнти моделі:")
for назва, вага in zip(["рік (від 2016)", "пам'ять, ГБ", "бал стану"], модель.coef_):
print(f" {назва:<16} {вага:8.1f} грн")
print(f" вільний член {модель.intercept_:8.1f} грн")
print()
print("рядків у навчальній вибірці:", len(ціни_навч), "· у тестовій:", len(ціни_тест))
коефіцієнти моделі: рік (від 2016) 1388.6 грн пам'ять, ГБ 9.5 грн бал стану 767.9 грн вільний член 1066.7 грн рядків у навчальній вибірці: 210 · у тестовій: 90
In [15]:
метрики_тесту = усі_метрики(ціни_тест, прогноз_тест)
for назва, значення in метрики_тесту.items():
print(f"{назва:<10} {значення:>12.3f}")
print()
print(f"Читається так: модель помиляється в середньому на {метрики_тесту['MAE']:.0f} грн")
print(f"і пояснює {метрики_тесту['R2']:.1%} розкиду цін на даних, яких не бачила.")
MAE 790.268 MSE 1046632.012 RMSE 1023.050 R2 0.912 SMAPE, % 11.953 MAPE, % 12.275 Читається так: модель помиляється в середньому на 790 грн і пояснює 91.2% розкиду цін на даних, яких не бачила.
Розподіл помилок каже більше, ніж одне числоГістограма залишків — найдешевша діагностика, яка є. Здорова модель дає симетричнухмару навколо нуля. Хвіст в один бік означає, що модель систематично завищуєабо занижує ціну.¶
In [16]:
залишки = ціни_тест - прогноз_тест
фігура, осі = plt.subplots(1, 2, figsize=(11, 4))
осі[0].hist(залишки, bins=20, color="#c2185b", alpha=0.75)
осі[0].axvline(0, color="#17212b", linewidth=1.2)
осі[0].set_title("Розподіл помилок на тесті")
осі[0].set_xlabel("факт − прогноз, грн")
осі[0].set_ylabel("кількість оголошень")
осі[1].scatter(прогноз_тест, ціни_тест, s=18, color="#0f766e", alpha=0.7)
межі = [ціни_тест.min(), ціни_тест.max()]
осі[1].plot(межі, межі, color="#17212b", linewidth=1.2)
осі[1].set_title("Факт проти прогнозу")
осі[1].set_xlabel("прогноз, грн")
осі[1].set_ylabel("факт, грн")
plt.tight_layout()
plt.show()
print(f"середня помилка (перекіс): {залишки.mean():.1f} грн")
print(f"медіанний модуль помилки : {np.median(np.abs(залишки)):.1f} грн")
print(f"MAE : {np.abs(залишки).mean():.1f} грн")
print()
print("Медіана менша за MAE — отже кілька великих промахів тягнуть середнє вгору.")
середня помилка (перекіс): 120.0 грн медіанний модуль помилки : 684.4 грн MAE : 790.3 грн Медіана менша за MAE — отже кілька великих промахів тягнуть середнє вгору.
Що зробить із цими метриками один викидПовторимо експеримент із лекції на великій вибірці: зіпсуємо одне оголошенняз тестової частини, виставивши ціну 1 грн. Модель не переучуємо.¶
In [17]:
ціни_зіпсовані = ціни_тест.copy()
ціни_зіпсовані[0] = 1.0 # одне шахрайське оголошення з 90
до = усі_метрики(ціни_тест, прогноз_тест)
після = усі_метрики(ціни_зіпсовані, прогноз_тест)
ефект = pd.DataFrame({"до": до, "після одного викиду": після})
ефект["у скільки разів"] = ефект["після одного викиду"] / ефект["до"]
print(ефект.round(3).to_string())
print()
print("Один рядок із", len(ціни_тест), "— а MAPE вже непридатна для звіту.")
до після одного викиду у скільки разів MAE 790.268 903.590 1.143 MSE 1046632.012 2331921.495 2.228 RMSE 1023.050 1527.063 1.493 R2 0.912 0.816 0.894 SMAPE, % 11.953 14.114 1.181 MAPE, % 12.275 11979.380 975.929 Один рядок із 90 — а MAPE вже непридатна для звіту.