Фаза 02 · урок 03

Логистическая регрессия

Цель урока: Вы хотите предсказать, является ли опухоль злокачественной или доброкачественной, по её размеру. Вы пробуете линейную регрессию. Она выдаёт числа вроде 0.3, 1.7 или -0.5. Что они означают? Является ли 1.7 «очень злокачественной»? А -0.5…

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

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

Логистическая регрессия изгибает прямую в S-образную кривую, чтобы отвечать на вопросы «да или нет» вероятностями.

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

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

  • Реализовать логистическую регрессию с нуля с помощью сигмоидной функции и бинарной кросс-энтропийной функции потерь
  • Вычислять и интерпретировать точность (precision), полноту (recall), F1-меру и матрицу ошибок для бинарной классификации
  • Объяснять, почему MSE не подходит для классификации и почему бинарная кросс-энтропия создаёт выпуклую поверхность стоимости
  • Построить модель softmax-регрессии для многоклассовой классификации и оценить компромиссы настройки порога

Проблема

Вы хотите предсказать, является ли опухоль злокачественной или доброкачественной, по её размеру. Вы пробуете линейную регрессию. Она выдаёт числа вроде 0.3, 1.7 или -0.5. Что они означают? Является ли 1.7 «очень злокачественной»? А -0.5 — «очень доброкачественной»? Линейная регрессия выдаёт неограниченные числа. Для классификации нужны ограниченные вероятности от 0 до 1 и ясное решение: да или нет.

Логистическая регрессия решает эту проблему. Она берёт ту же линейную комбинацию (wx + b) и пропускает её через сигмоидную функцию, которая сжимает любое число до диапазона (0, 1). Результат — вероятность. Вы задаёте порог (обычно 0.5) и принимаете решение.

Это один из наиболее широко применяемых на практике алгоритмов. Несмотря на название, логистическая регрессия — алгоритм классификации, а не регрессии. Название происходит от используемой ею логистической (сигмоидной) функции.

Концепция

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

Представьте, что вы предсказываете сдачу/несдачу экзамена (1/0) по часам учёбы. Линейная регрессия проводит через данные прямую:

hours:  1   2   3   4   5   6   7   8   9   10
actual: 0   0   0   0   1   1   1   1   1   1

Линейная модель может выдать предсказания -0.2 для 1 часа и 1.3 для 10 часов. Эти значения не являются вероятностями: они уходят ниже 0 и выше 1. Хуже того, единственный выброс (например, человек, учившийся 50 часов) потянул бы всю прямую, изменив предсказания для всех.

Для классификации нужна функция, которая:

  • Выдаёт значения от 0 до 1 (вероятности)
  • Создаёт резкий переход (границу решения)
  • Не искажается выбросами, находящимися далеко от границы

Сигмоидная функция

Сигмоидная функция делает именно это:

sigmoid(z) = 1 / (1 + e^(-z))

Свойства:

  • Когда z велико и положительно, sigmoid(z) приближается к 1
  • Когда z велико и отрицательно, sigmoid(z) приближается к 0
  • Когда z = 0, sigmoid(z) = 0.5
  • Результат всегда находится между 0 и 1
  • Функция гладкая и дифференцируема всюду

Производная имеет удобный вид: sigmoid’(z) = sigmoid(z) * (1 - sigmoid(z)). Благодаря этому градиент вычисляется эффективно.

Логистическая регрессия = линейная модель + сигмоида

Модель вычисляет z = wx + b (как и линейная регрессия), затем применяет сигмоиду:

Диаграмма к уроку «Логистическая регрессия»

Результат p интерпретируется как P(y=1 | x), вероятность того, что вход принадлежит классу 1. Граница решения находится там, где wx + b = 0; тогда сигмоида выдаёт ровно 0.5.

Бинарная кросс-энтропийная функция потерь

Нельзя использовать MSE для логистической регрессии. MSE вместе с сигмоидой создаёт невыпуклую поверхность стоимости со множеством локальных минимумов. Вместо неё используйте бинарную кросс-энтропию (log loss):

Loss = -(1/n) * sum(y * log(p) + (1-y) * log(1-p))

