Практика 10 · k найближчих сусідів> 📖 Теорія: відкрий lecture.html у цій же теці.> 📝 Домашнє: homework.md · 🧪 Тест: quiz.htmlУ лекції kNN показано ззовні: межа рішень на площині, голосування сусідів, повзунок k.Тут ми розберемо його зсередини — так, щоб не лишилось жодного місця, де «бібліотекащось порахувала сама».Що зробимо:1. Зберемо таблицю оголошень про вживані телефони й розділимо її на навчальну й тестову частини2. Порахуємо евклідову відстань руками на NumPy й знайдемо k найближчих сусідів3. Напишемо власний kNN-класифікатор і зіставимо його прогнози з KNeighborsClassifier — до останнього обʼєкта4. Проженемо k від 1 до 50 і побудуємо криву точності5. Увімкнемо StandardScaler і побачимо стрибок6. Наприкінці оцінимо ціну телефона через KNeighborsRegressor¶

1. Дані: дошка оголошень про вживані телефониТой самий приклад, що в лекції. Кожен рядок — одне оголошення:| колонка | що це ||---|---|| модель, рік, памʼять_гб, стан, ємність_батареї | що саме продають || типова_ціна | скільки такий телефон коштує на ринку || ціна | скільки просить продавець || відносна_ціна | ціна / типова_ціна — головна ознака || вік_акаунта | скільки днів акаунту продавця || ціна_продажу | за скільки телефон реально пішов (знадобиться в кінці) || шахрайство | 1, якщо оголошення виявилось приманкою |Шахрайство тут двох ґатунків: дешеві приманки зі свіжих акаунтів і вужчий згусток«преміум»-приманок — майже ринкова ціна, зате акаунту менш як два місяці. Плюс 6% мітокперевернуто: у житті розмітка теж буває помилковою, і саме через цей шум вибір kвзагалі має значення.¶

In [1]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier, KNeighborsRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.metrics import accuracy_score, mean_absolute_error

генератор = np.random.default_rng(42)
КІЛЬКІСТЬ = 800

# скільки коштує модель у базовій комплектації
базова_ціна_моделі = {"iPhone 12": 12000, "Samsung S21": 9000, "Xiaomi Note 11": 5000}
надбавка_за_памʼять = {64: 0.90, 128: 1.00, 256: 1.15}
надбавка_за_стан = {3: 0.85, 4: 1.00, 5: 1.10}

модель = генератор.choice(list(базова_ціна_моделі), КІЛЬКІСТЬ)
рік = генератор.integers(2019, 2024, КІЛЬКІСТЬ)
памʼять_гб = генератор.choice([64, 128, 256], КІЛЬКІСТЬ)
стан = генератор.choice([3, 4, 5], КІЛЬКІСТЬ)
ємність_батареї = генератор.uniform(70, 100, КІЛЬКІСТЬ).round(1)

# типова ринкова ціна: модель × памʼять × стан × свіжість року × стан батареї
типова_ціна = np.array([базова_ціна_моделі[m] for m in модель], dtype=float)
типова_ціна *= np.array([надбавка_за_памʼять[p] for p in памʼять_гб])
типова_ціна *= np.array([надбавка_за_стан[s] for s in стан])
типова_ціна *= 1 + 0.08 * (рік - 2019)
типова_ціна *= 0.85 + 0.01 * (ємність_батареї - 70)

# дві ознаки, за якими ловитимемо шахрайство
відносна_ціна = генератор.uniform(0.30, 1.45, КІЛЬКІСТЬ)
вік_акаунта = генератор.integers(0, 366, КІЛЬКІСТЬ)

дешева_приманка = (відносна_ціна < 0.85) & (вік_акаунта < 150)
преміум_приманка = (відносна_ціна >= 0.95) & (відносна_ціна <= 1.28) & (вік_акаунта < 48)
шахрайство = (дешева_приманка | преміум_приманка).astype(int)

# 6% міток перевертаємо: без шуму k = 1 був би ідеальним і крива точності нічого б не показала
помилкова_розмітка = генератор.random(КІЛЬКІСТЬ) < 0.06
шахрайство[помилкова_розмітка] = 1 - шахрайство[помилкова_розмітка]

