简介面向机器学习入门者的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 ├── NBLR ├── 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, ddof1): 计算均值和方差ddof1 表示无偏估计ddof0 表示有偏估计 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, axis0) centered X - mean cov centered.T centered / (n - 1) return cov这里 ddof1 是统计意义上的无偏代价是分母变小、方差变大但期望值正好等于总体方差。在机器学习里除非数据量小到 n 只有几十否则 ddof 取 0 还是 1 对模型参数影响不大真正的差异体现在协方差矩阵上。第二段代码里的 sample_cov 用的是 centered.T centered也就是先把每个特征减去均值再做内积。这里的 是 Python 3.5 之后的矩阵乘法运算符等价于 np.dot。协方差矩阵的对角线是各特征的方差非对角线是两两特征之间的共变关系。这个矩阵在后面的线性回归和 HMM 的发射概率估计里都会出现。源码包里对应的 numpy 实现路径是 np.cov(X, rowvarFalse)rowvarFalse 是为了告诉 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, k0.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, axis1) / (2 * k ** 2)) W np.diag(w) return np.linalg.inv(X.T W X) X.T W y def ridge(X, y, lam1.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) 强行把特征值抬高让矩阵可逆。lam0 时退化为标准回归lam 越大参数被压缩得越狠。这里有一个新手容易犯的错np.eye(d) 的 d 是加完偏置列之后的维度如果你忘了 column_stack 拼接全 1 列后面矩阵维度就对不上。我在第一次跑岭回归时就是没算对维度报错信息一直指向矩阵乘法排查了半天才发现是常数项那一列的问题。这三种方法在源码包里分别有独立脚本建议按上面这个顺序跑一遍输出同一个数据集上的拟合曲线对比比只看公式印象深刻得多。3.2 NBLR朴素贝叶斯与逻辑回归的文本分类实现NBLR 目录名很直白两个算法放在一起。朴素贝叶斯走的是生成式路线先估计先验概率 P(c) 和条件概率 P(w|c)再用贝叶斯公式做后验最大化逻辑回归走的是判别式路线直接对 P(c|w) 建模。文本分类场景下我的经验是数据量小、特征稀疏时朴素贝叶斯更稳数据量大时逻辑回归上限更高。朴素贝叶斯的多项式模型实现核心就三个循环from collections import Counter class NBClassifier: def __init__(self, alpha1.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, keylambda t: t[1])[0]这个实现里用了两个关键的工程技巧。第一是拉普拉斯平滑alpha1 时每个词至少有一个伪计数避免某个词在训练集没出现导致概率为 0连乘结果直接归零。第二是提前取 log——注意 predict 里的 log_p 是累加对数概率而不是累乘原始概率。这一点放在后面避坑章节详细说。逻辑回归那边源码包里的完整实现在 NBLR 目录里我在复现时更倾向于用 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_features5000, ngram_range(1, 2))), (lr, LogisticRegression(C1.0, solverlbfgs, max_iter200)) ]) clf.fit(train_texts, train_labels)C 是正则强度的倒数C 越大正则越弱。solver 在文本分类这种稠密特征场景下优先选 lbfgsliblinear 在样本量大时会慢。把这份 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] / totaltotal 是总交易数不是候选集总数分母别搞混。关联规则生成阶段还有一个 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, encodingutf-8)。如果你要把结果写回 CSV 给 Excel 看用encodingutf-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, lam1.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_size0.2, random_state42) for lam in [0, 0.01, 0.1, 1, 10]: model RidgeRegression(lamlam) scores cross_val_score(model, X_train, y_train, cv5, scoringneg_mean_squared_error) print(flam{lam}, mse{-scores.mean():.4f})这样调参就有依据了。把 lam 从 0 调到 10观察测试集误差先降后升那个拐点就是当前数据集上的合适正则强度。这个流程也适用于朴素贝叶斯、逻辑回归和决策树——只要接口对齐sklearn 的评分函数就能一视同仁地评估它们的好坏。另外一个我很推荐的做法是把源码包里的笔记改写成 markdown 模板每个算法一节固定四个标题——输入、输出、核心参数、边界条件。比如 Apriori 那一节就写着min_support 低则候选项集爆炸高则规则稀疏决策树那节写着不剪枝必然过拟合HMM 那节写着三参数矩阵必须先归一化再进 Viterbi。这样下次翻笔记不用重新读一遍代码就能回忆起当时的结论。这套源码包对我的价值体现在把理论变成可运行的东西。在那之后我每次拿到开源算法包都会先跑通最小样例再做交叉验证最后才敢把自己的数据套进去。这个习惯就是被这份源码里的下溢、奇异矩阵和编码问题逼出来的。希望这几节拆解能让你少走我走过的弯路直接跳到跑通参数、看懂边界这一步。本文还有配套的精品资源点击获取