Pdch's log

PRML 第四章:线性分类模型 — 从决策边界到贝叶斯逻辑回归

12 分钟
来源: Pixiv 125848669

PRML 第四章:线性分类模型

第三章里,我们用线性模型预测连续值;现在把目标换成离散类别。看起来只是把输出从 $\mathbb{R}$ 换成 $\{0,1\}$,实际却带来三个新问题:

  1. 边界:模型依据什么把特征空间切成不同区域?
  2. 概率:怎样让输出不仅是类别,还能表达置信程度?
  3. 损失:为什么逻辑回归使用交叉熵,而不是继续使用均方误差?

本章沿着 PRML Chapter 4 的两条主线前进:在共享协方差高斯假设下,生成式模型学习 $p(\mathbf{x}\mid C_k)p(C_k)$ 并导出线性边界;判别式模型直接学习 $p(C_k\mid\mathbf{x})$ 或决策边界。

“线性”仍然是指决策函数对特征 $\boldsymbol\phi(\mathbf{x})$ 线性;只要改变基函数,原始输入空间中的边界完全可以是弯的。

环境准备与分类数据

我们先制造两团有少量重叠的二维高斯数据。标签统一记为 $t\in\{0,1\}$。训练前根据训练集统计量做标准化,并显式在设计矩阵首列添加 $1$,让截距成为权重向量的一部分。

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
from sklearn.metrics import confusion_matrix, precision_recall_fscore_support, roc_curve, roc_auc_score
from sklearn.model_selection import train_test_split

plt.style.use("seaborn-v0_8-whitegrid")
rng = np.random.default_rng(42)

n_per_class = 100
x0 = rng.multivariate_normal([-1.2, -0.7], [[0.9, 0.25], [0.25, 0.7]], n_per_class)
x1 = rng.multivariate_normal([1.0, 0.9], [[0.8, -0.2], [-0.2, 0.8]], n_per_class)
X = np.vstack([x0, x1])
t = np.r_[np.zeros(n_per_class), np.ones(n_per_class)]

X_train_raw, X_test_raw, t_train, t_test = train_test_split(
    X, t, test_size=0.3, random_state=42, stratify=t
)
mean = X_train_raw.mean(axis=0)
std = X_train_raw.std(axis=0)
X_train = (X_train_raw - mean) / std
X_test = (X_test_raw - mean) / std
Phi_train = np.c_[np.ones(len(X_train)), X_train]
Phi_test = np.c_[np.ones(len(X_test)), X_test]

fig, ax = plt.subplots(figsize=(7, 5))
for label, color in [(0, "#2878B5"), (1, "#D95319")]:
    points = X_train[t_train == label]
    ax.scatter(points[:, 0], points[:, 1], s=35, alpha=0.8, label=f"class {label}", color=color)
ax.set(xlabel="$x_1$ (standardized)", ylabel="$x_2$ (standardized)", title="Binary classification data")
ax.legend()
plt.show()

print("train/test:", len(X_train), len(X_test), "design matrix:", Phi_train.shape)
notebook output
train/test: 140 60 design matrix: (140, 3)

1. 判别函数与决策边界(PRML §4.1)

最简单的二分类判别函数是

$$ a(\mathbf{x})=\mathbf{w}^\top\boldsymbol\phi(\mathbf{x})=w_0+w_1x_1+w_2x_2. $$

规定 $a(\mathbf{x})\ge 0$ 属于类别 1,否则属于类别 0。满足 $a(\mathbf{x})=0$ 的点组成决策边界;$\mathbf{w}_{1:}$ 是边界的法向量。任一点到超平面的有符号距离为

$$ r=\frac{a(\mathbf{x})}{\lVert\mathbf{w}_{1:}\rVert_2}. $$

因此 $a(\mathbf{x})$ 的绝对值可以表示几何上的远近,却还不是概率。把权重整体乘以 100 不会改变边界,但会把分数放大 100 倍。

def plot_binary_boundary(ax, X_data, labels, weights, title, probability=False):
    pad = 0.7
    x_min, x_max = X_data[:, 0].min() - pad, X_data[:, 0].max() + pad
    y_min, y_max = X_data[:, 1].min() - pad, X_data[:, 1].max() + pad
    xx, yy = np.meshgrid(np.linspace(x_min, x_max, 250), np.linspace(y_min, y_max, 250))
    grid_phi = np.c_[np.ones(xx.size), xx.ravel(), yy.ravel()]
    scores = (grid_phi @ weights).reshape(xx.shape)
    if probability:
        values = stable_sigmoid(scores)
        ax.contourf(xx, yy, values, levels=np.linspace(0, 1, 11), cmap="RdBu_r", alpha=0.28)
    else:
        ax.contourf(xx, yy, scores >= 0, levels=[-0.5, 0.5, 1.5], cmap="RdBu_r", alpha=0.18)
    ax.contour(xx, yy, scores, levels=[0], colors="black", linewidths=2)
    ax.scatter(X_data[:, 0], X_data[:, 1], c=labels, cmap="RdBu_r", edgecolor="white", s=38)
    ax.set(title=title, xlabel="$x_1$", ylabel="$x_2$")

w_demo = np.array([-0.1, 1.2, 0.8])
fig, ax = plt.subplots(figsize=(7, 5))
plot_binary_boundary(ax, X_train, t_train, w_demo, "A linear discriminant")
plt.show()