таблиця = pd.DataFrame({
    "модель": модель,
    "рік": рік,
    "памʼять_гб": памʼять_гб,
    "стан": стан,
    "ємність_батареї": ємність_батареї,
    "типова_ціна": типова_ціна.round().astype(int),
    "ціна": (типова_ціна * відносна_ціна).round().astype(int),
    "відносна_ціна": відносна_ціна,
    "вік_акаунта": вік_акаунта,
    "ціна_продажу": (типова_ціна * генератор.normal(1.0, 0.07, КІЛЬКІСТЬ)).round().astype(int),
    "шахрайство": шахрайство,
})

print(таблиця.head(6).round(3).to_string(index=False))
print(f"\nусього оголошень: {len(таблиця)}")
print(f"шахрайських: {таблиця['шахрайство'].sum()} ({таблиця['шахрайство'].mean():.1%})")
        модель  рік  памʼять_гб  стан  ємність_батареї  типова_ціна  ціна  відносна_ціна  вік_акаунта  ціна_продажу  шахрайство
     iPhone 12 2019          64     3             97.4        10318 10173          0.986          196          9842           0
Xiaomi Note 11 2020         256     4             74.2         5539  6709          1.211          278          6190           0
   Samsung S21 2021         256     5             90.3        13907 16216          1.166          113         13723           0
   Samsung S21 2023          64     4             94.4        11697 15515          1.326          337         11334           0
   Samsung S21 2022          64     4             77.1         9251 11347          1.227          297          9960           0
Xiaomi Note 11 2022         256     5             78.3         7318  4276          0.584          335          6948           1

усього оголошень: 800
шахрайських: 218 (27.3%)

2. Навчальна й тестова частиниЦе той самий розподіл ролей, що в теміTrain / Validation / Test. Для kNN навчальначастина — це буквально і є модель: серед цих рядків він шукатиме сусідів. Тестову чіпаємотільки для оцінки.stratify тримає однакову частку шахрайських оголошень в обох частинах — інакше оцінкана рідшому класі стрибала б без причини.¶

In [2]:
ознаки_класифікації = ["відносна_ціна", "вік_акаунта"]

X = таблиця[ознаки_класифікації].to_numpy(dtype=float)
y = таблиця["шахрайство"].to_numpy()

X_навч, X_тест, y_навч, y_тест = train_test_split(
    X, y, test_size=0.35, random_state=42, stratify=y)

базова_точність = 1 - y_тест.mean()
print(f"навчальна вибірка: {len(X_навч)} оголошень, шахрайських {y_навч.mean():.1%}")
print(f"тестова вибірка:   {len(X_тест)} оголошень, шахрайських {y_тест.mean():.1%}")
print(f"\nбазова лінія «усі оголошення чесні»: точність {базова_точність:.4f}")
print("нижче цього числа модель не має права опускатись — інакше вона гірша за бездіяльність")
навчальна вибірка: 520 оголошень, шахрайських 27.3%
тестова вибірка:   280 оголошень, шахрайських 27.1%

базова лінія «усі оголошення чесні»: точність 0.7286
нижче цього числа модель не має права опускатись — інакше вона гірша за бездіяльність

3. Відстань рукамиЕвклідова відстань між двома оголошеннями — корінь із суми квадратів різниць по кожній ознаці:$$d(a, b) = \sqrt{\sum_j (a_j - b_j)^2}$$NumPy рахує відстань від одного оголошення до всіх одразу: віднімання рядка від матрицірозтягує цей рядок на всі рядки (це називають broadcasting), далі підносимо до квадрата,додаємо по осі ознак і беремо корінь.¶

In [3]:
def відстані_до_всіх(оголошення, таблиця_оголошень):
    """Евклідові відстані від одного оголошення до кожного рядка таблиці.

    Повертає одновимірний масив довжиною в кількість рядків таблиці."""
    різниці = таблиця_оголошень - оголошення     # broadcasting: рядок мінус уся матриця
    return np.sqrt(np.sum(різниці ** 2, axis=1))


# беремо одне тестове оголошення — на ньому далі буде добре видно роль k
НОМЕР_ЗАПИТУ = 23
запит = X_тест[НОМЕР_ЗАПИТУ]
відстані = відстані_до_всіх(запит, X_навч)

