第3章:线性模型(Linear Models)
一、导读
1.1 本章学习目标
第3章介绍机器学习中最基础也最重要的模型——线性模型。尽管形式简单,线性模型却蕴含着机器学习的核心思想,是理解更复杂模型的基石。
本章的核心学习目标包括:
- 掌握线性回归的原理与求解:最小二乘法的数学推导、正规方程的推导过程
- 理解对数几率回归(逻辑回归):如何用 sigmoid 函数将线性输出映射为概率
- 掌握线性判别分析(LDA):最大化类间距离、最小化类内距离的思想
- 了解多分类学习策略:一对一(OvO)、一对其余(OvR)、多对多(MvM)
- 理解类别不平衡问题的处理方法:欠采样、过采样、代价敏感学习
- 理解线性模型的可解释性优势:为什么线性模型在需要解释性的场景中仍然重要
1.2 为什么本章如此重要
线性模型是机器学习的"原子模型"。即使在实际应用中很少直接使用简单的线性模型,但几乎所有高级模型都以某种方式包含了线性成分:
- 神经网络的每一层本质上是一个线性变换加上非线性激活
- SVM 在高维空间中寻找线性超平面
- 决策树的叶节点可以看作是一个线性决策
- 广义线性模型将线性模型推广到更一般的场景
更重要的是,线性模型具有解析解(closed-form solution),这使得我们可以用数学工具深入分析其性质。相比之下,大多数复杂模型只能通过数值优化求解,难以进行理论分析。
1.3 核心问题引导
为什么最小二乘法要最小化平方误差而不是绝对误差?
对数几率回归为什么叫"回归"却是一个分类算法?
LDA 为什么叫"判别分析"而不是"分类"?
多分类问题能否直接转化为多个二分类问题?各种转化策略有什么优劣?
当数据线性不可分时,线性模型还能用吗?
线性模型的可解释性在实际应用中有多重要?
二、核心概念详解
2.1 线性回归
2.1.1 问题定义
给定数据集 $D = \{(x_1, y_1), (x_2, y_2), \ldots, (x_m, y_m)\}$,其中 $x_i = (x_{i1}; x_{i2}; \ldots; x_{id})$,线性回归试图学得一个线性模型:
$$f(x) = w_1 x_1 + w_2 x_2 + \ldots + w_d x_d + b$$
使得 $f(x) \approx y$。
用向量形式表示:$f(x) = w^T x + b$,其中 $w = (w_1; w_2; \ldots; w_d)$。
2.1.2 最小二乘法
最小二乘法的目标是最小化所有样本上的平方误差之和:
$$\min_{w, b} \sum_{i=1}^{m} (f(x_i) - y_i)^2$$
import numpy as np
class LinearRegression:
"""线性回归 - 最小二乘法"""
def __init__(self):
self.weights = None
self.bias = None
def fit(self, X, y):
"""
训练线性回归模型
X: (n_samples, n_features)
y: (n_samples,)
"""
# 添加偏置项
X_b = np.c_[np.ones((X.shape[0], 1)), X]
# 正规方程: w = (X^T X)^{-1} X^T y
self.weights = np.linalg.inv(X_b.T @ X_b) @ X_b.T @ y
self.bias = self.weights[0]
self.weights = self.weights[1:]
return self
def predict(self, X):
return X @ self.weights + self.bias
def score(self, X, y):
"""R² 决定系数"""
y_pred = self.predict(X)
ss_res = np.sum((y - y_pred) ** 2)
ss_tot = np.sum((y - np.mean(y)) ** 2)
return 1 - ss_res / ss_tot
# 示例
np.random.seed(42)
X = np.random.rand(100, 2)
y = 3 * X[:, 0] + 5 * X[:, 1] + 2 + np.random.randn(100) * 0.1
model = LinearRegression()
model.fit(X, y)
print(f"真实权重: [3, 5], 真实偏置: 2")
print(f"学习权重: {model.weights.round(2)}, 学习偏置: {model.bias:.2f}")
print(f"R² 分数: {model.score(X, y):.4f}")2.1.3 正规方程的推导
将目标函数写成矩阵形式:
$$E_{w,b} = (y - Xw)^T(y - Xw)$$
对 $w$ 求导并令其为零:
$$\frac{\partial E}{\partial w} = -2X^T(y - Xw) = 0$$
解得:$w = (X^T X)^{-1} X^T y$
def normal_equation(X, y):
"""正规方程的逐步推导"""
# 步骤1: 添加偏置列
X_b = np.c_[np.ones((X.shape[0], 1)), X]
# 步骤2: 计算 X^T X
XtX = X_b.T @ X_b
# 步骤3: 计算 (X^T X)^{-1}
XtX_inv = np.linalg.inv(XtX)
# 步骤4: 计算 X^T y
Xty = X_b.T @ y
# 步骤5: 计算 w
w = XtX_inv @ Xty
return w
# 验证
np.random.seed(42)
X = np.random.rand(100, 2)
y = 3 * X[:, 0] + 5 * X[:, 1] + 2 + np.random.randn(100) * 0.1
w = normal_equation(X, y)
print(f"正规方程求解: w = {w.round(2)}")
print(f"结果: 偏置={w[0]:.2f}, 权重={w[1:].round(2)}")2.1.4 最小二乘法的几何解释
最小二乘法可以理解为将 $y$ 投影到 $X$ 的列空间上。投影后的向量 $\hat{y} = Xw$ 是 $y$ 在 $X$ 列空间上的最佳近似。
2.2 对数几率回归(逻辑回归)
2.2.1 从回归到分类
对数几率回归(logistic regression)虽然名字里有"回归",但实际上是一个分类算法。它的核心思想是:先用线性模型计算一个实数值,然后用 sigmoid 函数将其映射到 (0, 1) 区间,作为概率输出。
$$p(y=1|x) = \frac{1}{1 + e^{-(w^T x + b)}} = \sigma(w^T x + b)$$
class LogisticRegression:
"""对数几率回归(逻辑回归)"""
def __init__(self, lr=0.01, n_iters=1000):
self.lr = lr
self.n_iters = n_iters
self.weights = None
self.bias = None
def sigmoid(self, z):
return 1 / (1 + np.exp(-np.clip(z, -500, 500)))
def fit(self, X, y):
n_samples, n_features = X.shape
self.weights = np.zeros(n_features)
self.bias = 0
for _ in range(self.n_iters):
# 前向传播
z = X @ self.weights + self.bias
y_pred = self.sigmoid(z)
# 计算梯度
dw = (1 / n_samples) * X.T @ (y_pred - y)
db = (1 / n_samples) * np.sum(y_pred - y)
# 更新参数
self.weights -= self.lr * dw
self.bias -= self.lr * db
return self
def predict_proba(self, X):
z = X @ self.weights + self.bias
return self.sigmoid(z)
def predict(self, X, threshold=0.5):
return (self.predict_proba(X) >= threshold).astype(int)
def score(self, X, y):
return np.mean(self.predict(X) == y)
# 示例:西瓜分类
np.random.seed(42)
# 好瓜:密度高、颜色深
X_good = np.random.randn(30, 2) * 0.5 + [1, 1]
# 坏瓜:密度低、颜色浅
X_bad = np.random.randn(30, 2) * 0.5 + [-0.5, -0.5]
X = np.vstack([X_good, X_bad])
y = np.array([1] * 30 + [0] * 30)
model = LogisticRegression(lr=0.1, n_iters=1000)
model.fit(X, y)
print(f"训练准确率: {model.score(X, y):.2%}")
print(f"权重: {model.weights.round(2)}")
print(f"偏置: {model.bias:.2f}")2.2.2 对数几率的含义
sigmoid 函数的反函数是 logit 函数:
$$\ln \frac{p}{1-p} = w^T x + b$$
其中 $p = p(y=1|x)$。这个等式说明:线性模型的输出 $w^T x + b$ 实际上是对数几率(log-odds)。
def interpret_logistic_regression(weights, bias, feature_names):
"""解释逻辑回归的系数"""
print("逻辑回归系数解释:")
print("-" * 40)
for name, w in zip(feature_names, weights):
odds_ratio = np.exp(w)
print(f"{name}: 系数={w:.3f}, "
f"odds ratio={odds_ratio:.3f}")
if w > 0:
print(f" → {name}增加1单位,正类几率增加{odds_ratio:.1f}倍")
else:
print(f" → {name}增加1单位,正类几率变为{odds_ratio:.3f}倍")
print(f"\n偏置: {bias:.3f}")
interpret_logistic_regression(
model.weights, model.bias,
['密度', '色泽']
)2.2.3 极大似然估计
逻辑回归的参数通过极大似然估计求解。给定训练数据,似然函数为:
$$L(w, b) = \prod_{i=1}^{m} p(y_i | x_i)$$
取对数后,最大化对数似然等价于最小化对数损失(交叉熵损失):
$$\ell(w, b) = -\frac{1}{m} \sum_{i=1}^{m} [y_i \log \hat{y}_i + (1-y_i) \log(1-\hat{y}_i)]$$
def cross_entropy_loss(y_true, y_pred):
"""交叉熵损失函数"""
eps = 1e-15
y_pred = np.clip(y_pred, eps, 1 - eps)
return -np.mean(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))
# 验证
y_true = np.array([1, 0, 1, 1, 0])
y_pred = np.array([0.9, 0.1, 0.8, 0.7, 0.2])
loss = cross_entropy_loss(y_true, y_pred)
print(f"交叉熵损失: {loss:.4f}")2.3 线性判别分析(LDA)
2.3.1 基本思想
线性判别分析(Linear Discriminant Analysis)的核心思想是:将数据投影到一条直线上,使得同类样本的投影点尽可能接近,异类样本的投影点尽可能远离。
class LDA:
"""线性判别分析"""
def __init__(self):
self.w = None
self.threshold = None
def fit(self, X, y):
"""
训练LDA模型
X: (n_samples, n_features)
y: (n_samples,) 二分类标签 0/1
"""
# 分离两类
X_0 = X[y == 0]
X_1 = X[y == 1]
# 计算均值向量
mu_0 = X_0.mean(axis=0)
mu_1 = X_1.mean(axis=0)
# 计算类内散度矩阵
S_0 = (X_0 - mu_0).T @ (X_0 - mu_0)
S_1 = (X_1 - mu_1).T @ (X_1 - mu_1)
S_w = S_0 + S_1
# 投影方向: w = S_w^{-1} (mu_0 - mu_1)
self.w = np.linalg.inv(S_w) @ (mu_0 - mu_1)
# 计算分类阈值
self.threshold = 0.5 * (mu_0 + mu_1) @ self.w
return self
def predict(self, X):
projections = X @ self.w
return (projections > self.threshold).astype(int)
def score(self, X, y):
return np.mean(self.predict(X) == y)
# 示例
np.random.seed(42)
X = np.vstack([
np.random.randn(30, 2) * 0.5 + [1, 1],
np.random.randn(30, 2) * 0.5 + [-1, -1]
])
y = np.array([0] * 30 + [1] * 30)
lda = LDA()
lda.fit(X, y)
print(f"LDA 训练准确率: {lda.score(X, y):.2%}")
print(f"投影方向: {lda.w.round(3)}")2.3.2 LDA 的几何解释
LDA 将高维数据投影到一维空间(对二分类),投影方向的选择使得:
- 类间距离最大化:$||w^T \mu_0 - w^T \mu_1||^2$ 最大
- 类内距离最小化:$w^T S_0 w + w^T S_1 w$ 最小
优化目标:$\max_w \frac{w^T S_b w}{w^T S_w w}$,其中 $S_b = (\mu_0 - \mu_1)(\mu_0 - \mu_1)^T$
2.4 多分类学习
2.4.1 一对一(OvO)
将 N 个类两两配对,训练 N(N-1)/2 个二分类器。
class OvOClassifier:
"""一对一多分类"""
def __init__(self, base_classifier):
self.base_classifier = base_classifier
self.classifiers = {}
self.classes = None
def fit(self, X, y):
self.classes = np.unique(y)
for i in range(len(self.classes)):
for j in range(i + 1, len(self.classes)):
c_i, c_j = self.classes[i], self.classes[j]
mask = (y == c_i) | (y == c_j)
X_sub = X[mask]
y_sub = (y[mask] == c_j).astype(int)
clf = self.base_classifier()
clf.fit(X_sub, y_sub)
self.classifiers[(c_i, c_j)] = clf
return self
def predict(self, X):
votes = np.zeros((X.shape[0], len(self.classes)))
for (c_i, c_j), clf in self.classifiers.items():
pred = clf.predict(X)
votes[:, c_i] += (pred == 0)
votes[:, c_j] += (pred == 1)
return np.argmax(votes, axis=1)
# 示例:三分类
np.random.seed(42)
X = np.vstack([
np.random.randn(30, 2) + [0, 2],
np.random.randn(30, 2) + [-2, -1],
np.random.randn(30, 2) + [2, -1],
])
y = np.array([0] * 30 + [1] * 30 + [2] * 30)
ovo = OvOClassifier(LogisticRegression)
ovo.fit(X, y)
print(f"OvO 训练准确率: {np.mean(ovo.predict(X) == y):.2%}")2.4.2 一对其余(OvR)
对每个类训练一个二分类器,将该类作为正例,其余类作为负例。
class OvRClassifier:
"""一对其余多分类"""
def __init__(self, base_classifier):
self.base_classifier = base_classifier
self.classifiers = {}
self.classes = None
def fit(self, X, y):
self.classes = np.unique(y)
for c in self.classes:
y_binary = (y == c).astype(int)
clf = self.base_classifier()
clf.fit(X, y_binary)
self.classifiers[c] = clf
return self
def predict(self, X):
scores = np.zeros((X.shape[0], len(self.classes)))
for i, c in enumerate(self.classes):
scores[:, i] = self.classifiers[c].predict_proba(X)
return self.classes[np.argmax(scores, axis=1)]
ovr = OvRClassifier(LogisticRegression)
ovr.fit(X, y)
print(f"OvR 训练准确率: {np.mean(ovr.predict(X) == y):.2%}")2.5 类别不平衡问题
2.5.1 问题描述
当数据集中不同类别的样本数量差异很大时,分类器可能倾向于预测多数类。
def demonstrate_class_imbalance():
"""演示类别不平衡问题"""
np.random.seed(42)
# 990个负例,10个正例
X_neg = np.random.randn(990, 2) * 0.5
X_pos = np.random.randn(10, 2) * 0.5 + [3, 3]
X = np.vstack([X_neg, X_pos])
y = np.array([0] * 990 + [1] * 10)
# 简单分类器:总是预测多数类
always_neg = np.zeros(1000)
accuracy = np.mean(always_neg == y)
print(f"总是预测负类的准确率: {accuracy:.1%}")
print("虽然准确率很高,但模型完全没用!")
# 正确的评估方式
from collections import Counter
pred = np.zeros(1000)
pred[990:] = 1 # 假设能正确识别所有正例
tp = np.sum((pred == 1) & (y == 1))
fp = np.sum((pred == 1) & (y == 0))
fn = np.sum((pred == 0) & (y == 1))
precision = tp / (tp + fp) if (tp + fp) > 0 else 0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0
print(f"\n正确识别正例时:")
print(f"精确率: {precision:.2%}")
print(f"召回率: {recall:.2%}")
print(f"F1: {f1:.2%}")
demonstrate_class_imbalance()2.5.2 处理方法
def handle_imbalance(X, y, method='oversample'):
"""处理类别不平衡"""
n_pos = np.sum(y == 1)
n_neg = np.sum(y == 0)
print(f"原始数据: 正例 {n_pos}, 负例 {n_neg}")
if method == 'undersample':
# 欠采样:减少多数类
neg_idx = np.where(y == 0)[0]
pos_idx = np.where(y == 1)[0]
sampled_neg = np.random.choice(neg_idx, n_pos, replace=False)
idx = np.concatenate([sampled_neg, pos_idx])
np.random.shuffle(idx)
return X[idx], y[idx]
elif method == 'oversample':
# 过采样:增加少数类
neg_idx = np.where(y == 0)[0]
pos_idx = np.where(y == 1)[0]
sampled_pos = np.random.choice(pos_idx, n_neg, replace=True)
idx = np.concatenate([neg_idx, sampled_pos])
np.random.shuffle(idx)
return X[idx], y[idx]
elif method == 'cost_sensitive':
# 代价敏感:调整分类阈值
print("代价敏感方法:调整分类阈值")
return X, y
# 示例
np.random.seed(42)
X_imb = np.vstack([np.random.randn(990, 2) * 0.5,
np.random.randn(10, 2) * 0.5 + [3, 3]])
y_imb = np.array([0] * 990 + [1] * 10)
for method in ['undersample', 'oversample', 'cost_sensitive']:
print(f"\n=== {method} ===")
X_res, y_res = handle_imbalance(X_imb, y_imb, method)
print(f"处理后: 正例 {np.sum(y_res == 1)}, 负例 {np.sum(y_res == 0)}")三、实战案例
3.1 案例一:波士顿房价预测
def boston_housing_prediction():
"""使用线性回归预测房价"""
np.random.seed(42)
# 模拟波士顿房价数据
n = 506
# 特征:房间数、犯罪率、师生比
rooms = np.random.normal(6.28, 0.7, n)
crime = np.random.exponential(3.6, n)
ratio = np.random.normal(18.5, 2.2, n)
# 真实关系:房价 = 5*房间数 - 0.3*犯罪率 - 0.5*师生比 + 噪声
price = 5 * rooms - 0.3 * crime - 0.5 * ratio + np.random.randn(n) * 3
X = np.column_stack([rooms, crime, ratio])
y = price
# 划分训练集和测试集
indices = np.random.permutation(n)
split = int(0.8 * n)
train_idx, test_idx = indices[:split], indices[split:]
X_train, X_test = X[train_idx], X[test_idx]
y_train, y_test = y[train_idx], y[test_idx]
# 训练模型
model = LinearRegression()
model.fit(X_train, y_train)
# 评估
train_score = model.score(X_train, y_train)
test_score = model.score(X_test, y_test)
print("=== 波士顿房价预测 ===")
print(f"训练集 R²: {train_score:.4f}")
print(f"测试集 R²: {test_score:.4f}")
print(f"\n特征权重:")
features = ['房间数', '犯罪率', '师生比']
for name, w in zip(features, model.weights):
print(f" {name}: {w:.3f}")
print(f" 偏置: {model.bias:.3f}")
boston_housing_prediction()3.2 案例二:鸢尾花分类
def iris_classification():
"""使用逻辑回归和LDA进行鸢尾花分类"""
np.random.seed(42)
# 模拟鸢尾花数据(二分类)
n = 100
X_setosa = np.random.randn(50, 4) * 0.3 + [5.0, 3.4, 1.5, 0.2]
X_versicolor = np.random.randn(50, 4) * 0.4 + [5.9, 2.8, 4.3, 1.3]
X = np.vstack([X_setosa, X_versicolor])
y = np.array([0] * 50 + [1] * 50)
# 划分数据集
indices = np.random.permutation(n)
split = 80
train_idx, test_idx = indices[:split], indices[split:]
X_train, X_test = X[train_idx], X[test_idx]
y_train, y_test = y[train_idx], y[test_idx]
# 逻辑回归
lr = LogisticRegression(lr=0.1, n_iters=1000)
lr.fit(X_train, y_train)
lr_score = lr.score(X_test, y_test)
# LDA
lda = LDA()
lda.fit(X_train, y_train)
lda_score = lda.score(X_test, y_test)
print("=== 鸢尾花分类 ===")
print(f"逻辑回归准确率: {lr_score:.2%}")
print(f"LDA 准确率: {lda_score:.2%}")
print(f"\n逻辑回归特征权重:")
features = ['花萼长', '花萼宽', '花瓣长', '花瓣宽']
for name, w in zip(features, lr.weights):
print(f" {name}: {w:.3f}")
iris_classification()3.3 案例三:正则化线性模型
def regularized_regression():
"""Ridge和Lasso正则化"""
np.random.seed(42)
n_samples = 100
n_features = 20
X = np.random.randn(n_samples, n_features)
# 只有前3个特征真正有用
true_w = np.zeros(n_features)
true_w[:3] = [3, 5, 2]
y = X @ true_w + np.random.randn(n_samples) * 0.5
# 普通线性回归
model_ols = LinearRegression()
model_ols.fit(X, y)
# Ridge回归(L2正则化)
def ridge_regression(X, y, alpha=1.0):
X_b = np.c_[np.ones((X.shape[0], 1)), X]
I = np.eye(X_b.shape[1])
I[0, 0] = 0 # 不正则化偏置
w = np.linalg.inv(X_b.T @ X_b + alpha * I) @ X_b.T @ y
return w[0], w[1:]
# Lasso回归(L1正则化)- 坐标下降法简化版
def lasso_regression(X, y, alpha=0.1, n_iters=100):
n, p = X.shape
w = np.zeros(p)
b = np.mean(y)
for _ in range(n_iters):
for j in range(p):
residual = y - X @ w - b + X[:, j] * w[j]
rho = X[:, j] @ residual / n
z = np.sum(X[:, j] ** 2) / n
if rho > alpha / 2:
w[j] = (rho - alpha / 2) / z
elif rho < -alpha / 2:
w[j] = (rho + alpha / 2) / z
else:
w[j] = 0
return b, w
b_ridge, w_ridge = ridge_regression(X, y, alpha=1.0)
b_lasso, w_lasso = lasso_regression(X, y, alpha=0.1)
print("=== 正则化对比 ===")
print(f"{'特征':<8} {'真实值':<8} {'OLS':<8} {'Ridge':<8} {'Lasso':<8}")
print("-" * 45)
for i in range(10):
print(f"x{i+1:<5} {true_w[i]:<8.2f} {model_ols.weights[i]:<8.2f} "
f"{w_ridge[i]:<8.2f} {w_lasso[i]:<8.2f}")
print("\n观察:")
print("- OLS: 所有特征都有非零权重(过拟合噪声)")
print("- Ridge: 权重整体缩小,但不为零")
print("- Lasso: 不重要特征的权重被压缩为零(特征选择)")
regularized_regression()四、本章小结
第3章系统介绍了三种基本的线性模型:
线性回归:用最小二乘法求解,具有解析解 $w = (X^T X)^{-1} X^T y$。适用于连续值预测。
对数几率回归(逻辑回归):用 sigmoid 函数将线性输出映射为概率,通过极大似然估计求解。虽然名字有"回归",但实际是分类算法。
线性判别分析(LDA):投影到使类间距离最大、类内距离最小的方向上。适用于分类任务。
核心知识点:
- 多分类可以通过 OvO、OvR 等策略转化为多个二分类问题
- 类别不平衡需要通过欠采样、过采样或代价敏感学习来处理
- 正则化(Ridge/Lasso)可以防止过拟合并进行特征选择
- 线性模型具有可解释性强的优势
线性模型是理解更复杂模型的基础。神经网络中的每一层、SVM 的超平面、广义线性模型等都以线性模型为组件。掌握线性模型的数学原理,对于理解整个机器学习体系至关重要。
五、思考与练习
思考题
最小二乘法为什么使用平方误差而不是绝对误差?从数学和统计两个角度分析。
对数几率回归中,如果两类数据线性可分,模型参数会怎样?如何解决这个问题?
LDA 和 PCA 都是投影方法,它们的优化目标有什么不同?
OvO 和 OvR 各训练多少个分类器?在预测时各自的决策方式是什么?
L1 正则化为什么能产生稀疏解?从几何角度解释。
编程练习
实现带 L2 正则化的线性回归(Ridge 回归),在存在多重共线性的数据上比较它与普通最小二乘法的表现。
实现 softmax 回归(多分类逻辑回归),在鸢尾花三分类数据集上训练和测试。
设计一个实验,比较 OvO 和 OvR 在多分类任务上的性能和效率。