Skip to content

逻辑回归 (Logistic Regression)

逻辑回归会把一条直线压弯成 S 曲线,用概率来回答“是”或“否”的问题。

类型: 构建 语言: Python 先修要求: 阶段 2 第 1-2 课(什么是 ML、线性回归) 时间: ~90 分钟

学习目标

  • 使用 sigmoid 函数 (Sigmoid Function) 和二元交叉熵损失 (Binary Cross-Entropy Loss) 从零实现逻辑回归
  • 计算并解释二分类中的精确率 (Precision)、召回率 (Recall)、F1 分数和混淆矩阵 (Confusion Matrix)
  • 解释为什么 MSE 不适合分类,以及为什么二元交叉熵会产生凸的代价曲面
  • 构建一个用于多分类的 softmax 回归 (Softmax Regression) 模型,并评估阈值调节的权衡

问题

你想根据肿瘤大小预测它是恶性还是良性。你先试了线性回归。它会输出 0.3、1.7、-0.5 这样的数字。这些数字到底是什么意思?1.7 是“非常恶性”吗?-0.5 是“非常良性”吗?线性回归输出的是无界数值,而分类任务需要的是 0 到 1 之间有界的概率,以及一个清晰的决策:是或否。

逻辑回归就是为此而生的。它会先计算同样的线性组合 (wx + b),再把结果送入 sigmoid 函数,把任意数压缩到 (0, 1) 区间内。输出值就是一个概率。然后你设定一个阈值(通常是 0.5),据此做出决策。

这是实践中使用最广泛的算法之一。尽管名字里有 “regression”,逻辑回归其实是分类算法,而不是回归算法。这个名字来自它使用的 logistic(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 小时),整条直线都会被拖偏,进而改变所有人的预测结果。

分类任务需要一个函数,它必须:

  • 输出位于 0 和 1 之间的值(概率)
  • 形成一个清晰的跃迁(决策边界)
  • 不会被远离边界的离群点严重扭曲

Sigmoid 函数

sigmoid 函数恰好满足这些要求:

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))。这让梯度计算非常高效。

逻辑回归 = 线性模型 + Sigmoid

模型先计算 z = wx + b(和线性回归相同),然后应用 sigmoid:

mermaid
flowchart LR
    X[输入特征 x] --> L["线性部分: z = wx + b"]
    L --> S["Sigmoid: p = 1/(1+e^-z)"]
    S --> D{"p >= 0.5?"}
    D -->|是| P[预测 1]
    D -->|否| N[预测 0]

输出 p 被解释为 P(y=1 | x),也就是输入属于类别 1 的概率。决策边界出现在 wx + b = 0 的位置,此时 sigmoid 的输出恰好是 0.5。

二元交叉熵损失

逻辑回归不能使用 MSE。MSE 和 sigmoid 组合在一起时会得到一个非凸的代价曲面,存在许多局部最小值。更好的选择是二元交叉熵 (binary cross-entropy,也叫 log loss):

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

它为什么有效:

  • y=1p 接近 1 时:log(1) = 0,损失接近 0(预测正确,代价很低)
  • y=1p 接近 0 时:log(0) 趋向负无穷,损失巨大(预测错误,代价极高)
  • y=0p 接近 0 时:log(1) = 0,损失接近 0(预测正确,代价很低)
  • y=0p 接近 1 时: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 引入了非线性,但梯度更新规则本身没有变。

mermaid
flowchart TD
    A[初始化 w=0, b=0] --> B[前向传播: z = wx+b, p = sigmoid z]
    B --> C[计算损失: 二元交叉熵]
    C --> D["计算梯度: dw = (1/n) * sum((p-y)*x)"]
    D --> E[更新: w = w - lr*dw, b = b - lr*db]
    E --> F{收敛了吗?}
    F -->|否| B
    F -->|是| G[模型训练完成]

决策边界

对于二维输入(两个特征),决策边界是满足下式的那条直线:

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。预测类别就是概率最大的那个。

此时损失函数会变成类别交叉熵 (categorical cross-entropy):

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

其中,真实类别对应的 y_k 为 1,其他类别为 0(即 one-hot encoding)。

评估指标

只看准确率是不够的。假设一个数据集里 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:当你需要一个平衡两者的单一指标时

动手构建

第 1 步:Sigmoid 函数与数据生成

python
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 步:从零实现逻辑回归

python
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 步:从零实现混淆矩阵与指标

python
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 步:分析决策边界

python
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 处理多分类

python
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 步:阈值调节

python
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 做同样的事情。

python
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 相同的决策边界和指标。scikit-learn 额外提供了求解器选项(liblinearlbfgssaga)、自动正则化、多分类策略(one-vs-rest、multinomial)以及数值稳定性优化。

交付成果

本课会产出:

  • code/logistic_regression.py - 带评估指标的从零实现逻辑回归

练习

  1. 生成一个不是线性可分的数据集(例如两个同心圆)。训练逻辑回归并观察它为什么失败。然后加入多项式特征(x1^2x2^2x1*x2)再训练一次,展示准确率如何提升。
  2. 为 3 类 softmax 模型实现一个多分类混淆矩阵。计算每一类的精确率和召回率。哪一类最难分类?
  3. 从零实现 ROC 曲线。对 0 到 1 之间的 100 个阈值,计算真正率和假正率。再用梯形法则计算 AUC(曲线下面积)。

关键术语

术语人们常说实际含义
逻辑回归 (Logistic Regression)“用于分类的回归”一个线性模型,后接 sigmoid 函数,用于输出类别概率
Sigmoid 函数“S 形曲线”1/(1+e^(-z)) 这个函数,它把任意实数映射到 (0, 1) 区间
二元交叉熵 (Binary Cross-Entropy)“Log loss”损失函数 -[y*log(p) + (1-y)*log(1-p)],会对“自信但错误”的预测施加重罚
决策边界 (Decision Boundary)“分界线”模型输出概率等于 0.5 的那条边界,用来分隔不同预测类别
Softmax“多分类版 sigmoid”一个把分数向量转换为总和为 1 的概率向量的函数
精确率 (Precision)“选出来的有多少是真的”TP / (TP + FP),即正类预测中真正为正类的比例
召回率 (Recall)“真正相关的抓到了多少”TP / (TP + FN),即所有真实正类中被模型识别出来的比例
F1 分数“平衡后的准确率”精确率与召回率的调和平均数:2*P*R / (P+R)
混淆矩阵 (Confusion Matrix)“错误拆解表”一个展示 TP、TN、FP、FN 计数的表,用于分析分类结果
阈值 (Threshold)“截断点”当模型预测某样本属于类别 1 的概率高于该值时,就判为类别 1(默认 0.5,可调)
One-hot 编码“给类别做二进制列”把类别 k 表示成一个只有第 k 位为 1,其余都为 0 的向量
类别交叉熵 (Categorical Cross-Entropy)“多分类 log loss”将二元交叉熵扩展到 k 个类别、并配合 one-hot 标签使用的损失函数