signed_distance = Phi_train @ w_demo / np.linalg.norm(w_demo[1:])
print("first five signed distances:", np.round(signed_distance[:5], 3))
notebook output
first five signed distances: [-1.377  0.749  1.189 -2.8   -0.174]

1.1 最小二乘分类:能用,但目标错位(PRML §4.1.3)

把类别 0、1 当成回归目标,最小化

$$ E(\mathbf{w})=\frac12\lVert\Phi\mathbf{w}-\mathbf{t}\rVert^2 $$

可以得到闭式解 $\mathbf{w}=\Phi^\dagger\mathbf{t}$。多分类时则把标签写成 one-hot 矩阵 $T$,一次解出 $W=\Phi^\dagger T$,预测取 $\arg\max_k \mathbf{w}_k^\top\boldsymbol\phi(\mathbf{x})$。

问题在于:平方误差努力逼近的是人工编码的数值,不是分类边界,也不保证输出落在 $[0,1]$。

不过,离得远不等于一定会破坏拟合。若远处样本的标签与当前趋势一致,它虽然杠杆高,残差却可能很小,边界不会明显移动。真正有影响力的是同时满足两点的样本:

  1. 特征坐标远离数据中心,具有高杠杆;
  2. 标签与当前模型预测冲突,产生大残差。

下面故意把 3 个类别 1 样本放到类别 0 一侧。它们既远又“唱反调”,平方损失会迫使整条边界向它们旋转。

w_ls = np.linalg.pinv(Phi_train) @ t_train
ls_scores = Phi_test @ w_ls
ls_accuracy = np.mean((ls_scores >= 0.5) == t_test)

# High-leverage, contradictory samples: class 1 points placed deep on the class 0 side.
outliers = np.array([[-4.2, -0.15], [-4.0, 0.0], [-3.8, 0.15]])
X_outlier = np.vstack([X_train, outliers])
t_outlier = np.r_[t_train, np.ones(len(outliers))]
Phi_outlier = np.c_[np.ones(len(X_outlier)), X_outlier]
w_ls_outlier = np.linalg.pinv(Phi_outlier) @ t_outlier

# plot_binary_boundary uses a zero threshold, so shift the intercept for the 0.5 LS threshold.
w_ls_boundary = w_ls.copy()
w_ls_boundary[0] -= 0.5
w_ls_outlier_boundary = w_ls_outlier.copy()
w_ls_outlier_boundary[0] -= 0.5

fig, axes = plt.subplots(1, 2, figsize=(12, 4.5), sharex=True, sharey=True)
plot_binary_boundary(axes[0], X_train, t_train, w_ls_boundary, "Clean data")
plot_binary_boundary(axes[1], X_outlier, t_outlier, w_ls_outlier_boundary, "With contradictory outliers")

# Overlay the clean-data boundary on the second panel for a direct visual comparison.
x_limits = (-4.6, 3.2)
y_limits = (-3.0, 3.2)
x_line = np.linspace(*x_limits, 300)
y_line = -(w_ls_boundary[0] + w_ls_boundary[1] * x_line) / w_ls_boundary[2]
axes[1].plot(x_line, y_line, "k--", linewidth=2, label="clean boundary")
axes[1].scatter(outliers[:, 0], outliers[:, 1], marker="X", s=130, color="#D95319", edgecolor="black", label="outliers")
for ax in axes:
    ax.set_xlim(x_limits)
    ax.set_ylim(y_limits)
axes[1].legend(loc="upper left")
plt.tight_layout()
plt.show()

clean_normal = w_ls_boundary[1:]
outlier_normal = w_ls_outlier_boundary[1:]
cosine = np.dot(clean_normal, outlier_normal) / (np.linalg.norm(clean_normal) * np.linalg.norm(outlier_normal))
boundary_rotation = np.degrees(np.arccos(np.clip(cosine, -1.0, 1.0)))
xx, yy = np.meshgrid(np.linspace(*x_limits, 250), np.linspace(*y_limits, 250))
comparison_grid = np.c_[np.ones(xx.size), xx.ravel(), yy.ravel()]
changed_fraction = np.mean(
    (comparison_grid @ w_ls_boundary >= 0) != (comparison_grid @ w_ls_outlier_boundary >= 0)
)

print(f"test accuracy without outliers: {ls_accuracy:.3f}")
print(f"decision-boundary rotation: {boundary_rotation:.1f} degrees")
print(f"changed predictions in the shown region: {changed_fraction:.1%}")
print(f"raw score range: [{ls_scores.min():.3f}, {ls_scores.max():.3f}] (not constrained to [0, 1])")
notebook output
test accuracy without outliers: 0.900
decision-boundary rotation: 23.8 degrees
changed predictions in the shown region: 17.7%
raw score range: [-0.312, 1.319] (not constrained to [0, 1])

1.2 Fisher 线性判别:投影后尽量分开(PRML §4.1.4)

Fisher 不直接拟合标签,而是寻找一个投影方向 $\mathbf{w}$:投影后两类均值要尽量远,同一类内部要尽量紧。样本均值记为 $\mathbf{m}_k$,类内散布矩阵记为 $S_k$,目标函数是

$$ J(\mathbf{w})= \frac{\bigl(\mathbf{w}^\top(\mathbf{m}_1-\mathbf{m}_0)\bigr)^2} {\mathbf{w}^\top S_W\mathbf{w}}, \qquad S_W=S_0+S_1. $$

