Метрики качества

⏱ 40 мин · 🧩 заданий: 7

Потери и метрики — не одно и то же

Функция потерь нужна оптимизатору: она гладкая и дифференцируемая (кросс-энтропия, 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

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

Модель нашла 8 мошеннических транзакций, из них 6 действительно мошеннические. Всего мошеннических было 10. Чему равна precision?

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

В той же ситуации (найдено 8, верно 6, всего мошеннических 10) чему равен recall?

Задание 3 Заполнить таблицу

Какая метрика важнее в этой задаче?

recall (не пропустить)precision (не ошибиться в найденном)
Скрининг опасной болезни: потом всё равно будет точный анализ
Фильтр спама: нельзя, чтобы важное письмо попало в спам
Поиск подозрительных транзакций для ручной проверки, проверять дёшево
Рекомендация: показываем пользователю 5 товаров, все должны быть уместны
Отметьте ячейку — «+», пустая — «−».
Задание 4 Один вариант ответа

Чему равен ROC-AUC модели, которая выдаёт случайные оценки?

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

Напишите для бинарной классификации (массивы 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 считаем нулями"

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

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"

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

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²"

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