print(f"запит: відносна ціна {запит[0]:.3f}, вік акаунта {запит[1]:.0f} днів")
print(f"справжня мітка: {'шахрайство' if y_тест[НОМЕР_ЗАПИТУ] == 1 else 'чесне'}\n")

# argsort повертає номери рядків у порядку зростання відстані
порядок = np.argsort(відстані)
найближчі = pd.DataFrame({
    "відносна_ціна": X_навч[порядок[:8], 0].round(3),
    "вік_акаунта": X_навч[порядок[:8], 1].astype(int),
    "відстань": відстані[порядок[:8]].round(3),
    "мітка": y_навч[порядок[:8]],
}, index=range(1, 9))
print(найближчі.to_string())
запит: відносна ціна 0.329, вік акаунта 52 днів
справжня мітка: шахрайство

   відносна_ціна  вік_акаунта  відстань  мітка
1          0.599           52     0.270      1
2          0.373           51     1.001      1
3          0.707           51     1.069      1
4          0.886           51     1.145      0
5          1.171           53     1.307      0
6          0.469           54     2.005      1
7          0.681           54     2.031      1
8          0.927           50     2.088      0

Придивись до цієї таблиціВідстань майже дорівнює різниці у віці акаунта, а відносна ціна на порядок сусідів майжене впливає: серед «найближчих» є і 0.5, і 1.3. Це рівно та пастка, про яку йшлося в лекції —вік живе в діапазоні 0…365, відносна ціна у 0.30…1.45, і дні розчавлюють частки.Полагодимо це в розділі 6. Спершу розберемось із самим механізмом.¶

4. Голосування: пишемо kNN саміПрогноз для одного оголошення — три дії: порахувати відстані, взяти k найменших,подивитись, яких міток серед них більше.¶

In [4]:
def передбачити_одне(оголошення, X_навч, y_навч, k):
    """Голос більшості серед k найближчих сусідів."""
    відстані = відстані_до_всіх(оголошення, X_навч)
    номери_найближчих = np.argsort(відстані)[:k]
    мітки_сусідів = y_навч[номери_найближчих]
    # сусідів рівно k, тож «більшість» — це строго більше половини
    return int(мітки_сусідів.sum() * 2 > k)


мітка_словами = "шахрайство" if y_тест[НОМЕР_ЗАПИТУ] else "чесне"
print(f"справжня мітка запиту: {мітка_словами}\n")
for k in [1, 3, 5, 9, 15, 25]:
    номери = np.argsort(відстані)[:k]
    голосів_за_шахрайство = int(y_навч[номери].sum())
    вирок = передбачити_одне(запит, X_навч, y_навч, k)
    print(f"k = {k:2d}: за шахрайство {голосів_за_шахрайство:2d} з {k:2d}"
          f"  ->  {'шахрайство' if вирок else 'чесне'}")
справжня мітка запиту: шахрайство

k =  1: за шахрайство  1 з  1  ->  шахрайство
k =  3: за шахрайство  3 з  3  ->  шахрайство
k =  5: за шахрайство  3 з  5  ->  шахрайство
k =  9: за шахрайство  5 з  9  ->  шахрайство
k = 15: за шахрайство  6 з 15  ->  чесне
k = 25: за шахрайство 12 з 25  ->  чесне

Одне й те саме оголошення, різні k — різні відповіді. Саме тому k називають головнимналаштуванням методу.Тепер прогноз для всієї тестової вибірки. Ніякого «навчання» перед цим не було: kNN простотримає навчальну таблицю під рукою.

In [5]:
def передбачити_багато(X_запитів, X_навч, y_навч, k):
    """Прогноз для кожного рядка X_запитів."""
    прогнози = np.zeros(len(X_запитів), dtype=int)
    for номер, оголошення in enumerate(X_запитів):
        прогнози[номер] = передбачити_одне(оголошення, X_навч, y_навч, k)
    return прогнози


наші_прогнози = передбачити_багато(X_тест, X_навч, y_навч, k=9)
наша_точність = accuracy_score(y_тест, наші_прогнози)

print(f"власний kNN, k = 9: точність {наша_точність:.4f}")
print(f"базова лінія «усі чесні»:  {базова_точність:.4f}")
print("\nрозрив крихітний — і це не вада алгоритму, а наслідок несиметричних масштабів")
власний kNN, k = 9: точність 0.7750
базова лінія «усі чесні»:  0.7286

