← Программа курса
Занятие 02 · Модуль 1 · Классический ML · 38 мин чтения

Что такое машинное обучение

«Модель учится» на примерах: чем ML отличается от обычного кода, типы задач, метод k-ближайших соседей с границей решений, гиперпараметр k, и главное правило курса — честная проверка train/test.

типы задачk-NNграница решенийtrain/testгиперпараметры
Скачать ноутбук урока ↓

Машинное обучение звучит загадочно, но идея за ним на удивление простая, и сегодня мы разберём её с нуля — на бытовых примерах, без формул в первых абзацах. В прошлом уроке мы научились держать данные в руках: X — матрица признаков, а y обозначает вектор ответов. Теперь сделаем следующий шаг: поймём, что вообще значит «модель учится», какие бывают задачи, обучим первую настоящую модель (метод k-ближайших соседей) и встретим самое важное правило всего курса — про честную проверку.

Урок длинный и неспешный. Мы не просто запустим model.fit() в две строки — мы увидим глазами, как модель разрезает плоскость на классы, как параметр kk меняет её поведение, и почему проверка на тех же данных, на которых учились, — это самообман.

Чем ML отличается от обычной программы

В обычном программировании мы сами пишем правила. Хотим отличить спам от не-спама — пишем: «если в письме есть слово „выигрыш“ и три восклицательных знака — спам». Работает, пока спамеры не начнут писать «вы-и-грыш» или «в1игрыш». Тогда мы дописываем ещё правило. И ещё. Беда в том, что таких правил тысячи, они противоречат друг другу, устаревают за неделю, и поддерживать их вручную невозможно.

Машинное обучение переворачивает подход. Мы не пишем правила сами, а показываем компьютеру много примеров («вот это спам, а это — нет») и даём ему самому найти закономерность.

Обычный код: данные + правила → ответ. Машинное обучение: данные + ответы → правила.

Эти «правила», которые модель вывела сама из примеров, и есть результат обучения. В случае со спамом мы скармливаем модели тысячи писем с метками «спам / не спам», а она сама выясняет, какие слова, длины, отправители и сочетания признаков характерны для спама. Когда правил слишком много, чтобы прописать руками, — это территория ML.

Объекты, признаки, ответы

Любая задача ML описывается тремя словами. Мы уже встретили их в прошлом уроке — закрепим и свяжем с обозначениями.

Набор объектов с известными ответами — обучающая выборка. На ней модель учится. Цель — построить модель (обозначим её a(x)a(x) — просто имя функции, как f(x)f(x) в школе): она по признакам нового объекта выдаёт предсказание. Предсказание принято писать с «крышечкой» — y^\hat{y} («игрек с крышкой»), чтобы на письме отличать его от настоящего ответа yy.

признаки x    a(x)    предсказание y^\text{признаки } x \;\longrightarrow\; a(x) \;\longrightarrow\; \text{предсказание } \hat{y}

Вся остальная теория курса — это разные способы устроить функцию aa и разные способы её обучить. Но рамка всегда одна: на вход признаки, на выход предсказание.

Два главных типа задач

Почти всё, с чем столкнёшься в начале, делится на два типа — по тому, какой ответ нужен.

Регрессия → число Классификация → класс
Два типа задач: регрессия предсказывает число, классификация — класс.

Регрессия — ответ это число на непрерывной шкале: цена квартиры, завтрашняя температура, время в приложении. Ответом может быть 5.3, или 5.31, или 5.312 — любая точка на шкале.

Классификация — ответ это класс из заранее известного списка: спам / не спам; кошка / собака / птица; вернёт клиент кредит или нет. Промежуточных значений нет — только одна из категорий.

Посмотрим на оба типа на настоящих данных. Слева — регрессия: облако точек и непрерывный тренд, по которому для любого признака можно прочитать число. Справа — классификация: два класса (кружки и квадраты), которые нужно разделить границей.

import numpy as np
import matplotlib.pyplot as plt
rng = np.random.default_rng(1)

# регрессия: ответ — число
xr = rng.uniform(0, 10, size=40)
yr = 2 + 1.3 * xr + rng.normal(0, 1.4, size=40)

# классификация: ответ — класс (два облака точек)
cA = rng.normal([2.2, 2.4], 0.7, size=(30, 2))
cB = rng.normal([4.4, 4.6], 0.7, size=(30, 2))

fig, ax = plt.subplots(1, 2)
ax[0].scatter(xr, yr);                         ax[0].set_title("Регрессия → число")
ax[1].scatter(cA[:,0], cA[:,1]); ax[1].scatter(cB[:,0], cB[:,1], marker="s")
ax[1].set_title("Классификация → класс")
plt.show()
Слева облако точек с прямым трендом (регрессия); справа два класса точек, разделённых пунктирной линией (классификация)
Два типа задач на настоящих данных. Слева регрессия предсказывает положение точки на непрерывной шкале; справа классификация проводит границу между классами.