Почему это работает:

  • Когда y=1 и p близко к 1: log(1) = 0, поэтому потери близки к 0 (правильно, низкая стоимость)
  • Когда y=1 и p близко к 0: log(0) приближается к минус бесконечности, поэтому потери огромны (неправильно, высокая стоимость)
  • Когда y=0 и p близко к 0: log(1) = 0, поэтому потери близки к 0 (правильно, низкая стоимость)
  • Когда y=0 и p близко к 1: log(0) приближается к минус бесконечности, поэтому потери огромны (неправильно, высокая стоимость)

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

Градиентный спуск для логистической регрессии

Градиенты бинарной кросс-энтропии с сигмоидой имеют простой вид:

dL/dw = (1/n) * sum((p - y) * x)
dL/db = (1/n) * sum(p - y)

Они выглядят идентично градиентам линейной регрессии. Разница в том, что p = sigmoid(wx + b), а не p = wx + b. Сигмоида вносит нелинейность, но правило обновления по градиенту остаётся тем же.

Диаграмма к уроку «Логистическая регрессия»

Граница решения

Для двумерного входа (двух признаков) граница решения — это прямая, где:

w1*x1 + w2*x2 + b = 0

Точки по одну сторону классифицируются как 1, а по другую — как 0. Логистическая регрессия всегда строит линейную границу решения. Если вам нужна изогнутая граница, добавьте полиномиальные признаки или используйте нелинейную модель.

Многоклассовая классификация с softmax

Бинарная логистическая регрессия работает с двумя классами. Для k классов используйте функцию softmax:

softmax(z_i) = e^(z_i) / sum(e^(z_j) for all j)

У каждого класса есть собственный вектор весов. Модель вычисляет оценку z_i для каждого класса, затем softmax преобразует оценки в вероятности, сумма которых равна 1. Предсказанным становится класс с наибольшей вероятностью.

Функцией потерь становится категориальная кросс-энтропия:

Loss = -(1/n) * sum(sum(y_k * log(p_k)))

где y_k равно 1 для истинного класса и 0 для всех остальных (one-hot-кодирование).

Метрики оценки

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

Матрица ошибок:

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

Точность (precision): среди всех предсказанных положительных, сколько действительно положительны?

Precision = TP / (TP + FP)

Полнота (recall), или чувствительность: среди всех фактически положительных, скольких мы обнаружили?

Recall = TP / (TP + FN)

F1-мера: гармоническое среднее точности и полноты. Уравновешивает обе метрики.

F1 = 2 * (Precision * Recall) / (Precision + Recall)

Когда что приоритизировать:

  • Точность (precision): когда ложноположительные результаты дороги (спам-фильтр: вы не хотите блокировать легитимную почту)
  • Полнота (recall): когда ложноотрицательные результаты дороги (скрининг рака: вы не хотите пропустить опухоль)
  • F1: когда нужна одна сбалансированная метрика
logistic-sigmoid

Соберём сами

Шаг 1: сигмоидная функция и генерация данных

import random
import math

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


random.seed(42)
N = 200
X = []
y = []