розрив крихітний — і це не вада алгоритму, а наслідок несиметричних масштабів

5. Звірка з бібліотекоюНайцінніша клітинка практики: переконатись, що всередині scikit-learn немає магії.fit тут просто запамʼятовує таблицю — тому й виконується миттєво.¶

In [6]:
бібліотечний_knn = KNeighborsClassifier(n_neighbors=9)
бібліотечний_knn.fit(X_навч, y_навч)          # усе «навчання» — копіювання таблиці
прогнози_sklearn = бібліотечний_knn.predict(X_тест)

assert np.array_equal(наші_прогнози, прогнози_sklearn), "прогнози розійшлися!"
print("✅ збігається: наші прогнози й прогнози KNeighborsClassifier однакові")
print(f"   перевірено обʼєктів: {len(X_тест)}")
print(f"   точність обох: {accuracy_score(y_тест, прогнози_sklearn):.4f}")
✅ збігається: наші прогнози й прогнози KNeighborsClassifier однакові
   перевірено обʼєктів: 280
   точність обох: 0.7750

6. Скільки брати сусідівПроженемо k від 1 до 50 і подивимось на дві криві: точність на навчальній вибірці йна тестовій. Найцікавіше — ліва межа графіка.¶

In [7]:
значення_k = list(range(1, 51))
точність_на_навчанні = []
точність_на_тесті = []

for k in значення_k:
    модель_knn = KNeighborsClassifier(n_neighbors=k)
    модель_knn.fit(X_навч, y_навч)
    точність_на_навчанні.append(accuracy_score(y_навч, модель_knn.predict(X_навч)))
    точність_на_тесті.append(accuracy_score(y_тест, модель_knn.predict(X_тест)))

найкраще_k = int(np.argmax(точність_на_тесті)) + 1
print(f"k =  1: навчання {точність_на_навчанні[0]:.4f}, тест {точність_на_тесті[0]:.4f}")
print(f"k = {найкраще_k:2d}: навчання {точність_на_навчанні[найкраще_k - 1]:.4f}, "
      f"тест {точність_на_тесті[найкраще_k - 1]:.4f}   <- найкраще")
print(f"k = 50: навчання {точність_на_навчанні[-1]:.4f}, тест {точність_на_тесті[-1]:.4f}")
print(f"\nточність на навчанні при k = 1 дорівнює {точність_на_навчанні[0]:.4f}:")
print("найближчий сусід навчального оголошення — завжди він сам, тож помилитись ніде")
k =  1: навчання 1.0000, тест 0.7821
k = 25: навчання 0.8058, тест 0.8393   <- найкраще
k = 50: навчання 0.8192, тест 0.7893

точність на навчанні при k = 1 дорівнює 1.0000:
найближчий сусід навчального оголошення — завжди він сам, тож помилитись ніде
In [8]:
plt.figure(figsize=(8, 4.2))
plt.plot(значення_k, точність_на_навчанні, label="навчальна вибірка")
plt.plot(значення_k, точність_на_тесті, label="тестова вибірка")
plt.axhline(базова_точність, linestyle=":", color="gray", label="«усі чесні»")
plt.axvline(найкраще_k, linestyle="--", color="gray", label=f"найкраще k = {найкраще_k}")
plt.xlabel("k — скільки сусідів питаємо")
plt.ylabel("точність")
plt.title("Криві точності на сирих, незрівняних ознаках")
plt.legend()
plt.grid(alpha=0.3)
plt.show()

print(f"максимум тестової точності: {max(точність_на_тесті):.4f} при k = {найкраще_k}")
print(f"це лише на {max(точність_на_тесті) - базова_точність:.4f} краще за бездіяльність")
No description has been provided for this image
максимум тестової точності: 0.8393 при k = 25
це лише на 0.1107 краще за бездіяльність

7. Масштаб ознак: головна пастка темиЗапамʼятай число вище — а тепер полагодимо відстань. Спершу подивимось, наскільки взагалінесиметричні наші дві ознаки.¶

In [9]:
розмах_ціни = X[:, 0].max() - X[:, 0].min()
розмах_віку = X[:, 1].max() - X[:, 1].min()

