Задача регрессии и её функция потерь

⏱ 35 мин · 🧩 заданий: 6

Что такое регрессия

Регрессия — задача предсказать число: цену квартиры, температуру завтра, время доставки, спрос на товар, возраст по фото. Ответ непрерывный, и ошибка измеряется тем, насколько предсказание далеко от правды.

Нейросеть для регрессии устроена просто:

  • скрытые слои — как обычно (Linear + ReLU/Tanh/GELU);
  • выходной слой — Linear(…, 1) без активации, чтобы выход мог быть любым числом (или Linear(…, k) для k чисел сразу);
  • функция потерь — MSE, MAE или Huber.

MSE — средний квадрат ошибки

\text{MSE} = \frac{1}{N}\sum_{i=1}^N (\hat y_i - y_i)^2, \qquad \frac{\partial\,\text{MSE}}{\partial \hat y_i} = \frac{2}{N}(\hat y_i - y_i)
  • Гладкая, с простым градиентом: «насколько промахнулись».
  • Следует из максимального правдоподобия, если шум в данных нормальный (урок о вероятностях).
  • Сильно штрафует большие ошибки: ошибка 10 стоит в 100 раз больше, чем ошибка 1.
  • Если модель может выдать только одно число, оптимум — среднее значение y.

Корень из MSE — RMSE — измеряется в тех же единицах, что и y («ошибаемся в среднем на 1.2 млн ₽»), поэтому его удобно показывать людям.

MAE — средний модуль ошибки

\text{MAE} = \frac{1}{N}\sum_{i=1}^N |\hat y_i - y_i|
  • Штраф растёт линейно: выбросы влияют гораздо меньше.
  • Оптимальная константа — медиана, которая не реагирует на несколько экстремальных значений.
  • Градиент — только знак ошибки (\pm 1), не зависит от её величины. Около минимума это неудобно: шаги не уменьшаются.

Huber — лучшее из двух

L_\delta(e) = \begin{cases} \frac12 e^2, & |e| \le \delta \\ \delta\left(|e| - \frac12\delta\right), & |e| > \delta \end{cases}

Для маленьких ошибок — как MSE (плавный градиент у минимума), для больших — как MAE (устойчивость к выбросам). Порог \delta — гиперпараметр.

import numpy as np
import vis

e = np.linspace(-4, 4, 300)
huber = np.where(np.abs(e) <= 1, 0.5 * e ** 2, np.abs(e) - 0.5)
vis.line(e, {"MSE: e²": e ** 2, "MAE: |e|": np.abs(e), "Huber (δ = 1)": huber},
         title="Штраф за ошибку e = ŷ − y", xlabel="ошибка")
vis.line(e, {"MSE": 2 * e, "MAE": np.sign(e), "Huber": np.clip(e, -1, 1)},
         title="Градиент по предсказанию", xlabel="ошибка")

Выбросы: эксперимент

Возьмём линейные данные и испортим несколько точек огромными выбросами. Обучим одну и ту же прямую с разными потерями.

import numpy as np
import nanotorch as nt
import vis

rng = np.random.default_rng(0)
x = rng.uniform(0, 10, 60).reshape(-1, 1)
y = 2 * x + 1 + rng.normal(0, 1, x.shape)
y[:5] += 40                                     # пять выбросов

def huber_loss(pred, target, delta=1.0):
    e = pred - nt.tensor(target)
    a = (e * e + 1e-12) ** 0.5                  # |e| (сглаженный, чтобы не делить на 0)
    quad = a * a * 0.5
    lin = a * delta - 0.5 * delta ** 2
    mask = (a.data <= delta).astype(float)
    return (quad * mask + lin * (1 - mask)).mean()

losses = {
    "MSE": lambda p, t: nt.mse_loss(p, t),
    "MAE": lambda p, t: (((p - nt.tensor(t)) ** 2 + 1e-12) ** 0.5).mean(),
    "Huber": huber_loss,
}
grid = np.linspace(0, 10, 50).reshape(-1, 1)
lines = {}
for name, loss_fn in losses.items():
    nt.manual_seed(0)
    model = nt.Linear(1, 1)
    opt = nt.Adam(model.parameters(), lr=0.1)
    for _ in range(600):
        loss = loss_fn(model(nt.Tensor(x)), y)
        opt.zero_grad(); loss.backward(); opt.step()
    w, b = model.W.data[0, 0], model.b.data[0]
    print(f"{name:6}: y = {w:.2f}·x + {b:.2f}   (истина: 2·x + 1)")
    lines[name] = (grid * w + b).ravel()

vis.scatter(x.ravel(), y.ravel(), title="Данные: 5 выбросов сверху")
vis.line(grid.ravel(), lines, title="Прямые, обученные с разными потерями", xlabel="x")

MSE «тянет» прямую вверх к выбросам, а MAE и Huber почти их игнорируют. Что выбрать — зависит от задачи: если выбросы — ошибки в данных, нужны устойчивые потери; если они реальны и важны (редкие, но дорогие события), MSE заставит модель их учитывать.

Нормализация целевой переменной