令导数为零可得

$$ \mathbf{w}\propto S_W^{-1}(\mathbf{m}_1-\mathbf{m}_0). $$

实现时仍然不显式求逆,而解线性方程。下面将阈值启发式地取为两类投影均值的中点;若要把它解释为 Bayes 最优阈值,还需要等类先验、等投影方差和对称错误成本等条件。

def fit_fisher(X_data, labels):
    class0, class1 = X_data[labels == 0], X_data[labels == 1]
    m0, m1 = class0.mean(axis=0), class1.mean(axis=0)
    S0 = (class0 - m0).T @ (class0 - m0)
    S1 = (class1 - m1).T @ (class1 - m1)
    direction = np.linalg.solve(S0 + S1 + 1e-8 * np.eye(X_data.shape[1]), m1 - m0)
    threshold = 0.5 * (direction @ m0 + direction @ m1)
    return direction, threshold

w_fisher, fisher_threshold = fit_fisher(X_train, t_train)
fisher_projection = X_train @ w_fisher
fisher_test_pred = (X_test @ w_fisher >= fisher_threshold).astype(int)
w_fisher_boundary = np.r_[-fisher_threshold, w_fisher]

fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))
plot_binary_boundary(axes[0], X_train, t_train, w_fisher_boundary, "Fisher boundary")
axes[1].hist(fisher_projection[t_train == 0], bins=18, alpha=0.65, label="class 0")
axes[1].hist(fisher_projection[t_train == 1], bins=18, alpha=0.65, label="class 1")
axes[1].axvline(fisher_threshold, color="black", linestyle="--", label="threshold")
axes[1].set(title="Projection onto Fisher direction", xlabel="$w^T x$")
axes[1].legend()
plt.tight_layout()
plt.show()
print(f"Fisher test accuracy: {np.mean(fisher_test_pred == t_test):.3f}")
notebook output
Fisher test accuracy: 0.900

1.3 感知机:只修正犯错的样本(PRML §4.1.7)

感知机把标签临时转换为 $s_n\in\{-1,+1\}$。若样本满足 $s_n\mathbf{w}^\top\phi_n\le 0$,它就被错分,更新

$$ \mathbf{w}\leftarrow\mathbf{w}+\eta s_n\boldsymbol\phi_n. $$

感知机准则只累计错分点的 $-s_n\mathbf{w}^\top\phi_n$。线性可分时,感知机收敛定理保证有限步找到一条分界线;不可分时,它可能一直在不同错分点之间来回修正。它给出硬边界,却不给出经过校准的概率。

def fit_perceptron(Phi, labels, learning_rate=0.05, epochs=80):
    signed_labels = 2 * labels.astype(int) - 1
    weights = np.zeros(Phi.shape[1])
    mistakes_history, loss_history = [], []
    local_rng = np.random.default_rng(7)
    for _ in range(epochs):
        mistakes = 0
        for index in local_rng.permutation(len(Phi)):
            margin = signed_labels[index] * (Phi[index] @ weights)
            if margin <= 0:
                weights += learning_rate * signed_labels[index] * Phi[index]
                mistakes += 1
        margins = signed_labels * (Phi @ weights)
        mistakes_history.append(mistakes)
        loss_history.append(np.maximum(0, -margins).sum())
    return weights, np.array(mistakes_history), np.array(loss_history)

w_perceptron, perceptron_mistakes, perceptron_loss = fit_perceptron(Phi_train, t_train)
perceptron_pred = (Phi_test @ w_perceptron >= 0).astype(int)

fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))
plot_binary_boundary(axes[0], X_train, t_train, w_perceptron, "Perceptron boundary")
axes[1].plot(perceptron_mistakes, label="mistakes per epoch")
axes[1].plot(perceptron_loss, label="perceptron criterion")
axes[1].set(title="Perceptron training", xlabel="epoch")
axes[1].legend()
plt.tight_layout()
plt.show()
print(f"Perceptron test accuracy: {np.mean(perceptron_pred == t_test):.3f}")
notebook output
Perceptron test accuracy: 0.867

2. 概率生成式分类(PRML §4.2)

生成式路线先描述“每一类的数据长什么样”:

$$ p(C_k\mid\mathbf{x})= \frac{p(\mathbf{x}\mid C_k)p(C_k)}{\sum_j p(\mathbf{x}\mid C_j)p(C_j)}. $$

假设两类条件分布都是高斯,并共享协方差矩阵 $\Sigma$:

$$ p(\mathbf{x}\mid C_k)=\mathcal N(\mathbf{x}\mid\boldsymbol\mu_k,\Sigma). $$

展开两个高斯密度的对数比后,$\mathbf{x}^\top\Sigma^{-1}\mathbf{x}$ 项彼此抵消,得到

$$ \log\frac{p(C_1\mid\mathbf{x})}{p(C_0\mid\mathbf{x})} =\mathbf{w}^\top\mathbf{x}+w_0, $$

其中

$$ \mathbf{w}=\Sigma^{-1}(\boldsymbol\mu_1-\boldsymbol\mu_0), $$$$ w_0=-\frac12\boldsymbol\mu_1^\top\Sigma^{-1}\boldsymbol\mu_1 +\frac12\boldsymbol\mu_0^\top\Sigma^{-1}\boldsymbol\mu_0 +\log\frac{\pi_1}{\pi_0}. $$

