Фаза 02 · урок 09

Оценка моделей

Цель урока: Возможно. А возможно, нет. Если 95% ваших данных принадлежат одному классу, модель, которая всегда предсказывает этот класс, получает 95% точности, оставаясь совершенно бесполезной. Если вы оценивали её на тех же данных, на которых…

Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.

Курс
AI Engineering from Scratch
Фаза
Основы машинного обучения
Чтение
22 мин.
Проверено
Содержание урока
  1. Цели обучения
  2. Проблема
  3. Концепция
  4. Обучение, валидация, тест
  5. K-fold кросс-валидация
  6. Метрики классификации
  7. Метрики регрессии
  8. Кривые обучения
  9. Валидационные кривые
  10. Типичные ошибки оценки
  11. Реализуйте
  12. Шаг 1: разбиение на обучение/валидацию/тест
  13. Шаг 2: K-fold и стратифицированная K-fold кросс-валидация
  14. Шаг 3: матрица ошибок и метрики классификации
  15. Шаг 4: метрики регрессии
  16. Шаг 5: кривые обучения
  17. Шаг 6: простой классификатор для тестирования и полная демонстрация
  18. Использование
  19. Внедрение
  20. Упражнения
  21. Ключевые термины
  22. Дополнительное чтение

Модель хороша ровно настолько, насколько хорош способ её измерения.

Тип: Создание Языки: Python Предварительные требования: Фаза 1 («Вероятность и распределения», «Статистика для ML»), уроки 1–8 фазы 2 Время: ~90 минут

Цели обучения

  • Реализовать с нуля K-fold и стратифицированную K-fold кросс-валидацию и объяснить, почему стратификация важна для несбалансированных данных
  • Вычислять с нуля precision, recall, F1, AUC-ROC и метрики регрессии (MSE, RMSE, MAE, R-squared)
  • Интерпретировать кривые обучения, чтобы диагностировать высокое смещение или высокую дисперсию модели
  • Выявлять типичные ошибки оценки, включая утечку данных, выбор неверной метрики и загрязнение тестового набора

Проблема

Вы обучили модель. Она показывает 95% точности на ваших данных. Хороша ли она?

Возможно. А возможно, нет. Если 95% ваших данных принадлежат одному классу, модель, которая всегда предсказывает этот класс, получает 95% точности, оставаясь совершенно бесполезной. Если вы оценивали её на тех же данных, на которых обучали, число 95% ничего не значит: модель просто запомнила ответы. Если в наборе данных есть временная составляющая, а перед разделением вы случайно перемешали данные, модель может использовать будущие данные для предсказания прошлого.

Оценка моделей — место, где ошибается большинство ML-проектов. Неверная метрика заставляет плохую модель выглядеть хорошей. Неверное разделение позволяет модели жульничать. Неверное сравнение заставляет выбрать худшую модель. Правильная оценка не является необязательной. Именно она отделяет модель, работающую в production, от модели, которая ломается при первой встрече с реальными данными.

Концепция

Обучение, валидация, тест

Диаграмма к уроку «Оценка моделей»

Три разбиения, три назначения:

  • Обучающий набор (training set): модель учится на этих данных. Во время обучения она видит эти примеры.
  • Валидационный набор (validation set): используется для настройки гиперпараметров и выбора между моделями. Модель никогда не обучается на этих данных, но они влияют на ваши решения.
  • Тестовый набор (test set): затрагивается ровно один раз, в самом конце, для сообщения итоговой производительности. Если вы посмотрели на тестовую производительность, а затем вернулись и изменили модель, это больше не тестовый набор. Он стал вторым валидационным набором.

Тестовый набор — это отложенная гарантия того, что сообщаемая производительность отражает работу модели на действительно невиданных данных.

K-fold кросс-валидация

При малых наборах данных одно разбиение на обучение и валидацию тратит данные впустую и даёт шумные оценки. K-fold кросс-валидация использует все данные и для обучения, и для валидации:

Диаграмма к уроку «Оценка моделей»

  1. Разделите данные на K фолдов одинакового размера.
  2. Для каждого фолда обучайте на K-1 фолдах и валидируйте на оставшемся фолде.
  3. Усредните K валидационных оценок.

Стандартный выбор — K=5 или K=10. Каждая точка данных используется для валидации ровно один раз. Средняя оценка стабильнее, чем оценка любого одиночного разбиения.