Простое правило: число — регрессия, категория — классификация. Этого различия хватит надолго.

Есть и третий большой тип — обучение без учителя (когда правильных ответов yy нет, и модель сама ищет структуру, например группирует похожие объекты в кластеры). До него дойдём позже; пока сосредоточимся на классификации.

Первая модель: «спроси у соседей» (k-NN)

Давай уже что-нибудь обучим. Самая интуитивная модель называется метод k-ближайших соседей (k-Nearest Neighbors, k-NN), и идея в ней буквально бытовая.

Хочешь понять, понравится ли человеку новый фильм? Найди несколько людей с похожими вкусами (ближайших «соседей») и посмотри, как оценили фильм они. Большинство сказало «да» — скорее всего, понравится и ему. Хочешь оценить квартиру? Посмотри на несколько похожих по площади и району и возьми их среднюю цену. Это и есть k-NN.

Как измерить «похожесть»

Чтобы алгоритм мог искать «похожих», нужно формализовать похожесть. Представь каждый объект как точку в пространстве: его признаки — это координаты. Квартира с признаками [площадь=50, этаж=4] превращается в точку (50, 4). Тогда похожие объекты стоят рядом, а «ближайшие соседи» — это буквально ближайшие точки по обычному (евклидову) расстоянию:

d(a,b)=(a1b1)2+(a2b2)2++(adbd)2d(a, b) = \sqrt{(a_1 - b_1)^2 + (a_2 - b_2)^2 + \dots + (a_d - b_d)^2}

Здесь aa и bb — два объекта, а a1,b1a_1, b_1 обозначают их первые признаки, и так далее по всем dd признакам. Под корнем — сумма квадратов разностей по каждой координате; это та же теорема Пифагора, просто в dd измерениях. В коде это одна строка:

import numpy as np
a = np.array([1.0, 2.0])
b = np.array([4.0, 6.0])
print(np.sqrt(((a - b) ** 2).sum()))   # → 5.0

(Разности 33 и 44, квадраты 99 и 1616, сумма 2525, корень 55 — классический египетский треугольник.)

Чтобы классифицировать новый объект, k-NN находит kk ближайших к нему точек обучающей выборки и устраивает голосование: какого класса среди соседей больше — такой ответ и выдаём.

новый объект k = 3 → 2 круга против 1 квадрата → класс «круг»
k-NN: новый объект относим к тому классу, которого больше среди k ближайших соседей.

На картинке новый объект — ромб в центре; берём 3 ближайших точки, и раз кругов среди них больше, чем квадратов (2 против 1), относим объект к классу «круг».

Обучаем k-NN на ирисах

Соберём это в коде. Возьмём встроенный учебный датасет про ирисы — три вида цветков, для каждого измерены длина и ширина чашелистика и лепестка (4 признака):

from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier

X, y = load_iris(return_X_y=True)   # X — 4 признака, y — вид цветка (0/1/2)
print(X.shape, X[0])                # → (150, 4) [5.1 3.5 1.4 0.2]

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y)   # про разбиение — ниже

model = KNeighborsClassifier(n_neighbors=3)   # спрашиваем 3 ближайших соседа
model.fit(X_train, y_train)                   # «обучение» k-NN = запомнить данные
print(model.score(X_test, y_test))            # → 1.0   — доля верных ответов на тесте

Точность на тесте — 1.0 (100% угаданных видов): ирисы хорошо разделяются по размерам лепестков, поэтому даже простейшая модель справляется идеально. Обрати внимание на странность: у k-NN обучение — это буквально «запомнить обучающую выборку». Никаких коэффициентов он не подбирает. Вся работа происходит в момент предсказания, когда он считает расстояния до всех запомненных точек и ищет ближайших. Такие модели называют ленивыми (lazy): они откладывают всю работу на момент запроса.

Граница решений: как k-NN видит мир

Самое наглядное в k-NN — это граница решений: линия (или поверхность), по которой модель разделяет классы. Возьмём только два признака ириса (длину и ширину лепестка), обучим k-NN и закрасим каждую точку плоскости тем цветом, какой класс модель в ней предсказывает:

from matplotlib.colors import ListedColormap
X2 = X[:, 2:4]                                  # длина и ширина лепестка
model = KNeighborsClassifier(n_neighbors=5).fit(X2, y)

# сетка точек по всей плоскости
xx, yy = np.meshgrid(np.arange(0.5, 7.5, 0.02), np.arange(-0.2, 2.8, 0.02))
Z = model.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)

