Фаза 02 · урок 03
Логистическая регрессия
Цель урока: Вы хотите предсказать, является ли опухоль злокачественной или доброкачественной, по её размеру. Вы пробуете линейную регрессию. Она выдаёт числа вроде 0.3, 1.7 или -0.5. Что они означают? Является ли 1.7 «очень злокачественной»? А -0.5…
Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.
Содержание урока
- Цели обучения
- Проблема
- Концепция
- Почему линейная регрессия не подходит для классификации
- Сигмоидная функция
- Логистическая регрессия = линейная модель + сигмоида
- Бинарная кросс-энтропийная функция потерь
- Градиентный спуск для логистической регрессии
- Граница решения
- Многоклассовая классификация с softmax
- Метрики оценки
- Соберём сами
- Шаг 1: сигмоидная функция и генерация данных
- Шаг 2: логистическая регрессия с нуля
- Шаг 3: матрица ошибок и метрики с нуля
- Шаг 4: анализ границы решения
- Шаг 5: многоклассовая классификация с softmax
- Шаг 6: настройка порога
- Используем библиотеку
- Готовый результат
- Упражнения
- Ключевые термины
Логистическая регрессия изгибает прямую в 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— логистическая регрессия с нуля с метриками
Упражнения
- Сгенерируйте набор данных, который НЕ является линейно разделимым (например, две концентрические окружности). Обучите логистическую регрессию и проследите её ошибку. Затем добавьте полиномиальные признаки (x1^2, x2^2, x1*x2) и обучите снова. Покажите, что точность улучшилась.
- Реализуйте многоклассовую матрицу ошибок для модели softmax с 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 — Деревья решений и случайные леса