Стратифицированная K-fold: сохраняет распределение классов в каждом фолде. Если ваш набор данных состоит на 70% из класса A и на 30% из класса B, в каждом фолде будет приблизительно такое же соотношение. Это важно для несбалансированных наборов, где случайное разбиение может поместить все образцы миноритарного класса в один фолд.

Метрики классификации

Матрица ошибок (confusion matrix): основа всего. Для бинарной классификации:

Предсказан положительный Предсказан отрицательный
Фактически положительный Истинно положительный (TP) Ложноотрицательный (FN)
Фактически отрицательный Ложноположительный (FP) Истинно отрицательный (TN)

Из этой матрицы следуют все остальные метрики:

  • Accuracy (точность) = (TP + TN) / (TP + TN + FP + FN). Доля верных предсказаний. Вводит в заблуждение при несбалансированных классах.
  • Precision (точность положительных предсказаний) = TP / (TP + FP). Из всего предсказанного положительным сколько действительно было положительным? Используйте, когда ложноположительные срабатывания дороги (например, спам-фильтр помечает настоящее письмо как спам).
  • Recall (полнота, чувствительность) = TP / (TP + FN). Сколько из всех фактических положительных примеров мы нашли? Используйте, когда дорого обходятся ложноотрицательные результаты (например, скрининг рака пропускает опухоль).
  • F1 score = 2 * precision * recall / (precision + recall). Гармоническое среднее precision и recall. Уравновешивает обе метрики, когда ни одна из них явно не важнее другой.
  • AUC-ROC: площадь под кривой Receiver Operating Characteristic. Показывает долю истинно положительных против доли ложноположительных при разных порогах классификации. AUC = 0.5 означает случайное угадывание, AUC = 1.0 — идеальное разделение. Не зависит от порога: измеряет, насколько хорошо модель ранжирует положительные примеры выше отрицательных, независимо от выбранного порога.

Метрики регрессии

  • MSE (Mean Squared Error, средняя квадратичная ошибка) = mean((y_true - y_pred)^2). Квадратично штрафует большие ошибки. Чувствительна к выбросам.
  • RMSE (Root Mean Squared Error, корень из средней квадратичной ошибки) = sqrt(MSE). Имеет те же единицы, что и целевая переменная. Интерпретировать проще, чем MSE.
  • MAE (Mean Absolute Error, средняя абсолютная ошибка) = mean(|y_true - y_pred|). Линейно учитывает все ошибки. Более устойчива к выбросам, чем MSE.
  • R-squared = 1 - SS_res / SS_tot, где SS_res = sum((y_true - y_pred)^2), а SS_tot = sum((y_true - y_mean)^2). Доля дисперсии, объяснённая моделью. R^2 = 1.0 — идеально. R^2 = 0.0 означает, что модель не лучше постоянного предсказания среднего. R^2 может быть отрицательным, если модель хуже среднего.

Кривые обучения

Постройте график оценок обучения и валидации в зависимости от размера обучающего набора:

  • Высокое смещение (недообучение): обе кривые сходятся к низкой оценке. Добавление данных не поможет. Нужна более сложная модель.
  • Высокая дисперсия (переобучение): оценка на обучении высока, но оценка на валидации заметно ниже. Разрыв между ними велик. Добавление данных должно помочь.

Валидационные кривые

Постройте график оценок обучения и валидации в зависимости от гиперпараметра:

  • При низкой сложности: обе оценки низкие (недообучение)
  • При правильной сложности: обе оценки высокие и близки друг к другу
  • При высокой сложности: оценка обучения остаётся высокой, но оценка валидации падает (переобучение)

Оптимальное значение гиперпараметра находится там, где оценка валидации достигает пика.

Типичные ошибки оценки

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

Дисбаланс классов: 99% транзакций легитимны, 1% — мошеннические. Модель, всегда предсказывающая «легитимная», получает 99% accuracy. Вместо этого используйте precision, recall, F1 или AUC-ROC.

Неверная метрика: оптимизация accuracy, когда следует оптимизировать recall (медицинская диагностика), или оптимизация RMSE, когда в данных много сильных выбросов (вместо этого используйте MAE).

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

Слишком частое тестирование: каждый раз, когда вы смотрите на тестовую производительность и что-то корректируете, вы переобучаетесь на тестовом наборе. Тестовый набор одноразовый.

