Метрики качества
Потери и метрики — не одно и то же
Функция потерь нужна оптимизатору: она гладкая и дифференцируемая (кросс-энтропия, MSE). Метрика нужна людям: она отвечает на вопрос «насколько модель хороша для нашей задачи?» — и может быть недифференцируемой. Модель обучают по потерям, а выбирают и оценивают по метрикам.
Точность и её ловушка
Точность (accuracy) — доля правильных ответов. Понятная, но обманчивая на несбалансированных данных. Если мошеннических транзакций 1%, модель «всегда нет» имеет точность 99% и при этом абсолютно бесполезна.
Матрица ошибок
Для бинарной задачи у каждого предсказания четыре исхода:
| Предсказано 1 | Предсказано 0 | |
|---|---|---|
| На самом деле 1 | TP — верно найден | FN — пропуск (ошибка II рода) |
| На самом деле 0 | FP — ложная тревога (ошибка I рода) | TN — верно отвергнут |
Из этих четырёх чисел строятся все метрики:
- Precision (точность найденного) = \frac{TP}{TP + FP} — какая доля того, что модель назвала «1», правда «1». Важна, когда ложная тревога дорога.
- Recall (полнота) = \frac{TP}{TP + FN} — какую долю всех настоящих «1» модель нашла. Важна, когда пропуск дорог.
- F1 = \frac{2 \cdot P \cdot R}{P + R} — среднее гармоническое: высокое, только если высоки обе.
import numpy as np
rng = np.random.default_rng(0)
y_true = (rng.random(1000) < 0.05).astype(int) # 5% положительных
y_lazy = np.zeros(1000, dtype=int) # «всегда 0»
y_model = np.where(rng.random(1000) < 0.8, y_true, 1 - y_true) # модель, правая в 80% случаев
def report(name, y_pred):
tp = ((y_pred == 1) & (y_true == 1)).sum()
fp = ((y_pred == 1) & (y_true == 0)).sum()
fn = ((y_pred == 0) & (y_true == 1)).sum()
p = tp / (tp + fp) if tp + fp else 0.0
r = tp / (tp + fn) if tp + fn else 0.0
f1 = 2 * p * r / (p + r) if p + r else 0.0
print(f"{name:12} accuracy {np.mean(y_pred == y_true):.3f} precision {p:.3f} recall {r:.3f} F1 {f1:.3f}")
report("всегда 0", y_lazy)
report("модель", y_model)
У «ленивой» модели точность 95%, но recall и F1 — ноль. Модель, правая лишь в 80% случаев, по точности хуже, но хоть что-то находит. Правда, precision у неё низкая: среди её срабатываний большинство — ложные тревоги (положительных мало, и 20% ошибок на огромном классе «0» дают много FP).
Порог и компромисс precision/recall
Классификатор выдаёт вероятность p, а решение «1» принимается при p > t. Порог t = 0.5 не священен:
- повышаем порог — модель срабатывает реже, но увереннее: precision растёт, recall падает;
- понижаем — находим больше, но и ошибаемся чаще.
Порог выбирают по цене ошибок в конкретной задаче.
import numpy as np
import nanotorch as nt
import vis
X, y = nt.make_blobs(600, centers=((-1, -1), (1.2, 1.2)), std=1.1, seed=0)
nt.manual_seed(0)
model = nt.Linear(2, 1)
opt = nt.Adam(model.parameters(), lr=0.05)
for _ in range(200):
loss = nt.binary_cross_entropy(model(nt.Tensor(X)), y)
opt.zero_grad(); loss.backward(); opt.step()
scores = model(nt.Tensor(X)).sigmoid().data.ravel()
ts = np.linspace(0.05, 0.95, 19)
prec, rec = [], []
for t in ts:
pred = scores > t
tp = (pred & (y == 1)).sum()
prec.append(tp / max(pred.sum(), 1))
rec.append(tp / (y == 1).sum())
vis.line(ts, {"precision": prec, "recall": rec}, title="Precision и recall при разных порогах", xlabel="порог")
ROC-кривая и AUC
ROC-кривая показывает работу классификатора сразу при всех порогах: по оси x — доля ложных тревог FPR = \frac{FP}{FP + TN}, по оси y — полнота TPR = \frac{TP}{TP + FN}. Случайная модель — диагональ, идеальная — угол (0, 1).
ROC-AUC — площадь под ROC-кривой. У неё красивый смысл: это вероятность того, что случайный объект класса 1 получит оценку выше, чем случайный объект класса 0. 0.5 — случайное угадывание, 1.0 — идеальное ранжирование. AUC не зависит от порога и от баланса классов — удобно сравнивать модели.
import numpy as np
import nanotorch as nt
import vis
X, y = nt.make_blobs(600, centers=((-1, -1), (1.2, 1.2)), std=1.1, seed=0)
scores_good = X.sum(axis=1) # хорошая оценка
scores_weak = X.sum(axis=1) + np.random.default_rng(1).normal(0, 3, 600) # зашумлённая
scores_rand = np.random.default_rng(2).random(600) # случайная
curves = {}
for name, s in [("хорошая", scores_good), ("слабая", scores_weak), ("случайная", scores_rand)]:
order = np.argsort(-s)
tpr = np.r_[0, np.cumsum(y[order] == 1) / (y == 1).sum()]
fpr = np.r_[0, np.cumsum(y[order] == 0) / (y == 0).sum()]
auc = np.sum(np.diff(fpr) * (tpr[1:] + tpr[:-1]) / 2)
curves[f"{name}, AUC = {auc:.2f}"] = np.interp(np.linspace(0, 1, 101), fpr, tpr)
vis.line(np.linspace(0, 1, 101), curves, title="ROC-кривые", xlabel="доля ложных тревог (FPR)", ylabel="полнота (TPR)")
Многоклассовые метрики
Для C классов матрица ошибок — C \times C: строка — настоящий класс, столбец — предсказанный. На диагонали — правильные ответы, вне её видно, что с чем путается. Precision и recall считают для каждого класса («этот против остальных»), а затем усредняют:
- macro — простое среднее по классам: все классы одинаково важны, редкие тоже;
- micro (или взвешенное) — учитывает размеры классов.
import numpy as np
import nanotorch as nt
import ml_data
import vis
X, y = ml_data.load_digits()
X = X / 16.0
X_train, X_test, y_train, y_test = nt.train_test_split(X, y, test_size=0.3, seed=0)
nt.manual_seed(0)
model = nt.Linear(64, 10) # нарочно простая модель, чтобы были ошибки
nt.fit(model, X_train, y_train, nt.cross_entropy, nt.SGD(model.parameters(), lr=0.05), epochs=8, batch_size=64, verbose=False)
with nt.no_grad():
pred = model(nt.Tensor(X_test)).data.argmax(1)
cm = nt.confusion_matrix(y_test, pred, 10)
vis.heatmap(cm, xlabels=[str(i) for i in range(10)], ylabels=[str(i) for i in range(10)],
title="Матрица ошибок: строка — правда, столбец — ответ модели", fmt=".0f")
precision = cm.diagonal() / np.maximum(cm.sum(axis=0), 1)
recall = cm.diagonal() / cm.sum(axis=1)
vis.table([[d, f"{precision[d]:.2f}", f"{recall[d]:.2f}"] for d in range(10)], header=["цифра", "precision", "recall"])
print(f"accuracy {np.mean(pred == y_test):.3f}, macro-F1 {np.mean(2 * precision * recall / (precision + recall + 1e-12)):.3f}")
Метрики регрессии
- MAE — средняя абсолютная ошибка, в единицах y, устойчива к выбросам.
- RMSE — корень из MSE, в единицах y, сильнее штрафует большие ошибки.
- R² (коэффициент детерминации) = 1 - \frac{\sum(y - \hat y)^2}{\sum(y - \bar y)^2} — какую долю разброса y объясняет модель. 1 — идеально, 0 — не лучше предсказания средним, меньше 0 — хуже среднего.
- MAPE — средняя относительная ошибка в процентах; понятна бизнесу, но ломается при y около нуля.
Итог
- Потери — для оптимизатора, метрики — для оценки и выбора модели.
- Accuracy обманчива на несбалансированных данных; смотрите на матрицу ошибок.
- Precision — доля верных среди найденного, recall — доля найденного среди всех нужных, F1 — их баланс; порог двигает компромисс.
- ROC-AUC — качество ранжирования при всех порогах (0.5 — случайно, 1 — идеально).
- Многоклассовые: матрица C \times C, macro/micro усреднение. Регрессия: MAE, RMSE, R².
Практика решено 0 из 7
Модель нашла 8 мошеннических транзакций, из них 6 действительно мошеннические. Всего мошеннических было 10. Чему равна precision?
В той же ситуации (найдено 8, верно 6, всего мошеннических 10) чему равен recall?
Какая метрика важнее в этой задаче?
| recall (не пропустить) | precision (не ошибиться в найденном) | |
|---|---|---|
| Скрининг опасной болезни: потом всё равно будет точный анализ | ||
| Фильтр спама: нельзя, чтобы важное письмо попало в спам | ||
| Поиск подозрительных транзакций для ручной проверки, проверять дёшево | ||
| Рекомендация: показываем пользователю 5 товаров, все должны быть уместны |
Чему равен ROC-AUC модели, которая выдаёт случайные оценки?
Напишите для бинарной классификации (массивы y_true и y_pred из 0 и 1) функцию
binary_metrics(y_true, y_pred), возвращающую словарь с ключами "accuracy", "precision",
"recall", "f1" (обычные float). Если знаменатель равен нулю, соответствующая метрика — 0.0.
Какие тесты проверяют код (2)
yt = np.array([1, 1, 1, 1, 0, 0, 0, 0, 0, 0]) yp = np.array([1, 1, 1, 0, 1, 0, 0, 0, 0, 0]) m = binary_metrics(yt, yp) assert abs(m["accuracy"] - 0.8) < 1e-12 and abs(m["precision"] - 0.75) < 1e-12 and abs(m["recall"] - 0.75) < 1e-12 assert abs(m["f1"] - 0.75) < 1e-12 m = binary_metrics(np.array([0, 0, 1]), np.array([0, 0, 0])) assert m["precision"] == 0.0 and m["recall"] == 0.0 and m["f1"] == 0.0, "Модель не нашла ни одного положительного: precision и F1 считаем нулями"
import numpy as np
def binary_metrics(y_true, y_pred):
tp = int(((y_pred == 1) & (y_true == 1)).sum())
fp = int(((y_pred == 1) & (y_true == 0)).sum())
fn = int(((y_pred == 0) & (y_true == 1)).sum())
precision = tp / (tp + fp) if tp + fp else 0.0
recall = tp / (tp + fn) if tp + fn else 0.0
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
return {"accuracy": float((y_true == y_pred).mean()), "precision": float(precision),
"recall": float(recall), "f1": float(f1)}
ROC-AUC. Напишите roc_auc(y_true, scores) — долю пар (объект класса 1, объект класса 0), в которых
оценка объекта класса 1 больше; при равных оценках пара засчитывается как 0.5. Без циклов по парам
(сравните pos[:, None] и neg[None, :] через broadcasting). Верните float.
Какие тесты проверяют код (3)
assert roc_auc(np.array([0, 0, 1, 1]), np.array([0.1, 0.4, 0.35, 0.8])) == 0.75 assert roc_auc(np.array([0, 1]), np.array([0.3, 0.9])) == 1.0 and roc_auc(np.array([0, 1]), np.array([0.5, 0.5])) == 0.5 rng = np.random.default_rng(0) y = rng.integers(0, 2, 2000) assert abs(roc_auc(y, rng.random(2000)) - 0.5) < 0.05, "Случайные оценки дают AUC ≈ 0.5"
import numpy as np
def roc_auc(y_true, scores):
pos, neg = scores[y_true == 1], scores[y_true == 0]
greater = (pos[:, None] > neg[None, :]).mean()
ties = (pos[:, None] == neg[None, :]).mean()
return float(greater + 0.5 * ties)
R² — коэффициент детерминации. Напишите r2_score(y_true, y_pred):
R^2 = 1 - \dfrac{\sum (y - \hat y)^2}{\sum (y - \bar y)^2}. Верните float.
Какие тесты проверяют код (1)
y = np.array([1.0, 2, 3, 4]) assert r2_score(y, y) == 1.0, "Идеальные предсказания: R² = 1" assert abs(r2_score(y, np.full(4, 2.5))) < 1e-12, "Предсказание средним: R² = 0" assert r2_score(y, np.array([4.0, 3, 2, 1])) < 0, "Хуже среднего — отрицательный R²"
import numpy as np
def r2_score(y_true, y_pred):
return float(1 - ((y_true - y_pred) ** 2).sum() / ((y_true - y_true.mean()) ** 2).sum())
Войдите, чтобы отмечать пройденные уроки.