for _ in range(N // 2):
    X.append([random.gauss(2, 1), random.gauss(2, 1)])
    y.append(0)

for _ in range(N // 2):
    X.append([random.gauss(5, 1), random.gauss(5, 1)])
    y.append(1)

combined = list(zip(X, y))
random.shuffle(combined)
X, y = zip(*combined)
X = list(X)
y = list(y)

print(f"Generated {N} samples (2 classes, 2 features)")
print(f"Class 0 center: (2, 2), Class 1 center: (5, 5)")
print(f"First 5 samples:")
for i in range(5):
    print(f"  Features: [{X[i][0]:.2f}, {X[i][1]:.2f}], Label: {y[i]}")

Шаг 2: логистическая регрессия с нуля

class LogisticRegression:
    def __init__(self, n_features, learning_rate=0.01):
        self.weights = [0.0] * n_features
        self.bias = 0.0
        self.lr = learning_rate
        self.loss_history = []

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

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

    def compute_loss(self, X, y):
        n = len(y)
        total = 0.0
        for i in range(n):
            p = self.predict_proba(X[i])
            p = max(1e-15, min(1 - 1e-15, p))
            total += y[i] * math.log(p) + (1 - y[i]) * math.log(1 - p)
        return -total / n

    def fit(self, X, y, epochs=1000, print_every=200):
        n = len(y)
        n_features = len(X[0])
        for epoch in range(epochs):
            dw = [0.0] * n_features
            db = 0.0
            for i in range(n):
                p = self.predict_proba(X[i])
                error = p - y[i]
                for j in range(n_features):
                    dw[j] += error * X[i][j]
                db += error
            for j in range(n_features):
                self.weights[j] -= self.lr * (dw[j] / n)
            self.bias -= self.lr * (db / n)
            loss = self.compute_loss(X, y)
            self.loss_history.append(loss)
            if epoch % print_every == 0:
                print(f"  Epoch {epoch:4d} | Loss: {loss:.4f} | w: [{self.weights[0]:.3f}, {self.weights[1]:.3f}] | b: {self.bias:.3f}")
        return self

    def accuracy(self, X, y):
        correct = sum(1 for i in range(len(y)) if self.predict(X[i]) == y[i])
        return correct / len(y)


split = int(0.8 * N)
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]

print("\n=== Training Logistic Regression ===")
model = LogisticRegression(n_features=2, learning_rate=0.1)
model.fit(X_train, y_train, epochs=1000, print_every=200)

print(f"\nTrain accuracy: {model.accuracy(X_train, y_train):.4f}")
print(f"Test accuracy:  {model.accuracy(X_test, y_test):.4f}")
print(f"Weights: [{model.weights[0]:.4f}, {model.weights[1]:.4f}]")
print(f"Bias: {model.bias:.4f}")

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

class ClassificationMetrics:
    def __init__(self, y_true, y_pred):
        self.tp = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 1)
        self.tn = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 0)
        self.fp = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 1)
        self.fn = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 0)

    def accuracy(self):
        total = self.tp + self.tn + self.fp + self.fn
        return (self.tp + self.tn) / total if total > 0 else 0

    def precision(self):
        denom = self.tp + self.fp
        return self.tp / denom if denom > 0 else 0

    def recall(self):
        denom = self.tp + self.fn
        return self.tp / denom if denom > 0 else 0

    def f1(self):
        p = self.precision()
        r = self.recall()
        return 2 * p * r / (p + r) if (p + r) > 0 else 0

    def print_confusion_matrix(self):
        print(f"\n  Confusion Matrix:")
        print(f"                  Predicted")
        print(f"                  Pos   Neg")
        print(f"  Actual Pos     {self.tp:4d}  {self.fn:4d}")
        print(f"  Actual Neg     {self.fp:4d}  {self.tn:4d}")

    def print_report(self):
        self.print_confusion_matrix()
        print(f"\n  Accuracy:  {self.accuracy():.4f}")
        print(f"  Precision: {self.precision():.4f}")
        print(f"  Recall:    {self.recall():.4f}")
        print(f"  F1 Score:  {self.f1():.4f}")


y_pred_test = [model.predict(x) for x in X_test]
print("\n=== Classification Report (Test Set) ===")
metrics = ClassificationMetrics(y_test, y_pred_test)
metrics.print_report()

Шаг 4: анализ границы решения

print("\n=== Decision Boundary ===")
w1, w2 = model.weights
b = model.bias
print(f"Decision boundary: {w1:.4f}*x1 + {w2:.4f}*x2 + {b:.4f} = 0")
if abs(w2) > 1e-10:
    print(f"Solved for x2:     x2 = {-w1/w2:.4f}*x1 + {-b/w2:.4f}")

print("\nSample predictions near the boundary:")
test_points = [
    [3.0, 3.0],
    [3.5, 3.5],
    [4.0, 4.0],
    [2.5, 2.5],
    [5.0, 5.0],
]
for point in test_points:
    prob = model.predict_proba(point)
    pred = model.predict(point)
    print(f"  [{point[0]}, {point[1]}] -> prob={prob:.4f}, class={pred}")

Шаг 5: многоклассовая классификация с softmax