precision-recall-threshold

Реализуйте

Шаг 1: разбиение на обучение/валидацию/тест

import random
import math


def train_val_test_split(X, y, train_ratio=0.6, val_ratio=0.2, seed=42):
    random.seed(seed)
    n = len(X)
    indices = list(range(n))
    random.shuffle(indices)

    train_end = int(n * train_ratio)
    val_end = int(n * (train_ratio + val_ratio))

    train_idx = indices[:train_end]
    val_idx = indices[train_end:val_end]
    test_idx = indices[val_end:]

    X_train = [X[i] for i in train_idx]
    y_train = [y[i] for i in train_idx]
    X_val = [X[i] for i in val_idx]
    y_val = [y[i] for i in val_idx]
    X_test = [X[i] for i in test_idx]
    y_test = [y[i] for i in test_idx]

    return X_train, y_train, X_val, y_val, X_test, y_test

Шаг 2: K-fold и стратифицированная K-fold кросс-валидация

def kfold_split(n, k=5, seed=42):
    random.seed(seed)
    indices = list(range(n))
    random.shuffle(indices)

    fold_size = n // k
    folds = []

    for i in range(k):
        start = i * fold_size
        end = start + fold_size if i < k - 1 else n
        val_idx = indices[start:end]
        train_idx = indices[:start] + indices[end:]
        folds.append((train_idx, val_idx))

    return folds


def stratified_kfold_split(y, k=5, seed=42):
    random.seed(seed)

    class_indices = {}
    for i, label in enumerate(y):
        class_indices.setdefault(label, []).append(i)

    for label in class_indices:
        random.shuffle(class_indices[label])

    folds = [{"train": [], "val": []} for _ in range(k)]

    for label, indices in class_indices.items():
        fold_size = len(indices) // k
        for i in range(k):
            start = i * fold_size
            end = start + fold_size if i < k - 1 else len(indices)
            val_part = indices[start:end]
            train_part = indices[:start] + indices[end:]
            folds[i]["val"].extend(val_part)
            folds[i]["train"].extend(train_part)

    return [(f["train"], f["val"]) for f in folds]


def cross_validate(X, y, model_fn, k=5, metric_fn=None, stratified=False):
    n = len(X)

    if stratified:
        folds = stratified_kfold_split(y, k)
    else:
        folds = kfold_split(n, k)

    scores = []
    for train_idx, val_idx in folds:
        X_train = [X[i] for i in train_idx]
        y_train = [y[i] for i in train_idx]
        X_val = [X[i] for i in val_idx]
        y_val = [y[i] for i in val_idx]

        model = model_fn()
        model.fit(X_train, y_train)
        predictions = [model.predict(x) for x in X_val]

        if metric_fn:
            score = metric_fn(y_val, predictions)
        else:
            score = sum(1 for yt, yp in zip(y_val, predictions) if yt == yp) / len(y_val)
        scores.append(score)

    return scores

Шаг 3: матрица ошибок и метрики классификации

def confusion_matrix(y_true, y_pred):
    tp = sum(1 for yt, yp in zip(y_true, y_pred) if yt == 1 and yp == 1)
    tn = sum(1 for yt, yp in zip(y_true, y_pred) if yt == 0 and yp == 0)
    fp = sum(1 for yt, yp in zip(y_true, y_pred) if yt == 0 and yp == 1)
    fn = sum(1 for yt, yp in zip(y_true, y_pred) if yt == 1 and yp == 0)
    return tp, tn, fp, fn


def accuracy(y_true, y_pred):
    tp, tn, fp, fn = confusion_matrix(y_true, y_pred)
    total = tp + tn + fp + fn
    return (tp + tn) / total if total > 0 else 0.0


def precision(y_true, y_pred):
    tp, tn, fp, fn = confusion_matrix(y_true, y_pred)
    return tp / (tp + fp) if (tp + fp) > 0 else 0.0


def recall(y_true, y_pred):
    tp, tn, fp, fn = confusion_matrix(y_true, y_pred)
    return tp / (tp + fn) if (tp + fn) > 0 else 0.0


def f1_score(y_true, y_pred):
    p = precision(y_true, y_pred)
    r = recall(y_true, y_pred)
    return 2 * p * r / (p + r) if (p + r) > 0 else 0.0