这就是线性判别分析(LDA)的概率版本。共享协方差是线性边界的关键;每类使用不同协方差会得到二次边界。

def fit_gaussian_lda(X_data, labels):
    classes = np.array([0, 1])
    means = np.vstack([X_data[labels == k].mean(axis=0) for k in classes])
    priors = np.array([np.mean(labels == k) for k in classes])
    covariance = sum(
        (X_data[labels == k] - means[k]).T @ (X_data[labels == k] - means[k])
        for k in classes
    ) / len(X_data)
    covariance += 1e-8 * np.eye(X_data.shape[1])
    precision_mean = np.linalg.solve(covariance, (means[1] - means[0]))
    solved_means = np.linalg.solve(covariance, means.T).T
    intercept = (
        -0.5 * means[1] @ solved_means[1]
        + 0.5 * means[0] @ solved_means[0]
        + np.log(priors[1] / priors[0])
    )
    return np.r_[intercept, precision_mean], means, covariance, priors

w_lda, lda_means, lda_covariance, lda_priors = fit_gaussian_lda(X_train, t_train)
lda_pred = (Phi_test @ w_lda >= 0).astype(int)

fig, ax = plt.subplots(figsize=(7, 5))
plot_binary_boundary(ax, X_train, t_train, w_lda, "Gaussian generative classifier (LDA)")
ax.scatter(lda_means[:, 0], lda_means[:, 1], marker="X", s=180, c=[0, 1], cmap="RdBu_r", edgecolor="black")
plt.show()
print("estimated priors:", lda_priors)
print(f"LDA test accuracy: {np.mean(lda_pred == t_test):.3f}")
notebook output
estimated priors: [0.5 0.5]
LDA test accuracy: 0.900

3. 概率判别式模型:逻辑回归(PRML §4.3)

生成式模型需要把每类数据分布都解释清楚。逻辑回归更直接:只建模后验概率

$$ y(\mathbf{x})=p(C_1\mid\mathbf{x})=\sigma(a), \qquad a=\mathbf{w}^\top\boldsymbol\phi(\mathbf{x}), $$$$ \sigma(a)=\frac{1}{1+e^{-a}}. $$

Sigmoid 把任意实数压到 $(0,1)$,且 $\sigma(0)=0.5$,所以 $\mathbf{w}^\top\phi(\mathbf{x})=0$ 同时是概率 0.5 的等值线和分类边界。

3.1 从伯努利似然到交叉熵

对 $t_n\in\{0,1\}$,伯努利似然为

$$ p(t_n\mid\mathbf{x}_n,\mathbf{w})=y_n^{t_n}(1-y_n)^{1-t_n}. $$

假设样本独立,取负对数得到二元交叉熵:

$$ E(\mathbf{w})=-\sum_{n=1}^N\left[t_n\log y_n+(1-t_n)\log(1-y_n)\right]. $$

利用 $\mathrm{d}\sigma(a)/\mathrm{d}a=\sigma(a)(1-\sigma(a))$,链式法则中的分母与 sigmoid 导数抵消,最终

$$ \nabla E(\mathbf{w})=\Phi^\top(\mathbf{y}-\mathbf{t}). $$

所以交叉熵不是瞎选的:它是伯努利噪声假设下的负对数似然,而且恰好给出非常干净的梯度。

def stable_sigmoid(values):
    values = np.asarray(values, dtype=float)
    result = np.empty_like(values)
    positive = values >= 0
    result[positive] = 1.0 / (1.0 + np.exp(-values[positive]))
    exp_values = np.exp(values[~positive])
    result[~positive] = exp_values / (1.0 + exp_values)
    return result


def binary_cross_entropy_from_logits(logits, labels):
    # log(1 + exp(a)) - t*a is BCE without taking log(sigmoid(a)).
    return np.mean(np.logaddexp(0.0, logits) - labels * logits)


logit_grid = np.linspace(-8, 8, 400)
fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(logit_grid, stable_sigmoid(logit_grid), linewidth=2)
ax.axhline(0.5, color="black", linestyle="--")
ax.axvline(0, color="black", linestyle="--")
ax.set(xlabel="logit a", ylabel="$\\sigma(a)$", title="Sigmoid maps scores to probabilities")
plt.show()
notebook output
def fit_logistic_gd(Phi, labels, learning_rate=0.2, iterations=6000, l2=0.0, tolerance=1e-9):
    weights = np.zeros(Phi.shape[1])
    losses = []
    regularizer = np.r_[0.0, np.ones(Phi.shape[1] - 1)]  # Do not penalize the intercept.
    for _ in range(iterations):
        logits = Phi @ weights
        probabilities = stable_sigmoid(logits)
        gradient = Phi.T @ (probabilities - labels) / len(Phi)
        gradient += l2 * regularizer * weights
        weights -= learning_rate * gradient
        losses.append(
            binary_cross_entropy_from_logits(Phi @ weights, labels)
            + 0.5 * l2 * np.sum(weights[1:] ** 2)
        )
        if np.linalg.norm(gradient) < tolerance:
            break
    return weights, np.array(losses)

w_logistic, logistic_losses = fit_logistic_gd(Phi_train, t_train)
logistic_test_probability = stable_sigmoid(Phi_test @ w_logistic)
logistic_pred = (logistic_test_probability >= 0.5).astype(int)

fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))
plot_binary_boundary(axes[0], X_train, t_train, w_logistic, "Logistic regression probability", probability=True)
axes[1].plot(logistic_losses)
axes[1].set(title="Binary cross-entropy", xlabel="iteration", ylabel="loss")
plt.tight_layout()
plt.show()
print(f"Logistic regression test accuracy: {np.mean(logistic_pred == t_test):.3f}")
print(f"Final BCE: {logistic_losses[-1]:.4f}")
notebook output
Logistic regression test accuracy: 0.900
Final BCE: 0.0729

3.2 IRLS:用曲率修正步长(PRML §4.3.3)

梯度只告诉我们下坡方向,Newton 法还使用二阶曲率:

$$ H=\Phi^\top R\Phi, \qquad R=\operatorname{diag}\{y_n(1-y_n)\}. $$

更新为

$$ \mathbf{w}_{\text{new}}= \mathbf{w}_{\text{old}}-H^{-1}\nabla E. $$

定义 working response

$$ \mathbf{z}=\Phi\mathbf{w}_{\text{old}}+R^{-1}(\mathbf{t}-\mathbf{y}), $$

同一个 Newton 步就能写成

$$ \mathbf{w}_{\text{new}}=(\Phi^\top R\Phi)^{-1}\Phi^\top R\mathbf{z}. $$

每轮都在解一个权重 $R$ 随当前预测变化的最小二乘问题,因此称为 迭代重加权最小二乘(IRLS)。接近 0.5 的样本有更大 $y_n(1-y_n)$,对局部曲率贡献更大。实现中使用 solve,并向 Hessian 加入很小的数值阻尼;它用于改善条件数,并不是目标函数中的 L2 正则项。

def fit_logistic_irls(Phi, labels, max_iterations=30, damping=1e-6, tolerance=1e-9):
    weights = np.zeros(Phi.shape[1])
    losses = []
    for _ in range(max_iterations):
        probabilities = stable_sigmoid(Phi @ weights)
        gradient = Phi.T @ (probabilities - labels)
        r = np.clip(probabilities * (1 - probabilities), 1e-9, None)
        hessian = Phi.T @ (r[:, None] * Phi) + damping * np.eye(Phi.shape[1])
        step = np.linalg.solve(hessian, gradient)
        weights -= step
        losses.append(binary_cross_entropy_from_logits(Phi @ weights, labels))
        if np.linalg.norm(step) < tolerance:
            break
    return weights, np.array(losses)

w_irls, irls_losses = fit_logistic_irls(Phi_train, t_train)
print(f"Gradient descent: {len(logistic_losses)} steps, final BCE={logistic_losses[-1]:.6f}")
print(f"IRLS: {len(irls_losses)} steps, final BCE={irls_losses[-1]:.6f}")

fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(np.arange(1, len(irls_losses) + 1), irls_losses, marker="o")
ax.set(xlabel="IRLS iteration", ylabel="BCE", title="Newton / IRLS convergence")
plt.show()
Gradient descent: 6000 steps, final BCE=0.072916
IRLS: 11 steps, final BCE=0.072282
notebook output

3.3 多分类 Softmax 回归(PRML §4.3.4)

对 $K$ 个类别,每类有一个 logit $a_k=\mathbf{w}_k^\top\boldsymbol\phi(\mathbf{x})$。Softmax 将它们归一化:

$$ y_k=p(C_k\mid\mathbf{x})= \frac{\exp(a_k)}{\sum_j\exp(a_j)}. $$

下面采用平均多分类交叉熵

$$ E(W)=-\frac1N\sum_n\sum_k t_{nk}\log y_{nk}, $$

其梯度仍保留熟悉的“预测减标签”结构:

$$ \nabla_W E=\frac1N\Phi^\top(Y-T). $$

对所有类别的权重同时加上同一个向量不会改变 softmax,因此参数存在冗余。最清晰的处理是固定一个参考类;下面保留对称参数化,将每轮权重投影到类别方向之和为零的表示中,并对非截距权重加入微小 L2 正则。数值计算时还必须先减去每行最大 logit,避免指数溢出。

def stable_softmax(logits):
    shifted = logits - logits.max(axis=1, keepdims=True)
    exponentials = np.exp(shifted)
    return exponentials / exponentials.sum(axis=1, keepdims=True)


def fit_softmax(Phi, labels, class_count, learning_rate=0.2, iterations=1500, l2=1e-4):
    targets = np.eye(class_count)[labels]
    weights = np.zeros((Phi.shape[1], class_count))
    losses = []
    regularizer = np.r_[np.zeros((1, class_count)), np.ones((Phi.shape[1] - 1, class_count))]
    for _ in range(iterations):
        probabilities = stable_softmax(Phi @ weights)
        gradient = Phi.T @ (probabilities - targets) / len(Phi) + l2 * regularizer * weights
        weights -= learning_rate * gradient
        weights -= weights.mean(axis=1, keepdims=True)  # Choose the zero-sum representative.
        updated_probabilities = stable_softmax(Phi @ weights)
        cross_entropy = -np.mean(
            np.sum(targets * np.log(np.clip(updated_probabilities, 1e-12, 1.0)), axis=1)
        )
        losses.append(cross_entropy + 0.5 * l2 * np.sum(weights[1:] ** 2))
    return weights, np.array(losses)

