逻辑回归:用 S 曲线回答是非题 本节摘要:逻辑回归(Logistic Regression)把直线弯成 S 曲线,用概率回答是非题。你想根据肿瘤大小判断恶性还是良性,线性回归会吐出 0.3、1.7、-0.5 这样无界的数字——1.7 算「很恶性」吗?-0.5 算「很良性」吗?分类需要的是 0 到 1 之间的概率,外加一个清晰的是/否判决。逻辑回归把同样的线性组合 喂给 sigmoid 函数,把任意实数压进 (0, 1) 区间,输出概率,再设个阈值(通常 0.5)做决定。本节将吃透:为什么 MSE 不能用于分类、sigmoid 的数学性质、二元交叉熵损失为何保证凸性、梯度下降在逻辑回归里与线性回归的同构之美、softmax 多分类、以及精确率/召回率/F1 等评估指标。
本节摘要:逻辑回归(Logistic Regression)把直线弯成 S 曲线,用概率回答是非题。你想根据肿瘤大小判断恶性还是良性,线性回归会吐出 0.3、1.7、-0.5 这样无界的数字——1.7 算「很恶性」吗?-0.5 算「很良性」吗?分类需要的是 0 到 1 之间的概率,外加一个清晰的是/否判决。逻辑回归把同样的线性组合
wx + b喂给 sigmoid 函数,把任意实数压进 (0, 1) 区间,输出概率,再设个阈值(通常 0.5)做决定。本节将吃透:为什么 MSE 不能用于分类、sigmoid 的数学性质、二元交叉熵损失为何保证凸性、梯度下降在逻辑回归里与线性回归的同构之美、softmax 多分类、以及精确率/召回率/F1 等评估指标。尽管名字带「回归」,它其实是分类算法。
阅读完本节,你应当能够:
你想根据肿瘤大小判断恶性还是良性。你试了线性回归,它输出 0.3、1.7、-0.5 这样的数字。这些是什么意思?1.7 是「很恶性」?-0.5 是「很良性」?线性回归输出无界数字,而分类需要 0 到 1 之间有界的概率,以及一个清晰的判决:是或否。
逻辑回归解决这个问题。它取同样的线性组合 wx + b,送入 sigmoid 函数,把任意数字压进 (0, 1) 区间。输出是概率。你设一个阈值(通常 0.5)做决定。
这是实践中最常用的算法之一。尽管名字带「回归」,逻辑回归是分类算法而非回归算法。名字来自它使用的逻辑(sigmoid)函数。
想象根据学习时长预测通过/不通过(1/0)。线性回归在数据上拟合一条直线:
hours: 1 2 3 4 5 6 7 8 9 10 actual: 0 0 0 0 1 1 1 1 1 1
线性拟合可能在 1 小时处给出 -0.2、在 10 小时处给出 1.3。这些值不是概率,它们低于 0 也高于 1。更糟的是,一个离群点(学了 50 小时的人)会拖动整条线,改变所有人的预测。
分类需要一个能这样做的函数:
sigmoid 函数恰好做到这些:
sigmoid(z) = 1 / (1 + e^(-z))
性质:
它的导数形式很简洁:sigmoid'(z) = sigmoid(z) * (1 - sigmoid(z)),这让梯度计算高效。
模型先算 z = wx + b(同线性回归),再套 sigmoid:
输出 p 被解读为 P(y=1 | x),即输入属于类 1 的概率。决策边界在 wx + b = 0 处,那里 sigmoid 输出正好 0.5。
逻辑回归不能用 MSE。MSE 配 sigmoid 会产生非凸代价曲面,有大量局部极小。改用二元交叉熵(对数损失,Log Loss):
Loss = -(1/n) * sum(y * log(p) + (1-y) * log(1-p))
为什么有效:
log(1) = 0,损失接近 0(对的,代价低)log(0) 趋近负无穷,损失巨大(错的,代价高)log(1) = 0,损失接近 0(对的,代价低)log(0) 趋近负无穷,损失巨大(错的,代价高)这个损失函数对逻辑回归是凸的,保证唯一全局最小。
二元交叉熵配 sigmoid 的梯度形式很干净:
dL/dw = (1/n) * sum((p - y) * x) dL/db = (1/n) * sum(p - y)
它们与线性回归梯度看起来一模一样。区别在于 p = sigmoid(wx + b) 而非 p = wx + b。sigmoid 引入非线性,但梯度更新规则不变。
对二维输入(两个特征),决策边界是这样一条直线:
w1*x1 + w2*x2 + b = 0
一侧的点被判为 1,另一侧判为 0。逻辑回归始终产生线性决策边界。要弯曲的边界,要么加多项式特征,要么用非线性模型。
二元逻辑回归处理两类。对 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% 准确率却毫无用处。
混淆矩阵(Confusion Matrix):
| 预测为正 | 预测为负 | |
|---|---|---|
| 实际为正 | 真正例(TP) | 假负例(FN) |
| 实际为负 | 假正例(FP) | 真负例(TN) |
精确率(Precision):所有预测为正的里,实际为正的有多少?
Precision = TP / (TP + FP)
召回率(Recall / 灵敏度):所有实际为正的里,我们抓到了多少?
Recall = TP / (TP + FN)
F1 分数:精确率和召回率的调和平均,平衡两者。
F1 = 2 * (Precision * Recall) / (Precision + Recall)
何时优先:
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]}")
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}")
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()
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}")
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)}]")
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 |
|---|---|---|
| 决策边界与指标 | 与 sklearn 一致 | 一致 |
| 求解器 | 纯梯度下降 | 提供 liblinear / lbfgs / saga 等多种求解器 |
| 附加能力 | 仅核心逻辑 | 自动正则化、多分类策略(OvR / 多项式)、数值稳定优化 |
本节产出:
code/logistic_regression.py —— 带评估指标的、从零实现的逻辑回归。wx+b 经 sigmoid 压成 (0,1) 概率,设阈值做判决。sigmoid(z)*(1-sigmoid(z)),z=0 时为 0.5。dL/dw = (1/n)*sum((p-y)*x),只是 p 由 sigmoid 产生。w1*x1+w2*x2+b=0,要弯曲边界就加多项式特征。下一节,我们离开线性世界,进入决策树与随机森林——用递归切分特征空间,既能拟合任意非线性边界,又能解释每一个决策。