print(f"відносна ціна: {X[:, 0].min():.2f} … {X[:, 0].max():.2f}   розмах {розмах_ціни:.2f}")
print(f"вік акаунта:   {X[:, 1].min():.0f} … {X[:, 1].max():.0f}    розмах {розмах_віку:.0f}")
print(f"\nвік більший за ціну у {розмах_віку / розмах_ціни:.0f} разів,")
print(f"а у квадраті — у {(розмах_віку / розмах_ціни) ** 2:.0f} разів.")
print("саме у квадраті ознака й входить у відстань, тож ціна не важить майже нічого")
відносна ціна: 0.30 … 1.45   розмах 1.15
вік акаунта:   0 … 365    розмах 365

вік більший за ціну у 318 разів,
а у квадраті — у 101027 разів.
саме у квадраті ознака й входить у відстань, тож ціна не важить майже нічого

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

In [10]:
масштабувальник = StandardScaler()
масштабувальник.fit(X_навч)                      # середнє й відхилення — тільки з навчальної
X_навч_масштаб = масштабувальник.transform(X_навч)
X_тест_масштаб = масштабувальник.transform(X_тест)

запит_масштаб = X_тест_масштаб[НОМЕР_ЗАПИТУ]
відстані_масштаб = відстані_до_всіх(запит_масштаб, X_навч_масштаб)
порядок_масштаб = np.argsort(відстані_масштаб)

найближчі_після = pd.DataFrame({
    "відносна_ціна": X_навч[порядок_масштаб[:8], 0].round(3),
    "вік_акаунта": X_навч[порядок_масштаб[:8], 1].astype(int),
    "відстань": відстані_масштаб[порядок_масштаб[:8]].round(3),
    "мітка": y_навч[порядок_масштаб[:8]],
}, index=range(1, 9))

print(f"запит: відносна ціна {запит[0]:.3f}, вік акаунта {запит[1]:.0f} днів\n")
print(найближчі_після.to_string())
print("\nтепер сусіди схожі на запит за обома ознаками, а не лише за віком акаунта")
запит: відносна ціна 0.329, вік акаунта 52 днів

   відносна_ціна  вік_акаунта  відстань  мітка
1          0.373           51     0.134      1
2          0.336           38     0.134      1
3          0.347           37     0.153      1
4          0.319           69     0.164      1
5          0.342           27     0.240      1
6          0.411           37     0.286      1
7          0.427           48     0.299      1
8          0.415           34     0.310      1

тепер сусіди схожі на запит за обома ознаками, а не лише за віком акаунта
In [11]:
точність_масштаб = []
for k in значення_k:
    модель_knn = KNeighborsClassifier(n_neighbors=k)
    модель_knn.fit(X_навч_масштаб, y_навч)
    точність_масштаб.append(accuracy_score(y_тест, модель_knn.predict(X_тест_масштаб)))

найкраще_k_масштаб = int(np.argmax(точність_масштаб)) + 1

print("                     без масштабування   зі StandardScaler")
print(f"k = 9                      {точність_на_тесті[8]:.4f}             {точність_масштаб[8]:.4f}")
print(f"найкраще k                 {найкраще_k:2d}                 {найкраще_k_масштаб:2d}")
print(f"точність при ньому         {max(точність_на_тесті):.4f}             {max(точність_масштаб):.4f}")
print(f"\nстрибок при k = 9: {точність_масштаб[8] - точність_на_тесті[8]:+.4f}")
                     без масштабування   зі StandardScaler
k = 9                      0.7750             0.9214
найкраще k                 25                 17
точність при ньому         0.8393             0.9286

стрибок при k = 9: +0.1464
In [12]:
plt.figure(figsize=(8, 4.2))
plt.plot(значення_k, точність_на_тесті, label="сирі ознаки")
plt.plot(значення_k, точність_масштаб, label="після StandardScaler")
plt.axhline(базова_точність, linestyle=":", color="gray", label="«усі чесні»")
plt.xlabel("k — скільки сусідів питаємо")
plt.ylabel("точність на тестовій вибірці")
plt.title("Одна модель, одні дані — різниця лише в одиницях відстані")
plt.legend()
plt.grid(alpha=0.3)
plt.show()

print(f"розрив між кривими у найкращих точках: "
      f"{max(точність_масштаб) - max(точність_на_тесті):+.4f}")
