简介:一份面向机器学习初学者与算法学习者的Python实现代码包,覆盖概率统计基础概念、Apriori、决策树、HMM维特比、朴素贝叶斯、逻辑回归以及标准线性回归、局部加权线性回归和岭回归等常用算法。压缩包共38个文件,以Python脚本、Markdown笔记、JPG/PNG图片和PDF文档为主,其中5个py可直接运行演示,7个md记录算法推导与《统计学习方法》学习总结,多张图片辅助理解公式与流程,整体约29.26MB。已有289人学习下载,适合理论对照代码、课程实验或面试复习。通过源码、笔记与图示结合,读者可快速复现常见算法流程,并理解均值、方差、协方差等统计概念在模型中的实际应用,按目录分模块浏览更加高效。
1. 这套机器学习算法源码包:先回答三个值不值得下的问题
机器学习算法这东西,光调库是学不到东西的——sklearn 一行 fit 能帮你出结果,但你不知道背后发生了七次矩阵运算还是三次概率连乘。这套基于 Python 的机器学习算法源码包,把《统计学习方法》里那些经典算法的理论总结和手写实现放在一起,包括 Apriori、决策树、HMM 维特比、朴素贝叶斯、逻辑回归,以及三种线性回归变体。适合两种人:刚入门 Python 想搞懂算法原理的,和准备算法面试需要快速过一遍手写实现的。包里还有 ml_notes、algo_notes、math_notes 三块笔记,外加约十个可直接运行的脚本,README 里标注了阅读顺序。比起到处找《图解机器学习算法》的 PDF,这批代码的注释密度对 python 入门阶段的人反而更友好。下载后别急着跑代码,先按本文把目录结构和几个关键坑过一遍,能省下不少时间。
2. 概率统计与《统计学习方法》:动手前先把数学底子补齐
2.1 ml_notes 和 algo_notes:两套笔记的分工
这个包在源码之外先放了两套笔记,很多人下载后直接跳过,这是最亏的。ml_notes 对应的是《统计学习方法》全书的学习总结,按章节记录了感知机、K 近邻、朴素贝叶斯、决策树、SVM、Adaboost、EM、HMM、CRF 这些模型的推导要点;algo_notes 则是算法实现笔记,讲的是 Apriori、决策树、HMM 这些代码在写的时候要注意什么。两套笔记的定位不一样:一个是「为什么有这个算法」,一个是「这个算法在代码里长什么样」。
我一般建议的阅读顺序是:先花一晚上把 ml_notes 里对应章节过一遍,建立模型之间的关联,再去看 algo_notes 和源码。比如你要跑决策树,先看 ml_notes 里信息增益的定义,再打开决策树代码,你会发现代码里每一行都是在算那个公式。跳过笔记直接看代码也能跑通,但遇到调参就懵——你不知道改的是哪个公式里的哪个变量。
另外提一句,如果你需要做算法汇报 PPT,直接从 ml_notes 里抄章节结构就行,它本身就是按《统计学习方法》的目录组织的,比自己重新梳理省事很多。
2.2 概率统计概念:总体、样本与无偏估计
包里的数学笔记部分整理了一批概率统计基础概念,包括总体均值、总体方差、样本均值、样本方差、无偏估计和有偏估计、样本标准差、样本协方差与协方差矩阵。这些概念是后面所有算法的地基,尤其是「无偏估计」这四个字,直接关系到你写代码时方差分母到底除以 n 还是除以 n−1。
总体方差是对整个总体求平均,公式里分母是总体规模 N;但实际场景里你拿到的永远是样本,如果直接用样本均值代替总体均值、再用 n 做分母,算出来的样本方差会系统性偏小。数学上可以证明,除以 n−1 得到的才是总体方差的无偏估计。这就是「自由度」的直观含义:样本均值已经被估计出来了,n 个样本里只有 n−1 个是自由的。
import numpy as np rng = np.random.default_rng(42) data = rng.normal(170, 5, size=100) # 模拟 100 个身高样本,真实均值 170,标准差 5 var_n = np.sum((data - data.mean()) ** 2) / len(data) # 除以 n var_n1 = np.sum((data - data.mean()) ** 2) / (len(data) - 1) # 除以 n-1 print("除以 n 的方差:", var_n) print("除以 n-1 的方差:", var_n1)这段代码用同一份数据算两个方差值,你会发现除以 n 的那个数字总会略小一点。样本量越大,两者的差距越小;样本量只有二三十时,这个差距会明显到影响后续判断。平时用 numpy 的np.var(data)默认就是除以 n,要拿它做无偏估计得显式传ddof=1。这个参数在包里多处用到,看代码时留意一下。
2.3 协方差与协方差矩阵:特征相关性的起点
协方差衡量两个特征一起变化的趋势:为正说明同增同减,为负说明反向变动,接近零说明两者关系不大。协方差矩阵把这种关系扩展到所有特征对之间,对角线是各特征自己的方差,非对角线是两两之间的协方差。
在包里的线性回归和逻辑回归代码中,都会出现X.T * X这类计算,它就是协方差矩阵的雏形。为什么X.T * X这么重要?因为线性回归的最小二乘解w = (XᵀX)⁻¹Xᵀy里,矩阵求逆的可行性完全取决于XᵀX是否可逆。如果两个特征高度相关,XᵀX的行列式趋近于零,求逆结果会剧烈震荡,这就是后面岭回归要解决的问题。
看 math_notes 里的公式时,建议拿笔把XᵀX展开写一遍:每一行是一个样本,每一列是一个特征,转置相乘后第 i 行第 j 列刚好是所有样本在第 i 个特征和第 j 个特征上的乘积求和。想通这一步,协方差矩阵和最小二乘之间的关系就彻底打通了。
2.4 math_notes:两张图怎么配合代码看
包里 res/math_notes 目录下的 math1.jpg 和 math2.jpg 是手写的数学推导笔记,内容覆盖矩阵求导、概率分布、梯度推导这些高频考点。这两张图适合在跑代码跑不明白的时候回头看,尤其是逻辑回归的梯度上升推导,只看文字很容易绕晕,配合手写推导能快速定位自己在哪一步断了。
我的习惯是:遇到一个看不懂的公式,先在代码里找到对应变量,把公式里的每个符号映射到代码变量名,再回到笔记看推导。比如逻辑回归的权重更新式weights = weights + alpha * data_matrix.T * error,error对应label - sigmoid(data_matrix * weights),alpha是学习率。做一次符号映射,比对着公式发呆半小时有效得多。
3. Apriori、决策树、HMM:三个经典算法的源码拆解
3.1 Apriori:频繁项集生成的剪枝逻辑
Apriori 解决的是关联规则挖掘问题,核心思想一句话:如果一个项集是频繁的,那它的所有子集也必须是频繁的;反过来,如果一个项集是非频繁的,那它的所有超集都不可能是频繁的。这个性质叫先验性质,Apriori 算法就是靠它来剪枝,避免生成海量候选集。
这是我见过初学者最容易懵的地方——为什么生成候选集时要先比较前 k−2 项?因为要保证合并出来的 k 项集的所有 k−1 项子集都已经被验证为频繁。如果两个 k−1 项集的前 k−2 项不同,合并出来的 k 项集必然有一个 k−1 项子集不在频繁集里,可以直接剪掉。
def apriori_gen(lk, k): """由频繁 k-1 项集生成候选 k 项集""" ret_list = [] len_lk = len(lk) for i in range(len_lk): for j in range(i + 1, len_lk): # 取前 k-2 项比较,相同才合并 l1 = list(lk[i])[:k - 2] l2 = list(lk[j])[:k - 2] l1.sort() l2.sort() if l1 == l2: ret_list.append(lk[i] | lk[j]) # 集合求并集 return ret_list这段代码需要配合两个参数理解:lk是频繁 k−1 项集的列表,每个元素是一个 frozenset;k是当前要生成的候选集大小。lk[i] | lk[j]是集合合并操作,比如{1, 2}和{1, 3}合并成{1, 2, 3}。之所以要求前 k−2 项相同,是为了保证合并不重复——如果两个集合前 k−2 项不同,合并结果已经被其他组合生成过了。
这个生成函数要配合两个步骤使用:先用初始数据生成所有单元素项集 C1,再逐层往上扫描数据集统计支持度,过滤掉支持度低于阈值的候选集。支持度就是「这个项集在多少条事务里出现过」除以总事务数,它是 Apriori 唯一的调参入口——阈值设太低,候选集爆炸,跑起来奇慢;设太高,挖不出有意义的规则。
3.2 决策树:信息增益的计算与特征选择
决策树这块代码对应的是 ID3 算法的核心逻辑:每次选择一个特征来划分数据,选特征的标准是信息增益最大。信息增益的定义是划分前的熵减去划分后的加权熵,熵越低说明数据越纯,信息增益越大说明这个特征带来的纯度提升越多。
import numpy as np def calc_entropy(data_set): """计算数据集的经验熵 H(D)""" label_counts = {} for feat_vec in data_set: label = feat_vec[-1] # 最后一列是标签 label_counts[label] = label_counts.get(label, 0) + 1 entropy = 0.0 total = len(data_set) for label in label_counts: prob = float(label_counts[label]) / total entropy -= prob * np.log2(prob) return entropy def split_data_set(data_set, axis, value): """按第 axis 列是否等于 value 划分数据集""" ret_data_set = [] for feat_vec in data_set: if feat_vec[axis] == value: # 去掉第 axis 列特征,避免重复使用 reduced = feat_vec[:axis] + feat_vec[axis + 1:] ret_data_set.append(reduced) return ret_data_setcalc_entropy的输入是二维列表,每行最后一个元素是类别标签。prob是某一类样本占比,信息熵就是-Σp·log2(p)。当数据全部属于同一类时熵为 0,这是最纯的状态;类别各占一半时熵最大,数据最混乱。split_data_set负责按特征值切分数据,返回的新数据集里已经去掉了用过的特征列。
建树时对每个特征都做一次切分,计算切分后各子集的熵,再用样本占比加权求和,得到条件熵。父集熵减去条件熵就是信息增益,选择增益最大的特征作为当前划分节点。这就是包里决策树代码的主流程。实际跑的时候要注意:特征必须是离散值,连续值需要先离散化;如果某个特征的取值特别多,ID3 会倾向于选它,因为细粒度划分天然能降低熵——这是 ID3 的已知缺陷,C4.5 用信息增益率来修,代码里没有实现后者,理解到这个层次就够了。
3.3 HMM 维特比:状态序列的动态规划求解
HMM 解决三类问题:概率计算(给定模型和观测序列,算观测序列出现的概率)、学习(给定观测序列,估计模型参数)、解码(给定模型和观测序列,求最可能的状态序列)。这个包里实现的是维特比算法,属于第三类解码问题,也是实际应用最广的——语音识别里把声学特征映射到音素,基因序列里找编码区,本质都是维特比。
维特比算法的本质是动态规划:定义V[t][s]为「到第 t 个观测时,处于状态 s 的所有路径中概率最大的那条路径的概率」,递推关系是当前状态的概率等于上一时刻所有状态概率乘以转移概率再乘以发射概率的最大值。
def viterbi(obs, states, start_p, trans_p, emit_p): """维特比解码:返回最优路径概率和对应的状态序列""" V = [{}] # V[t][s] = 到 t 时刻状态为 s 的最大概率 path = {} # path[s] = 以 s 结尾的最优状态序列 # 初始化 t=0 for s in states: V[0][s] = start_p[s] * emit_p[s][obs[0]] path[s] = [s] # 递推 t=1..T-1 for t in range(1, len(obs)): V.append({}) new_path = {} for s in states: # 遍历上一时刻所有状态,挑概率最大的那个跳转来源 prob, prev = max( (V[t - 1][s0] * trans_p[s0][s] * emit_p[s][obs[t]], s0) for s0 in states ) V[t][s] = prob new_path[s] = path[prev] + [s] path = new_path # 终止:取最后一时刻概率最大的状态 best_prob, best_state = max((V[-1][s], s) for s in states) return best_prob, path[best_state]参数含义:obs是观测序列列表,states是状态集合,start_p是初始状态概率字典,trans_p是状态转移概率字典(trans_p[s0][s]表示从 s0 转移到 s 的概率),emit_p是发射概率字典(emit_p[s][o]表示状态 s 产生观测 o 的概率)。
这里最容易踩的坑是概率连乘导致数值下溢。观测序列一长,几十个小于 1 的概率乘在一起,浮点数直接变成 0,最后所有路径概率都是 0,回溯出来的状态序列毫无意义。常见解法是在 log 域计算,把乘法变成加法。这个包里的实现没有做 log 域处理,所以你拿它跑长序列时如果发现V里全是 0,别怀疑代码逻辑,自己把乘法改成np.log加法即可。
4. 文本分类双方案:朴素贝叶斯与逻辑回归的代码对比
4.1 朴素贝叶斯:多项式模型与拉普拉斯平滑
针对文本分类的朴素贝叶斯,用的是多项式模型:把一篇文档看成一系列词的独立出现,文档属于某个类别的概率等于类别先验概率乘以每个词在该类别下出现概率的连乘。这里的「朴素」体现在一个强假设上——所有词的出现互相独立,明知道这个假设不成立,数学上却极其好算。
import numpy as np def train_nb(train_matrix, train_category): """朴素贝叶斯训练:统计每个词在各类别下的条件概率""" num_docs = len(train_matrix) num_words = len(train_matrix[0]) # 类别先验概率 P(侮辱类) p_abusive = sum(train_category) / float(num_docs) # 拉普拉斯平滑:分子初始化为 1,分母初始化为 2 p0_num = np.ones(num_words) p1_num = np.ones(num_words) p0_denom = 2.0 p1_denom = 2.0 for i in range(num_docs): if train_category[i] == 1: p1_num += train_matrix[i] # 累加该类下每个词的出现次数 p1_denom += sum(train_matrix[i]) # 累加该类下的总词数 else: p0_num += train_matrix[i] p0_denom += sum(train_matrix[i]) # 取对数,防止多个小概率连乘下溢 p0_vec = np.log(p0_num / p0_denom) p1_vec = np.log(p1_num / p1_denom) return p0_vec, p1_vec, p_abusivetrain_matrix是一个二维数组,每行是一篇文档的词向量表示,元素为 0 或 1(这个词在不在文档里,或者是 TF-IDF 权重);train_category是对应的类别标签列表。分子p1_num统计每个词在侮辱类文档中出现的总次数,分母p1_denom统计侮辱类文档的总词数,两者相除就是每个词的条件概率估计。
拉普拉斯平滑是必须的:如果某个词在训练集的侮辱类里从没出现过,它的条件概率就是 0,连乘时会让整个文档被判为概率 0。分子加 1、分母加词表大小(这里简化为 2),保证每个词的概率都大于 0。注意这里取了对数,预测时直接累加 log 概率而不是连乘原始概率,否则和维特比一样会遇到下溢。
预测阶段对每个类别累加log(先验) + Σlog(条件概率),取最大值的类别作为预测结果。因为对数函数是单调的,所以取 log 不影响相对大小比较,但能把连乘变成连加,数值上安全得多。
4.2 逻辑回归:梯度上升与文本特征向量化
同一个文本分类任务,逻辑回归走的是另一条路:它不是统计词频,而是学一组权重,每个词对应一个权重,加权求和后过 sigmoid 函数得到属于正类的概率。决策边界是线性的,所以逻辑回归是个线性分类器。
def sigmoid(z): """sigmoid 函数:把任意实数压缩到 0~1 之间""" return 1.0 / (1.0 + np.exp(-z)) def grad_ascent(data_mat, class_labels, max_cycles=500): """批量梯度上升:最大化对数似然""" data_mat = np.mat(data_mat) label_mat = np.mat(class_labels).transpose() # 转为列向量 m, n = np.shape(data_mat) alpha = 0.01 # 学习率 weights = np.ones((n, 1)) # 权重初始化为 1 for _ in range(max_cycles): h = sigmoid(data_mat * weights) # 预测概率 error = label_mat - h # 预测值与真实值之差 weights = weights + alpha * data_mat.transpose() * error return weightsdata_mat每一行是一个样本的特征向量,文本场景下就是词向量;class_labels是二分类标签。核心更新公式weights = weights + alpha * Xᵀ * error,其中error = y - h。这个形式看起来像在减误差,实际上它是对数似然函数的梯度上升:把负对数似然当作损失函数,对其求导发现梯度恰好等于Xᵀ(y - h),所以沿着这个方向更新权重就是在最大化似然。
alpha控制每一步的步长,max_cycles控制迭代轮数。这两个参数是逻辑回归唯一的两个旋钮:alpha 太大会震荡不收敛,太小收敛得慢;max_cycles太小模型没学完,太大会过拟合。值得注意的是这里用的是批量梯度上升,每轮迭代都要把所有样本算一遍,数据量大时建议改成随机梯度上升——每次只用一个样本来更新权重,代码改动只有一行(去掉sum的循环),收敛速度会快很多。
和朴素贝叶斯有个本质区别:逻辑回归的权重是学出来的,特征之间可以互相补偿;朴素贝叶斯是数出来的,每个词独立投票。这导致逻辑回归在小样本上容易过拟合,朴素贝叶斯小样本反而更稳。
4.3 两个模型怎么选:一张表说清楚
| 对比维度 | 朴素贝叶斯 | 逻辑回归 |
|---|---|---|
| 训练方式 | 统计词频,单遍扫描 | 迭代优化,多轮遍历 |
| 小样本表现 | 好,先验正则化 | 差,容易过拟合 |
| 特征独立性假设 | 强假设,实际常违反 | 无假设,权重互相补偿 |
| 可解释性 | 每个词独立贡献 | 权重正负反映方向 |
| 训练速度 | 极快 | 依赖迭代轮数 |
| 超参数 | 拉普拉斯平滑系数 | 学习率、迭代轮数、正则项 |
我的经验是:如果特征是稀疏的 0/1 词向量、样本量不大、且你希望快速出一个能解释的 baseline,先跑朴素贝叶斯;如果样本量过万、特征经过 TF-IDF 加权、且你想要更好的分类精度,上逻辑回归。包里的两个实现刚好覆盖这两种典型场景,代码风格也接近,对比着读一遍,比各刷十道题更能理解「生成式」和「判别式」模型的差别。
5. 线性回归三件套:从最小二乘到岭回归,附五个常见坑
5.1 标准线性回归:正规方程一把梭
线性回归的目标是找一组权重 w,让y = Xw的预测误差平方和最小。对误差平方和求导并令导数为零,可以直接解出闭式解w = (XᵀX)⁻¹Xᵀy,这就是正规方程。不用迭代,一步到位,样本量不大时这是最省事的方案。
import numpy as np def stand_regress(x_arr, y_arr): """标准线性回归:正规方程求解""" x_mat = np.mat(x_arr) y_mat = np.mat(y_arr).T xTx = x_mat.T * x_mat # 行列式为 0 说明矩阵不可逆,求逆会失败 if np.linalg.det(xTx) == 0: return None return xTx.I * (x_mat.T * y_mat) # 等价于 np.linalg.inv(xTx) * x_mat.T * y_matx_arr是特征矩阵,第一列一般是 1(对应偏置项 bias);y_arr是目标值列表。xTx.I是矩阵求逆,xTx可逆的前提是特征之间不存在完全共线性。如果两个特征完全线性相关,xTx行列式为 0,代码会返回 None。实际数据里很少出现完全共线,但高度相关会让xTx的行列式趋近于 0,求逆结果奇大无比,预测值漂到离谱——这就是「病态矩阵」问题。
正规方程的时间复杂度是 O(n³),n 是特征数。特征数上万时算xTx的逆会很吃力,这时应该切换到梯度下降。包里的实现只覆盖了特征数较少的情况,特征维度高的时候你需要自己换方案。
5.2 局部加权线性回归:k 值决定拟合与过拟合
标准线性回归得到的是一个全局模型,所有样本共享同一组权重。局部加权线性回归(LWLR)换了个思路:预测每个点的时候,只让它附近的样本说话,离得远的样本权重小。权重由高斯核函数生成,k参数控制「附近」的范围。
def lwlr(test_point, x_arr, y_arr, k=1.0): """局部加权线性回归:对单个测试点预测""" x_mat = np.mat(x_arr) y_mat = np.mat(y_arr).T m = np.shape(x_mat)[0] # 对角矩阵,每个对角线元素是该样本的权重 weights = np.mat(np.eye(m)) for j in range(m): diff = test_point - x_mat[j] # 高斯核:距离越近权重越大,k 控制衰减速度 weights[j, j] = np.exp(diff * diff.T / (-2.0 * k ** 2)) xTx = x_mat.T * (weights * x_mat) if np.linalg.det(xTx) == 0: return None ws = xTx.I * (x_mat.T * (weights * y_mat)) return test_point * wstest_point是待预测的样本,k是带宽参数。weights是一个 m×m 的对角矩阵,第 j 个对角线元素表示第 j 个训练样本对当前测试点的影响力。diff * diff.T是当前测试点与某个训练样本的欧氏距离平方,k越小,指数衰减越快,真正参与建模的样本越少。
这个算法没有训练过程,每次预测都要重新遍历所有样本算权重,预测 m 个点的时间复杂度是 O(m×n³),非常慢。k是唯一参数:k太大退化成标准线性回归,太小则过拟合,每个点都被最近的一两个样本带着走,拟合曲线剧烈震荡。包里的示例数据是散点拟合,k=0.1能看到曲线贴合每个点,k=1.0曲线变平滑,你可以自己切换几个值直观感受一下。
5.3 岭回归:当 X^T X 不可逆时的后悔药
当特征之间存在多重共线性(两个特征强相关),或者特征数比样本数还多时,XᵀX不可逆或者病态严重,直接求逆的结果没有意义。岭回归的做法很简单:在XᵀX的对角线上加一个常数 λ,变成XᵀX + λI。加了 λI 之后,矩阵一定是可逆的,而且 λ 越大,权重的数值越小,对过拟合的抑制越强。
def ridge_regression(x_mat, y_mat, lam=0.2): """岭回归:加 L2 正则项的最小二乘""" x_mat = np.mat(x_mat) y_mat = np.mat(y_mat).T xTx = x_mat.T * x_mat # 对角线上加 lam,保证矩阵可逆 denom = xTx + np.eye(np.shape(x_mat)[1]) * lam if np.linalg.det(denom) == 0: return None ws = denom.I * (x_mat.T * y_mat) return wslam是正则化系数,对应损失函数里λ||w||²那一项。λ 从 0 开始增大时,权重绝对值被压缩,模型从过拟合慢慢走向欠拟合。这里有个配套操作:用岭回归前必须对特征做标准化,否则量纲大的特征会被惩罚得更狠——因为 L2 正则对所有权重一视同仁,特征数值范围不同会导致正则效果失真。
包里的实现是单一 λ 求解,实际使用时通常要跑一个 λ 的网格搜索,画岭迹图(横轴是 λ,纵轴是各权重系数),看权重什么时候趋于稳定。λ 太小等于没加正则,λ 太大模型被压成一条水平线,这个度得靠交叉验证来选。
5.4 五个常见坑:现象、原因、解决
坑一:LWLR 预测结果全是接近 0 的小数
现象:跑局部加权线性回归,输出的预测值全部在 0 附近徘徊,和真实值完全对不上。
原因:特征没有归一化。diff * diff.T计算的是原始尺度上的欧氏距离,如果特征量纲是几千几万,距离平方会爆炸,高斯核的指数部分变成极大的负数,exp结果直接下溢为 0,所有样本权重都是 0。
解决:对特征做标准化,让每个特征均值为 0、方差为 1,再跑 LWLR。这也是包里的示例代码能跑出正常结果而你的数据翻车的最常见原因。
坑二:维特比算法在长序列上所有路径概率为 0
现象:观测序列一长,V矩阵里全是 0,最终回溯出来的路径是随机选的。
原因:概率连乘下溢。比如 200 个观测,每个发射概率 0.1,乘起来是 10⁻²⁰⁰,远低于浮点数能表示的最小值。
解决:全程用 log 概率。把start_p[s] * emit_p[s][obs[0]]改成np.log(start_p[s]) + np.log(emit_p[s][obs[0]]),递推里的乘法全部换成加法。路径回溯逻辑不用动,比较大小不受 log 单调性影响。
坑三:朴素贝叶斯遇到没见过的词直接判 0
现象:测试文档里某几个词在训练集的某个类别下从没出现过,整篇文档被判为该类别的概率为 0。
原因:分子为 0,条件概率为 0,连乘后整个概率为 0,没有做平滑处理。
解决:用拉普拉斯平滑,分子加 1、分母加词表大小。包里代码已经写了,但如果你自己复现时漏掉这一步,短文本上会非常明显——训练集覆盖越少,翻车概率越高。
坑四:岭回归 λ 设太大,训练集误差反而升高
现象:λ 从 0.1 调到 100,测试误差先降后升,但训练误差一路飙升,模型像是「什么都没学」。
原因:L2 正则把所有权重压向 0,模型容量被严重限制,偏差主导了误差。λ 不是越大越好,它是在偏差和方差之间找平衡。
解决:用交叉验证选 λ。把 λ 按对数尺度从 0.001 到 100 排一排,每个 λ 跑 K 折交叉验证,画验证误差曲线,取最低点附近的 λ。血泪经验:不要直接抄别人论文里的 λ,数据不同最优值差几个数量级很正常。
坑五:决策树不设深度限制,训练集上的「准确率」假象
现象:决策树在训练集上准确率接近 100%,测试集上一塌糊涂,而且树深得离谱。
原因:ID3 算法不加停止条件会把每个样本都分到单独的叶子节点,完美记住训练集,等于背答案。
解决:限制最大深度、最小叶子样本数,或者用预剪枝——每次划分前用验证集评估,划分后验证集误差不再下降就停止。包里的决策树代码是教学版,没有剪枝,用的时候要自己补这一层。
6. 把源码包改造成自己的算法库:三条验证路径
6.1 拿 sklearn 当守门员
手写算法的最大风险是不知道自己写错了。我的习惯是把 sklearn 的实现当作「标准答案」,用自己的实现和它对跑同一份数据,对比结果。这不丢人——手写代码的目的是理解原理,不是为了替代 sklearn。
from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error # 自己的实现 w = stand_regress(x_train, y_train) pred_mine = np.mat(x_test) * w # sklearn 的实现 lr = LinearRegression().fit(x_train, y_train) pred_sklearn = lr.predict(x_test) print("MSE (mine):", mean_squared_error(y_test, pred_mine)) print("MSE (sklearn):", mean_squared_error(y_test, pred_sklearn))如果两个 MSE 差了超过 1%,先检查自己的输入格式——sklearn 默认要二维数组,手写实现要 numpy 矩阵,列顺序不一致就会对不上。如果格式没问题,再检查有没有做同样的标准化。这套「对照实验」能筛掉九成的低级错误。
6.2 固定随机种子,写一个冒烟测试脚本
数据切分、初始化权重都有随机性,不固定种子的话,两次跑出来的结果不一样,你根本分不清是代码改了还是运气变了。包里的代码没有统一处理随机种子,所以我每次跑之前都会加一行:
import numpy as np np.random.seed(42)然后准备一个几十行的迷你数据集,把每个算法都跑一遍冒烟测试,只验证「能不能跑通、形状对不对」,不追求精度。逻辑回归看一下梯度是否在下降,决策树看一下特征选择结果是否合理,HMM 看一眼路径长度是不是等于观测序列长度。跑通了再上大数据集,能省下大量 debugging 时间。
6.3 把公式抄进注释里
这套源码有价值的地方在于理论和代码是配套的,但代码本身基本没有把对应公式写进注释。我读的时候做了一件收益极高的事:每读完一个算法,把核心公式用注释补在函数上方。比如在stand_regress上面补# w = (XᵀX)⁻¹Xᵀy,在逻辑回归的梯度上升上补# w := w + α·Xᵀ(y - σ(Xw))。
从那以后我每次写完一个算法都强制走一遍这个流程——先用 sklearn 对拍验证正确性,再固定随机种子跑冒烟测试,最后把公式抄进注释里。半年后回头翻代码,看到注释里的公式,三秒就能想起当时的设计思路,比重新读一遍实现快得多。这个习惯让我跑通了包里所有算法,也让我后来换到 TensorFlow 和 Keras 时,能清楚知道框架里那些fit调用背后到底在算什么。希望帮到你。
本文还有配套的精品资源,点击获取