class SoftmaxRegression:
    def __init__(self, n_features, n_classes, learning_rate=0.01):
        self.n_features = n_features
        self.n_classes = n_classes
        self.lr = learning_rate
        self.weights = [[0.0] * n_features for _ in range(n_classes)]
        self.biases = [0.0] * n_classes

    def softmax(self, scores):
        max_score = max(scores)
        exp_scores = [math.exp(s - max_score) for s in scores]
        total = sum(exp_scores)
        return [e / total for e in exp_scores]

    def predict_proba(self, x):
        scores = [
            sum(self.weights[k][j] * x[j] for j in range(self.n_features)) + self.biases[k]
            for k in range(self.n_classes)
        ]
        return self.softmax(scores)

    def predict(self, x):
        probs = self.predict_proba(x)
        return probs.index(max(probs))

    def fit(self, X, y, epochs=1000, print_every=200):
        n = len(y)
        for epoch in range(epochs):
            grad_w = [[0.0] * self.n_features for _ in range(self.n_classes)]
            grad_b = [0.0] * self.n_classes
            total_loss = 0.0
            for i in range(n):
                probs = self.predict_proba(X[i])
                for k in range(self.n_classes):
                    target = 1.0 if y[i] == k else 0.0
                    error = probs[k] - target
                    for j in range(self.n_features):
                        grad_w[k][j] += error * X[i][j]
                    grad_b[k] += error
                true_prob = max(probs[y[i]], 1e-15)
                total_loss -= math.log(true_prob)
            for k in range(self.n_classes):
                for j in range(self.n_features):
                    self.weights[k][j] -= self.lr * (grad_w[k][j] / n)
                self.biases[k] -= self.lr * (grad_b[k] / n)
            if epoch % print_every == 0:
                print(f"  Epoch {epoch:4d} | Loss: {total_loss / n:.4f}")
        return self

    def accuracy(self, X, y):
        correct = sum(1 for i in range(len(y)) if self.predict(X[i]) == y[i])
        return correct / len(y)


random.seed(42)
X_3class = []
y_3class = []

centers = [(1, 1), (5, 1), (3, 5)]
for label, (cx, cy) in enumerate(centers):
    for _ in range(50):
        X_3class.append([random.gauss(cx, 0.8), random.gauss(cy, 0.8)])
        y_3class.append(label)

combined = list(zip(X_3class, y_3class))
random.shuffle(combined)
X_3class, y_3class = zip(*combined)
X_3class = list(X_3class)
y_3class = list(y_3class)

split_3 = int(0.8 * len(X_3class))
X_train_3 = X_3class[:split_3]
y_train_3 = y_3class[:split_3]
X_test_3 = X_3class[split_3:]
y_test_3 = y_3class[split_3:]

print("\n=== Multi-class Softmax Regression (3 classes) ===")
softmax_model = SoftmaxRegression(n_features=2, n_classes=3, learning_rate=0.1)
softmax_model.fit(X_train_3, y_train_3, epochs=1000, print_every=200)
print(f"\nTrain accuracy: {softmax_model.accuracy(X_train_3, y_train_3):.4f}")
print(f"Test accuracy:  {softmax_model.accuracy(X_test_3, y_test_3):.4f}")

print("\nSample predictions:")
for i in range(5):
    probs = softmax_model.predict_proba(X_test_3[i])
    pred = softmax_model.predict(X_test_3[i])
    print(f"  True: {y_test_3[i]}, Predicted: {pred}, Probs: [{', '.join(f'{p:.3f}' for p in probs)}]")

Шаг 6: настройка порога

print("\n=== Threshold Tuning ===")
print("Default threshold: 0.5. Adjusting the threshold trades precision for recall.\n")

thresholds = [0.3, 0.4, 0.5, 0.6, 0.7]
print(f"{'Threshold':>10} {'Accuracy':>10} {'Precision':>10} {'Recall':>10} {'F1':>10}")
print("-" * 52)

for t in thresholds:
    y_pred_t = [1 if model.predict_proba(x) >= t else 0 for x in X_test]
    m = ClassificationMetrics(y_test, y_pred_t)
    print(f"{t:>10.1f} {m.accuracy():>10.4f} {m.precision():>10.4f} {m.recall():>10.4f} {m.f1():>10.4f}")

Используем библиотеку

Теперь то же самое с scikit-learn.

