简介:这份代码练习包面向学习周志华《机器学习》的本科生、研究生及自学者,配合教材章节提供可运行的算法实现,用于把线性模型、分类与集成方法等抽象概念落到代码层面,适合课程实验与自学对照。压缩包共16个文件,以15个Python脚本为主体,另有1个Markdown说明文件,整体约20KB,体积轻便,便于随课程进度逐章练习。内容覆盖逻辑回归、交叉验证与留一法、LDA、决策树及Friedman相关实现,也涉及Iterated Bagging、MultiBoosting等集成学习思路、偏差—方差实验和SVM的简化、比较与回归训练示例,并配有说明文档帮助理解代码组织与运行方式。已有510人学习下载,读者可据此对照教材复现实验、修改参数观察模型变化,积累调参与排错经验,并巩固机器学习基础概念与算法直觉。
1. 别急着背公式,先把教材代码跑一遍
很多人学机器学习是从公式推导开始的,推完一遍合上书,换到真实数据集上还是不知道第一行代码该写什么。这份《机器学习》代码练习包填的正是这道缝:它把教材每章的算法都落成了能直接执行的 Python 脚本和 Notebook,从最小二乘、决策树一路到 BP 网络、AdaBoost,配着教材里的西瓜数据集和几份小样本数据,跑一遍就能看见公式里每个符号在代码里长什么样。它适合两类人:跟着教材自学、需要把推导落到实现上的学生,以及想复习经典算法细节、不愿只会调 sklearn 接口的开发者。包里没有工程封装,也不追求性能,定位就是一个看得懂、改得动的练习场,读代码的门槛比读框架源码低得多。
2. 目录结构与技术栈:先摸清这份练习包的地图
拿到压缩包别急着双击运行,先花十分钟看清它怎么组织。练习类代码包最怕的就是打开一看二十个同名main.py,不知道哪个对应哪一章。这类按教材章节组织的包通常有一套很稳定的结构,认清它,后面找代码、改代码都快。
2.1 目录骨架:按章节编号切分
常见的目录长这样,章节号和教材对齐,算法名做子目录,数据和工具函数单独抽出来:
ml-exercises/ ├── ch03_linear/ # 线性模型:线性回归、逻辑回归、多分类 │ ├── linreg_gd.py │ ├── logreg_gd.py │ └── demo.ipynb ├── ch04_tree/ # 决策树:划分准则、剪枝、连续值处理 ├── ch05_nn/ # 神经网络:BP 算法手写 ├── ch06_svm/ # 支持向量机:对偶、核函数 ├── ch07_bayes/ # 贝叶斯分类器:朴素贝叶斯、半朴素 ├── ch08_ensemble/ # 集成学习:AdaBoost、Bagging、随机森林 ├── ch09_cluster/ # 聚类:k-means、DBSCAN、层次聚类 ├── ch10_dimreduc/ # 降维:PCA、LDA ├── data/ # 数据集,csv 明文,方便直接看 ├── utils/ # 指标、绘图、数据加载的公共函数 │ ├── metrics.py │ ├── plot.py │ └── datasets.py ├── requirements.txt └── README.md这里有两个设计选择值得注意。一是数据放csv明文而不是npz或pickle,练习包里这点很重要,你需要肉眼确认某个特征取值到底是什么,二进制文件做不到。二是utils/目录的存在,说明作者没有让每个脚本各自复制一份准确率计算和绘图代码,公共逻辑收敛到了一处,改动的时候只改一个地方。
| 目录 | 主要产出 | 依赖关系 |
|---|---|---|
ch03_linear~ch10_dimreduc | 各章算法实现与演示 | 依赖utils/和data/ |
utils/metrics.py | 准确率、MSE、混淆矩阵 | 无,纯 numpy |
utils/datasets.py | 数据加载与编码 | 依赖pandas |
utils/plot.py | 决策边界、loss 曲线 | 依赖matplotlib |
如果包里没有utils/,而是每个脚本自己写一遍accuracy,那也不用惊讶,练习包常见的写法就是把所有东西摊平在一个文件里,好处是单文件可读、复制出去就能跑,坏处是改一个指标要改十几处。看清这一点,后面重构才有方向。
2.2 环境与依赖:三行命令跑起来
练习包的依赖基本固定在科学计算那一套,requirements.txt内容大致是:
numpy>=1.23 pandas>=1.5 matplotlib>=3.6 scikit-learn>=1.2 jupyter>=1.0装的时候我一般先建虚拟环境再装,避免和系统里已有的 numpy 打架:
# 建环境并激活,Windows 用 .venv\Scripts\activate python -m venv .venv source .venv/bin/activate # 用 python -m pip 而不是裸 pip,保证装进当前解释器 python -m pip install --upgrade pip python -m pip install -r requirements.txtpython -m pip这个写法看着啰嗦,但它是排掉「明明装了却 import 不到」这类玄学问题的第一步。裸pip有可能指向另一个 Python,尤其是在同时装了系统 Python 和 conda 的机器上,血泪经验。
版本上有个细节:scikit-learn从 1.0 到 1.3 之间,DecisionTreeClassifier的splitter默认值和随机性处理有过调整,如果你在比较自己手写的决策树和 sklearn 的结果,两边版本不一致会出现树结构对不上的情况。这不是代码写错了,是库的行为变了。练习包里所有对拍脚本都建议锁定同一个大版本。
启动单章演示用 Notebook:
# 在项目根目录启动,Notebook 里用相对路径引 data/ 才不会断 jupyter notebookNotebook 里读数据一律用相对路径data/xxx.csv,因为 Jupyter 的工作目录是启动时所在的目录。你在ch04_tree/下再起一个 notebook 内核,相对路径就会失效,报FileNotFoundError,这几乎是每个新手必踩的一次。
2.3 数据文件与编码约定:先统一接口再谈算法
练习包的价值一半在算法,一半在数据格式的统一。这类包通常约定:X是形状(n_samples, n_features)的ndarray,y是(n_samples,),分类标签用0/1或+1/-1,函数签名统一成fit(X, y)和predict(X)。看着像废话,但正是这条约定让每个算法文件可以互换着跑同一份数据。
教材里的西瓜数据集 3.0 有六个离散属性加两个连续属性,落成 csv 后需要做编码。看这段加载代码:
import pandas as pd import numpy as np def load_watermelon(path="data/watermelon3a.csv"): """加载教材西瓜数据集,返回 (X, y) X: (17, 8) float ndarray,离散列已做序数编码 y: (17,) int ndarray,1 表示好瓜,0 表示坏瓜 """ df = pd.read_csv(path) cat_cols = ["色泽", "根蒂", "敲声", "纹理", "脐部", "触感"] for col in cat_cols: # cat.codes 按字典序编码,保证同一份数据每次编码结果一致 df[col] = df[col].astype("category").cat.codes X = df.drop(columns=["好瓜"]).to_numpy(dtype=float) y = (df["好瓜"] == "是").to_numpy(dtype=int) return X, y这段代码里有三个需要注意的参数级细节。astype("category").cat.codes用的是字典序而不是出现顺序,好处是可复现,坏处是「纹理」编码成 0/1/2 之后,模型会误以为它们有大小关系,而决策树和信息增益这类算法其实不关心编码顺序,逻辑回归就会受影响,所以后面做线性模型时更稳妥的做法是独热编码。dtype=float是必须的,否则离散列是 int64,和后面标准化函数里的 float 运算混在一起会有隐式转换警告。y用== "是"转成 0/1,而不是直接cat.codes,是为了避免标签含义在编码后失真。
另一份常用数据是鸢尾花,sklearn.datasets自带,练习包里一般会包一层:
from sklearn.datasets import load_iris def load_iris_2class(): """只取前两类,方便画二维决策边界""" X, y = load_iris(return_X_y=True) mask = y < 2 return X[mask][:, :2], y[mask] # 只留前两个特征便于可视化取两类的目的是可视化,三类以上没法在平面上画出干净的决策边界。取两个特征也是同样的道理。这些取舍在练习包里很常见,读代码时要意识到「简化是为了看得见」,不是算法只能处理二维。
3. 核心模块拆解:线性模型与决策树的手写实现
这两章是整份练习包的地基。线性模型让你理解梯度到底在更新什么,决策树让你理解「划分」这个动作怎么用信息论量出来。两段代码都不长,但每一行都对应教材上的一段推导。
3.1 线性回归:批量梯度下降的完整循环
先看最容易上手的一段,最小二乘配批量梯度下降:
import numpy as np def linear_regression_gd(X, y, lr=0.01, epochs=2000, tol=1e-8): """线性回归 + 批量梯度下降 X: (n, d) 已标准化,标准化与否直接决定能否收敛 y: (n,) 目标值 lr: 学习率,标准化后 0.01~0.1 通常能收敛 epochs: 最大迭代轮数 tol: 相邻两轮 loss 差值小于该值则提前停 """ n, d = X.shape Xb = np.hstack([np.ones((n, 1)), X]) # 补一列 1,把偏置 b 吸收进权重 w = np.zeros((d + 1, 1)) # 参数初始化为 0,凸问题无需随机初始化 y = y.reshape(-1, 1) loss_hist = [] for it in range(epochs): y_hat = Xb @ w # 前向:(n,1) err = y_hat - y grad = (2.0 / n) * Xb.T @ err # dMSE/dw,注意 2/n 这个系数 w -= lr * grad loss = float(np.mean(err ** 2)) loss_hist.append(loss) if it > 0 and abs(loss_hist[-2] - loss) < tol: break # 提前停止,省时间也防震荡 return w, loss_hist逐点说。np.hstack([np.ones((n,1)), X])这一步叫增广,把b合并进w,后面梯度公式就只剩一个矩阵乘,不用分开写w和b的更新,教材上分开写是为了推导清楚,代码里合并更简洁。grad里的2/n来自均方误差对参数的偏导,如果你把它漏掉,等价于把学习率缩小一半,收敛会慢但不至于错,这也是很多人「代码能跑但就是慢」的原因。tol的判断放在it > 0之后,否则第一轮loss_hist[-2]会索引到空列表。
真正容易翻车的是标准化。这段代码默认X已经标准化过,如果直接拿原始含糖率(0.1 到 0.8 之间)和密度(0.2 到 0.7 之间)喂进去还勉强能跑,一旦换成量纲差三四个数量级的数据,loss 会直接变inf或nan。我一般会在调用前强制过一遍:
def standardize(X, mean=None, std=None): """训练集算 mean/std,测试集复用,避免数据泄漏""" if mean is None: mean, std = X.mean(axis=0), X.std(axis=0) std = np.where(std == 0, 1.0, std) # 常数列保护,防除零 return (X - mean) / std, mean, std注意std == 0的处理。西瓜数据集里如果有某一列全是同一个值(比如某个属性在 17 条样本上取值相同),标准差就是 0,直接除会出nan,然后整个 loss 曲线变成一条nan直线,排查起来很折磨。
逻辑回归的代码结构和这段几乎一样,只换两处:y_hat = sigmoid(Xb @ w),梯度里的2/n变成1/n。教材上把逻辑回归的极大似然推导写了两页,落到代码上就是这么点差别。
3.2 决策树:信息增益落成可执行的划分函数
决策树的核心是「选哪个特征划分」,教材用的是信息增益。先写熵和增益两个纯函数:
from collections import Counter import numpy as np def entropy(y): """信息熵,y 为标签数组,底数 2 对应单位为比特""" cnt = Counter(y) n = len(y) return -sum((c / n) * np.log2(c / n) for c in cnt.values()) def info_gain(X, y, feat_idx): """按第 feat_idx 列划分的信息增益 只适用于离散特征;连续特征需先按候选切分点二分 """ base = entropy(y) # 划分前的熵 vals = np.unique(X[:, feat_idx]) cond = 0.0 for v in vals: mask = X[:, feat_idx] == v cond += mask.sum() / len(y) * entropy(y[mask]) return base - cond # 增益 = 原熵 - 条件熵Counter比np.unique(return_counts=True)在标签是字符串或混合类型时更省事。mask.sum() / len(y)这个权重不能漏,它是每个分支的样本占比,漏掉就变成各分支熵的简单平均,划分准则就错了,而且错得不明显,树还是能建出来,只是结构不对。
递归建树的部分通常长这样:
def build_tree(X, y, feat_names, depth=0, max_depth=5): # 停止条件一:全同类 if len(np.unique(y)) == 1: return {"leaf": True, "label": y[0]} # 停止条件二:特征用尽或达到深度上限 if X.shape[1] == 0 or depth >= max_depth: return {"leaf": True, "label": Counter(y).most_common(1)[0][0]} # 选增益最大的特征 gains = [info_gain(X, y, i) for i in range(X.shape[1])] best = int(np.argmax(gains)) if gains[best] <= 1e-12: # 增益为 0,再分也没意义 return {"leaf": True, "label": Counter(y).most_common(1)[0][0]} node = {"leaf": False, "feat": feat_names[best], "children": {}} for v in np.unique(X[:, best]): mask = X[:, best] == v node["children"][v] = build_tree( np.delete(X[mask], best, axis=1), # 用完的特征要从矩阵里删掉 y[mask], np.delete(feat_names, best), depth + 1, max_depth) return nodenp.delete(X[mask], best, axis=1)这一步是教材伪代码里「从属性集中去掉已用属性」的直接翻译。忘了删会怎样?树会在同一个特征上反复划分,深度爆炸,最后每个叶子一个样本,训练集准确率 100%,测试集一塌糊涂。这是手写决策树最高频的错误,也是理解「递归下降」最直观的例子。gains[best] <= 1e-12这个判断处理的是「所有特征增益都是 0 但标签还没纯」的情况,理论上不会发生,实战中因为浮点误差会出现极小的负数,加个阈值兜底。
max_depth=5是我自己加的参数,教材上为了讲清楚剪枝另开一节,练习包里未必有。17 条样本的西瓜数据不剪枝也能建完,但换成鸢尾花 150 条,不限制深度基本必过拟合。
3.3 用手写实现和 sklearn 对拍
写完手写版,最该做的一件事是拿 sklearn 对拍。不是为了证明自己写得对,而是为了快速定位「哪一步的理解偏了」:
from sklearn.model_selection import StratifiedKFold, cross_val_score from sklearn.tree import DecisionTreeClassifier skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) # 分层抽样保证每折类别比例一致,小样本必备 scores = cross_val_score( DecisionTreeClassifier(criterion="entropy", max_depth=5, random_state=42), X, y, cv=skf, scoring="accuracy") print(f"sklearn 五折: {scores.mean():.4f} ± {scores.std():.4f}")StratifiedKFold而不是普通KFold,因为 17 条样本里正负样本本来就不均衡,普通划分有可能某一折全是正例,准确率就没意义了。shuffle=True配random_state=42是固定划分,否则每次跑出来的分数都不一样,你会以为是算法不稳,其实是数据划分在变。
对拍的正确姿势是:先保证两边用同一份X、y、同一个max_depth,再比准确率。差 10 个点以上说明有实质 bug,差 1 到 2 个点通常是平局时选特征的顺序不同,属于正常。svg 这类库内部对相同增益的特征有固定的选择规则,手写版用argmax取第一个最大值,两者可能不一致,这时候不要急着改代码,先确认数据划分一致。
4. 神经网络与集成学习:两个最容易被跳过的部分
这两章是教材里公式最密的地方,也是练习包里最值得逐行读的代码。BP 网络让你看清链式法则在矩阵层面长什么样,AdaBoost 让你看清「样本权重」这个抽象概念怎么变成几行乘法。
4.1 BP 网络:把反向传播写成矩阵乘
先看一个单隐层的完整实现:
import numpy as np def sigmoid(z): return 1.0 / (1.0 + np.exp(-z)) def bp_network(X, y, hidden=8, lr=0.1, epochs=5000, seed=42): """单隐层 BP 网络,二分类 X: (n, d) 已标准化 hidden: 隐层神经元个数 lr: 学习率,sigmoid + MSE 下不宜超过 0.5 """ rng = np.random.default_rng(seed) n, d = X.shape W1 = rng.normal(0, 0.5, size=(d, hidden)) # 小随机数打破对称 b1 = np.zeros((1, hidden)) W2 = rng.normal(0, 0.5, size=(hidden, 1)) b2 = np.zeros((1, 1)) y = y.reshape(-1, 1) for _ in range(epochs): # 前向传播 H = sigmoid(X @ W1 + b1) # (n, hidden) O = sigmoid(H @ W2 + b2) # (n, 1) # 反向传播:输出层 delta,含 sigmoid 导数 O*(1-O) dO = (O - y) * O * (1 - O) # 隐层 delta,误差经 W2 回传后再乘本层导数 dH = (dO @ W2.T) * H * (1 - H) # 参数更新,除以 n 是对 batch 求平均 W2 -= lr * (H.T @ dO) / n b2 -= lr * dO.mean(axis=0, keepdims=True) W1 -= lr * (X.T @ dH) / n b1 -= lr * dH.mean(axis=0, keepdims=True) return {"W1": W1, "b1": b1, "W2": W2, "b2": b2}rng.normal(0, 0.5, ...)里那个 0.5 是初始化尺度。全零初始化会让同一隐层的所有神经元更新完全一致,等于只有一个神经元在工作,这叫对称失效,教材上专门提过。0.5 这个值不算讲究,更规范的做法是按sqrt(2/fan_in)之类的公式缩放,练习包里简单处理也够用。
dO = (O - y) * O * (1 - O)这一行是链式法则的落点:前面(O - y)是均方误差对输出的偏导,后面O * (1 - O)是 sigmoid 的导数。两者相乘才是对W2的梯度中的一部分。如果你只写(O - y),网络照样能「学」,但学的是线性回归的梯度,输出永远不会被压缩到 (0,1) 区间,这是很多人抄代码抄漏的地方。
dH = (dO @ W2.T) * H * (1 - H)里dO @ W2.T是把输出层误差按权重反向分配回隐层,形状从(n,1)变成(n,hidden),再逐元素乘隐层激活的导数。矩阵乘和逐元素乘在这里必须分清,写成*或@用错一个,形状可能还能对上,结果就是错的,而且不会报错。
还有一个隐蔽的坑:sigmoid 配 MSE 在输出接近 0 或 1 时梯度接近 0,训练几千轮 loss 就卡在 0.25 附近不动。改进办法是把输出层损失换成交叉熵,此时dO直接等于(O - y),导数项被消掉,收敛快很多。练习包里如果只给了 MSE 版本,建议自己加一个交叉熵分支对比着跑,这是理解「损失函数选择影响梯度」最省时间的实验。
4.2 AdaBoost:样本权重更新的两行核心
集成学习那章最容易看糊的是权重更新,落到代码上其实很短:
import numpy as np from sklearn.tree import DecisionTreeClassifier def adaboost(X, y, n_estimators=10): """AdaBoost 二分类,基学习器用深度 1 的决策树(决策 stump) y 取值 0/1,内部转成 +1/-1 参与权重更新 """ n = len(y) w = np.full(n, 1.0 / n) # 初始均匀分布 y_signed = np.where(y == 1, 1, -1) models, alphas = [], [] for _ in range(n_estimators): clf = DecisionTreeClassifier(max_depth=1) clf.fit(X, y, sample_weight=w) # 关键:把 w 传给 sklearn pred = np.where(clf.predict(X) == 1, 1, -1) err = np.sum(w[pred != y_signed]) / w.sum() err = max(err, 1e-10) # 防 log(0) alpha = 0.5 * np.log((1 - err) / err) # 基学习器的话语权 w = w * np.exp(-alpha * y_signed * pred) w = w / w.sum() # 重新归一化成分布 models.append(clf) alphas.append(alpha) return models, alphassample_weight=w是整段的关键。很多人手写 AdaBoost 时自己按权重重采样数据集,效果一样但慢,而且引入了额外的随机性。sklearn 的树支持加权,直接传权重就行,划分时用加权基尼或加权熵。这点如果没意识到,会觉得「书上说要调分布,代码里怎么没见重采样」。
err = max(err, 1e-10)是防除零。当某个基学习器在训练集上完全正确时err = 0,log((1-0)/0)直接炸。真实场景里几乎不会发生,但如果你用很小的数据做 demo,比如 5 条样本,就会撞上。
w = w * np.exp(-alpha * y_signed * pred)这一行是 AdaBoost 的灵魂。y_signed * pred在预测正确时是 +1,错误时是 -1,所以分错的样本权重乘以exp(alpha)被放大,分对的乘以exp(-alpha)被缩小。注意符号,写成exp(alpha * y * pred)就反了,错误样本反而降权,准确率会掉到 50% 以下,而且不报错,只是效果莫名其妙地差。
alpha有个众所周知的边界:当err > 0.5时alpha变负数,这个基学习器实际上在起反作用。教材上的处理是把它的权重设为零或者直接重采样重新训练。练习包里如果没有处理,跑小数据集时会看到准确率先升后降的曲线,这不是实现错,是没做这个兜底。
4.3 SVM:核函数在工程里的正确姿势
教材讲 SVM 花了大篇幅在对偶问题和 KKT 条件上,代码里通常不会手写 SMO,而是调 sklearn。但核函数这块有个必须自己写的环节,就是把核矩阵喂进precomputed模式:
import numpy as np from sklearn.svm import SVC def rbf_kernel(X, Z, gamma=1.0): """RBF 核矩阵,返回 (len(X), len(Z)) gamma 越大,单个样本影响范围越小,越容易过拟合 """ # 用平方展开避免构造 (n, m, d) 的三维数组,省内存 X2 = (X ** 2).sum(axis=1)[:, None] Z2 = (Z ** 2).sum(axis=1)[None, :] d2 = X2 + Z2 - 2 * X @ Z.T return np.exp(-gamma * np.maximum(d2, 0)) # 数值误差可能让 d2 略小于 0 K_train = rbf_kernel(X_train, X_train) clf = SVC(kernel="precomputed", C=1.0).fit(K_train, y_train) # 预测时构造 (n_test, n_train) 的核矩阵,不是 (n_test, n_test) K_test = rbf_kernel(X_test, X_train) pred = clf.predict(K_test)np.maximum(d2, 0)这一手是数值保护。X2 + Z2 - 2XZ^T这个展开式在数学上恒等于欧氏距离平方,但浮点运算下可能出现 -1e-16 这种极小负数,exp(-gamma * 负数)会得到略大于 1 的值,核矩阵不再半正定,sklearn 内部求解可能不收敛。这个问题在你用默认kernel="rbf"时不会遇到,只有走precomputed才需要自己兜。
K_test的形状是最容易错的地方。precomputed模式下,predict期待的是测试样本和训练样本之间的核,形状(n_test, n_train)。写成rbf_kernel(X_test, X_test)形状是(n_test, n_test),样本数和训练集不一样时直接报维度错,一样时(比如 17 条数据分成 12/5,恰好不相等)会报错,但如果碰巧相等就静默出错,预测结果全是乱的。
5. 跑不通的七个地方:环境、数据和数值的排查清单
练习包能不能跑通,八成卡在这三类问题上。下面这几条是我在不同机器上反复撞见的,按现象、原因、解决写清楚,撞上了直接对号入座。
5.1 环境与导入:装上了却 import 不到
现象:pip list里明明有scikit-learn,import sklearn还是报ModuleNotFoundError。
原因:机器上有多个 Python 解释器,pip装进了 A,运行代码用的是 B。以及虚拟环境忘了激活,或者 Notebook 内核选的是系统解释器而不是虚拟环境里的。
解决:统一用python -m pip install,并且用python -c "import sys; print(sys.executable)"打印当前解释器路径,对比pip -V显示的路径是否一致。Jupyter 里还要单独确认内核:jupyter kernelspec list,把虚拟环境注册成内核后重新选。
现象:matplotlib 画出来的图,中文标题全是一片方块。
原因:默认字体 DejaVu Sans 不含中文字形,不会报错,只是渲染成方框。
解决:在绘图脚本开头统一定字体,别每张图单独设:
import matplotlib.pyplot as plt # Windows 用 SimHei,macOS 用 PingFang SC,Linux 按已装字体选 plt.rcParams["font.sans-serif"] = ["SimHei", "PingFang SC", "DejaVu Sans"] plt.rcParams["axes.unicode_minus"] = False # 负号也会变方块,一起关掉5.2 数据与随机性:每次跑结果都不一样
现象:同一个脚本连跑三次,测试集准确率在 0.72 到 0.88 之间跳。
原因:train_test_split没固定random_state,加上样本量小(几十条),一次划分的方差极大。17 条样本按 7:3 分,测试集只有 5 条,错一条就是 20 个百分点。
解决:分两层。第一层固定random_state,保证结果可复现;第二层用StratifiedKFold做五折交叉验证,报均值加减标准差,而不是报单次划分的分数。小样本上单次划分的数字没有任何参考价值。
现象:决策树每次建出来的结构都不一样,但特征顺序没变。
原因:DecisionTreeClassifier在特征取值相同、增益并列时,内部有随机特征置换,没设random_state就会变。另外max_features默认值在分类任务里不是None时也会引入随机性。
解决:显式写random_state=42和max_features=None,手写版则要保证np.argmax在并列时取第一个,两边规则对齐后再比较。
5.3 数值与收敛:loss 变 nan 或卡住不动
现象:线性回归或 BP 网络的 loss 打印出来是nan,从第一轮就是。
原因:三个高频来源,特征没标准化导致梯度爆炸、学习率过大、除零(标准差为 0 的列做了标准化)。BP 网络还多一个:sigmoid里np.exp(-z)在z是很大的负数时溢出。
解决:按顺序排查。先打印X.mean()和X.std()确认量纲,再打印第一轮的grad范数,如果大到 1e6 以上就是学习率问题。sigmoid换成数值稳定的写法:
def stable_sigmoid(z): """分段计算,避免 exp 溢出""" out = np.empty_like(z, dtype=float) pos = z >= 0 out[pos] = 1.0 / (1.0 + np.exp(-z[pos])) ez = np.exp(z[~pos]) out[~pos] = ez / (1.0 + ez) return out现象:BP 网络训练几千轮,loss 一直停在 0.25 附近,准确率 50%。
原因:输出层用 sigmoid 配均方误差,当输出饱和在 0 或 1 附近时梯度接近 0,参数几乎不动。初始权重太大也会加速饱和。
解决:把损失换成交叉熵,dO直接写成(O - y),去掉O*(1-O)这一项;或者把初始化尺度从 0.5 降到1/sqrt(d);再不行加动量项。三个改法里换损失函数收益最大。
现象:SVC(kernel="precomputed")在predict时报特征数不匹配。
原因:传进去的核矩阵形状不对。训练时是(n_train, n_train),预测时必须是(n_test, n_train)。
解决:把核矩阵的.shape打印出来,和clf.shape_fit_对比。这个属性记录了训练时的样本数,对不上就是形状错了。养成在precomputed分支里断言形状的习惯,比事后 debug 省时间。
6. 二次开发:把练习脚本改成自己的实验骨架
练习包最大的问题是每个脚本自成一体,跑完就扔。想拿它做自己的实验,第一步是把散落的fit/predict收成统一接口,这样换算法只需要换一个类名:
class BaseModel: """所有手写模型的统一父类,配合交叉验证函数使用""" def fit(self, X, y): raise NotImplementedError def predict(self, X): raise NotImplementedError def score(self, X, y): return float((self.predict(X) == y).mean())有了这层,写一个通用的评估函数就能跑遍包里所有模型,不用为每个脚本单独写循环:
def evaluate(model, X, y, n_splits=5, seed=42): """分层 K 折,返回 (mean, std),模型需每次重新实例化""" from sklearn.model_selection import StratifiedKFold skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed) scores = [] for tr, te in skf.split(X, y): m = model.__class__(**model.get_params()) # 深拷贝一份,避免状态残留 m.fit(X[tr], y[tr]) scores.append(m.score(X[te], y[te])) return float(np.mean(scores)), float(np.std(scores))model.get_params()这一手要求模型实现get_params/set_params,sklearn 的估计器天然支持,手写类补上就能混用。m = model.__class__(...)而不是直接复用model,是为了避免上一折训练出的参数污染下一折,这个坑我在早期做对比实验时踩过,分数虚高得离谱,查了半天才发现是模型没重置。
最后给一个我常用的对比表模板,把包里的手写实现和 sklearn 版本放一起跑,差多少一目了然:
| 算法 | 手写实现准确率 | sklearn 准确率 | 主要差异来源 |
|---|---|---|---|
| 决策树 | 0.76 ± 0.08 | 0.79 ± 0.06 | 并列增益时的特征选择顺序 |
| AdaBoost | 0.82 ± 0.07 | 0.82 ± 0.07 | 基学习器完全一致,无差异 |
| BP 网络 | 0.74 ± 0.10 | 0.80 ± 0.05 | 初始化、损失函数、迭代轮数 |
表里的数字不重要,重要的是「主要差异来源」这一列怎么写。写不出来,说明你还没真正搞清楚两个实现的区别。
那次我为了让手写决策树和 sklearn 对齐,熬到半夜改代码,最后发现是两边max_depth一个默认None一个设了 5,跟算法本身毫无关系。从那以后我每次做对拍实验,都强制先把两边的参数打印成一行贴在一起看,确认逐项一致再跑。改练习代码这件事,慢就是快,希望帮到你。
本文还有配套的精品资源,点击获取