def roc_curve(y_true, y_scores):
    thresholds = sorted(set(y_scores), reverse=True)
    tpr_list = []
    fpr_list = []

    total_positives = sum(y_true)
    total_negatives = len(y_true) - total_positives

    for threshold in thresholds:
        y_pred = [1 if s >= threshold else 0 for s in y_scores]
        tp = sum(1 for yt, yp in zip(y_true, y_pred) if yt == 1 and yp == 1)
        fp = sum(1 for yt, yp in zip(y_true, y_pred) if yt == 0 and yp == 1)

        tpr = tp / total_positives if total_positives > 0 else 0.0
        fpr = fp / total_negatives if total_negatives > 0 else 0.0

        tpr_list.append(tpr)
        fpr_list.append(fpr)

    return fpr_list, tpr_list, thresholds


def auc_roc(y_true, y_scores):
    fpr_list, tpr_list, _ = roc_curve(y_true, y_scores)

    pairs = sorted(zip(fpr_list, tpr_list))
    fpr_sorted = [p[0] for p in pairs]
    tpr_sorted = [p[1] for p in pairs]

    area = 0.0
    for i in range(1, len(fpr_sorted)):
        width = fpr_sorted[i] - fpr_sorted[i - 1]
        height = (tpr_sorted[i] + tpr_sorted[i - 1]) / 2
        area += width * height

    return area

Шаг 4: метрики регрессии

def mse(y_true, y_pred):
    n = len(y_true)
    return sum((yt - yp) ** 2 for yt, yp in zip(y_true, y_pred)) / n


def rmse(y_true, y_pred):
    return math.sqrt(mse(y_true, y_pred))


def mae(y_true, y_pred):
    n = len(y_true)
    return sum(abs(yt - yp) for yt, yp in zip(y_true, y_pred)) / n


def r_squared(y_true, y_pred):
    mean_y = sum(y_true) / len(y_true)
    ss_res = sum((yt - yp) ** 2 for yt, yp in zip(y_true, y_pred))
    ss_tot = sum((yt - mean_y) ** 2 for yt in y_true)
    if ss_tot == 0:
        return 0.0
    return 1.0 - ss_res / ss_tot

Шаг 5: кривые обучения

def learning_curve(X, y, model_fn, metric_fn, train_sizes=None, val_ratio=0.2, seed=42):
    random.seed(seed)
    n = len(X)
    indices = list(range(n))
    random.shuffle(indices)

    val_size = int(n * val_ratio)
    val_idx = indices[:val_size]
    pool_idx = indices[val_size:]

    X_val = [X[i] for i in val_idx]
    y_val = [y[i] for i in val_idx]

    if train_sizes is None:
        train_sizes = [int(len(pool_idx) * r) for r in [0.1, 0.2, 0.4, 0.6, 0.8, 1.0]]

    train_scores = []
    val_scores = []

    for size in train_sizes:
        subset = pool_idx[:size]
        X_train = [X[i] for i in subset]
        y_train = [y[i] for i in subset]

        model = model_fn()
        model.fit(X_train, y_train)

        train_pred = [model.predict(x) for x in X_train]
        val_pred = [model.predict(x) for x in X_val]

        train_scores.append(metric_fn(y_train, train_pred))
        val_scores.append(metric_fn(y_val, val_pred))

    return train_sizes, train_scores, val_scores

Шаг 6: простой классификатор для тестирования и полная демонстрация

class SimpleLogistic:
    def __init__(self, lr=0.1, epochs=100):
        self.lr = lr
        self.epochs = epochs
        self.weights = None
        self.bias = 0.0

    def sigmoid(self, z):
        z = max(-500, min(500, z))
        return 1.0 / (1.0 + math.exp(-z))

    def fit(self, X, y):
        n_features = len(X[0])
        self.weights = [0.0] * n_features
        self.bias = 0.0

        for _ in range(self.epochs):
            for xi, yi in zip(X, y):
                z = sum(w * x for w, x in zip(self.weights, xi)) + self.bias
                pred = self.sigmoid(z)
                error = yi - pred
                for j in range(n_features):
                    self.weights[j] += self.lr * error * xi[j]
                self.bias += self.lr * error

    def predict_proba(self, x):
        z = sum(w * xi for w, xi in zip(self.weights, x)) + self.bias
        return self.sigmoid(z)

    def predict(self, x):
        return 1 if self.predict_proba(x) >= 0.5 else 0