from sklearn.linear_model import LogisticRegression as SklearnLR
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
from sklearn.metrics import confusion_matrix, classification_report
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
import numpy as np

np.random.seed(42)
X_0 = np.random.randn(100, 2) + [2, 2]
X_1 = np.random.randn(100, 2) + [5, 5]
X_sk = np.vstack([X_0, X_1])
y_sk = np.array([0] * 100 + [1] * 100)

X_tr, X_te, y_tr, y_te = train_test_split(X_sk, y_sk, test_size=0.2, random_state=42)

scaler = StandardScaler()
X_tr_sc = scaler.fit_transform(X_tr)
X_te_sc = scaler.transform(X_te)

lr = SklearnLR()
lr.fit(X_tr_sc, y_tr)
y_pred = lr.predict(X_te_sc)

print("=== Scikit-learn Logistic Regression ===")
print(f"Accuracy:  {accuracy_score(y_te, y_pred):.4f}")
print(f"Precision: {precision_score(y_te, y_pred):.4f}")
print(f"Recall:    {recall_score(y_te, y_pred):.4f}")
print(f"F1:        {f1_score(y_te, y_pred):.4f}")
print(f"\nConfusion Matrix:\n{confusion_matrix(y_te, y_pred)}")
print(f"\nClassification Report:\n{classification_report(y_te, y_pred)}")

Ваша реализация с нуля создаёт те же границу решения и метрики. Scikit-learn добавляет варианты решателей (liblinear, lbfgs, saga), автоматическую регуляризацию, многоклассовые стратегии (one-vs-rest, multinomial) и оптимизации численной устойчивости.

Готовый результат

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

  • code/logistic_regression.py — логистическая регрессия с нуля с метриками

Упражнения

  1. Сгенерируйте набор данных, который НЕ является линейно разделимым (например, две концентрические окружности). Обучите логистическую регрессию и проследите её ошибку. Затем добавьте полиномиальные признаки (x1^2, x2^2, x1*x2) и обучите снова. Покажите, что точность улучшилась.
  2. Реализуйте многоклассовую матрицу ошибок для модели softmax с 3 классами. Вычислите точность и полноту для каждого класса. Какой класс труднее всего классифицировать?
  3. Постройте ROC-кривую с нуля. Для 100 значений порога от 0 до 1 вычислите долю истинно положительных и долю ложноположительных результатов. Рассчитайте AUC (площадь под кривой) по правилу трапеций.

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

Термин Как обычно говорят Что это в действительности означает
Логистическая регрессия «Регрессия для классификации» Линейная модель, за которой следует сигмоидная функция, выдающая вероятности классов
Сигмоидная функция «S-образная кривая» Функция 1/(1+e^(-z)), отображающая любое вещественное число в диапазон (0, 1)
Бинарная кросс-энтропия «Log loss» Функция потерь -[y*log(p) + (1-y)*log(1-p)], которая строго штрафует уверенные неверные предсказания
Граница решения «Разделяющая линия» Поверхность, где выходная вероятность модели равна 0.5 и которая разделяет предсказываемые классы
Softmax «Многоклассовая сигмоида» Функция, преобразующая вектор оценок в вероятности с суммой 1
Точность (precision) «Сколько выбранных релевантны» TP / (TP + FP), доля положительных предсказаний, которые действительно положительны
Полнота (recall) «Сколько релевантных выбрано» TP / (TP + FN), доля фактически положительных объектов, которые модель правильно находит
F1-мера «Сбалансированная точность» Гармоническое среднее точности и полноты: 2PR / (P+R)
Матрица ошибок «Разбивка ошибок» Таблица с количеством TP, TN, FP, FN для каждой пары классов
Порог «Точка отсечения» Значение вероятности, выше которого модель предсказывает класс 1 (по умолчанию 0.5, можно настраивать)
One-hot-кодирование «Бинарные столбцы для категорий» Представление класса k вектором нулей с 1 в позиции k
Категориальная кросс-энтропия «Многоклассовая log loss» Расширение бинарной кросс-энтропии на k классов с one-hot-кодированными метками

Источник: Logistic Regression 02.02 — Линейная регрессия · Фаза 2 — Основы машинного обучения · Полный каталог · 02.04 — Деревья решений и случайные леса