Задача регрессии и её функция потерь
Что такое регрессия
Регрессия — задача предсказать число: цену квартиры, температуру завтра, время доставки, спрос на товар, возраст по фото. Ответ непрерывный, и ошибка измеряется тем, насколько предсказание далеко от правды.
Нейросеть для регрессии устроена просто:
- скрытые слои — как обычно (Linear + ReLU/Tanh/GELU);
- выходной слой —
Linear(…, 1)без активации, чтобы выход мог быть любым числом (илиLinear(…, k)для k чисел сразу); - функция потерь — MSE, MAE или Huber.
MSE — средний квадрат ошибки
- Гладкая, с простым градиентом: «насколько промахнулись».
- Следует из максимального правдоподобия, если шум в данных нормальный (урок о вероятностях).
- Сильно штрафует большие ошибки: ошибка 10 стоит в 100 раз больше, чем ошибка 1.
- Если модель может выдать только одно число, оптимум — среднее значение y.
Корень из MSE — RMSE — измеряется в тех же единицах, что и y («ошибаемся в среднем на 1.2 млн ₽»), поэтому его удобно показывать людям.
MAE — средний модуль ошибки
- Штраф растёт линейно: выбросы влияют гораздо меньше.
- Оптимальная константа — медиана, которая не реагирует на несколько экстремальных значений.
- Градиент — только знак ошибки (\pm 1), не зависит от её величины. Около минимума это неудобно: шаги не уменьшаются.
Huber — лучшее из двух
Для маленьких ошибок — как 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
Модель всегда предсказывает одно число c. При каком c минимальна MSE на выборке? А MAE?
В данных о зарплатах есть несколько миллиардеров. Какая функция потерь меньше всего исказит модель из-за них?
Предсказания [2, 4, 6], правильные ответы [3, 4, 9]. Чему равна MSE?
Для тех же данных ([2, 4, 6] против [3, 4, 9]) чему равна MAE?
Напишите на 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"
import numpy as np
def mse(pred, y):
return float(((pred - y) ** 2).mean())
def mae(pred, y):
return float(np.abs(pred - y).mean())
def huber(pred, y, delta=1.0):
e = np.abs(pred - y)
return float(np.where(e <= delta, 0.5 * e ** 2, delta * (e - 0.5 * delta)).mean())
Регрессия нейросетью. Обучите сеть приближать функцию 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"
import numpy as np
import nanotorch as nt
def train_regressor(x, y):
nt.manual_seed(0)
model = nt.Sequential(nt.Linear(1, 32), nt.Tanh(), nt.Linear(32, 32), nt.Tanh(), nt.Linear(32, 1))
opt = nt.Adam(model.parameters(), lr=0.03)
for _ in range(400):
loss = nt.mse_loss(model(nt.Tensor(x)), y)
opt.zero_grad()
loss.backward()
opt.step()
return model
Войдите, чтобы отмечать пройденные уроки.