class SimpleLinearRegression:
    def __init__(self, lr=0.001, epochs=200):
        self.lr = lr
        self.epochs = epochs
        self.weights = None
        self.bias = 0.0

    def fit(self, X, y):
        n_features = len(X[0])
        self.weights = [0.0] * n_features
        self.bias = 0.0
        n = len(X)

        for _ in range(self.epochs):
            for xi, yi in zip(X, y):
                pred = sum(w * x for w, x in zip(self.weights, xi)) + self.bias
                error = yi - pred
                for j in range(n_features):
                    self.weights[j] += self.lr * error * xi[j] / n
                self.bias += self.lr * error / n

    def predict(self, x):
        return sum(w * xi for w, xi in zip(self.weights, x)) + self.bias


def standardize(values):
    n = len(values)
    mean = sum(values) / n
    var = sum((v - mean) ** 2 for v in values) / n
    std = math.sqrt(var) if var > 0 else 1.0
    return [(v - mean) / std for v in values], mean, std


def make_classification_data(n=300, seed=42):
    random.seed(seed)
    X = []
    y = []
    for _ in range(n):
        x1 = random.gauss(0, 1)
        x2 = random.gauss(0, 1)
        label = 1 if (x1 + x2 + random.gauss(0, 0.5)) > 0 else 0
        X.append([x1, x2])
        y.append(label)
    return X, y


def make_regression_data(n=200, seed=42):
    random.seed(seed)
    X = []
    y = []
    for _ in range(n):
        x1 = random.uniform(0, 10)
        x2 = random.uniform(0, 5)
        target = 3 * x1 + 2 * x2 + random.gauss(0, 2)
        X.append([x1, x2])
        y.append(target)
    return X, y


def make_imbalanced_data(n=300, minority_ratio=0.05, seed=42):
    random.seed(seed)
    X = []
    y = []
    for _ in range(n):
        if random.random() < minority_ratio:
            x1 = random.gauss(3, 0.5)
            x2 = random.gauss(3, 0.5)
            label = 1
        else:
            x1 = random.gauss(0, 1)
            x2 = random.gauss(0, 1)
            label = 0
        X.append([x1, x2])
        y.append(label)
    return X, y