centers = np.array([[-1.8, -1.0], [1.8, -0.8], [0.0, 1.8]])
X_multi = np.vstack([rng.normal(center, [0.65, 0.65], size=(90, 2)) for center in centers])
t_multi = np.repeat(np.arange(3), 90)
X_multi_train, X_multi_test, t_multi_train, t_multi_test = train_test_split(
    X_multi, t_multi, test_size=0.3, random_state=42, stratify=t_multi
)
multi_mean, multi_std = X_multi_train.mean(0), X_multi_train.std(0)
X_multi_train = (X_multi_train - multi_mean) / multi_std
X_multi_test = (X_multi_test - multi_mean) / multi_std
Phi_multi_train = np.c_[np.ones(len(X_multi_train)), X_multi_train]
Phi_multi_test = np.c_[np.ones(len(X_multi_test)), X_multi_test]
W_softmax, softmax_losses = fit_softmax(Phi_multi_train, t_multi_train, 3)
softmax_pred = np.argmax(Phi_multi_test @ W_softmax, axis=1)
print(f"Softmax test accuracy: {np.mean(softmax_pred == t_multi_test):.3f}")
Softmax test accuracy: 1.000
def plot_multiclass_regions(ax, X_data, labels, weights, title):
    x_min, x_max = X_data[:, 0].min() - 0.6, X_data[:, 0].max() + 0.6
    y_min, y_max = X_data[:, 1].min() - 0.6, X_data[:, 1].max() + 0.6
    xx, yy = np.meshgrid(np.linspace(x_min, x_max, 250), np.linspace(y_min, y_max, 250))
    grid = np.c_[np.ones(xx.size), xx.ravel(), yy.ravel()]
    regions = np.argmax(grid @ weights, axis=1).reshape(xx.shape)
    ax.contourf(xx, yy, regions, levels=np.arange(4) - 0.5, cmap="viridis", alpha=0.2)
    ax.scatter(X_data[:, 0], X_data[:, 1], c=labels, cmap="viridis", edgecolor="white", s=38)
    ax.set(title=title, xlabel="$x_1$", ylabel="$x_2$")

fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))
plot_multiclass_regions(axes[0], X_multi_train, t_multi_train, W_softmax, "Softmax decision regions")
axes[1].plot(softmax_losses)
axes[1].set(title="Multiclass cross-entropy", xlabel="iteration", ylabel="loss")
plt.tight_layout()
plt.show()
notebook output

4. 分类评估:0.5 不是法律

准确率把所有错误看成同一种错误。在正样本只有 5% 的任务里,永远预测负类就有 95% 准确率,却没有任何使用价值。

  • Precision:预测为正的样本中,多少真的为正?
  • Recall:真实正样本中,多少被找出来?
  • F1:precision 与 recall 的调和平均。
  • ROC/AUC:扫描所有阈值,观察真正率与假正率之间的权衡。

阈值应由错误成本决定。疾病筛查通常更怕漏诊,会降低阈值换取 recall;昂贵的人工复核可能更强调 precision。概率模型提供的是排序和决策依据,不会替业务自动决定成本。

for threshold in [0.3, 0.5, 0.7]:
    predictions = (logistic_test_probability >= threshold).astype(int)
    precision, recall, f1, _ = precision_recall_fscore_support(
        t_test, predictions, average="binary", zero_division=0
    )
    print(f"threshold={threshold:.1f}  precision={precision:.3f}  recall={recall:.3f}  F1={f1:.3f}")

matrix = confusion_matrix(t_test, logistic_pred)
fpr, tpr, thresholds = roc_curve(t_test, logistic_test_probability)
auc = roc_auc_score(t_test, logistic_test_probability)

fig, axes = plt.subplots(1, 2, figsize=(10, 4.2))
image = axes[0].imshow(matrix, cmap="Blues")
for row in range(2):
    for column in range(2):
        axes[0].text(column, row, matrix[row, column], ha="center", va="center", fontsize=14)
axes[0].set(title="Confusion matrix (threshold=0.5)", xlabel="predicted", ylabel="true", xticks=[0, 1], yticks=[0, 1])
axes[1].plot(fpr, tpr, linewidth=2, label=f"AUC={auc:.3f}")
axes[1].plot([0, 1], [0, 1], "--", color="gray")
axes[1].set(xlabel="false positive rate", ylabel="true positive rate", title="ROC curve")
axes[1].legend()
plt.tight_layout()
plt.show()
threshold=0.3  precision=0.875  recall=0.933  F1=0.903
threshold=0.5  precision=0.900  recall=0.900  F1=0.900
threshold=0.7  precision=0.900  recall=0.900  F1=0.900
notebook output

5. 拉普拉斯近似(PRML §4.4)

贝叶斯方法需要参数后验

$$ p(\mathbf{w}\mid\mathcal D)\propto p(\mathcal D\mid\mathbf{w})p(\mathbf{w}). $$

在线性回归中,高斯似然与高斯先验共轭,后验仍是高斯;逻辑回归的伯努利似然破坏了共轭性,归一化常数和预测积分都不再容易计算。

拉普拉斯近似在后验众数 $\mathbf{w}_{\text{MAP}}$ 附近对负对数后验 $E(\mathbf{w})$ 作二阶 Taylor 展开:

$$ E(\mathbf{w})\approx E(\mathbf{w}_{\text{MAP}}) +\frac12(\mathbf{w}-\mathbf{w}_{\text{MAP}})^\top H (\mathbf{w}-\mathbf{w}_{\text{MAP}}). $$