Если y измеряется в миллионах (цены в рублях), градиенты в начале обучения будут огромными, а выходной слой придётся долго «дотягивать» до нужного масштаба. Поэтому целевую переменную, как и признаки, часто стандартизируют: обучают на \tilde y = (y - \mu_y)/\sigma_y, а предсказания переводят обратно: \hat y = \tilde{\hat y}\,\sigma_y + \mu_y. Иногда предсказывают логарифм (для цен и других величин, растущих «в разы»).

Пример: сеть приближает кривую

import numpy as np
import nanotorch as nt
import vis

rng = np.random.default_rng(1)
x = np.sort(rng.uniform(-5, 5, 300)).reshape(-1, 1)
y = x * np.sin(x) + rng.normal(0, 0.4, x.shape)          # нелинейная зависимость с шумом
x_train, x_test, y_train, y_test = nt.train_test_split(x, y, test_size=0.25, seed=0)

nt.manual_seed(0)
model = nt.Sequential(nt.Linear(1, 64), nt.Tanh(), nt.Linear(64, 64), nt.Tanh(), nt.Linear(64, 1))
hist = nt.fit(model, x_train, y_train, nt.mse_loss, nt.Adam(model.parameters(), lr=0.01),
              epochs=80, batch_size=32, X_val=x_test, y_val=y_test, verbose=False)
print(f"MSE: train = {hist['loss'][-1]:.3f}, test = {hist['val_loss'][-1]:.3f} (дисперсия шума 0.16)")

grid = np.linspace(-5, 5, 200).reshape(-1, 1)
with nt.no_grad():
    pred = model(nt.Tensor(grid)).data
    resid = (model(nt.Tensor(x_test)).data - y_test).ravel()
vis.line(grid.ravel(), {"истинная x·sin x": (grid * np.sin(grid)).ravel(), "сеть": pred.ravel()},
         title="Предсказание сети", xlabel="x")
vis.scatter(x_test.ravel(), resid, title="Остатки на тесте (ошибка ŷ − y): без явной структуры — хорошо")

Тестовая MSE (около 0.2) близка к дисперсии шума 0.16 — это предел: случайный шум не может предсказать никакая модель. График остатков — полезная проверка: если в остатках видна закономерность (например, они растут с x), модель что-то не уловила.

Итог

  • Регрессия: выходной слой без активации, потери MSE / MAE / Huber.
  • MSE: гладкая, оптимум — среднее, чувствительна к выбросам; RMSE — в единицах y.
  • MAE: устойчива к выбросам, оптимум — медиана, градиент постоянной величины.
  • Huber: квадратичная около нуля, линейная для больших ошибок.
  • Нормализуйте целевую переменную; смотрите на остатки; шум данных — нижняя граница ошибки.

Практика решено 0 из 6

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Один вариант ответа

Модель всегда предсказывает одно число c. При каком c минимальна MSE на выборке? А MAE?

Задание 2 Один вариант ответа

В данных о зарплатах есть несколько миллиардеров. Какая функция потерь меньше всего исказит модель из-за них?

Задание 3 Ввод ответа

Предсказания [2, 4, 6], правильные ответы [3, 4, 9]. Чему равна MSE?

Задание 4 Ввод ответа

Для тех же данных ([2, 4, 6] против [3, 4, 9]) чему равна MAE?

Задание 5 Программа на Python

Напишите на NumPy функции потерь регрессии (на входе массивы pred и y одной формы, на выходе float):

  • mse(pred, y) — средний квадрат ошибки;
  • mae(pred, y) — средний модуль ошибки;
  • huber(pred, y, delta=1.0) — для ошибки e: \frac12 e^2 при |e| \le \delta, иначе \delta(|e| - \frac12\delta); среднее по объектам.
Какие тесты проверяют код (2)
p, t = np.array([2.0, 4, 6]), np.array([3.0, 4, 9])
assert abs(mse(p, t) - 10 / 3) < 1e-12 and abs(mae(p, t) - 4 / 3) < 1e-12

p, t = np.array([0.0, 0, 0]), np.array([0.5, -2, 4])
assert abs(huber(p, t) - (0.125 + 1.5 + 3.5) / 3) < 1e-12, "Малые ошибки — квадратично, большие — линейно"
assert abs(huber(p, t, delta=10) - 0.5 * mse(p, t)) < 1e-12, "При большом delta Huber = MSE / 2"

Задание 6 Программа на Python

Регрессия нейросетью. Обучите сеть приближать функцию y = x\sin x на отрезке [-5, 5]. Напишите train_regressor(x, y) (x и y — массивы формы (N, 1)), которая возвращает обученную модель nanotorch. Используйте MLP с линейным выходом, nt.mse_loss и nt.Adam. Тест требует MSE < 0.05 на тех же точках.

Подсказка: 1 → 32 → 32 → 1 с Tanh, Adam с lr = 0.03, около 400 шагов полным батчем. Слишком большая сеть или слишком много шагов не уложатся в ограничение по времени.

Какие тесты проверяют код (1)
x = np.linspace(-5, 5, 200).reshape(-1, 1)
y = x * np.sin(x)
model = train_regressor(x, y)
with nt.no_grad():
    err = float(((model(nt.Tensor(x)).data - y) ** 2).mean())
assert err < 0.05, f"MSE = {err:.4f}, нужно < 0.05"

Войдите, чтобы отмечать пройденные уроки.