简介围绕周志华《机器学习》教材整理的代码练习包面向正在学习机器学习课程、希望把书中算法原理落到代码实现的学生与自学者也适合作为课堂实验、课程作业和复习阶段的辅助材料。压缩包共16个文件以15个Python脚本和1个Markdown说明文档为主体量约20KB脚本按章节主题组织说明文档可用于了解目录结构与运行背景。内容覆盖线性模型、逻辑回归及交叉验证与留一法、LDA、决策树与Friedman相关实现、集成学习中的迭代Bagging与MultiBoosting、偏差方差分析以及SVM的简化实现、对比与回归训练等能帮助读者对照教材复现实验观察不同算法在同一问题上的表现并积累从公式到代码的转换经验。已有509人学习适合按模块查阅、逐章练习和查漏补缺尤其适合刚接触机器学习、需要借助可运行脚本理解经典算法流程的读者。1. 这套代码练习包练的是什么从一个跑不起来的决策树脚本说起很多人学机器学习的第一个跟头不是栽在公式上是栽在“照着公式敲完却跑不出书上那个结果”上。我见过有人把教材里的决策树推导抄了满满一页回头用代码实现树长到第八层还在往下分测试集准确率在 50% 上下晃——递归终止条件、连续特征的分裂点怎么选、样本权重带不带进熵的计算这些细节书上一笔带过代码里一个都不能省。这套按章节组织的代码练习包干的就是把省略号补全这件事每个算法一份能独立跑起来的最小实现配教材自带的小规模数据集和可视化脚本从线性模型一路写到集成学习、聚类与降维。适合两类人刚读完理论想动手验证一遍的以及想把 sklearn 里那几个参数到底改了什么的黑匣子撬开看看的。2. 目录结构与技术栈手写算法与 sklearn 对照的双轨布局这套代码的组织方式很朴素一个算法一个目录目录名对应章节序号。真正值得说的是它内部的“双轨”设计每个算法都有两份实现一份是纯手写、不依赖任何机器学习库的版本另一份是调用 sklearn 的对照版本。前者用来理解后者用来核对写完之后两边跑一遍结果差得离谱就说明自己哪里错了。这种布局对二次开发特别友好——你改手写版对照版不动就有了一个不会骗人的基准。2.1 逐章拆开看一份典型的工程目录不用打开文件也能从目录名看出它覆盖到哪线性模型、决策树、神经网络、支持向量机、贝叶斯分类器、集成学习、聚类、降维。代码包解压后大致是下面这个结构data放教材里的小规模数据集common放各章共用的工具函数outputs用来接跑出来的图。ml-exercises/ ├── data/ │ ├── linear.csv # 线性模型用的二维回归数据 │ ├── tree_binary.csv # 决策树用的二分类小样本 │ ├── digits_small.npz # 降采样后的手写数字供神经网络与聚类共用 │ └── README.md # 每个文件的列含义、标签编码方式 ├── common/ │ ├── data_utils.py # 数据加载、标准化、训练测试划分 │ ├── metrics.py # 准确率、混淆矩阵、轮廓系数 │ └── plot_utils.py # 决策边界、损失曲线、聚类散点图 ├── ch03_linear/ │ ├── gd.py # 批量梯度下降与随机梯度下降 │ └── run.py # 入口负责读数据、训练、画图 ├── ch04_tree/ │ ├── id3.py # 信息增益版 │ └── cart.py # 基尼指数版 ├── ch05_nn/ │ └── mlp.py ├── ch06_svm/ │ └── smo.py ├── ch07_bayes/ │ └── naive_bayes.py ├── ch08_ensemble/ │ ├── adaboost.py │ └── bagging.py ├── ch09_cluster/ │ ├── kmeans.py │ ├── gmm.py │ └── pca.py └── requirements.txt这个结构有两个刻意的地方。一是每章的算法文件和入口文件分开gd.py里只有函数没有任何print和plt.show()你可以在别的脚本里import它二是common目录独立数据加载和评估口径全章统一不会出现第三章算准确率用mean(pred y)、第八章又自己写一遍的情况。二次开发第一步要改的就是common把这里改顺手了后面所有章的实验都跟着统一。2.2 运行环境与依赖三条命令把环境跑起来依赖压得很轻核心就三个库numpy做矩阵运算matplotlib画图scikit-learn只用来做对照和数据划分不参与手写实现。Python 版本建议 3.9 以上太老的版本在类型注解和np.random.default_rng上会踩坑。# 建虚拟环境避免和系统里其它项目的 numpy 版本打架 python -m venv venv source venv/bin/activate # Windows 下换成 venv\Scripts\activate # 安装依赖如果 requirements.txt 里没有锁版本建议手动指定近两年的稳定版 pip install -r requirements.txt pip install numpy matplotlib scikit-learn # 先跑一个最简单的章验证环境通不通 python ch03_linear/run.py --algo gd --lr 0.01 --epochs 300这里有个细节不要用pip install -r requirements.txt之后就完事先跑一遍最简单的线性回归。因为神经网络的章会用到np.einsum和一些矩阵广播写法如果 numpy 装成了很老的版本报错信息会指向一个看起来完全无关的行号排查起来很浪费时间。三条命令跑通、弹出损失下降曲线环境这一步才算过关。提示如果机器上没有图形界面比如远程登录matplotlib默认后端会直接抛错。在入口脚本顶部加两行import matplotlib; matplotlib.use(Agg)把--save-fig打开存文件比折腾后端省事。2.3 公共工具层数据加载与度量为什么值得单独抽出来common/data_utils.py是整套代码里被 import 次数最多的文件它的职责就三件事读数据、划分、标准化。看似平淡但这里埋着后面很多结果的解释权。import numpy as np def load_csv(path, label_col-1, encodingutf-8): 读取逗号分隔的数据文件返回 (X, y) raw np.loadtxt(path, delimiter,, skiprows1, encodingencoding) if label_col -1: X, y raw[:, :-1], raw[:, -1] else: X np.delete(raw, label_col, axis1) y raw[:, label_col] return X, y.astype(int) def train_test_split(X, y, ratio0.3, seed42, shuffleTrue): 按比例切分返回 X_train, X_test, y_train, y_test n len(y) idx np.random.default_rng(seed).permutation(n) if shuffle else np.arange(n) cut int(n * (1 - ratio)) tr, te idx[:cut], idx[cut:] return X[tr], X[te], y[tr], y[te] def standardize(X_train, X_testNone): 只用训练集统计均值方差避免测试集信息泄漏 mu X_train.mean(axis0) sigma X_train.std(axis0) sigma[sigma 0] 1.0 # 常数列不做缩放防止除零 X_train (X_train - mu) / sigma if X_test is None: return X_train, mu, sigma return X_train, (X_test - mu) / sigma, mu, sigmaload_csv的label_col默认取最后一列这是整套代码的隐式约定改数据文件时要么保持这个约定要么显式传参别指望它自己猜。train_test_split用固定 seed是为了让你每次跑出来的结果可复现——做算法练习时结果忽好忽坏最打击人先把随机性按住。standardize里那个sigma 0的判断是血泪经验某次加载的数据里有一列全是一样的值标准化之后整列变nan错误一路传到梯度里训练损失直接变成nan而报错栈指向的是几百行之外的矩阵乘法查了半小时才回到数据本身。这套工具的调用顺序有讲究先划分再标准化参数只从训练集统计。反过来做测试集的均值和方差就混进了训练过程评估结果会虚高几个点在不平衡的小数据集上尤其明显很容易让人误以为自己的模型调得不错。3. 核心模块拆解从线性模型到集成学习的实现链路五个模块五种写法。这一段把每章最核心的那几十行拎出来讲重点不在代码本身在于代码里那些“书上不写但你不写就跑不对”的地方——初始化、停止条件、数值稳定、权重更新。看懂了这五段剩下的章节基本是同一套思路换皮。3.1 线性模型批量梯度下降的骨架import numpy as np def bgd_linear(X, y, lr0.01, epochs500, tol1e-7): 批量梯度下降求解线性回归 y Xw b m, n X.shape w np.zeros(n) # 零初始化配合标准化后收敛更稳 b 0.0 loss_prev np.inf history [] for epoch in range(epochs): y_pred X w b err y_pred - y grad_w (2.0 / m) * (X.T err) # 均方误差对 w 的偏导 grad_b (2.0 / m) * err.sum() # 均方误差对 b 的偏导 w - lr * grad_w b - lr * grad_b loss np.mean(err ** 2) history.append(loss) if abs(loss_prev - loss) tol: # 收敛判据省掉一半迭代 break loss_prev loss return w, b, historylr是学习率epochs是最大迭代轮数tol是两次损失差值的收敛阈值history返回给画图用。这段代码最容易翻车的地方不是公式是lr的取值特征没标准化的时候一个量纲是千级的列和一个量级是个位数的列同时进入梯度同一个lr下前者已经过冲震荡、后者还在缓慢爬行损失曲线看起来像心电图。所以run.py里永远是先standardize再进bgd_linear顺序不能反。另一个值得留意的是tol提前停止。练习数据一般几百行跑满epochs也就毫秒级很多人觉得提前停没必要。但等你把它接到稍大的数据上或者做网格搜索跑几百次这个判断能省掉一半时间。tol别设太大1e-7是个稳妥值设成1e-3会在损失还在缓慢下降时就停下最后准确率差那么零点几个点你还以为是模型本身不行。随机梯度下降版在旁边另一个函数里写法是每个样本更新一次参数。练习的目的是让你亲眼看到两者的损失曲线差异批量版平滑单调随机版抖动剧烈但前期下降更快。这个直观感受在后续调参时很有用看到损失曲线抖得厉害第一反应就应该是 batch size 是不是太小了。3.2 决策树信息增益与递归终止条件import numpy as np def entropy(y): 信息熵y 为标签数组 _, counts np.unique(y, return_countsTrue) p counts / counts.sum() return -np.sum(p * np.log2(p 1e-12)) # 加极小值防 log(0) def candidate_thresholds(col): 连续特征排序去重后取相邻值中点作为候选分裂阈值 vals np.unique(col) return (vals[:-1] vals[1:]) / 2.0 def best_split(X, y, min_samples2): 遍历所有特征与阈值返回信息增益最大的 (特征下标, 阈值, 增益) m, n X.shape base entropy(y) best (None, None, 0.0) for f in range(n): for thr in candidate_thresholds(X[:, f]): mask X[:, f] thr left, right y[mask], y[~mask] if len(left) min_samples or len(right) min_samples: continue # 太小的子集不参与比较 w len(left) / m gain base - (w * entropy(left) (1 - w) * entropy(right)) if gain best[2]: best (f, thr, gain) return bestmin_samples是每个子节点最少的样本数entropy里的1e-12是为纯节点准备的兜底。这段代码背后有三个必须补上的终止条件缺一个树就会长歪节点内样本全属于同一类、树深达到上限、最优增益小于某个阈值或者没有合法分裂点。练习包里通常把它们写成递归函数的三个if如果你自己写建议再加一个最大深度参数默认给 8 到 10防止在两三百行的小数据上直接长成完全树。连续特征的处理是另一个容易含糊的点。教材里推的是离散取值的信息增益真到了 CSV 数据上全是浮点数就得自己造候选阈值。candidate_thresholds取相邻唯一值的中点候选点数量等于唯一值个数减一数据量小的时候完全够用数据量上千之后这个双重循环会很慢常见的优化是先按特征排序、再线性扫描一遍算前缀熵复杂度从平方级降到排序级。练习阶段不必优化但要知道瓶颈在哪。还有一个隐蔽的坑np.unique会对浮点数做精确比较。如果前面做过标准化或者除法本该相等的两个值可能差1e-16唯一值数量翻倍候选阈值里混进一堆无意义的点。稳妥做法是先np.round(col, 6)再去重。3.3 神经网络反向传播的链式求导最小实现import numpy as np def train_mlp(X, y, hidden16, lr0.1, epochs2000, seed42): 单隐层 MLPReLU Softmax返回训练好的参数 rng np.random.default_rng(seed) n_in, n_out X.shape[1], len(np.unique(y)) W1 rng.normal(0, np.sqrt(2.0 / n_in), (n_in, hidden)) # He 初始化 b1 np.zeros(hidden) W2 rng.normal(0, np.sqrt(2.0 / hidden), (hidden, n_out)) b2 np.zeros(n_out) Y np.eye(n_out)[y] # one-hot 编码 for _ in range(epochs): H np.maximum(0, X W1 b1) # 前向ReLU logits H W2 b2 logits - logits.max(axis1, keepdimsTrue) # 防 exp 溢出 P np.exp(logits) P / P.sum(axis1, keepdimsTrue) d_logits (P - Y) / len(X) # softmax 交叉熵的梯度 dW2 H.T d_logits db2 d_logits.sum(axis0) dH d_logits W2.T dH[H 0] 0 # ReLU 的导数 dW1 X.T dH db1 dH.sum(axis0) W1 - lr * dW1; b1 - lr * db1 W2 - lr * dW2; b2 - lr * db2 return W1, b1, W2, b2hidden是隐层宽度lr学习率epochs迭代轮数seed控制初始化保证同一份代码每次跑出同样的结果。这段里有三个数值细节值得单独说logits减去行最大值是为了防止exp溢出成inf减完之后 softmax 结果不变但数值稳定初始化用sqrt(2/fan_in)而不是全零全零初始化会让同一层的所有神经元保持对称、永远学出一样的东西dH[H 0] 0是 ReLU 在零点的导数约定写成H 0的掩码也一样但别漏。如果你发现损失一开始就卡在log(n_out)附近不动八成是学习率太大把权重推到了 ReLU 的死区所有隐层输出恒为 0梯度再也传不回来。把lr从 0.1 降到 0.01 再试或者换成 Leaky ReLU。这也是我建议在练习阶段先把hidden设小8 到 16的原因小网络的问题更容易定位一上来堆到 256 宽出了错你都不知道是数据、初始化还是梯度的问题。3.4 集成学习AdaBoost 的样本权重更新import numpy as np def adaboost(X, y, rounds20): y 必须取值为 {-1, 1}基学习器用单层决策树桩 m len(y) w np.full(m, 1.0 / m) # 初始样本权重均匀 models [] for t in range(rounds): stump fit_stump(X, y, w) # 加权错误率最小的决策树桩 pred stump.predict(X) err np.sum(w * (pred ! y)) / w.sum() err np.clip(err, 1e-10, 1 - 1e-10) # 防止除零与 log(0) alpha 0.5 * np.log((1 - err) / err) # 该基学习器的投票权重 w w * np.exp(-alpha * y * pred) # 分错的样本权重变大 w / w.sum() # 重新归一化 models.append((alpha, stump)) if err 1e-6: # 已经完全分对提前结束 break return modelsrounds是基学习器个数alpha是每个树桩的话语权w是样本权重。这段的核心就两行alpha的计算和w的更新。err越接近 0alpha越大说明这个树桩很靠谱exp(-alpha * y * pred)里预测正确时y * pred 1权重乘一个小于 1 的数变小预测错误时y * pred -1权重乘一个大于 1 的数变大。下一轮训练会更关注那些一直分错的样本。np.clip那句不是可有可无的。练习数据小很容易出现某个树桩在加权后完全分对、err精确等于 0 的情况log((1-0)/0)直接变inf整个权重数组跟着报废。我第一次跑这段的时候没加裁剪屏幕上全是nan还以为是数据标签编码错了——标签确实是{0,1}而不是{-1,1}两个问题叠在一起查起来相当折磨。所以函数注释里那句“y 必须取值为 {-1, 1}”一定要当真run.py在读数据后第一件事就是做标签映射。3.5 聚类与降维k-means 的初始化和 PCA 的方差不变量import numpy as np def kmeans(X, k, max_iter100, seed42): 返回 (每个样本的簇标签, 簇中心) rng np.random.default_rng(seed) centers X[rng.choice(len(X), k, replaceFalse)] # 从样本里随机挑 k 个作初始中心 labels np.zeros(len(X), dtypeint) for _ in range(max_iter): d ((X[:, None, :] - centers[None, :, :]) ** 2).sum(axis2) labels d.argmin(axis1) new_centers np.array([ X[labels j].mean(axis0) if np.any(labels j) else centers[j] for j in range(k) ]) if np.allclose(new_centers, centers, atol1e-6): break # 中心不再移动就收敛 centers new_centers return labels, centers def pca(X, k): 返回投影矩阵与前 k 个主成分方差占比 Xc X - X.mean(axis0) # 去中心化必须做 U, S, Vt np.linalg.svd(Xc, full_matricesFalse) ratio (S ** 2) / np.sum(S ** 2) return Vt[:k].T, ratio[:k]k是簇数或主成分个数seed控制初始中心的选择max_iter是迭代上限。k-means 那段里np.any(labels j)的判断是为了处理空簇——某次迭代中某个中心一个样本都没分到直接取均值会得到nan然后整个中心矩阵污染下一轮距离计算全乱。保留上一次的中心是个务实的处理方式。PCA 那段去中心化忘了写是最高频的翻车点。不去均值直接做 SVD第一主成分方向会被数据的整体偏移带偏画出来的投影图看着像那么回事但方差占比能到 0.99一看就不对劲。另外注意S是奇异值方差是它的平方算占比的时候别漏了平方。这两个模块通常放在同一章因为练习流程是先用 PCA 把高维数据降到二维、再用 k-means 聚类、最后画散点图。顺序不能反先聚类再降维簇结构在降维后可能糊成一团先降维再聚类k-means 的欧氏距离在低维空间里更可信。4. 跑通与验证一次完整实验的执行路径代码能跑和结果可信是两件事。练习包的价值一半在算法实现一半在它给你的验证路径跑出结果、和对照实现比、把差异解释清楚。这一段按执行顺序走一遍从命令行入口到结果对照。4.1 单脚本运行与参数入口每章的run.py都带一个argparse入口把学习率、迭代轮数、随机种子这些暴露成命令行参数。这样做的原因很实际调参时改代码容易手滑改错地方命令行参数改错了只是这次运行不对代码本身还是干净的。import argparse def build_parser(): p argparse.ArgumentParser(description单章实验入口) p.add_argument(--algo, defaultgd, choices[gd, sgd], helpgd 为批量梯度下降sgd 为随机梯度下降) p.add_argument(--lr, typefloat, default0.01, help学习率) p.add_argument(--epochs, typeint, default500, help最大迭代轮数) p.add_argument(--seed, typeint, default42, help随机种子固定后结果可复现) p.add_argument(--save-fig, actionstore_true, help图片存到 outputs/ 而不弹窗) return p if __name__ __main__: args build_parser().parse_args() # 后续流程读数据 - 标准化 - 训练 - 评估 - 画图--algo决定走哪个训练函数--lr和--epochs直接透传给算法层--seed一路传到数据划分和参数初始化--save-fig决定画图是弹窗还是落盘。四个参数里--seed最容易被忽略但它是复现问题的前提线上讨论“为什么我的准确率是 0.82 而你是 0.79”时先确认两边的 seed 一样能省掉大量无意义的争论。跑的顺序建议按章节来ch03到ch09依次走一遍。不是因为后面的依赖前面的而是难度是递进的线性模型出问题通常是数据或学习率好定位集成学习出问题可能是基学习器、权重更新、标签编码三处叠加一上来就跑它报错信息会把你带到沟里。注意Windows 下路径分隔符和np.loadtxt的delimiter参数没关系但如果 CSV 里带了 BOM 头第一列列名会多出几个不可见字符skiprows1跳过去就没事。用 Excel 另存过的数据文件十有八九带 BOM。4.2 与 sklearn 对照怎么判断自己写对了对照不是比谁准确率高是比谁的结果在合理误差内一致。手写实现和 sklearn 的差别主要在初始化、正则项、收敛判据上准确率差一两个点很正常差十个点就一定有问题。from sklearn.linear_model import LogisticRegression from sklearn.metrics import accuracy_score import numpy as np def compare_with_sklearn(X_train, y_train, X_test, y_test, my_pred): 把手写实现和 sklearn 的结果放一起看 clf LogisticRegression(max_iter1000) clf.fit(X_train, y_train) ref_pred clf.predict(X_test) mine accuracy_score(y_test, my_pred) ref accuracy_score(y_test, ref_pred) agree np.mean(my_pred ref_pred) # 两个模型预测一致的样本比例 print(f手写实现准确率: {mine:.4f}) print(fsklearn 准确率: {ref:.4f}) print(f预测一致率: {agree:.4f}) return mine, ref, agree三个指标里预测一致率比准确率更有诊断价值。两个模型准确率都是 0.85一致率却只有 0.6说明它们在完全不同的样本上犯错你的实现大概率有系统性偏差一致率 0.95 以上准确率差两个点那只是收敛精度不同不用管。这个思路我在做特征工程时也一直用新老两版特征训出来的模型先看一致率再看指标。4.3 结果可视化与实验记录plot_utils.py里三个函数最常用plot_decision_boundary画二维决策边界plot_loss画损失曲线plot_clusters画聚类散点并按簇着色。二维决策边界是最直观的调试工具——线性模型画出弯弯曲曲的边界多半是特征没标准化决策树画出过于细碎的锯齿是深度没限制。实验记录建议直接在outputs/下按章节_算法_参数命名存图和日志比如ch03_gd_lr0.01_ep500.png。练习阶段数据小、跑得快很多人不记录等回头想复现某个“当时跑得特别好”的结果参数早忘了。我现在跑任何一次实验都强制留一行配置写日志的成本远低于重跑的成本。5. 避坑与排查五个高频翻车现场这一章的每一条都是实际跑代码时会遇到的按“现象 → 原因 → 解决”写。遇到问题先对照这里扫一遍比从报错栈第一行往下读快得多。5.1 环境与数据加载类现象np.loadtxt报ValueError: could not convert string to float但打开文件看全是数字。原因CSV 里混了空行、表头没跳过或者某一列是1,234这种带千分位的格式。还有一种情况是文件编码是 GBK中文字符被读成乱码塞进了数值列。解决先head -5看一眼文件头确认skiprows对不对再用encodingutf-8或gbk试两次。实在排查不出来用pandas.read_csv读进来再转numpy它的错误提示会直接告诉你哪一行的哪一列有问题。现象所有样本标准化之后全变成nan。原因某一列的方差为 0常量列除以 0 得到infinf - inf或者后续乘法就变nan。解决standardize里那个sigma[sigma 0] 1.0的兜底一定要留。另外在加载数据后加一行print(X.std(axis0))方差为 0 的列直接打印出来比在训练阶段看nan损失好查一百倍。5.2 算法实现类现象决策树训练完在训练集上准确率 100%测试集只有 50% 多一点树深得很夸张。原因递归终止条件只写了“节点内样本同类”没限制深度和最小分裂样本数树把每个样本都单独分到了一个叶子。解决加max_depth和min_samples_split两个参数默认 10 和 2。判断是不是过拟合看训练集和测试集的准确率差值超过 20 个点基本就是树太深了。现象AdaBoost 跑几轮之后alpha全是inf或者nan。原因某个树桩在当前权重下完全分对err 0log((1-0)/0)溢出。解决np.clip(err, 1e-10, 1 - 1e-10)同时检查标签是不是严格的{-1, 1}。如果标签是{0,1}y * pred里会出现 0权重更新直接失效这个错误不会报异常只会让准确率莫名其妙地不涨。现象k-means 跑完之后某个簇的样本数是 0簇中心在下一轮变成nan。原因初始中心选得太近或者数据分布本身有多个密集区某个中心抢不到样本。解决空簇时保留上一次的中心3.5 节代码里的写法或者改用 k-means 初始化让初始中心尽量分散。后者在 sklearn 里是默认行为对照时注意这个差异结果对不上先看这里。5.3 评估口径类现象同样一份数据、同一个模型两次跑出来准确率差了 5 个点。原因随机种子没固定数据划分和参数初始化每次都不一样。小数据集上这个波动尤其明显两三百个样本测试集才几十个一个样本对错就是两三个百分点。解决seed从数据划分一路传到模型初始化全部固定。要更稳的结论就上交叉验证单次划分在小数据上本来就不够可信。现象手写实现和 sklearn 差了十几个点但代码逻辑逐行看下来没毛病。原因预处理不一致。sklearn 的LogisticRegression默认带 L2 正则max_iter默认 100 可能没收敛你的实现没正则迭代 500 轮。两边优化的根本不是一个目标函数。解决对照时把 sklearn 的C调大等价于几乎不正则、max_iter调大再看一致率。对照的意义是验证逻辑不是比谁的默认参数更好。6. 二次开发把散装脚本收敛成一个能跑实验的小框架这套代码练到最后你会发现真正的瓶颈不是算法写不出来是每章一个run.py参数各写各的想横向比较几个模型得来回改代码。我一般会做三件事把它收成一个能用的实验框架统一接口、加交叉验证、留实验记录。改动量不大但之后每次试新想法能省掉大量重复劳动。6.1 抽象出统一的 Estimator 接口所有算法都套同一个壳fit训练、predict预测、score评估。这样上层流程不用关心底下是决策树还是神经网络换模型只换一个类名。import numpy as np class BaseEstimator: 所有手写算法的最小公共接口 def fit(self, X, y): raise NotImplementedError def predict(self, X): raise NotImplementedError def score(self, X, y): return float(np.mean(self.predict(X) y)) class LogisticGD(BaseEstimator): def __init__(self, lr0.01, epochs500, seed42): self.lr, self.epochs, self.seed lr, epochs, seed def fit(self, X, y): # 这里接 3.1 节或逻辑回归的训练逻辑 self.w_, self.b_ self._train(X, y) return self def predict(self, X): return (X self.w_ self.b_ 0).astype(int)改造时注意两点fit返回self这样可以链式调用LogisticGD(lr0.05).fit(X, y).score(X_test, y_test)参数全部放在__init__里训练产生的状态全部带下划线后缀w_、b_这是 sklearn 的惯例照着写不会错。已有的gd.py、id3.py这些文件不用删在类里调用原来的函数就行改造是加壳不是重写。6.2 交叉验证与网格搜索有了统一接口交叉验证就是十几行的事。小数据集上这一步不能省单次划分的评估结果方差太大调参容易调出玄学。import numpy as np def kfold_indices(n, k5, seed42): 生成 k 折的训练/验证下标返回迭代器 idx np.random.default_rng(seed).permutation(n) folds np.array_split(idx, k) for i in range(k): val folds[i] train np.concatenate([folds[j] for j in range(k) if j ! i]) yield train, val def cross_validate(model_factory, X, y, k5): model_factory 是个无参函数返回新模型实例 scores [] for train, val in kfold_indices(len(y), k): model model_factory().fit(X[train], y[train]) scores.append(model.score(X[val], y[val])) return float(np.mean(scores)), float(np.std(scores))model_factory用函数而不是实例是为了每折都拿一个全新的、未训练的模型直接传实例进去第二折开始模型已经带着上一折的状态结果会虚高。这个坑我在第一次做交叉验证时踩过五折的平均准确率比单次划分高了七八个点当时还挺高兴后来发现是模型记住了数据。np.std(scores)也值得看标准差超过 0.05 说明模型对数据划分很敏感这时候该怀疑的是样本量或者特征质量不是继续调参。上网格搜索就是两层循环遍历参数组合每组跑一次cross_validate结果存进列表按均值排序。二三十组参数、五折交叉在练习数据规模上几秒就跑完了比手动改参数试快得多。6.3 改造后的验证方法改完之后怎么确认没改坏我会跑三个检查第一同一份数据、同样的 seed改造前后的手写实现单次结果应该完全一致不一致说明封装过程中参数没传下去第二交叉验证的均值应该落在单次划分结果的附近偏差超过 5 个点多半是折划分有问题第三接上 sklearn 对照一致率不能比改造前下降。三条都过了这套框架才算能用。从那以后我每次动公共层——不管是data_utils的划分逻辑还是BaseEstimator的接口——都强制把这三条检查走一遍哪怕只改了一行。公共层的问题最阴它不会让你立刻报错只会在某章的某个参数组合下悄悄给出一个偏高的分数等你要写结论的时候才发现数据是假的。希望这些能帮你少走点弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?