No description has been provided for this image
розрив між кривими у найкращих точках: +0.0893

Тепер знайдемо конкретні оголошення, вирок для яких перевернувся.

In [13]:
прогноз_без = KNeighborsClassifier(n_neighbors=9).fit(X_навч, y_навч).predict(X_тест)
прогноз_зі = KNeighborsClassifier(n_neighbors=9).fit(X_навч_масштаб, y_навч).predict(X_тест_масштаб)

виправлені = np.where((прогноз_без != y_тест) & (прогноз_зі == y_тест))[0]
зіпсовані = np.where((прогноз_без == y_тест) & (прогноз_зі != y_тест))[0]
print(f"масштабування виправило {len(виправлені)} оголошень, зіпсувало {len(зіпсовані)}")

номер = виправлені[0]
print(f"\nприклад — оголошення №{номер} у тестовій вибірці:")
print(f"  відносна ціна  {X_тест[номер, 0]:.3f}")
print(f"  вік акаунта    {X_тест[номер, 1]:.0f} днів")
print(f"  справді        {'шахрайство' if y_тест[номер] else 'чесне'}")
print(f"  без масштабу   {'шахрайство' if прогноз_без[номер] else 'чесне'}   <- промах")
print(f"  з масштабом    {'шахрайство' if прогноз_зі[номер] else 'чесне'}   <- влучив")
масштабування виправило 46 оголошень, зіпсувало 5

приклад — оголошення №3 у тестовій вибірці:
  відносна ціна  0.511
  вік акаунта    117 днів
  справді        шахрайство
  без масштабу   чесне   <- промах
  з масштабом    шахрайство   <- влучив

Як це роблять у продакшені. Замість двох окремих обʼєктів беруть конвеєр:> make_pipeline(StandardScaler(), KNeighborsClassifier(k)). Тоді всередині> крос-валідації масштабування перераховується> для кожної частини окремо, і забути про витік даних просто ніде.

8. Той самий метод для регресіїЗадача змінюється: тепер потрібна не мітка, а число — скільки коштує телефон, схожийна цей. Змінюється рівно одна дія: замість голосування сусідів беремо середнє їхніх цін.Візьмемо колонку ціна_продажу — за скільки телефон реально пішов, — і обмежимось однієюмоделлю, щоб не заводити окремих ознак під назву моделі.> Якби моделей було кілька, колонку модель довелось би перетворити на числа —> наприклад, one-hot: кожна модель стає окремою ознакою 0/1.¶

In [14]:
одна_модель = таблиця[таблиця["модель"] == "iPhone 12"].copy()

ознаки_регресії = ["рік", "памʼять_гб", "стан", "ємність_батареї"]
X_рег = одна_модель[ознаки_регресії].to_numpy(dtype=float)
y_рег = одна_модель["ціна_продажу"].to_numpy(dtype=float)

X_рег_навч, X_рег_тест, y_рег_навч, y_рег_тест = train_test_split(
    X_рег, y_рег, test_size=0.3, random_state=42)

print(f"завершених угод по цій моделі: {len(одна_модель)}")
print(f"навчальна {len(X_рег_навч)}, тестова {len(X_рег_тест)}")
print(f"ціни продажу: {y_рег.min():.0f} … {y_рег.max():.0f} ₴, середня {y_рег.mean():.0f} ₴")
завершених угод по цій моделі: 264
навчальна 184, тестова 80
ціни продажу: 8030 … 22621 ₴, середня 13840 ₴
In [15]:
# рік вимірюється тисячами, стан — одиницями: без масштабування рік зʼїв би все,
# тож одразу беремо конвеєр
регресор = make_pipeline(StandardScaler(), KNeighborsRegressor(n_neighbors=5))
регресор.fit(X_рег_навч, y_рег_навч)
прогноз_ціни = регресор.predict(X_рег_тест)

# з чим порівнювати: найтупіший прогноз — завжди середня ціна навчальної вибірки
тупий_прогноз = np.full(len(y_рег_тест), y_рег_навч.mean())

print(f"kNN, 5 сусідів:    середня помилка {mean_absolute_error(y_рег_тест, прогноз_ціни):.0f} ₴")
print(f"«завжди середня»:  середня помилка {mean_absolute_error(y_рег_тест, тупий_прогноз):.0f} ₴")
print(f"середня ціна в тесті: {y_рег_тест.mean():.0f} ₴\n")

