☰
机器学习手写算法代码练习包:从sklearn对照到实验框架
2026/10/28 3:33:04 网站建设 项目流程

简介:围绕周志华《机器学习》教材整理的代码练习包,面向正在学习机器学习课程、希望把书中算法原理落到代码实现的学生与自学者,也适合作为课堂实验、课程作业和复习阶段的辅助材料。压缩包共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, encoding="utf-8"): """读取逗号分隔的数据文件,返回 (X, y)""" raw = np.loadtxt(path, delimiter=",", skiprows=1, encoding=encoding) if label_col == -1: X, y = raw[:, :-1], raw[:, -1] else: X = np.delete(raw, label_col, axis=1) y = raw[:, label_col] return X, y.astype(int) def train_test_split(X, y, ratio=0.3, seed=42, shuffle=True): """按比例切分,返回 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_test=None): """只用训练集统计均值方差,避免测试集信息泄漏""" mu = X_train.mean(axis=0) sigma = X_train.std(axis=0) 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, sigma

load_csv的label_col默认取最后一列,这是整套代码的隐式约定,改数据文件时要么保持这个约定,要么显式传参,别指望它自己猜。train_test_split用固定 seed,是为了让你每次跑出来的结果可复现——做算法练习时结果忽好忽坏最打击人,先把随机性按住。standardize里那个sigma == 0的判断是血泪经验:某次加载的数据里有一列全是一样的值,标准化之后整列变nan,错误一路传到梯度里,训练损失直接变成nan,而报错栈指向的是几百行之外的矩阵乘法,查了半小时才回到数据本身。

这套工具的调用顺序有讲究:先划分,再标准化,参数只从训练集统计。反过来做,测试集的均值和方差就混进了训练过程,评估结果会虚高几个点,在不平衡的小数据集上尤其明显,很容易让人误以为自己的模型调得不错。

3. 核心模块拆解:从线性模型到集成学习的实现链路

五个模块,五种写法。这一段把每章最核心的那几十行拎出来讲,重点不在代码本身,在于代码里那些“书上不写但你不写就跑不对”的地方——初始化、停止条件、数值稳定、权重更新。看懂了这五段,剩下的章节基本是同一套思路换皮。

3.1 线性模型:批量梯度下降的骨架

import numpy as np def bgd_linear(X, y, lr=0.01, epochs=500, tol=1e-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, history

lr是学习率,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_counts=True) 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_samples=2): """遍历所有特征与阈值,返回信息增益最大的 (特征下标, 阈值, 增益)""" 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 best

min_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, hidden=16, lr=0.1, epochs=2000, seed=42): """单隐层 MLP,ReLU + 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(axis=1, keepdims=True) # 防 exp 溢出 P = np.exp(logits) P /= P.sum(axis=1, keepdims=True) d_logits = (P - Y) / len(X) # softmax + 交叉熵的梯度 dW2 = H.T @ d_logits db2 = d_logits.sum(axis=0) dH = d_logits @ W2.T dH[H <= 0] = 0 # ReLU 的导数 dW1 = X.T @ dH db1 = dH.sum(axis=0) W1 -= lr * dW1; b1 -= lr * db1 W2 -= lr * dW2; b2 -= lr * db2 return W1, b1, W2, b2

hidden是隐层宽度,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, rounds=20): """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 models

rounds是基学习器个数,alpha是每个树桩的话语权,w是样本权重。这段的核心就两行:alpha的计算和w的更新。err越接近 0,alpha越大,说明这个树桩很靠谱;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_iter=100, seed=42): """返回 (每个样本的簇标签, 簇中心)""" rng = np.random.default_rng(seed) centers = X[rng.choice(len(X), k, replace=False)] # 从样本里随机挑 k 个作初始中心 labels = np.zeros(len(X), dtype=int) for _ in range(max_iter): d = ((X[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2) labels = d.argmin(axis=1) new_centers = np.array([ X[labels == j].mean(axis=0) if np.any(labels == j) else centers[j] for j in range(k) ]) if np.allclose(new_centers, centers, atol=1e-6): break # 中心不再移动就收敛 centers = new_centers return labels, centers def pca(X, k): """返回投影矩阵与前 k 个主成分方差占比""" Xc = X - X.mean(axis=0) # 去中心化,必须做 U, S, Vt = np.linalg.svd(Xc, full_matrices=False) 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", default="gd", choices=["gd", "sgd"], help="gd 为批量梯度下降,sgd 为随机梯度下降") p.add_argument("--lr", type=float, default=0.01, help="学习率") p.add_argument("--epochs", type=int, default=500, help="最大迭代轮数") p.add_argument("--seed", type=int, default=42, help="随机种子,固定后结果可复现") p.add_argument("--save-fig", action="store_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 头,第一列列名会多出几个不可见字符,skiprows=1跳过去就没事。用 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_iter=1000) 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(f"sklearn 准确率: {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对不对;再用encoding="utf-8"或"gbk"试两次。实在排查不出来,用pandas.read_csv读进来再转numpy,它的错误提示会直接告诉你哪一行的哪一列有问题。

现象:所有样本标准化之后全变成nan。原因:某一列的方差为 0(常量列),除以 0 得到inf,inf - inf或者后续乘法就变nan。解决:standardize里那个sigma[sigma == 0] = 1.0的兜底一定要留。另外在加载数据后加一行print(X.std(axis=0)),方差为 0 的列直接打印出来,比在训练阶段看nan损失好查一百倍。

5.2 算法实现类

现象:决策树训练完在训练集上准确率 100%,测试集只有 50% 多一点,树深得很夸张。原因:递归终止条件只写了“节点内样本同类”,没限制深度和最小分裂样本数,树把每个样本都单独分到了一个叶子。解决:加max_depth和min_samples_split两个参数,默认 10 和 2。判断是不是过拟合,看训练集和测试集的准确率差值,超过 20 个点基本就是树太深了。

现象:AdaBoost 跑几轮之后alpha全是inf或者nan。原因:某个树桩在当前权重下完全分对,err = 0,log((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, lr=0.01, epochs=500, seed=42): 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(lr=0.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, k=5, seed=42): """生成 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, k=5): """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的接口——都强制把这三条检查走一遍,哪怕只改了一行。公共层的问题最阴,它不会让你立刻报错,只会在某章的某个参数组合下悄悄给出一个偏高的分数,等你要写结论的时候才发现数据是假的。希望这些能帮你少走点弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询