简介:周志华《机器学习》代码练习资源包面向正在研读该书并希望动手实践算法的读者,覆盖线性模型、决策树、集成学习、支持向量机等核心章节,Python脚本可直接运行或在此基础上修改,用于加深对理论公式和流程的理解。压缩包共16个文件,其中15个为.py脚本,另附1个README.md说明文档,整体仅20KB,轻量易下载。脚本按主题组织,包括LogisticRegression及其交叉验证/留一验证版本、LDA、DecisionTree、Bagging与MultiBoosting等集成方法、bias-variance回归实验以及SVM的简化实现与对比训练,兼具基础实现与进阶探索。目前已有508人学习下载,适合机器学习初学者快速搭建练习环境,也适合进阶者对照参考不同算法的代码细节。
1. 机器学习代码练习:这本西瓜书配套实现到底能干什么
做机器学习的课程设计或者期末复习时,最容易卡人的往往不是书里的公式,而是「公式明明看懂了,一写代码就崩」。周志华《机器学习》(也就是大家口中的西瓜书)里大量算法都停留在数学表达层面,真要跑通一个线性判别分析或者 AdaBoost,光是数据格式、矩阵维度和符号对应就能耗掉你一个晚上。这套代码练习 zip 就是把西瓜书里最常考的算法整理成可以直接运行的 Python 脚本,覆盖线性模型、决策树、集成学习和支持向量机,配套了书中常用的数据集。对两类人最有用:一类是期末前突击的在校生,另一类是刚入门、想把书里公式变成可复现实验的从业者。
2. 解压之后第一步:环境版本、目录结构与数据格式
2.1 目录结构与代码组织
拿到压缩包先别急着跑,第一件事是看目录。常见做法是作者按章节或者按算法类别分目录,我拆过的这套练习包基本是下面这种组织结构:
| 目录/文件 | 内容 | 依赖 |
|---|---|---|
data/ | 西瓜数据集 3.0、3.0a、鸢尾花、手写数字等 CSV 数据 | 无 |
basic_model/ | 线性回归、对数几率回归、线性判别分析 | numpy |
tree/ | 决策树(ID3/C4.5 风格)、预剪枝与后剪枝 | numpy |
ensemble/ | AdaBoost、随机森林 | numpy |
svm/ | 简化版 SMO、高斯核与线性核 | numpy |
visualization/ | 决策边界、误差曲线、学习曲线绘图脚本 | matplotlib |
你可能会注意到,大部分脚本只依赖 numpy 和 matplotlib,没有把 sklearn 当成主要实现工具。这是刻意为之的——练习代码的核心目的是让你看到算法内部是怎么迭代的,而不是调包。sklearn 在部分脚本里出现,通常只用来做数据切分或者结果对照。
2.2 数据文件格式:CSV 与 ARFF 的差别
压缩包里的数据文件大部分是 CSV 格式,少数老版本数据集可能是 ARFF(Weka 的格式)。CSV 的读取非常直接,numpy 或者 pandas 都能处理,但有一个细节要注意:西瓜数据集里的特征列有的连续值、有的离散值,标记列一般放在最后一列。读取时要确认 delimiter 是逗号还是制表符,有的文件是从 Excel 导出的,可能出现空行或者带 BOM 头的问题。
ARFF 格式则多一个@relation和@attribute声明头,numpy 不认,读取前要去掉文件头几行。这类脚本里通常会封装一个 load 函数,我在实际使用时习惯先打印形状看一眼:
import numpy as np # 读取西瓜数据集 3.0a,最后一列是标记,前面是特征 data = np.loadtxt('data/watermelon_3a.csv', delimiter=',') X, y = data[:, :-1], data[:, -1] # 确认维度和标签取值,避免后续矩阵运算时形状对不上 print('X shape:', X.shape) # 期望 (17, 6) 之类 print('y shape:', y.shape) print('labels:', np.unique(y)) # 期望 [0. 1.]这里把特征和标记拆开,标记存成 0/1 浮点数,其中原因在于线性模型里 y 是作为数值参与矩阵乘法,不能是字符串。打印形状看起来是小事,但矩阵转置导致 shape 不匹配,是我见过这套练习代码里翻车频率最高的错误之一。
2.3 环境要求与导入测试
这套代码的常见版本是在 Python 3.7 到 3.9 之间写的,依赖 numpy 1.x、matplotlib 3.x、pandas 可选,sklearn 只在对照实验里用到。现在如果你直接用 Python 3.12 配最新版 numpy 2.x,有概率碰到老脚本里np.float、np.int这类别名已经移除而报错。先跑一个最小导入测试:
python -c "import numpy as np; print(np.__version__); print(np.float if hasattr(np, 'float') else 'np.float removed')"如果输出显示np.float removed,说明你的 numpy 版本太新,需要决定是降级到 1.26 以下,还是把脚本里的np.float全部替换成float。我建议优先替换代码而不是降级环境,因为新环境里其他项目可能依赖新版 numpy,强行降污染全局环境。用一个虚拟环境装好依赖后,逐个模块导入,确认没有ImportError再开始跑实验。
3. 线性模型与支持向量机:闭式解、梯度迭代与 SMO
3.1 线性回归的闭式解与梯度下降对照
西瓜书第 3 章线性回归给出了闭式解:w = (X^T X)^{-1} X^T y。练习包里最常见的实现是直接按这个公式写,但这里有个前提——X^T X必须可逆。当特征数量大于样本数量,或者特征之间存在多重共线性时,矩阵求逆会出现数值问题。
import numpy as np # 归一化:常见做法是 z-score,避免量纲差异影响梯度 X_norm = (X - X.mean(axis=0)) / X.std(axis=0) # 加一列偏置项,让截距被当作普通权重学习 X_b = np.c_[np.ones(X_norm.shape[0]), X_norm] # 闭式解 w = (X^T X)^{-1} X^T y w_closed = np.linalg.inv(X_b.T @ X_b) @ X_b.T @ y # 梯度下降对照,lr 是学习率,max_iters 是迭代轮数 w_gd = np.zeros(X_b.shape[1]) lr, max_iters = 0.01, 500 for i in range(max_iters): grad = 2.0 / len(y) * X_b.T @ (X_b @ w_gd - y) w_gd -= lr * grad逻辑上闭式解是「一步到位」,梯度下降是「步步逼近」。前者在数据量小、特征独立的场景下又快又准;后者用于数据量大到矩阵求逆不现实的情况。参数里最值得改的是学习率lr,我一般从 0.01 开始,如果损失震荡就降到 0.001,如果收敛太慢就提到 0.05,笔记本上跑几十轮就能看出趋势。
3.2 对数几率回归的似然函数与参数更新
对数几率回归(逻辑回归)是面试和期末都爱考的一个点。练习代码里通常用梯度上升最大化对数似然,核心是梯度更新式:w ← w + lr * X^T (y - y_hat),其中y_hat是 sigmoid 的输出。
def sigmoid(z): # 稳定版 sigmoid:z>=0 时用 1/(1+exp(-z)),z<0 时用 exp(z)/(1+exp(z)) # 避免 exp(很大的正数) 溢出成 inf return np.where(z >= 0, 1.0 / (1.0 + np.exp(-z)), np.exp(z) / (1.0 + np.exp(z))) def train_lr(X, y, lr=0.05, max_iters=1000): m, n = X.shape w = np.zeros(n) for i in range(max_iters): z = X @ w pred = sigmoid(z) # 预测概率向量 grad = X.T @ (y - pred) # 对数似然的梯度 w += lr * grad # 梯度上升,注意不是负号 if i % 200 == 0: log_loss = -np.mean(y * np.log(pred + 1e-10) + (1 - y) * np.log(1 - pred + 1e-10)) print(f'iter {i}, log_loss {log_loss:.4f}') return w重点说明grad这行的形状:X.T的维度是(n, m),(y - pred)是(m,),点乘结果恰好是(n,),和w形状一致。开始用这套代码时,我犯过一个典型错误——把X.T @ (y - pred)写成(y - pred) @ X.T,结果广播把维度搞乱,训练完全不动。还有个隐蔽点是pred经过 sigmoid 后永远是 (0,1) 开区间,不会真的取到 0 或 1,所以 log 的数值可以用1e-10做下限保护,但不能省略。
3.3 线性判别分析的投影实现
LDA 的关键公式是类间散度矩阵除以类内散度矩阵,求最大特征向量作为投影方向。练习代码里纯手写过程分三步:计算每类均值、计算类内散度矩阵Sw、计算类间散度矩阵Sb,最后求Sw^{-1} Sb的特征值和特征向量。
def lda(X, y): classes = np.unique(y) mu_all = X.mean(axis=0) Sw = np.zeros((X.shape[1], X.shape[1])) Sb = np.zeros_like(Sw) for c in classes: Xc = X[y == c] mu_c = Xc.mean(axis=0) # 类别均值向量 Xc_centered = Xc - mu_c Sw += Xc_centered.T @ Xc_centered # 类内散度累加 n_c = len(Xc) diff = (mu_c - mu_all).reshape(-1, 1) Sb += n_c * (diff @ diff.T) # 类间散度 eig_vals, eig_vecs = np.linalg.eig(np.linalg.inv(Sw) @ Sb) idx = np.argsort(eig_vals)[::-1] # 从大到小排 return eig_vecs[:, idx[0]] # 返回最大特征值对应方向实际运行中np.linalg.inv(Sw)可能报 Singular matrix,这往往不是因为代码写错,而是数据没有去均值或者特征里有一列全为常数,导致 Sw 不可逆。课后题里有些数据集本来就是病态的,应对办法是给 Sw 加1e-6 * I做正则,类似岭回归的套路。这个处理在很多开源实现里也叫 shrinkage,属于常规操作。
3.4 支持向量机的 SMO 实现与核函数选择
西瓜书第 6 章 SVM 的配套代码通常是个简化版 SMO,不会完整复刻 Platt 论文,但核心逻辑保留:每次选两个 alpha 更新,保证不违反 KKT 条件。标签要先转成 +1/-1,这是避坑重点。
def smo_simple(X, y_t, C=1.0, tol=1e-3, max_passes=5): m, n = X.shape alpha = np.zeros(m) b = 0.0 passes = 0 while passes < max_passes: num_changed = 0 for i in range(m): # 预测值和误差 f_i = (alpha * y_t) @ (X @ X[i]) + b E_i = f_i - y_t[i] # 检查 KKT 条件的强 violation if ((y_t[i] * E_i < -tol and alpha[i] < C) or (y_t[i] * E_i > tol and alpha[i] > 0)): # 内层循环选 j,这里用非随机固定顺序 j = (i + 1) % m f_j = (alpha * y_t) @ (X @ X[j]) + b E_j = f_j - y_t[j] # 保存旧值 alpha_i_old, alpha_j_old = alpha[i], alpha[j] # 计算 L, H 边界 if y_t[i] != y_t[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]) # 计算二阶导 eta,此处用线性核 K(x_i, x_j) eta = 2.0 * (X[i] @ X[i]) - (X[i] @ X[j]) - (X[j] @ X[i]) if eta <= 0: continue alpha[j] -= y_t[j] * (E_i - E_j) / eta alpha[j] = min(H, max(L, alpha[j])) # 修剪到边界 if abs(alpha[j] - alpha_j_old) < 1e-5: continue alpha[i] += y_t[i] * y_t[j] * (alpha_j_old - alpha[j]) # 更新 b b1 = b - E_i - y_t[i] * (alpha[i] - alpha_i_old) * X[i] @ X[i] - y_t[j] * (alpha[j] - alpha_j_old) * X[i] @ X[j] b2 = b - E_j - y_t[i] * (alpha[i] - alpha_i_old) * X[i] @ X[j] - y_t[j] * (alpha[j] - alpha_j_old) * X[j] @ X[j] b = (b1 + b2) / 2 num_changed += 1 if num_changed == 0: passes += 1 else: passes = 0 return alpha, b这段代码已经很接近练习包里的原始实现。关键参数是 C,它控制对误分类的惩罚:C 越大越不容忍训练集上的错误,容易过拟合;C 太小则决策边界过于平滑。真实数据集上我一般把 C 在[0.01, 0.1, 1, 10, 100]里网格搜索,再配合验证集确定。练习代码里max_passes用来控制外循环出口,数值小是为了跑得快,期末做实验改成 15 左右收敛更稳,代价是迭代时间明显变长。
4. 决策树与集成学习:信息增益、剪枝与权值更新
4.1 决策树生长与预剪枝的代码逻辑
决策树这块练习代码通常实现的是 ID3 风格的信息增益选择划分属性。计算香农熵的函数是核心:
def entropy(y): # 输入标签向量,返回熵值 _, counts = np.unique(y, return_counts=True) p = counts / len(y) return -np.sum(p * np.log2(p)) def info_gain(X_idx, y, feat): # 特征 feat 列上的信息增益 = H(y) - 加权平均条件熵 vals = np.unique(X_idx[:, feat]) h_total = entropy(y) h_cond = 0.0 for v in vals: y_v = y[X_idx[:, feat] == v] h_cond += (len(y_v) / len(y)) * entropy(y_v) return h_total - h_cond逻辑就是「按特征取值切分后,熵下降得多不多」。entropy的输入必须是 numpy 数组,不能是 list,否则np.unique返回的 counts 类型和/运算会有微小差异。练习包里决策树代码最常见的坑是递归深度:特征多、样本少时树会长得很深,python 默认递归上限只有 1000,容易RecursionError。解决方法是限制最大深度,或者实现预剪枝——在分裂前判断信息增益是否低于阈值。
预剪枝的代码逻辑不算复杂,但课设里经常有人漏掉「验证集」这个部分。正确的做法是分裂前用验证集评估,分裂后再次评估,只有验证集精度提升才允许这次分裂。这算整章里比较容易被忽略的一步,因为大部分人写决策树时只盯着训练集精度,结果画出来一棵完美拟合训练集、验证集一塌糊涂的树。
4.2 AdaBoost 的权值更新细节
AdaBoost 的循环里,每轮都会更新样本权重 D。练习包给的实现一般是这种结构:
def adaboost(X, y, num_iters=50): m = len(y) D = np.ones(m) / m # 样本权重,初始均匀 alphas = [] # 每个弱分类器的权重 classifiers = [] # 每轮的弱分类器(一般是决策树桩) for t in range(num_iters): # 根据权重 D 训练一个弱分类器,此处在练习包里通常是单层决策树 clf = train_stump(X, y, D) pred = clf.predict(X) err = D @ (pred != y) # 加权错误率 if err >= 0.5: break # 比随机猜还差,本轮作废 alpha = 0.5 * np.log((1 - err) / max(err, 1e-10)) D = D * np.exp(-alpha * y * pred) # 注意 y 取值为 +1/-1 D = D / D.sum() # 归一化保持分布 alphas.append(alpha) classifiers.append(clf) return alphas, classifiers这个循环里最容易出问题的是标签符号。如果数据里的 y 是 0/1,那么公式exp(-alpha * y * pred)就不能直接用,因为当 y=0 时无论预测对不对,指数项都是 0,等于废掉了错误样本的惩罚。我在初次跑这套代码时就踩了这个坑,现象是训练误差不下降,后来把 y 转成 +1/-1 就正常了。err >= 0.5的早停也是一道安全锁,因为如果加权错误率大于 0.5,说明这个弱分类器还不如随机猜测,继续推进反而破坏已有的集成效果。
4.3 随机森林的特征抽样与集成
随机森林的练习代码通常比 AdaBoost 更短,但有个细节是特征抽样而非只用全部特征。每个决策树在分裂时,随机选取sqrt(n_features)个特征做候选,这是随机森林和普通 Bagging 的本质区别。
def rf_predict(X, trees, feature_subsets): # 每棵树给出预测,最后按列取众数 votes = np.zeros((len(X), len(trees))) for i, (tree, feats) in enumerate(zip(trees, feature_subsets)): votes[:, i] = tree.predict(X[:, feats]) return np.array([np.bincount(v.astype(int)).argmax() for v in votes])随机森林在练习包里往往只作为 AdaBoost 的对比模型出现,所以代码不会写得特别工程化。feature_subsets记录每棵树实际使用的特征列索引,这个设计要保留,因为预测时也要用同样的特征子集,否则维度错位。树的棵数一般设 10 到 20 就够了,多了练习数据集上收益不明显,倒是把训练时间拉得很长。做期末实验时我会顺便测试一下不同树数量对精度的影响,这个结果写进报告里比单纯贴代码要有说服力。
5. 避坑与调试:跑这些代码最常翻车的五个点
5.1 现象:import 时报 AttributeError,找不到 np.float / np.int
原因:新版 numpy 2.x 移除了这些别名,老练习代码里大量出现np.float作为 dtype 的地方全部失效。这不是你的环境坏了,是 API 变了。
解决:与其降级 numpy,不如全局替换。用 IDE 的查找替换功能,把np.float改成float,np.int改成int,np.bool改成bool。替换后重新跑一遍导入测试,基本能过。如果还报错,看具体哪个模块引入的,往往是 matplotlib 和 numpy 版本不兼容,将 matplotlib 升到 3.8 以上即可。
5.2 现象:训练几轮之后误差变成 nan
原因:对数几率回归或 SVM 的计算中出现了exp溢出。比如 sigmoid 函数传进去一个很大的负数,np.exp(1000)直接变成 inf,后续 log 就变成 nan。
解决:换成稳定版 sigmoid,代码见 3.2 节。另外检查数据有没有缺失值或无穷大,numpy 读 CSV 时空单元格会变成 nan,一路算下去结果就是 nan。加一行np.isnan(X).any()排查是基本功,很多人一开始会忘记。
5.3 现象:梯度下降迭代很多轮但损失几乎不动
原因:没有做特征归一化。如果某个特征取值范围是 0 到 100000,另一个是 0 到 1,梯度方向会被大数值的特征主导,等于是走了极细的螺旋线,收敛极慢。
解决:训练前统一做 z-score 归一化。练习代码里有些脚本自带归一化,有些没有,跑之前先看一眼数据分布。如果特征是离散取值(比如色泽、纹理这种枚举值),不能直接套 z-score,要先做数值编码,再决定是否归一化。实数值特征默认都过一遍归一化,能省掉大部分收敛问题。
5.4 现象:画出来的图中文全部是方块
原因:matplotlib 默认字体不带中文字符集,而西瓜书数据集的标签又大量使用中文(比如好瓜/坏瓜)。
解决:在绘图脚本开头设置:
import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['SimHei', 'WenQuanYi Zen Hei'] plt.rcParams['axes.unicode_minus'] = Falseaxes.unicode_minus=False这一行很容易漏,它的作用是让坐标轴的负号正常显示,不然负号会变成方块。如果你在 Linux 服务器上跑,系统没有 SimHei,改成['DejaVu Sans']或者下载一个中文字体放进去。
5.5 现象:手写公式推导没问题,但代码结果和书上对不上
原因:符号对应错了。书里公式用的是数学符号,代码里变量名有时是用拼音简写,比如x是样本矩阵还是单个样本向量,y是 0/1 标签还是 +1/-1 标签,一个地方不一致,结果就全偏。
解决:拿到代码先做一次符号映射。打印每一行关键变量的 shape 和取值范围,确认和你推导时假设的一致。尤其是 SVM 和 AdaBoost 这两个章节,标签符号必须在代码开头显式转换。我自己的习惯是写一个注释块:
# 约定:X: (m, n),每行一个样本 # y: (m,),取值为 0 或 1,训练前转成 -1/1从那以后我每次拿到别人的练习代码,都强制先跑一遍最小数据集的端到端流程,再动公式。
6. 进阶验证:把练习代码改造成参数试验台
6.1 用 sklearn 做交叉验证对照,量化自己实现的差距
练习代码跑通是一回事,跑出来的模型到底行不行是另一回事。最直接的办法是把自己实现的和 sklearn 的标准实现放在同一个数据集上对比,用交叉验证打分,把差距量化出来。
from sklearn.model_selection import cross_val_score from sklearn.linear_model import LogisticRegression # 自己训练的模型输出预测函数 my_predict # sklearn 的模型做同样的十折交叉验证 clf = LogisticRegression(max_iter=1000) scores = cross_val_score(clf, X_norm, y, cv=10) print('sklearn logreg acc:', scores.mean().round(4)) # 自己实现的对数几率回归也可以套 sklearn 的交叉验证框架 # 只要模仿 fit/predict 接口,就能复用 cross_val_score这个步骤能直观告诉你书本级实现和工业级实现差多少。差距在 5% 以内算正常,因为 sklearn 里加了很多数值稳定策略;差距超过 10% 说明你的代码里有 bug,优先检查归一化和学习率。做课程设计时,把这张对比表放进报告,比你贴十页推导有用。
6.2 学习率与迭代次数敏感性分析
练习代码里的lr和max_iters是写死的,但它们对结果影响很大。做一个简单的敏感性实验:固定其他参数,把学习率从 0.001 到 0.1 按对数间隔取五六个点,每个点跑一遍,记录损失降到平稳所需的轮数和最终精度。
for lr in [0.001, 0.005, 0.01, 0.05, 0.1]: w = train_lr(X_norm, y, lr=lr, max_iters=2000) acc = np.mean((sigmoid(X_norm @ w) > 0.5) == y) print(f'lr={lr}, acc={acc:.4f}')你会看到学习率太低时损失下降极慢,太高时直接震荡。这个过程可以帮助理解梯度下降的收敛区间概念,而且实验成本极低,几分钟就能出一张表。这也是把练习代码变成自己东西的关键一步——真正理解了参数,才敢改参数。
6.3 用训练曲线判断过拟合
决策树和 AdaBoost 相关章节,画训练误差和测试误差随迭代轮数变化的曲线,是最直观的过拟合判断方式。以 AdaBoost 为例,随着弱分类器数量增加,训练误差会持续下降,但测试误差降到某个点之后反而回升,这个拐点就是模型复杂度上限。
train_errs, test_errs = [], [] for t in range(1, 51): # 用前 t 个弱分类器组合预测 pred_train = ensemble_predict(X_train, y_train, alphas[:t], classifiers[:t]) pred_test = ensemble_predict(X_test, y_test, alphas[:t], classifiers[:t]) train_errs.append(np.mean(pred_train != y_train)) test_errs.append(np.mean(pred_test != y_test)) plt.plot(range(1, 51), train_errs, label='train err') plt.plot(range(1, 51), test_errs, label='test err') plt.xlabel('num of weak classifiers') plt.ylabel('error') plt.legend() plt.show()这条曲线出来,验证集的作用、集成学习的阶段收益和过拟合的位置就全部一目了然了。从那以后我每次做模型实验,都强制走完整套流程:符号核对、归一化、交叉验证、画学习曲线,哪怕数据再小也不跳过。这套习惯让我后来看任何开源代码都能快速定位问题,希望帮到你。
本文还有配套的精品资源,点击获取