plt.contourf(xx, yy, Z, alpha=0.8)              # цветные зоны = предсказания
plt.scatter(X2[:,0], X2[:,1], c=y, edgecolor="k")
plt.xlabel("длина лепестка, см"); plt.ylabel("ширина лепестка, см")
plt.show()
Плоскость разбита на три цветные зоны k-NN; точки трёх видов ирисов лежат каждый в своей зоне
Граница решений k-NN (k=5) на двух признаках. Каждая зона — это область, где модель голосует за один вид. Точки — обучающая выборка; почти все попали в «свою» зону.

Вот что значит «модель что-то выучила»: она разбила плоскость на зоны, и для любой новой точки ответ — это цвет зоны, в которую она попала. Граница между зонами — там, где голоса соседей делятся пополам. Заметь, что граница у k-NN извилистая, не прямая: она в точности следует за тем, как разбросаны обучающие точки.

Параметры и гиперпараметры

Число соседей kk (n_neighbors) мы задаём сами до обучения — это гиперпараметр. Хорошая аналогия: гиперпараметры — как ручки на духовке, которые ты выставляешь до готовки (температуру, время). А параметры — то, что «само получается» в процессе обучения из данных. У k-NN параметры — это просто запомненные точки, а у моделей дальше в курсе это числа-коэффициенты (веса), которые модель подбирает сама.

Как kk влияет на границу? Сравним два крайних случая на искусственных данных с шумом:

for k in [1, 25]:
    m = KNeighborsClassifier(n_neighbors=k).fit(X, y)
    # ... строим границу решений, как выше ...
Слева (k=1) граница очень изрезанная, с островками вокруг отдельных точек; справа (k=25) граница гладкая и почти прямая
Влияние k на границу. Слева k=1: модель цепляется за каждый выброс, граница рваная (переобучение). Справа k=25: граница пересглажена, теряет детали (недообучение). Истина — где-то посередине.

Правильное kk подбирают, проверяя модель на отложенных данных. И тут возникает важнейшая идея всего ML.

Честная проверка: train / test

Мы обучили модель и хотим понять, насколько она хороша. Логично измерить, сколько ответов она угадывает. Но есть ловушка.

Если проверять модель на тех же данных, на которых она обучалась, она «жульничает» — она их уже видела. С k-NN это особенно наглядно: при k=1k=1 ближайший сосед каждой обучающей точки — она сама (расстояние 0), поэтому на обучающих данных k-NN угадывает ровно 100% и полностью нас обманывает.

Это как готовиться к экзамену по задачнику с ответами (train), а проверять себя на тех же задачах. Угадать заученное — не то же самое, что понять. Честная проверка — это новые задачи на самом экзамене (test).

Решение — разделить данные на две части до всякого обучения:

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42,  # 20% в тест; random_state фиксирует разбиение
    stratify=y                             # сохранить пропорции классов в train и test
)

random_state=42 — любое фиксированное число: оно делает случайное разбиение воспроизводимым, чтобы у тебя и у меня получились одинаковые train и test (само число значения не имеет, а 42 используют просто по традиции). А stratify=y следит, чтобы доли классов в train и test совпадали с исходными: ирисы в датасете идут подряд по видам, и без стратификации в тест мог бы попасть перекос (скажем, почти один вид).

Видим переобучение на графике

Теперь главное наблюдение урока. Переберём kk от 1 до 25 и для каждого нарисуем точность отдельно на train и на test:

ks = range(1, 26)
train_acc, test_acc = [], []
for k in ks:
    m = KNeighborsClassifier(n_neighbors=k).fit(X_train, y_train)
    train_acc.append(m.score(X_train, y_train))
    test_acc.append(m.score(X_test, y_test))

plt.plot(list(ks), train_acc, label="train")
plt.plot(list(ks), test_acc, label="test")
plt.xlabel("число соседей k"); plt.ylabel("доля верных ответов")
plt.legend(); plt.show()
Две кривые точности по k: train стартует с 1.0 при k=1, test ниже на краях и держится высоко в середине диапазона
Точность k-NN на train (вторичный индиго) и test (основной). При k=1 train = 100% — модель «зазубрила» данные, но test ниже. Лучшие k — там, где test-кривая держится высоко.

Смотри на расхождение кривых. При k=1k=1 train-точность равна 1.0 — модель идеально «помнит» обучение, но test-кривая ниже: это и есть переобучение в чистом виде. В середине диапазона test держится высоко — там и живёт хорошее kk. На большом kk обе кривые начинают проседать — недообучение. Запомни этот силуэт: разрыв «train высоко, test низко» — главный признак переобучения, и мы будем встречать его весь курс.

Главное правило всего курса. Качество модели оценивается только на данных, которых она не видела при обучении. Это «честная проверка», и обходить её нельзя ни при каких обстоятельствах.

