简介:面向机器学习入门者的Python算法实现与理论笔记整理包,围绕《统计学习方法》梳理了概率统计中的总体/样本均值、方差、无偏估计、协方差矩阵等基础概念,并给出Apriori、决策树、HMM维特比、朴素贝叶斯、Logistic回归及三类线性回归(标准、局部加权、岭回归)的Python实现代码,覆盖分类、关联分析、序列标注与回归任务,适合正在学习ML原理并希望动手验证的读者。资源共38个文件,以5个py算法脚本、7个md笔记文档、23张jpg/png示意图为主体,另含pdf汇总说明与txt辅助材料,整体压缩包约29.26MB,目录模块清晰,便于按算法检索对照。目前已有289人学习浏览,可用于课程复习、算法对比实验和课后编码练习,帮助从理论推导过渡到代码落地。
1. 机器学习算法源码包:一份能把《统计学习方法》跑起来的 Python 代码库
这份 zip 打开之后,不是那种只有 README 的空壳仓库,而是把机器学习算法里最常被点名的几类全部用 Python 写成了可运行脚本:标准的线性回归、局部加权线性回归、岭回归、文本分类用的朴素贝叶斯与逻辑回归、Apriori 关联分析、决策树,还有 HMM 模型里的 Viterbi 解码算法。除了代码,里面还带了两份能反复翻的笔记:一份概率统计常见概念总结,一份《统计学习方法》的阅读总结,外加 math_notes 目录和 math1.jpg、math2.jpg 两幅手写推导图。
适合谁用,一句话说清楚:正在啃统计学习方法、想把公式一个个变成能跑的 Python 代码的人;以及做课程设计、毕业设计需要算法演示的从业者。这类人最大的痛点不是看不懂理论,而是公式和代码对不上号——这份资源恰好把两边都备齐了。按照本文后面的顺序,半天之内可以把这个包完整跑一遍,顺带搞清楚每个算法的参数边界。
2. 概率统计笔记与算法理论:先看懂推导再动手跑代码
2.1 math_notes 与 ml_notes:两份笔记的配合阅读顺序
解压后第一件事,我建议不是直接跑代码,而是先看目录结构。整个仓库长这样:
MachineLearningAlgorithm-master ├── ml_notes ├── Keras ├── sklearn ├── Projects ├── TensorFlow ├── algo_notes ├── Apriori ├── NB&LR ├── LinearRegression ├── HMM ├── DesicionTree ├── math_notes ├── math1.jpg ├── math2.jpg └── README.md注意两个细节:目录里写的是 DesicionTree,这是作者保留的历史命名,里面的实现就是决策树;除了经典算法,仓库还开了 Keras、TensorFlow、sklearn 三个实践目录,说明作者当年是把深度学习和传统算法放在一起维护的。
math_notes 对应的是摘要里说的概率统计常见概念总结,内容包括总体均值、总体方差、样本均值、样本方差、无偏估计、有偏估计、样本标准差、样本协方差和协方差矩阵。这些概念看着基础,但后面所有算法都绕不开它们。我一般的阅读顺序是:先过一遍 math_notes 里的概念表,再翻"统计学习方法总结",最后才打开算法脚本。直接看代码的问题在于,你根本不知道岭回归里的那个 I 矩阵为什么要加,也不知道朴素贝叶斯里的先验概率到底是从哪来的——这些答案全在概率统计笔记里。
2.2 从公式到 numpy:无偏估计与协方差矩阵的落地
概率统计总结里最容易让人困惑的一组概念是总体方差和样本方差。总体的方差是除以 N,但样本方差要除以 n-1,这就是无偏估计的含义。用 numpy 实现的话,关键在于 ddof 参数,我通常会写成这样:
import numpy as np def mean_and_var(x, ddof=1): """计算均值和方差,ddof=1 表示无偏估计,ddof=0 表示有偏估计""" n = len(x) mean = np.sum(x) / n var = np.sum((x - mean) ** 2) / (n - ddof) return mean, var def sample_cov(X): """计算样本协方差矩阵,X 的每一行是一个样本,每一列是一个特征""" n, d = X.shape mean = np.mean(X, axis=0) centered = X - mean cov = centered.T @ centered / (n - 1) return cov这里 ddof=1 是统计意义上的无偏,代价是分母变小、方差变大,但期望值正好等于总体方差。在机器学习里,除非数据量小到 n 只有几十,否则 ddof 取 0 还是 1 对模型参数影响不大,真正的差异体现在协方差矩阵上。
第二段代码里的 sample_cov 用的是 centered.T @ centered,也就是先把每个特征减去均值,再做内积。这里的 @ 是 Python 3.5 之后的矩阵乘法运算符,等价于 np.dot。协方差矩阵的对角线是各特征的方差,非对角线是两两特征之间的共变关系。这个矩阵在后面的线性回归和 HMM 的发射概率估计里都会出现。源码包里对应的 numpy 实现路径是 np.cov(X, rowvar=False),rowvar=False 是为了告诉 numpy 每一行是样本而不是变量,这是最容易写反的一个参数。
这一节的要点是:笔记里的每个公式都要在 numpy 里找到对应的函数或写法,公式和代码对不上,后面算法实现一定出问题。
3. 线性回归与文本分类:四种监督学习算法的代码路径
3.1 LinearRegression 文件夹里的三件套:标准、局部加权、岭回归
LinearRegression 目录下实现了三种回归,它们共同的理论起点是最小二乘法,区别只在权重矩阵上做文章。标准的线性回归闭式解是 w = (XᵀX)⁻¹Xᵀy,它假设所有样本对模型的贡献相同;局部加权线性回归则给每个预测点附近的样本赋予不同权重,离得越近权重越大;岭回归在 XᵀX 上加了一个 λI,专门对付矩阵奇异和过拟合。
我在复现时把三者写成了一个对比脚本,核心逻辑如下:
import numpy as np def lr_standard(X, y): """标准线性回归:最小二乘闭式解""" X = np.column_stack([np.ones(X.shape[0]), X]) return np.linalg.inv(X.T @ X) @ X.T @ y def lr_local_weight(X, y, x_pred, k=0.5): """局部加权线性回归:k 是带宽参数,控制权重衰减速度""" m = X.shape[0] X = np.column_stack([np.ones(m), X]) x_pred = np.r_[1, x_pred] w = np.exp(-np.sum((X[:, 1:] - x_pred[1:]) ** 2, axis=1) / (2 * k ** 2)) W = np.diag(w) return np.linalg.inv(X.T @ W @ X) @ X.T @ W @ y def ridge(X, y, lam=1.0): """岭回归:lam 是 L2 正则系数""" X = np.column_stack([np.ones(X.shape[0]), X]) n, d = X.shape return np.linalg.inv(X.T @ X + lam * np.eye(d)) @ X.T @ y参数上最值得玩味的是局部加权里的 k 和岭回归里的 lam。k 的取值直接决定权重矩阵 W 的形状:k 太大时权重趋于均匀,退化成标准回归;k 太小时只有最近邻的样本有影响,曲线会剧烈抖动甚至过拟合。经验上 k 在 0.1 到 1.0 之间尝试,看到预测曲线从欠拟合到过拟合的变化,基本就能理解这个参数的意义。
lam 的作用更直观:当 XᵀX 不可逆时,加上 lam * np.eye(d) 强行把特征值抬高,让矩阵可逆。lam=0 时退化为标准回归,lam 越大参数被压缩得越狠。这里有一个新手容易犯的错:np.eye(d) 的 d 是加完偏置列之后的维度,如果你忘了 column_stack 拼接全 1 列,后面矩阵维度就对不上。我在第一次跑岭回归时就是没算对维度,报错信息一直指向矩阵乘法,排查了半天才发现是常数项那一列的问题。
这三种方法在源码包里分别有独立脚本,建议按上面这个顺序跑一遍,输出同一个数据集上的拟合曲线对比,比只看公式印象深刻得多。
3.2 NB&LR:朴素贝叶斯与逻辑回归的文本分类实现
NB&LR 目录名很直白,两个算法放在一起。朴素贝叶斯走的是生成式路线,先估计先验概率 P(c) 和条件概率 P(w|c),再用贝叶斯公式做后验最大化;逻辑回归走的是判别式路线,直接对 P(c|w) 建模。文本分类场景下,我的经验是数据量小、特征稀疏时朴素贝叶斯更稳,数据量大时逻辑回归上限更高。
朴素贝叶斯的多项式模型实现,核心就三个循环:
from collections import Counter class NBClassifier: def __init__(self, alpha=1.0): self.alpha = alpha # 拉普拉斯平滑系数,防止零概率 def fit(self, X, y): self.classes = list(set(y)) self.prior = {} self.cond = {} for c in self.classes: docs = [X[i] for i in range(len(y)) if y[i] == c] self.prior[c] = len(docs) / len(y) vocab = set(w for doc in X for w in doc) counts = Counter(w for doc in docs for w in doc) total = sum(counts.values()) + self.alpha * len(vocab) self.cond[c] = {} for w in vocab: self.cond[c][w] = (counts.get(w, 0) + self.alpha) / total def predict(self, doc): scores = [] for c in self.classes: log_p = np.log(self.prior[c]) for w in doc: if w in self.cond[c]: log_p += np.log(self.cond[c][w]) scores.append((c, log_p)) return max(scores, key=lambda t: t[1])[0]这个实现里用了两个关键的工程技巧。第一是拉普拉斯平滑:alpha=1 时,每个词至少有一个伪计数,避免某个词在训练集没出现导致概率为 0,连乘结果直接归零。第二是提前取 log——注意 predict 里的 log_p 是累加对数概率,而不是累乘原始概率。这一点放在后面避坑章节详细说。
逻辑回归那边,源码包里的完整实现在 NB&LR 目录里,我在复现时更倾向于用 sklearn 做对照实验,因为手写逻辑回归的梯度下降在文本场景下收敛很慢。常见做法是 TF-IDF 向量化加 LogisticRegression,一行 pipeline 就够:
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline clf = Pipeline([ ('tfidf', TfidfVectorizer(max_features=5000, ngram_range=(1, 2))), ('lr', LogisticRegression(C=1.0, solver='lbfgs', max_iter=200)) ]) clf.fit(train_texts, train_labels)C 是正则强度的倒数,C 越大正则越弱。solver 在文本分类这种稠密特征场景下优先选 lbfgs,liblinear 在样本量大时会慢。把这份 sklearn 实现和源码包里的手写逻辑回归放在一起跑同一份数据,能明显看到工程实现的好处。
4. Apriori、决策树与 HMM Viterbi:关联、树与序列模型的实现拆解
4.1 Apriori 算法:频繁项集与关联规则的参数边界
Apriori 解决的问题是:在一堆交易记录里,找出哪些商品经常一起出现。它的核心逻辑是"频繁项集的所有非空子集也一定是频繁的",于是可以从 1 项集开始,逐层组合生成候选项集,再用最小支持度剪枝。源码包里 Apriori 目录下的实现遵循的就是这个经典流程:
def create_C1(D): """生成所有 1 项候选集""" C1 = [] for t in D: for item in t: if [item] not in C1: C1.append([item]) return list(map(frozenset, C1)) def scan_D(D, Ck, min_support): """计算候选项集的支持度,筛掉低于 min_support 的项集""" ssCnt = {} for tid in D: for can in Ck: if can.issubset(tid): ssCnt[can] = ssCnt.get(can, 0) + 1 total = len(D) ret_list = [] support_data = {} for key in ssCnt: support = ssCnt[key] / total if support >= min_support: ret_list.append(key) support_data[key] = support return ret_list, support_datamin_support 是第一个要调的参数。设得太高,比如 0.5,只有极少数高频组合能留下,挖掘结果寥寥无几;设得太低,比如 0.01,候选项集爆炸,内存和时间都扛不住。我的习惯是先跑一遍 0.1 看结果分布,再往两侧微调。这里有个容易被忽略的细节:scan_D 里支持度算的是 ssCnt[key] / total,total 是总交易数,不是候选集总数,分母别搞混。
关联规则生成阶段还有一个 min_confidence 参数,衡量的是在 X 出现的前提下 Y 出现的条件概率。但实际业务里置信度有个坑:它只关注 X→Y 的条件概率,忽略了 Y 本身的基础频率。一个更靠谱的指标是提升度 lift = P(X∪Y) / (P(X)P(Y)),lift 大于 1 才说明 X 和 Y 有正向关联。源码包的 README 里没有明确写提升度的实现,如果你要拿去做购物篮分析,建议自己补上这个指标。
4.2 决策树实现:信息增益计算与递归建树
DesicionTree 目录里的实现是标准的递归建树流程,选特征的依据是信息增益。信息增益的本质是:用某个特征划分数据前后,熵下降了多少。先写计算熵的函数:
def entropy(y): """计算标签集合的熵""" from collections import Counter counter = Counter(y) total = len(y) ent = 0.0 for cnt in counter.values(): p = cnt / total ent -= p * np.log2(p) return ent def info_gain(X, y, feature): """计算按 feature 列划分后的信息增益""" base_ent = entropy(y) values = set(X[:, feature]) new_ent = 0.0 for v in values: subset_idx = np.where(X[:, feature] == v)[0] subset_y = [y[i] for i in subset_idx] new_ent += len(subset_y) / len(y) * entropy(subset_y) return base_ent - new_ent信息增益越大,说明该特征对分类的不确定性消除越多。但 ID3 用信息增益有一个倾向:取值特别多的特征占便宜。比如把样本 ID 当特征,每个值只对应一个样本,信息增益直接拉满,模型却毫无泛化能力。C4.5 改成信息增益比来惩罚多取值特征,CART 用基尼系数替代熵。源码里的实现是 ID3 风格,你在实际使用时如果特征里混入高基数变量,建议换成信息增益比或干脆用 sklearn 的 DecisionTreeClassifier 兜底。
跑通这个脚本后,建议顺手打印一下每层选择的特征和对应的信息增益值,然后对比 sklearn 的 tree 模块输出,能直观看到手写版和工业版的差异。决策树的剪枝边界也在这一层体现:不剪枝的树在训练集上可以做到零错误,但测试集上一塌糊涂。源码包没有实现后剪枝,这部分需要自己补或者依赖 sklearn 的 ccp_alpha 参数。
4.3 HMM 与 Viterbi:从观测序列反推隐状态
HMM 目录下是隐马尔可夫模型加 Viterbi 算法,解决的是"已知观测序列,求最可能的隐状态序列"这个解码问题。Viterbi 本质是动态规划:记录每一步到每个状态的最大概率路径,最后回溯得到全局最优。一个精简实现如下:
def viterbi(obs, states, start_p, trans_p, emit_p): """Viterbi 解码,返回最优隐状态序列 obs: 观测序列 states: 隐状态列表 start_p: 初始状态概率 dict trans_p: 状态转移概率矩阵 emit_p: 发射概率矩阵 """ V = [{}] path = {} for s in states: V[0][s] = np.log(start_p[s]) + np.log(emit_p[s][obs[0]]) path[s] = [s] for t in range(1, len(obs)): V.append({}) newpath = {} for s in states: prob, prev = max( (V[t - 1][s0] + np.log(trans_p[s0][s]) + np.log(emit_p[s][obs[t]]), s0) for s0 in states ) V[t][s] = prob newpath[s] = path[prev] + [s] path = newpath prob, state = max((V[len(obs) - 1][s], s) for s in states) return path[state]注意这里从第一步就开始取 log。HMM 的转移概率和发射概率都是小数,沿着序列连乘几百步后概率会小于浮点数能表示的最小值,直接变成 0。Viterbi 里的每个概率都用对数相加,既保证数值稳定,又让"乘法变加法"的运算更快。start_p、trans_p、emit_p 三个参数从哪来?源码包里是直接给定的示例值,真实场景下它们需要从数据里统计或者用 Baum-Welch 算法迭代估计。
这个 HMM 实现在分词、词性标注、语音识别里都是同一套套路。跑通这个脚本后,你可以试着把 states 改成"晴天/雨天",obs 改成"散步/购物/散步"之类的小序列,把三个参数矩阵手填进去,观察 Viterbi 输出结果的变化,比直接看公式直观得多。
5. 常见问题与避坑:跑这份代码库最容易翻车的几个位置
5.1 朴素贝叶斯概率连乘直接下溢成 0
现象:用源码包里的朴素贝叶斯跑文本分类,predict 阶段所有类别的分数都变成 0,最终输出永远是第一个类别。
原因:文本分类里一篇文档包含几十上百个词,每个词的条件概率是个零点几的小数,几十个数连乘下来直接低于浮点数精度下限变成 0。这是数值下溢问题,不是算法逻辑错误。
解决:所有概率相乘改对数相加。我在前文 NBClassifier 的实现里已经这样处理了,如果你拿到的版本还在直接乘,把这行log_p += np.log(self.cond[c][w])换掉原始概率累乘即可。顺带提一句,对数相加不影响最终排序结果,因为 log 是单调函数。
5.2 矩阵求逆报错:特征共线与奇异矩阵
现象:跑标准线性回归时,np.linalg.inv(X.T @ X)抛异常LinAlgError: Singular matrix。
原因:特征之间存在高度线性相关,或者样本数量小于特征数量,导致 XᵀX 的行列式为 0,矩阵不可逆。这种情况在真实数据集里太常见了。
解决:优先换岭回归,给 XᵀX 加上 lam * np.eye(d) 扰动;或者用np.linalg.pinv求伪逆,它内部做了奇异值截断,不会报错。我在前文代码里就是用的第一种方案。另外排查一下特征是否有重复列,删掉冗余特征往往比调参更有效。
5.3 中文路径与编码错乱
现象:源码在 Windows 下运行,读取文本数据时打印出来全是乱码,或者open()直接报UnicodeDecodeError。
原因:Windows 下 Python 的默认编码是 GBK,而源码里的数据文件和笔记大概率是 UTF-8 编码。两边对不上就崩。
解决:所有读取文件的地方显式指定编码:open('data.txt', encoding='utf-8')。如果你要把结果写回 CSV 给 Excel 看,用encoding='utf-8-sig',否则 Excel 打开中文 CSV 会乱成一团。这是我在 Windows 上跑这类开源包踩过最多的坑,没有之一。
5.4 Python 2 遗留语法导致脚本直接跑不起来
现象:运行某个脚本,解释器直接报SyntaxError,比如print 'hello'或者xrange is not defined。
原因:源码仓库创建年份较早,部分脚本是 Python 2 的语法。Python 3 不兼容这些写法。
解决:先跑一遍python -m py_compile 脚本名.py,把语法错误一次性暴露出来。最常见的三个修复:print 加括号、xrange 改 range、dict.iteritems() 改 dict.items()。也可以用python -m lib2to3 -w批量转换,但转换后最好人工检查一遍改动。这个资源里大部分代码已经兼容 Python 3,少数边角脚本可能还有老语法。
5.5 sklearn 版本差异引发的 API 报错
现象:调用 sklearn 里的LogisticRegression时提示 solver 参数不合法,或者GridSearchCV、accuracy_score的参数签名对不上。
原因:sklearn 版本演进过程中改过不少 API 的默认值和参数名,源码包的编写年代和你本地安装的版本相差较大。
解决:统一环境是最省事的方案。我的建议是创建一个虚拟环境,安装 scikit-learn 1.x 版本,然后逐个脚本试跑,遇到报错就按当前版本的 API 签名修改。这里提一个通用排查命令:python -c "import sklearn; print(sklearn.__version__)",先确认版本,再根据报错信息反查当前版本的文档。
6. 进阶:把散装脚本改造成 fit/predict 模板并做交叉验证
源码包里的每个算法脚本基本都是"顶格写"的风格——数据加载、训练、预测全在一个文件里顺序执行。这样看演示很方便,但换数据集就得改源码。我拿到这类资源后做的第一件事,是把每个算法改造成统一的 fit/predict 类接口,后续做实验只需要替换数据加载那一段。拿岭回归举例:
class RidgeRegression: def __init__(self, lam=1.0): self.lam = lam def fit(self, X, y): X = np.column_stack([np.ones(X.shape[0]), X]) n, d = X.shape self.w = np.linalg.inv(X.T @ X + self.lam * np.eye(d)) @ X.T @ y def predict(self, X): X = np.column_stack([np.ones(X.shape[0]), X]) return X @ self.w接口统一之后,直接套 sklearn 的交叉验证工具做横向对比:
from sklearn.model_selection import train_test_split, cross_val_score X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) for lam in [0, 0.01, 0.1, 1, 10]: model = RidgeRegression(lam=lam) scores = cross_val_score(model, X_train, y_train, cv=5, scoring='neg_mean_squared_error') print(f"lam={lam}, mse={-scores.mean():.4f}")这样调参就有依据了。把 lam 从 0 调到 10,观察测试集误差先降后升,那个拐点就是当前数据集上的合适正则强度。这个流程也适用于朴素贝叶斯、逻辑回归和决策树——只要接口对齐,sklearn 的评分函数就能一视同仁地评估它们的好坏。
另外一个我很推荐的做法是:把源码包里的笔记改写成 markdown 模板,每个算法一节,固定四个标题——输入、输出、核心参数、边界条件。比如 Apriori 那一节就写着:min_support 低则候选项集爆炸,高则规则稀疏;决策树那节写着:不剪枝必然过拟合;HMM 那节写着:三参数矩阵必须先归一化再进 Viterbi。这样下次翻笔记,不用重新读一遍代码就能回忆起当时的结论。
这套源码包对我的价值体现在"把理论变成可运行的东西"。在那之后我每次拿到开源算法包,都会先跑通最小样例,再做交叉验证,最后才敢把自己的数据套进去。这个习惯就是被这份源码里的下溢、奇异矩阵和编码问题逼出来的。希望这几节拆解能让你少走我走过的弯路,直接跳到"跑通参数、看懂边界"这一步。
本文还有配套的精品资源,点击获取