if __name__ == "__main__":
    X_clf, y_clf = make_classification_data(300)

    print("=== Train/Validation/Test Split ===")
    X_train, y_train, X_val, y_val, X_test, y_test = train_val_test_split(X_clf, y_clf)
    print(f"  Train: {len(X_train)}, Val: {len(X_val)}, Test: {len(X_test)}")
    print(f"  Train class distribution: {sum(y_train)}/{len(y_train)} positive")
    print(f"  Val class distribution: {sum(y_val)}/{len(y_val)} positive")

    model = SimpleLogistic(lr=0.1, epochs=200)
    model.fit(X_train, y_train)

    print("\n=== Classification Metrics ===")
    y_pred = [model.predict(x) for x in X_test]
    tp, tn, fp, fn = confusion_matrix(y_test, y_pred)
    print(f"  Confusion matrix: TP={tp}, TN={tn}, FP={fp}, FN={fn}")
    print(f"  Accuracy:  {accuracy(y_test, y_pred):.4f}")
    print(f"  Precision: {precision(y_test, y_pred):.4f}")
    print(f"  Recall:    {recall(y_test, y_pred):.4f}")
    print(f"  F1 Score:  {f1_score(y_test, y_pred):.4f}")

    y_scores = [model.predict_proba(x) for x in X_test]
    auc = auc_roc(y_test, y_scores)
    print(f"  AUC-ROC:   {auc:.4f}")

    print("\n=== K-Fold Cross-Validation (K=5) ===")
    cv_scores = cross_validate(
        X_clf, y_clf,
        model_fn=lambda: SimpleLogistic(lr=0.1, epochs=200),
        k=5,
        metric_fn=accuracy,
    )
    mean_cv = sum(cv_scores) / len(cv_scores)
    std_cv = math.sqrt(sum((s - mean_cv) ** 2 for s in cv_scores) / len(cv_scores))
    print(f"  Fold scores: {[round(s, 4) for s in cv_scores]}")
    print(f"  Mean: {mean_cv:.4f} (+/- {std_cv:.4f})")

    print("\n=== Stratified K-Fold Cross-Validation (K=5) ===")
    strat_scores = cross_validate(
        X_clf, y_clf,
        model_fn=lambda: SimpleLogistic(lr=0.1, epochs=200),
        k=5,
        metric_fn=accuracy,
        stratified=True,
    )
    strat_mean = sum(strat_scores) / len(strat_scores)
    strat_std = math.sqrt(sum((s - strat_mean) ** 2 for s in strat_scores) / len(strat_scores))
    print(f"  Fold scores: {[round(s, 4) for s in strat_scores]}")
    print(f"  Mean: {strat_mean:.4f} (+/- {strat_std:.4f})")

    print("\n=== Imbalanced Data: Why Accuracy Lies ===")
    X_imb, y_imb = make_imbalanced_data(300, minority_ratio=0.05)
    positives = sum(y_imb)
    print(f"  Class distribution: {positives} positive, {len(y_imb) - positives} negative ({positives/len(y_imb)*100:.1f}% positive)")

    always_negative = [0] * len(y_imb)
    print(f"  Always-negative baseline:")
    print(f"    Accuracy:  {accuracy(y_imb, always_negative):.4f}")
    print(f"    Precision: {precision(y_imb, always_negative):.4f}")
    print(f"    Recall:    {recall(y_imb, always_negative):.4f}")
    print(f"    F1 Score:  {f1_score(y_imb, always_negative):.4f}")

    X_tr_i, y_tr_i, X_v_i, y_v_i, X_te_i, y_te_i = train_val_test_split(X_imb, y_imb)
    model_imb = SimpleLogistic(lr=0.5, epochs=500)
    model_imb.fit(X_tr_i, y_tr_i)
    y_pred_imb = [model_imb.predict(x) for x in X_te_i]
    print(f"\n  Trained model on imbalanced data:")
    print(f"    Accuracy:  {accuracy(y_te_i, y_pred_imb):.4f}")
    print(f"    Precision: {precision(y_te_i, y_pred_imb):.4f}")
    print(f"    Recall:    {recall(y_te_i, y_pred_imb):.4f}")
    print(f"    F1 Score:  {f1_score(y_te_i, y_pred_imb):.4f}")

    print("\n=== Regression Metrics ===")
    X_reg, y_reg = make_regression_data(200)

    col0 = [x[0] for x in X_reg]
    col1 = [x[1] for x in X_reg]
    col0_s, m0, s0 = standardize(col0)
    col1_s, m1, s1 = standardize(col1)
    X_reg_scaled = [[col0_s[i], col1_s[i]] for i in range(len(X_reg))]

    X_tr_r, y_tr_r, X_v_r, y_v_r, X_te_r, y_te_r = train_val_test_split(X_reg_scaled, y_reg)
    reg_model = SimpleLinearRegression(lr=0.01, epochs=500)
    reg_model.fit(X_tr_r, y_tr_r)
    y_pred_r = [reg_model.predict(x) for x in X_te_r]

    print(f"  MSE:       {mse(y_te_r, y_pred_r):.4f}")
    print(f"  RMSE:      {rmse(y_te_r, y_pred_r):.4f}")
    print(f"  MAE:       {mae(y_te_r, y_pred_r):.4f}")
    print(f"  R-squared: {r_squared(y_te_r, y_pred_r):.4f}")

    mean_baseline = [sum(y_tr_r) / len(y_tr_r)] * len(y_te_r)
    print(f"\n  Mean baseline:")
    print(f"    MSE:       {mse(y_te_r, mean_baseline):.4f}")
    print(f"    R-squared: {r_squared(y_te_r, mean_baseline):.4f}")

    print("\n=== Learning Curve ===")
    sizes, train_sc, val_sc = learning_curve(
        X_clf, y_clf,
        model_fn=lambda: SimpleLogistic(lr=0.1, epochs=200),
        metric_fn=accuracy,
    )
    print(f"  {'Size':>6} {'Train':>8} {'Val':>8}")
    for s, tr, va in zip(sizes, train_sc, val_sc):
        print(f"  {s:>6} {tr:>8.4f} {va:>8.4f}")

    print("\n=== Statistical Model Comparison ===")
    model_a_scores = cross_validate(
        X_clf, y_clf,
        model_fn=lambda: SimpleLogistic(lr=0.1, epochs=100),
        k=5, metric_fn=accuracy,
    )
    model_b_scores = cross_validate(
        X_clf, y_clf,
        model_fn=lambda: SimpleLogistic(lr=0.1, epochs=500),
        k=5, metric_fn=accuracy,
    )
    diffs = [a - b for a, b in zip(model_a_scores, model_b_scores)]
    mean_diff = sum(diffs) / len(diffs)
    std_diff = math.sqrt(sum((d - mean_diff) ** 2 for d in diffs) / len(diffs))
    t_stat = mean_diff / (std_diff / math.sqrt(len(diffs))) if std_diff > 0 else 0.0
    print(f"  Model A (100 epochs) mean: {sum(model_a_scores)/len(model_a_scores):.4f}")
    print(f"  Model B (500 epochs) mean: {sum(model_b_scores)/len(model_b_scores):.4f}")
    print(f"  Mean difference: {mean_diff:.4f}")
    print(f"  Paired t-statistic: {t_stat:.4f}")
    print(f"  (|t| > 2.78 for significance at p<0.05 with df=4)")