Итог урока

  1. ML находит правила сам по примерам, а не получает их от программиста: «данные + ответы → правила».
  2. Любая задача — это объекты, их признаки xx и ответы yy; предсказание модели a(x)a(x) пишут как y^\hat{y}.
  3. Число на выходе — регрессия, категория — классификация (есть ещё обучение без учителя).
  4. k-NN — первая модель: «найди kk ближайших соседей по расстоянию и проголосуй»; обучение у неё = просто запомнить данные.
  5. Граница решений — то, как модель разбивает пространство на классы; у k-NN она извилистая и зависит от kk.
  6. Гиперпараметры (как kk) мы задаём сами до обучения; параметры модель подбирает по данным сама.
  7. Маленькое kk → переобучение (рваная граница), большое kk → недообучение (пересглаженная).
  8. Качество меряем только на тесте; разрыв «train высоко, test низко» — признак переобучения.

В следующем уроке построим модель — линейную регрессию, которая не запоминает данные целиком, а выводит из них компактный закон в виде формулы.


Домашнее задание

Задания идут от простых к сложным. 🧠 — теория, 💻 — код, 🔮 — «что будет, если…». Сначала попробуй сам, потом открой разбор.

  1. 🧠 Приведи три задачи из жизни и определи для каждой: регрессия или классификация? Что объект, признаки, ответ?
Разбор

Примеры: (а) предсказать завтрашнюю температуру — регрессия, объект = день, признаки = сегодняшняя погода/влажность/сезон, ответ = число градусов. (б) распознать, есть ли на фото кошка — классификация, объект = фото, признаки = пиксели, ответ = да/нет. (в) предсказать время доставки заказа — регрессия, объект = заказ, признаки = расстояние/загрузка/погода, ответ = минуты. Ключ: если ответ — число на шкале, регрессия; если категория, классификация.

  1. 🧠 Объясни своими словами разницу «данные + правила → ответ» и «данные + ответы → правила» на примере спама.
Разбор

В обычном коде мы пишем правила («если есть слово выигрыш — спам») и применяем их к письму, чтобы получить ответ. В ML мы даём модели письма вместе с готовыми ответами (спам/не спам), а модель сама выводит правила, которые мы не формулировали. Преимущество: когда признаков спама тысячи и они меняются, человек не успевает писать правила вручную, а модель переучивается на новых данных.

  1. 🧠 В k-NN с k=1k=1 модель даёт ровно 100% на обучающих данных. Почему это не значит, что она хорошая?
Разбор

Потому что для каждой обучающей точки её ближайший сосед — это она сама (расстояние 0), значит ответ всегда совпадает с истинным. Это измерение на данных, которые модель «видела», — она их просто помнит, а не понимает закономерность. Честная оценка — на тесте, где новой точке соседями служат другие объекты. Там k=1 обычно слабее, чем k=5–10.

  1. 💻 Возьми пример с ирисами из урока, обучи k-NN с k=3 и посчитай точность на тесте. Получилось выше 0.9?
Разбор
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
X, y = load_iris(return_X_y=True)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
m = KNeighborsClassifier(n_neighbors=3).fit(Xtr, ytr)
print(m.score(Xte, yte))   # → 1.0

Да, с таким разбиением получается 1.0 — ирисы хорошо разделимы. На других данных так красиво бывает редко.

  1. 🔮 Перебери kk от 1 до 20 и построй график точности на тесте. При каком kk лучше? Что происходит на краях диапазона?
Разбор

Код — как в разделе про переобучение. На малом kk (1–2) test обычно чуть ниже из-за чувствительности к шуму; на большом kk (ближе к 20) начинает падать из-за пересглаживания. Лучшие значения — в середине (для ирисов часто 3–7). Точные числа зависят от random_state, но силуэт «провал на краях, плато в середине» устойчив.

  1. 🧠 Чем гиперпараметр отличается от параметра? Приведи по примеру каждого.
Разбор

Гиперпараметр мы задаём сами до обучения, он не выводится из данных: пример — число соседей kk в k-NN. Параметр модель подбирает сама в процессе обучения из данных: у k-NN это запомненные обучающие точки, у линейной регрессии (следующий урок) — коэффициенты w0,w1w_0, w_1. Гиперпараметры настраивают по тесту/валидации, параметры — автоматически на train.

  1. 🔮 Почему k-NN сильно замедляется на больших данных? (Подумай, что он делает в момент предсказания.)
Разбор

Чтобы классифицировать один новый объект, k-NN считает расстояние до всех запомненных обучающих точек, а потом ищет среди них kk ближайших. Если в обучении миллион объектов — миллион вычислений расстояний на каждый запрос. Обучение у k-NN мгновенное (просто запомнить), но предсказание дорогое и растёт линейно с размером данных. Поэтому на больших данных он медленный, и применяют ускорители (KD-деревья) или другие модели.

Нужен разбор?

Застрял на теме — разберём один на один

Объясню сложное на пальцах и доведу до результата: код, формулы, проект.

Записаться на разбор