指数化后得到高斯近似

$$ q(\mathbf{w})=\mathcal N(\mathbf{w}\mid\mathbf{w}_{\text{MAP}},H^{-1}). $$

这是一个局部近似:后验偏斜、多峰,或 MAP 附近并不接近二次曲面时,它可能不可靠。

6. 贝叶斯逻辑回归(PRML §4.5)

对斜率权重加入零均值高斯先验,并对截距使用平坦先验。令

$$ P=\operatorname{diag}(0,\alpha,\ldots,\alpha), $$

则先验中与 $\mathbf{w}$ 有关的部分为 $\exp(-\mathbf{w}^\top P\mathbf{w}/2)$,MAP 目标比交叉熵多出

$$ \frac12\mathbf{w}^\top P\mathbf{w} =\frac\alpha2\sum_{j\ge 1}w_j^2. $$

这再次验证了第三章的结论:L2 正则化等价于高斯先验。截距不惩罚可以避免正则化效果依赖特征坐标原点。

在 MAP 处,拉普拉斯后验协方差为

$$ S_N=(\Phi^\top R\Phi+P)^{-1}. $$

对新样本 $\boldsymbol\phi_*$,激活量 $a_*=\mathbf{w}^\top\boldsymbol\phi_*$ 在高斯后验下也服从高斯:

$$ \mu_a=\mathbf{w}_{\text{MAP}}^\top\boldsymbol\phi_* ,\qquad \sigma_a^2=\boldsymbol\phi_*^\top S_N\boldsymbol\phi_*. $$

真正的预测概率要对参数不确定性积分:

$$ p(C_1\mid\mathbf{x}_*,\mathcal D) =\int\sigma(a_*)p(a_*\mid\mathcal D)\,\mathrm da_*. $$

它没有初等闭式解。常用的 probit-style 近似为

$$ p(C_1\mid\mathbf{x}_*,\mathcal D) \approx \sigma\!\left(\kappa(\sigma_a^2)\mu_a\right), \qquad \kappa(\sigma_a^2)=\left(1+\frac{\pi\sigma_a^2}{8}\right)^{-1/2}. $$

固定 $\mu_a$ 时,方差越大,$\kappa$ 越小,预测越向 0.5 收缩。它表达的是模型参数不确定性带来的校正,并不自动构成分布外检测器,也不保证所有远离训练数据的位置都恰好回到 0.5。

def fit_bayesian_logistic_laplace(Phi, labels, alpha=1.0, max_iterations=50):
    weights = np.zeros(Phi.shape[1])
    prior_precision = alpha * np.eye(Phi.shape[1])
    prior_precision[0, 0] = 0.0
    for _ in range(max_iterations):
        probabilities = stable_sigmoid(Phi @ weights)
        r = np.clip(probabilities * (1 - probabilities), 1e-9, None)
        gradient = Phi.T @ (probabilities - labels) + prior_precision @ weights
        hessian = Phi.T @ (r[:, None] * Phi) + prior_precision + 1e-8 * np.eye(Phi.shape[1])
        step = np.linalg.solve(hessian, gradient)
        weights -= step
        if np.linalg.norm(step) < 1e-9:
            break
    probabilities = stable_sigmoid(Phi @ weights)
    r = np.clip(probabilities * (1 - probabilities), 1e-9, None)
    hessian = Phi.T @ (r[:, None] * Phi) + prior_precision + 1e-8 * np.eye(Phi.shape[1])
    covariance = np.linalg.solve(hessian, np.eye(Phi.shape[1]))
    return weights, covariance


def bayesian_predict(Phi, weights, covariance):
    mean_activation = Phi @ weights
    variance_activation = np.einsum("ij,jk,ik->i", Phi, covariance, Phi)
    kappa = 1.0 / np.sqrt(1.0 + np.pi * variance_activation / 8.0)
    return stable_sigmoid(kappa * mean_activation), variance_activation

w_map, posterior_covariance = fit_bayesian_logistic_laplace(Phi_train, t_train, alpha=1.0)
bayes_test_probability, bayes_test_variance = bayesian_predict(Phi_test, w_map, posterior_covariance)
print(f"Bayesian approximate test accuracy: {np.mean((bayes_test_probability >= 0.5) == t_test):.3f}")
print("posterior standard deviations:", np.round(np.sqrt(np.diag(posterior_covariance)), 3))
Bayesian approximate test accuracy: 0.900
posterior standard deviations: [0.354 0.483 0.485]
grid_axis = np.linspace(-4.5, 4.5, 260)
xx, yy = np.meshgrid(grid_axis, grid_axis)
Phi_grid = np.c_[np.ones(xx.size), xx.ravel(), yy.ravel()]
map_probability = stable_sigmoid(Phi_grid @ w_map).reshape(xx.shape)
bayes_probability, activation_variance = bayesian_predict(Phi_grid, w_map, posterior_covariance)
bayes_probability = bayes_probability.reshape(xx.shape)
activation_variance = activation_variance.reshape(xx.shape)