порівняння = pd.DataFrame({
    "рік": X_рег_тест[:6, 0].astype(int),
    "памʼять": X_рег_тест[:6, 1].astype(int),
    "стан": X_рег_тест[:6, 2].astype(int),
    "батарея": X_рег_тест[:6, 3],
    "справжня_ціна": y_рег_тест[:6].round().astype(int),
    "прогноз": прогноз_ціни[:6].round().astype(int),
})
print(порівняння.to_string(index=False))
kNN, 5 сусідів:    середня помилка 934 ₴
«завжди середня»:  середня помилка 2429 ₴
середня ціна в тесті: 14273 ₴

 рік  памʼять  стан  батарея  справжня_ціна  прогноз
2022      128     5     83.5          17738    16197
2019      256     4     78.1          13447    12798
2022       64     5     79.1          14036    14736
2021       64     5     72.7          13247    12365
2022      256     4     77.4          14484    16481
2023       64     4     87.2          13606    14834

І остання перевірка: переконаймось, що KNeighborsRegressor справді бере просте середнєцін сусідів. Порахуємо його руками для першого тестового обʼєкта.

In [16]:
# повторюємо те, що робить конвеєр: масштабуємо тими самими числами, що й на навчанні
масштабувальник_рег = StandardScaler().fit(X_рег_навч)
X_рег_навч_масштаб = масштабувальник_рег.transform(X_рег_навч)
X_рег_тест_масштаб = масштабувальник_рег.transform(X_рег_тест)

запит_рег = X_рег_тест_масштаб[0]
відстані_рег = відстані_до_всіх(запит_рег, X_рег_навч_масштаб)
пʼять_найближчих = np.argsort(відстані_рег)[:5]

ціни_сусідів = y_рег_навч[пʼять_найближчих]
наше_середнє = ціни_сусідів.mean()
прогноз_бібліотеки = регресор.predict(X_рег_тест[:1])[0]

print("ціни пʼятьох найближчих сусідів:", ціни_сусідів.round().astype(int).tolist())
print(f"їхнє середнє:                 {наше_середнє:.2f} ₴")
print(f"прогноз KNeighborsRegressor:  {прогноз_бібліотеки:.2f} ₴")

assert np.isclose(наше_середнє, прогноз_бібліотеки), "розрахунок розійшовся!"
print("\n✅ збігається: kNN-регресія — це буквально середнє цін сусідів")
ціни пʼятьох найближчих сусідів: [14524, 17034, 18038, 16806, 14583]
їхнє середнє:                 16197.00 ₴
прогноз KNeighborsRegressor:  16197.00 ₴

✅ збігається: kNN-регресія — це буквально середнє цін сусідів

Завдання### 🟢 Рівень 1Повтори криву точності з розділу 7 (на масштабованих ознаках), але зweights="distance" замість голосування «одна точка — один голос». Побудуй обидві кривіна одному графіку.Зроблено, якщо на графіку видно обидві криві й у тексті названо, при якому kрізниця між ними найбільша.### 🟡 Рівень 2Заміни евклідову відстань на манхеттенську (metric="manhattan") і порівняй точність принайкращому k з розділу 7. Потім додай до ознак три колонки чистого шуму(генератор.normal(0, 1, КІЛЬКІСТЬ)) і поміряй точність ще раз — обовʼязково післямасштабування.Зроблено, якщо у зошиті є таблиця з чотирьох чисел (евклід / манхеттен × без шуму /з шумом) і одне речення про те, чому шумові ознаки шкодять kNN сильніше, ніж лінійніймоделі з регуляризацією.### 🔴 Рівень 3Напиши власний kNN-регресор із вагами: прогноз має бути середнім цін сусідів, зваженимна 1 / відстань. Обережно з нульовою відстанню — якщо запит збігається з навчальнимобʼєктом, вага стає нескінченною, і бібліотека в цьому випадку повертає значення самецього обʼєкта.Зроблено, якщо проходить перевірка np.allclose між твоїми прогнозами й прогнозамиKNeighborsRegressor(n_neighbors=5, weights="distance") на всій тестовій підвибірціз розділу 8.¶