Использование

В scikit-learn оценка встроена в рабочий процесс:

from sklearn.model_selection import cross_val_score, StratifiedKFold, learning_curve
from sklearn.metrics import (
    accuracy_score, precision_score, recall_score, f1_score,
    roc_auc_score, confusion_matrix, mean_squared_error, r2_score,
)
from sklearn.linear_model import LogisticRegression

model = LogisticRegression()
scores = cross_val_score(model, X, y, cv=StratifiedKFold(5), scoring="f1")

Версии «с нуля» показывают, что именно делает кросс-валидация (никакой магии — только циклы for и отслеживание индексов), как вычисляется каждая метрика (простой подсчёт TP/FP/TN/FN) и почему важна стратификация (сохранение соотношений классов в каждом фолде). Библиотечные версии добавляют параллелизм, больше вариантов оценивания и интеграцию с pipeline.

Внедрение

Этот урок создаёт:

  • outputs/skill-evaluation.md — навык, охватывающий стратегию оценки моделей классификации и регрессии

Упражнения

  1. Реализуйте кривые precision-recall: постройте precision против recall при разных порогах. Вычислите average precision (площадь под PR-кривой). Сравните PR-кривую с ROC-кривой на несбалансированном наборе данных и объясните, когда каждая из них информативнее.
  2. Постройте цикл вложенной кросс-валидации: внешний цикл оценивает производительность модели, внутренний настраивает гиперпараметры. Используйте его, чтобы честно сравнить две модели без утечки валидационных данных в оценку.
  3. Реализуйте перестановочный тест для сравнения моделей: перемешайте метки, переобучите модель и измерьте производительность. Повторите 100 раз, чтобы построить нулевое распределение. Вычислите p-value наблюдаемой производительности модели относительно этого распределения.

Ключевые термины

Термин Как обычно говорят Что это на самом деле означает
Переобучение «Запоминание обучающих данных» Модель захватывает шум обучающих данных: хорошо работает на обучении, но плохо — на невиданных данных
Кросс-валидация «Тестирование на разных подмножествах» Систематическая смена части данных, используемой для валидации, с усреднением результатов всех смен
Precision «Сколько предсказанных положительных верны» TP / (TP + FP): доля положительных предсказаний, которые действительно положительны
Recall «Сколько фактических положительных мы нашли» TP / (TP + FN): доля фактических положительных, правильно идентифицированных моделью
AUC-ROC «Насколько хорошо модель разделяет классы» Площадь под кривой доли истинно положительных против доли ложноположительных для всех порогов, от 0.5 (случайно) до 1.0 (идеально)
R-squared «Какая доля дисперсии объяснена» 1 - (сумма квадратов остатков / полная сумма квадратов): доля дисперсии целевой переменной, захваченная моделью
Утечка данных «Модель сжульничала» Использование при обучении информации, которая не была бы доступна в момент предсказания, что ведёт к оптимистичной оценке
Кривая обучения «Как производительность меняется с ростом данных» График оценок обучения и валидации в зависимости от размера обучающего набора, выявляющий недообучение или переобучение
Стратифицированное разбиение «Сохранение баланса соотношений классов» Разделение данных так, чтобы каждое подмножество имело ту же долю каждого класса, что и полный набор

Дополнительное чтение


Источник: Model Evaluation 02.08 — Проектирование и отбор признаков · Фаза 2 — Основы машинного обучения · Полный каталог · 02.10 — Компромисс между смещением и дисперсией