简介:这份资源是周志华《机器学习》(西瓜书)课程习题的代码实现合集,面向正在学习机器学习基础理论、需要动手复现课后习题的高校学生与自学者,帮助读者把聚类、线性模型、集成学习等章节的公式推导落到可运行代码上。压缩包共90个文件,约11.74MB,以22个Python脚本为核心,覆盖KMeans、KNN、PCA、AdaBoost、Bagging、SVM、决策树等典型算法实现,并配有10份Markdown习题笔记、34张结果图与6张示意图,另有csv、txt、mat等西瓜数据集及少量xml、ipynb配置文件,便于对照实验与复现结果。目前已有934人学习下载。资源按章节组织目录,从模型评估与选择到神经网络、贝叶斯分类、支持向量机、降维与度量学习逐章展开,读者可据此梳理算法脉络、参考代码实现思路,并借助笔记与图像验证运行效果,适合作为课程作业参考与机器学习入门实践素材。
1. 西瓜书作业代码实现:从公式到可运行脚本的第一道坎
很多人第一次翻开《机器学习》(周志华著,圈内叫“西瓜书”)时,前几章还能跟上,到了第三章线性模型的对数几率回归、第四章决策树的信息增益、第五章神经网络的误差逆传播,公式一多就卡住了。真正让人翻车的不是数学本身,而是把公式翻译成代码这一步——书上写的是矩阵求导,落到 Python 里要处理维度对齐、数值溢出、收敛判断。这篇笔记面向正在做西瓜书课程作业、想自己动手把课后习题跑通的人,也适合用吴恩达机器学习作业入门后想换一本教材巩固的读者。我会按章节把常见作业题的实现路径拆开,给出能直接抄的代码骨架、参数设置和调试方法,重点讲清楚每一步为什么这么写、哪里容易出错。西瓜书配套的习题答案网上版本很多,但代码实现质量参差不齐,与其抄一份跑不通的,不如自己按下面的路子搭一遍。
2. 线性模型作业:对数几率回归与线性判别分析的代码落地
2.1 为什么先做线性模型:梯度推导和代码的对应关系
西瓜书第三章的作业核心是两件事:对数几率回归(逻辑回归)和线性判别分析(LDA)。这两个模型的共同点是都能写成矩阵形式,代码量不大,但正好用来建立“公式到代码”的映射习惯。
对数几率回归的模型是 $y = \frac{1}{1+e^{-(\mathbf{w}^T\mathbf{x}+b)}}$,对数似然最大化等价于最小化交叉熵损失。书上用牛顿法求解,但实际写作业时用梯度下降更直观,也更容易调参。关键推导只有一步:对 $w$ 求导得到 $\sum_i (\hat{y}_i - y_i)\mathbf{x}_i$,这个形式非常干净,代码里就是一个矩阵乘法。
LDA 的作业通常要求实现二分类的投影方向求解。书上给的闭式解是 $\mathbf{w} = \mathbf{S}_w^{-1}(\boldsymbol{\mu}_0 - \boldsymbol{\mu}_1)$,其中 $\mathbf{S}_w$ 是类内散度矩阵。代码实现时最容易忽略的是 $\mathbf{S}_w$ 可能奇异,需要加正则项或者用伪逆。
2.2 对数几率回归的最小实现:从数据生成到训练
下面这段代码用 numpy 从零实现对数几率回归,不依赖 sklearn,适合直接作为作业提交的基础版本。
import numpy as np def sigmoid(z): # 裁剪防止 exp 溢出,这是数值稳定的关键 z = np.clip(z, -500, 500) return 1.0 / (1.0 + np.exp(-z)) def train_logistic(X, y, lr=0.1, epochs=1000, tol=1e-6): """ X: (n_samples, n_features) y: (n_samples,) 取值 0 或 1 lr: 学习率 epochs: 最大迭代轮数 tol: 梯度范数收敛阈值 """ n, d = X.shape w = np.zeros(d) b = 0.0 prev_loss = float('inf') for epoch in range(epochs): z = X @ w + b y_hat = sigmoid(z) # 交叉熵损失 loss = -np.mean(y * np.log(y_hat + 1e-12) + (1 - y) * np.log(1 - y_hat + 1e-12)) # 梯度 error = y_hat - y grad_w = X.T @ error / n grad_b = np.mean(error) w -= lr * grad_w b -= lr * grad_b if abs(prev_loss - loss) < tol: break prev_loss = loss return w, b def predict(X, w, b): return (sigmoid(X @ w + b) >= 0.5).astype(int)这段代码的逻辑说明:sigmoid里做clip是防止 $z$ 过大时exp溢出,这是很多人第一次写逻辑回归会遇到的问题,表现为 loss 变成 nan。梯度推导对应error = y_hat - y,然后X.T @ error就是 $\sum_i (\hat{y}_i - y_i)\mathbf{x}_i$ 的矩阵形式。学习率lr默认 0.1,如果 loss 震荡就降到 0.01;epochs设 1000 通常够用,配合tol提前停止。参数b的梯度是np.mean(error),因为对偏置求导没有 $x$ 项。
2.3 LDA 实现中的矩阵奇异问题与正则化处理
LDA 的代码短,但坑集中在 $\mathbf{S}_w$ 求逆。下面是一个带正则的版本。
def train_lda(X, y, reg=1e-4): """ X: (n_samples, n_features) y: (n_samples,) 取值 0 或 1 reg: 正则化系数,防止类内散度矩阵奇异 """ X0 = X[y == 0] X1 = X[y == 1] mu0 = X0.mean(axis=0) mu1 = X1.mean(axis=0) # 类内散度矩阵 Sw = np.zeros((X.shape[1], X.shape[1])) for x in X0: diff = (x - mu0).reshape(-1, 1) Sw += diff @ diff.T for x in X1: diff = (x - mu1).reshape(-1, 1) Sw += diff @ diff.T # 加正则项,等价于岭回归思路 Sw += reg * np.eye(X.shape[1]) w = np.linalg.solve(Sw, (mu0 - mu1)) return w def predict_lda(X, w, threshold=0.0): proj = X @ w return (proj >= threshold).astype(int)逻辑说明:Sw累加每个样本的外积,这一步用循环写清楚,实际作业里可以向量化。reg默认 1e-4,如果数据维度高、样本少,Sw接近奇异,加大到 1e-2 或 1e-1。np.linalg.solve比直接求逆数值更稳。阈值threshold通常取两类投影均值的中点,这里简化成 0,实际作业里应该用(mu0 @ w + mu1 @ w) / 2。
2.4 线性模型作业的验证方法与常见误用
写完代码后,用西瓜书第三章的西瓜数据集 3.0α 验证。这个数据集只有 17 个样本,2 个特征,适合手算对照。常见误用有两个:一是把y的标签设成 -1 和 1 却套用 0/1 的交叉熵公式,导致 loss 计算错误;二是 LDA 里忘记减均值,直接对原始数据算散度矩阵。验证时打印前几轮 loss,正常应该单调下降,如果上下跳动就是学习率太大。另外,逻辑回归的准确率在西瓜数据集上大概 70% 左右,不要期望太高,这个数据集本身线性可分性一般。
3. 决策树作业:信息增益、基尼指数与剪枝的代码实现
3.1 划分准则的选型:信息增益 vs 基尼指数
西瓜书第四章作业通常要求实现 ID3 或 C4.5,核心是划分准则。信息增益对取值多的属性有偏好,这是 ID3 的已知缺陷;C4.5 用增益率修正,但增益率对取值少的属性有偏好,所以书上建议先找信息增益高于平均的属性,再从中选增益率最高的。基尼指数是 CART 用的,计算更简单,没有对数运算。
作业里如果没指定准则,我一般用基尼指数,因为代码短、数值稳定。如果题目明确要求信息增益,就按书上公式来。下面以基尼指数为例。
3.2 递归建树的代码骨架与停止条件
import numpy as np from collections import Counter def gini(y): # y 是标签数组 if len(y) == 0: return 0.0 counts = Counter(y) probs = np.array(list(counts.values())) / len(y) return 1.0 - np.sum(probs ** 2) def split_dataset(X, y, feature, value): # 按特征 feature 是否等于 value 划分 mask = X[:, feature] == value return X[mask], y[mask], X[~mask], y[~mask] def choose_best_split(X, y): best_gain = -1 best_feature = None best_value = None base_gini = gini(y) n = len(y) for feature in range(X.shape[1]): for value in np.unique(X[:, feature]): X_left, y_left, X_right, y_right = split_dataset(X, y, feature, value) if len(y_left) == 0 or len(y_right) == 0: continue # 加权基尼 weighted = (len(y_left) / n) * gini(y_left) + (len(y_right) / n) * gini(y_right) gain = base_gini - weighted if gain > best_gain: best_gain = gain best_feature = feature best_value = value return best_feature, best_value, best_gain def build_tree(X, y, depth=0, max_depth=10, min_samples=2): # 停止条件:纯节点、样本太少、达到最大深度 if len(set(y)) == 1 or len(y) < min_samples or depth >= max_depth: return Counter(y).most_common(1)[0][0] feature, value, gain = choose_best_split(X, y) if feature is None or gain <= 0: return Counter(y).most_common(1)[0][0] X_left, y_left, X_right, y_right = split_dataset(X, y, feature, value) tree = { 'feature': feature, 'value': value, 'left': build_tree(X_left, y_left, depth + 1, max_depth, min_samples), 'right': build_tree(X_right, y_right, depth + 1, max_depth, min_samples) } return tree逻辑说明:gini计算基尼值,choose_best_split遍历所有特征和取值,选加权基尼最小的划分。build_tree的停止条件有三个:节点纯、样本数小于min_samples、深度达到max_depth。max_depth默认 10,西瓜数据集上 3 到 5 就够;min_samples默认 2,防止过拟合。注意gain <= 0时也停止,避免无意义划分。
3.3 预剪枝与后剪枝的实现差异
西瓜书第四章专门讲了预剪枝和后剪枝。预剪枝在build_tree里加验证集判断:划分前先在验证集上算准确率,划分后再算,如果没提升就不划分。后剪枝是先建完整棵树,再自底向上遍历,把子树替换成叶节点,看验证集准确率是否提升。
预剪枝代码改动小,在choose_best_split之后加一段验证逻辑即可。后剪枝需要额外写一个遍历函数,用递归返回剪枝后的树。作业里如果要求对比,建议两种都实现,用同一份验证集。注意预剪枝有欠拟合风险,后剪枝计算量大但泛化通常更好。
3.4 决策树作业的调试:连续值处理和缺失值
西瓜数据集 3.0α 全是离散值,但作业有时会给连续值版本。连续值处理方法是二分法:对每个特征排序,取相邻值中点作为划分点,选基尼最小的。代码里np.unique换成排序后的中点数组即可。
缺失值处理书上讲了权重法,代码实现复杂,作业里如果没要求可以跳过。如果要求,核心是给每个样本加权重,划分时按权重计算基尼,缺失样本同时进入所有分支但权重按分支比例调整。这部分容易写错,建议先用小数据手工验证。
4. 神经网络作业:BP 算法的矩阵实现与梯度检查
4.1 为什么 BP 作业容易翻车:维度对齐和链式法则
西瓜书第五章的 BP 神经网络作业,翻车率最高。原因不是算法难,而是矩阵维度容易对不上。书上公式用下标表示,代码里要转成矩阵乘法,稍不注意就广播错误。另一个坑是链式法则的中间项漏乘,导致梯度方向对但大小错,训练很慢。
我一般建议先写一个单隐层网络,用西瓜数据集 3.0α 的 17 个样本做二分类,隐藏层 5 个神经元,这样维度小,出错容易查。
4.2 单隐层 BP 网络的完整代码
import numpy as np def sigmoid(z): z = np.clip(z, -500, 500) return 1.0 / (1.0 + np.exp(-z)) def sigmoid_deriv(a): # a 是 sigmoid 的输出 return a * (1 - a) def train_bp(X, y, hidden=5, lr=0.5, epochs=5000): n, d = X.shape y = y.reshape(-1, 1) # 初始化权重,小随机数 np.random.seed(42) W1 = np.random.randn(d, hidden) * 0.1 b1 = np.zeros((1, hidden)) W2 = np.random.randn(hidden, 1) * 0.1 b2 = np.zeros((1, 1)) losses = [] for epoch in range(epochs): # 前向 z1 = X @ W1 + b1 a1 = sigmoid(z1) z2 = a1 @ W2 + b2 a2 = sigmoid(z2) # 损失 loss = -np.mean(y * np.log(a2 + 1e-12) + (1 - y) * np.log(1 - a2 + 1e-12)) losses.append(loss) # 反向 dz2 = a2 - y dW2 = a1.T @ dz2 / n db2 = np.mean(dz2, axis=0, keepdims=True) da1 = dz2 @ W2.T dz1 = da1 * sigmoid_deriv(a1) dW1 = X.T @ dz1 / n db1 = np.mean(dz1, axis=0, keepdims=True) # 更新 W2 -= lr * dW2 b2 -= lr * db2 W1 -= lr * dW1 b1 -= lr * db1 return W1, b1, W2, b2, losses逻辑说明:W1是输入到隐藏层,形状(d, hidden);W2是隐藏到输出,形状(hidden, 1)。反向传播里dz2 = a2 - y是输出层误差,dW2 = a1.T @ dz2 / n对应链式法则。da1 = dz2 @ W2.T把误差传回隐藏层,再乘sigmoid_deriv(a1)得到dz1。学习率lr默认 0.5,BP 网络对学习率敏感,太大震荡,太小收敛慢。epochs设 5000,配合观察losses曲线。
4.3 梯度检查:数值梯度验证解析梯度
写完反向传播后,用数值梯度检查。对每个参数,扰动一小量,算损失变化,和解析梯度对比。
def gradient_check(X, y, W1, b1, W2, b2, epsilon=1e-4): # 只检查 W1 的第一个元素作为示例 def loss_fn(W1, b1, W2, b2): a1 = sigmoid(X @ W1 + b1) a2 = sigmoid(a1 @ W2 + b2) return -np.mean(y * np.log(a2 + 1e-12) + (1 - y) * np.log(1 - a2 + 1e-12)) # 解析梯度 a1 = sigmoid(X @ W1 + b1) a2 = sigmoid(a1 @ W2 + b2) dz2 = a2 - y.reshape(-1, 1) dW2 = a1.T @ dz2 / len(y) da1 = dz2 @ W2.T dz1 = da1 * sigmoid_deriv(a1) dW1 = X.T @ dz1 / len(y) # 数值梯度 W1_plus = W1.copy() W1_plus[0, 0] += epsilon W1_minus = W1.copy() W1_minus[0, 0] -= epsilon num_grad = (loss_fn(W1_plus, b1, W2, b2) - loss_fn(W1_minus, b1, W2, b2)) / (2 * epsilon) print(f"解析梯度: {dW1[0, 0]:.6f}, 数值梯度: {num_grad:.6f}")逻辑说明:数值梯度用中心差分,精度比前向差分高。解析梯度和数值梯度相对误差小于 1e-4 算通过。如果差很多,检查链式法则哪一项漏了。这个检查在作业里很加分,也能帮你快速定位 bug。
4.4 BP 作业的调参经验:学习率、隐藏层节点和初始化
学习率从 0.1 到 1.0 试,西瓜数据集上 0.5 左右通常可以。隐藏层节点数从 3 到 10 试,太多会过拟合,太少欠拟合。初始化用小的随机数,全零会导致对称权重无法打破,这是经典坑。如果 loss 不下降,先检查sigmoid_deriv是不是用了a * (1 - a),而不是z * (1 - z)。
5. 支持向量机作业:对偶问题、核函数与 SMO 的简化实现
5.1 SVM 作业的难点:从原始问题到对偶问题的代码映射
西瓜书第六章的 SVM 作业,理论推导长,代码实现更难。书上先讲原始问题,再讲对偶问题,最后讲 KKT 条件和 SMO。作业通常要求实现一个简化版 SMO,在西瓜数据集上跑通。
核心公式:对偶问题是 $\max_\alpha \sum_i \alpha_i - \frac{1}{2}\sum_{i,j}\alpha_i\alpha_j y_i y_j \mathbf{x}_i^T\mathbf{x}_j$,约束是 $0 \le \alpha_i \le C$ 和 $\sum_i \alpha_i y_i = 0$。SMO 每次选两个 $\alpha$ 更新,固定其他。
5.2 简化 SMO 的代码实现
import numpy as np def smo_simple(X, y, C=1.0, tol=1e-3, max_iter=100): n, d = X.shape y = y.astype(float) alpha = np.zeros(n) b = 0.0 # 核矩阵,线性核 K = X @ X.T for it in range(max_iter): alpha_pairs_changed = 0 for i in range(n): # 计算误差 f_i = np.sum(alpha * y * K[:, i]) + b E_i = f_i - y[i] if (y[i] * E_i < -tol and alpha[i] < C) or (y[i] * E_i > tol and alpha[i] > 0): # 选第二个 alpha,简化版随机选 j = (i + 1) % n f_j = np.sum(alpha * y * K[:, j]) + b E_j = f_j - y[j] alpha_i_old = alpha[i] alpha_j_old = alpha[j] # 计算上下界 if y[i] != y[j]: L = max(0, alpha[j] - alpha[i]) H = min(C, C + alpha[j] - alpha[i]) else: L = max(0, alpha[i] + alpha[j] - C) H = min(C, alpha[i] + alpha[j]) if L == H: continue eta = 2 * K[i, j] - K[i, i] - K[j, j] if eta >= 0: continue alpha[j] = alpha_j_old - y[j] * (E_i - E_j) / eta alpha[j] = np.clip(alpha[j], L, H) if abs(alpha[j] - alpha_j_old) < 1e-5: continue alpha[i] = alpha_i_old + y[i] * y[j] * (alpha_j_old - alpha[j]) # 计算 b b1 = b - E_i - y[i] * (alpha[i] - alpha_i_old) * K[i, i] - y[j] * (alpha[j] - alpha_j_old) * K[i, j] b2 = b - E_j - y[i] * (alpha[i] - alpha_i_old) * K[i, j] - y[j] * (alpha[j] - alpha_j_old) * K[j, j] if 0 < alpha[i] < C: b = b1 elif 0 < alpha[j] < C: b = b2 else: b = (b1 + b2) / 2 alpha_pairs_changed += 1 if alpha_pairs_changed == 0: break return alpha, b逻辑说明:K是核矩阵,线性核直接X @ X.T。外层循环遍历每个样本,内层选第二个 $\alpha$,简化版直接取下一个。eta是二阶导数,小于等于 0 时跳过。L和H是 $\alpha_j$ 的上下界,分 $y_i \ne y_j$ 和相等两种情况。b的计算分三种情况,对应书上公式。C默认 1.0,控制软间隔;tol默认 1e-3,KKT 违反容忍度。
5.3 核函数的替换与参数选择
把K = X @ X.T换成高斯核:
def rbf_kernel(X, sigma=1.0): # 计算平方欧氏距离矩阵 sq = np.sum(X ** 2, axis=1, keepdims=True) dist = sq + sq.T - 2 * X @ X.T return np.exp(-dist / (2 * sigma ** 2))sigma控制核宽度,太小过拟合,太大欠拟合。西瓜数据集上从 0.5 到 2.0 试。多项式核类似,改K = (X @ X.T + 1) ** degree。
5.4 SVM 作业的验证:支持向量和决策边界
训练完后,alpha > 1e-5的样本就是支持向量。打印支持向量个数,西瓜数据集上通常 5 到 8 个。决策边界用w = np.sum(alpha * y * X.T, axis=1)算,然后画线。如果支持向量太多,说明C太大或核参数不合适。
6. 集成学习与聚类作业:从 AdaBoost 到 K-means 的代码收尾
6.1 AdaBoost 的实现要点:样本权重更新和基学习器
西瓜书第八章的 AdaBoost 作业,核心是样本权重 $D_t$ 的更新和基学习器权重 $\alpha_t$ 的计算。基学习器通常用决策树桩,即深度为 1 的决策树。
def adaboost(X, y, T=10): n = len(y) D = np.ones(n) / n models = [] alphas = [] for t in range(T): # 训练决策树桩,用加权基尼 stump = build_stump(X, y, D) pred = predict_stump(stump, X) err = np.sum(D[pred != y]) if err >= 0.5: break alpha = 0.5 * np.log((1 - err) / (err + 1e-12)) # 更新权重 D = D * np.exp(-alpha * y * pred) D = D / np.sum(D) models.append(stump) alphas.append(alpha) return models, alphas逻辑说明:build_stump是带权重的决策树桩,选加权基尼最小的划分。err是加权错误率,大于等于 0.5 时停止。alpha是基学习器权重,错误率越低权重越大。D更新后归一化。预测时加权投票。
6.2 K-means 的坑:初始化、空簇和收敛判断
西瓜书第九章的 K-means 作业,代码短但坑多。初始化用随机选点,可能选到同一个点,导致空簇。解决办法是用 K-means++ 初始化,或者选到重复点时重新选。
def kmeans(X, k, max_iter=100, tol=1e-4): n, d = X.shape # K-means++ 初始化 centers = [X[np.random.randint(n)]] for _ in range(1, k): dist = np.min([np.sum((X - c) ** 2, axis=1) for c in centers], axis=0) prob = dist / np.sum(dist) centers.append(X[np.random.choice(n, p=prob)]) centers = np.array(centers) for it in range(max_iter): # 分配 dist = np.array([np.sum((X - c) ** 2, axis=1) for c in centers]) labels = np.argmin(dist, axis=0) # 更新 new_centers = np.array([X[labels == i].mean(axis=0) if np.sum(labels == i) > 0 else centers[i] for i in range(k)]) if np.sum((new_centers - centers) ** 2) < tol: break centers = new_centers return labels, centers逻辑说明:K-means++ 初始化让初始中心尽量分散。分配步骤算每个点到中心的距离,取最小。更新步骤对每个簇求均值,空簇保持原中心。tol默认 1e-4,中心变化小于这个值就停。
6.3 聚类作业的评估:轮廓系数和可视化
K-means 没有标签,评估用轮廓系数。西瓜数据集上,k 从 2 到 5 试,选轮廓系数最大的。可视化用 PCA 降到二维画散点图,看簇是否分开。如果簇重叠严重,可能数据本身不适合 K-means,换 DBSCAN 或高斯混合。
6.4 集成学习作业的对比:AdaBoost vs 随机森林
随机森林是 Bagging 的代表,和 AdaBoost 的 Boosting 思路不同。作业里如果要求对比,用同一份数据,随机森林的基学习器用完整决策树,AdaBoost 用树桩。随机森林对噪声更鲁棒,AdaBoost 对噪声敏感,因为错误样本权重会不断增大。西瓜数据集上两者准确率接近,但随机森林训练更快,因为可以并行。
我做完这一轮作业最大的习惯是:每写一个模型,先用小数据手工算一遍前向和反向,确认公式和代码对得上,再上完整数据。这个习惯帮我省了无数调试时间。希望帮到你。
本文还有配套的精品资源,点击获取