fig, axes = plt.subplots(1, 3, figsize=(15, 4.3))
for ax, values, title, cmap in [
    (axes[0], map_probability, "MAP plug-in probability", "RdBu_r"),
    (axes[1], bayes_probability, "Laplace predictive probability", "RdBu_r"),
    (axes[2], activation_variance, "Activation uncertainty", "magma"),
]:
    contour = ax.contourf(xx, yy, values, levels=20, cmap=cmap)
    ax.scatter(X_train[:, 0], X_train[:, 1], c=t_train, cmap="RdBu_r", edgecolor="white", s=20)
    ax.set(title=title, xlabel="$x_1$", ylabel="$x_2$")
    fig.colorbar(contour, ax=ax, shrink=0.82)
plt.tight_layout()
plt.show()
notebook output

7. 生成式与判别式:不是谁淘汰谁

维度生成式分类判别式分类
建模对象$p(\mathbf{x}\mid C_k)$ 与 $p(C_k)$$p(C_k\mid\mathbf{x})$ 或直接学习边界
代表高斯生成式分类、朴素贝叶斯逻辑回归、softmax 回归
优势可采样;先验自然;模型正确时小样本可能更高效只解决分类所需问题;较少依赖输入分布假设
代价错误的类条件分布假设会传递到后验通常不能生成输入;处理缺失特征需额外机制
缺失值若联合分布完整,可通过边缘化处理不能仅凭条件模型自动解决

这里没有无条件赢家。数据量、模型假设是否可信、是否需要生成/缺失值处理,都会改变选择。

8. 固定基函数的局限

到目前为止,无论感知机、LDA 还是逻辑回归,原始二维空间中都只能画直线。make_moons 数据故意让两类弯曲交错,原始特征上的线性模型会欠拟合。

但只要手工加入二次基函数

$$ \phi(\mathbf{x})=[1,x_1,x_2,x_1^2,x_1x_2,x_2^2]^\top, $$

特征空间中的线性边界就会映射成原空间中的二次曲线。这能解决一部分问题,也暴露了更大的问题:谁来选择基函数?

X_moons, t_moons = make_moons(n_samples=300, noise=0.18, random_state=42)
moon_mean, moon_std = X_moons.mean(0), X_moons.std(0)
X_moons = (X_moons - moon_mean) / moon_std


def linear_features(X_data):
    return np.c_[np.ones(len(X_data)), X_data]


def quadratic_features(X_data):
    x1, x2 = X_data[:, 0], X_data[:, 1]
    return np.c_[np.ones(len(X_data)), x1, x2, x1**2, x1 * x2, x2**2]

Phi_moon_linear = linear_features(X_moons)
Phi_moon_quadratic = quadratic_features(X_moons)
w_moon_linear, _ = fit_logistic_gd(Phi_moon_linear, t_moons, learning_rate=0.12, iterations=1600, l2=1e-3)
w_moon_quadratic, _ = fit_logistic_gd(Phi_moon_quadratic, t_moons, learning_rate=0.12, iterations=1600, l2=1e-3)

axis = np.linspace(-2.5, 2.5, 260)
xx, yy = np.meshgrid(axis, axis)
grid_points = np.c_[xx.ravel(), yy.ravel()]
fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))
for ax, feature_fn, weights, title in [
    (axes[0], linear_features, w_moon_linear, "Original linear features"),
    (axes[1], quadratic_features, w_moon_quadratic, "Quadratic basis features"),
]:
    probability = stable_sigmoid(feature_fn(grid_points) @ weights).reshape(xx.shape)
    ax.contourf(xx, yy, probability, levels=np.linspace(0, 1, 11), cmap="RdBu_r", alpha=0.3)
    ax.contour(xx, yy, probability, levels=[0.5], colors="black", linewidths=2)
    ax.scatter(X_moons[:, 0], X_moons[:, 1], c=t_moons, cmap="RdBu_r", edgecolor="white", s=28)
    accuracy = np.mean((stable_sigmoid(feature_fn(X_moons) @ weights) >= 0.5) == t_moons)
    ax.set(title=f"{title}\naccuracy={accuracy:.3f}", xlabel="$x_1$", ylabel="$x_2$")
plt.tight_layout()
plt.show()
notebook output

9. 本章总结

PRML 小节核心结论动手实现
§4.1判别函数用超平面切分特征空间决策边界、最小二乘、Fisher、感知机
§4.2共享协方差高斯生成模型导出线性 log-odds手写高斯 LDA
§4.3逻辑回归直接建模后验;交叉熵来自伯努利 MLE梯度下降、IRLS、softmax
实践扩展阈值与指标必须匹配错误成本混淆矩阵、precision/recall/F1、ROC/AUC
§4.4拉普拉斯方法在 MAP 周围用高斯近似非高斯后验二阶 Taylor 展开与 Hessian
§4.5贝叶斯逻辑回归对参数不确定性积分MAP、后验协方差、近似预测概率

贯穿第四章的三个核心认知:

  1. 决策边界相同,不代表模型相同。 感知机、LDA 与逻辑回归可能画出相似的线,但优化目标和输出含义不同。
  2. 交叉熵来自概率假设。 伯努利似然取负对数自然得到 BCE,softmax 与类别分布自然得到多分类交叉熵。
  3. 概率预测不等于不确定性已经解决。 点估计逻辑回归只输出给定权重下的概率;贝叶斯方法还要对参数后验积分。

固定基函数要求我们提前决定怎样表示数据。下一章进入 PRML 第五章神经网络:不再手工规定 $\phi(\mathbf{x})$,而是让模型从数据中学习表示,并用反向传播同时训练这些表示与分类器。

说些什么吧!