简介:这份资源面向希望深入理解机器学习底层原理的学习者与开发者,聚焦决策树、随机森林、XGBoost、PCA、SVM、贝叶斯回归等经典算法的简洁实现,帮助读者摆脱对现成库的依赖,从零掌握模型构建与调参思路。压缩包共47个文件,以26个Python源码为主,辅以10个Markdown说明文档、10个pyc缓存文件及1个txt数据文件,整体约58KB,目录按算法模块划分,结构清晰便于检索。内容涵盖KNN、支持向量机、逻辑回归、线性回归、随机森林、梯度提升树、朴素贝叶斯、PCA等多个独立子模块,每个模块均配有示例脚本与README说明,并包含核函数、损失函数、数据操作等公共工具代码。已有185人学习下载,适合作为课程实验、算法复现或面试复习的参考素材,读者可借此快速搭建可运行的算法原型,理解各模型的核心实现细节与工程组织方式。
1. 从一个压缩包说起:为什么我劝你手撸一遍这六类算法
很多人第一次接触机器学习,是从sklearn的fit和predict开始的。三行代码跑通一个分类器,准确率还挺好看,于是觉得自己会了。直到某天业务方丢来一份带缺失值、类别极不平衡、特征量纲差三个数量级的数据,你发现调包调不动了——不知道max_depth该设几,不知道n_estimators加到多少会过拟合,不知道PCA降维后为什么反而掉点。这时候才明白,调包和懂算法之间隔着一整个压缩包的距离。
这个标题里的「常用机器学习的算法简洁实现(决策树、随机森林、XGBoost、PCA、SVM、贝叶斯回归等)」,本质上就是一份把主流算法从公式落到可运行代码的清单。它解决的不是「有没有库可用」,而是「我能不能自己写出来、改得动、讲得清」。适合两类人:一类是刚学完理论想找最小可复现实现的在校生,另一类是工作中被调参和排错反复折磨、想回头补底层逻辑的工程师。决策树、随机森林、XGBoost、PCA、SVM、贝叶斯回归这几个名字,几乎覆盖了表格数据场景下八成以上的基线方案,把它们串起来手写一遍,比刷十篇原理博客都管用。
2. 决策树与随机森林:从信息增益到袋外估计的代码落地
2.1 决策树分裂准则的选型逻辑
决策树的核心就一件事:在每个节点上,选一个特征和一个阈值,把当前样本集切成两份,让切完之后的「不纯度」下降最多。分类任务里不纯度有两种主流度量——信息增益(ID3)和基尼指数(CART)。信息增益用熵做减法,对取值多的特征有偏好,容易偏向 ID 这种无意义的高基数特征;基尼指数计算量小、没有对数运算,是sklearn默认的选择。回归任务则用 MSE 或 MAE 衡量分裂质量。
我一般会先看特征类型:如果全是连续数值特征,直接上 CART 的基尼或 MSE;如果类别特征基数很高,要么先做目标编码,要么手动限制分裂时的最小样本数。分裂停止条件比分裂准则更影响最终效果,max_depth、min_samples_split、min_samples_leaf这三个参数不设,树能长到每个叶子只剩一个样本,训练集准确率 100%,测试集一塌糊涂。
下面是一个不依赖sklearn的决策树分类最小实现,用基尼指数做分裂准则,方便你改准则、加剪枝:
import numpy as np def gini(y): """计算标签数组的基尼指数""" if len(y) == 0: return 0 _, counts = np.unique(y, return_counts=True) probs = counts / len(y) return 1 - np.sum(probs ** 2) def best_split(X, y): """遍历所有特征和候选阈值,返回基尼下降最多的切分""" best_gain, best_feat, best_thr = 0, None, None parent_gini = gini(y) n = len(y) for feat in range(X.shape[1]): thresholds = np.unique(X[:, feat]) for thr in thresholds: left_mask = X[:, feat] <= thr right_mask = ~left_mask if left_mask.sum() == 0 or right_mask.sum() == 0: continue # 加权基尼:左右子集按样本占比加权 w_gini = (left_mask.sum() / n) * gini(y[left_mask]) + \ (right_mask.sum() / n) * gini(y[right_mask]) gain = parent_gini - w_gini if gain > best_gain: best_gain, best_feat, best_thr = gain, feat, thr return best_feat, best_thr, best_gain class SimpleTree: def __init__(self, max_depth=5, min_samples=2): self.max_depth = max_depth self.min_samples = min_samples self.tree = None def fit(self, X, y, depth=0): # 停止条件:纯节点、样本太少、达到深度上限 if len(np.unique(y)) == 1 or len(y) < self.min_samples or depth >= self.max_depth: return np.bincount(y).argmax() feat, thr, gain = best_split(X, y) if feat is None or gain <= 0: return np.bincount(y).argmax() left_mask = X[:, feat] <= thr return { 'feat': feat, 'thr': thr, 'left': self.fit(X[left_mask], y[left_mask], depth + 1), 'right': self.fit(X[~left_mask], y[~left_mask], depth + 1) } def predict_one(self, x, node): if not isinstance(node, dict): return node if x[node['feat']] <= node['thr']: return self.predict_one(x, node['left']) return self.predict_one(x, node['right'])gini函数里np.unique同时拿到类别和计数,避免手写循环。best_split对每个特征的每个唯一取值都试一遍,这是最朴素的暴力搜索,复杂度是 O(特征数 × 唯一值数 × 样本数),小数据集够用,上万行数据就得换预排序或直方图近似。SimpleTree.fit的停止条件里gain <= 0很关键,不加的话遇到无法再降低不纯度的节点会无限递归。max_depth控制树高,min_samples控制叶子最小样本,这两个是防过拟合的第一道闸。
2.2 随机森林的两层随机性与袋外估计
单棵树方差大,随机森林用「样本扰动 + 特征扰动」把多棵树的预测平均起来降方差。样本扰动是自助采样(bootstrap),每棵树从原始 N 个样本里有放回抽 N 个,大约 36.8% 的样本没被抽中,这些就是袋外样本(OOB)。特征扰动是每次分裂时只从全部特征里随机抽max_features个候选,分类任务默认sqrt(n_features),回归任务默认n_features/3。
OOB 估计是随机森林白送的一个验证集,不用额外切分就能评估泛化误差。实现上,每棵树训练完后用它的 OOB 样本预测,把所有树的 OOB 预测汇总投票或平均即可。下面是在上面SimpleTree基础上搭随机森林的代码:
from sklearn.utils import resample class SimpleForest: def __init__(self, n_trees=10, max_depth=5, max_features='sqrt'): self.n_trees = n_trees self.max_depth = max_depth self.max_features = max_features self.trees = [] self.oob_indices = [] def fit(self, X, y): n_features = X.shape[1] if self.max_features == 'sqrt': m = int(np.sqrt(n_features)) else: m = n_features for _ in range(self.n_trees): # 自助采样:有放回抽同样本数 X_boot, y_boot = resample(X, y, replace=True) oob = np.setdiff1d(np.arange(len(y)), np.unique( np.random.choice(len(y), len(y), replace=True))) self.oob_indices.append(oob) # 特征子空间:随机选 m 个特征索引 feat_idx = np.random.choice(n_features, m, replace=False) tree = SimpleTree(max_depth=self.max_depth) tree.tree = tree.fit(X_boot[:, feat_idx], y_boot) tree.feat_idx = feat_idx self.trees.append(tree) def predict(self, X): # 每棵树在自己的特征子空间上预测,多数投票 preds = [] for tree in self.trees: p = np.array([tree.predict_one(x[tree.feat_idx], tree.tree) for x in X]) preds.append(p) preds = np.array(preds) return np.array([np.bincount(preds[:, i]).argmax() for i in range(X.shape[0])])resample做有放回抽样,oob的计算用setdiff1d找出没被抽中的索引。注意这里np.random.choice抽了两次,实际工程里应该复用同一组索引,我故意分开写是为了让你看清 OOB 的定义。feat_idx记录每棵树的特征子空间,预测时必须用同一组索引,否则特征对不上,这是手写随机森林最容易翻车的地方。n_trees一般从 100 起步,加到 500 之后收益递减;max_depth在随机森林里可以比单棵树设得深一些,因为平均已经降了方差。
3. XGBoost 简洁实现:从目标函数到增益计算的拆解
3.1 为什么 XGBoost 不是「随机森林的加强版」
很多人把 XGBoost 当成更猛的随机森林,这是误解。随机森林是 Bagging 思路,树与树独立并行,靠平均降方差;XGBoost 是 Boosting 思路,树与树串行,每棵新树拟合前面所有树的残差,靠逐步纠错降偏差。XGBoost 的目标函数由两部分组成:损失函数 + 正则项。正则项里既有叶子节点数的惩罚 γT,也有叶子权重的 L2 惩罚 λ∑w²,这是它比传统 GBDT 抗过拟合的关键。
XGBoost 的分裂增益公式是面试高频考点,也是你手写时必须吃透的一步。对某个节点,假设切分后左子集样本集合为 I_L,右子集为 I_R,增益为:
Gain = 0.5 * [ (G_L²/(H_L+λ) + G_R²/(H_R+λ) - (G_L+G_R)²/(H_L+H_R+λ) ) ] - γ其中 G 是一阶梯度之和,H 是二阶梯度之和。对平方损失,一阶梯度是预测值减真实值,二阶梯度恒为 1;对逻辑损失,一阶是预测概率减标签,二阶是 p(1-p)。增益小于 γ 就不分裂,这就是预剪枝。
3.2 手写一个二分类 XGBoost 的最小版本
下面用 NumPy 实现二分类场景下的 XGBoost 单轮训练,包含梯度计算、增益评估和叶子权重更新:
import numpy as np def sigmoid(x): return 1 / (1 + np.exp(-np.clip(x, -500, 500))) def xgb_gain(G, H, lam=1.0, gamma=0.0): """计算单个节点的分裂增益,G/H 为左右子集的一阶/二阶梯度之和""" G_L, G_R = G H_L, H_R = H base = (G_L + G_R) ** 2 / (H_L + H_R + lam) left = G_L ** 2 / (H_L + lam) right = G_R ** 2 / (H_R + lam) return 0.5 * (left + right - base) - gamma def xgb_fit_round(X, y, pred, lam=1.0, gamma=0.0, max_depth=3): """单轮 boosting:用当前预测算梯度,找最优切分,返回叶子权重""" p = sigmoid(pred) g = p - y # 一阶梯度 h = p * (1 - p) # 二阶梯度 best = {'gain': 0, 'feat': None, 'thr': None} for feat in range(X.shape[1]): for thr in np.unique(X[:, feat]): left = X[:, feat] <= thr if left.sum() == 0 or (~left).sum() == 0: continue G = (g[left].sum(), g[~left].sum()) H = (h[left].sum(), h[~left].sum()) gain = xgb_gain(G, H, lam, gamma) if gain > best['gain']: best = {'gain': gain, 'feat': feat, 'thr': thr, 'G_L': G[0], 'G_R': G[1], 'H_L': H[0], 'H_R': H[1]} if best['feat'] is None: return None # 叶子权重 = -G / (H + λ),左叶和右叶分别算 w_left = -best['G_L'] / (best['H_L'] + lam) w_right = -best['G_R'] / (best['H_R'] + lam) return {'feat': best['feat'], 'thr': best['thr'], 'w_left': w_left, 'w_right': w_right}xgb_gain严格按公式实现,lam是 L2 正则,gamma是分裂门槛。xgb_fit_round里g = p - y和h = p * (1 - p)是逻辑损失的梯度,换成平方损失就是g = p - y、h = 1。叶子权重-G/(H+λ)是使目标函数最小的解析解,负号不能丢。实际工程中 XGBoost 用近似直方图算法找分裂点,把连续特征分桶,复杂度从 O(样本数) 降到 O(桶数),这是它比手写版本快几个数量级的原因。learning_rate在更新时乘在叶子权重上,一般设 0.01 到 0.3,越小需要越多轮次。
3.3 超参数自动设置的实用策略
XGBoost 超参数多,手动调容易迷失。我一般分三步走:先固定learning_rate=0.1、n_estimators=300跑基线;再用RandomizedSearchCV在max_depth(3 到 10)、subsample(0.6 到 1.0)、colsample_bytree(0.6 到 1.0)、min_child_weight(1 到 10)上随机搜 50 组;最后把learning_rate降到 0.01,n_estimators翻三倍,用早停(early_stopping_rounds)防止过拟合。reg_lambda和reg_alpha在特征多、噪声大时加到 1 到 10 之间,能明显压住过拟合。
4. PCA 与 SVM:降维和核函数的参数边界
4.1 PCA 的方差解释率与白化选择
PCA 做两件事:找一组正交基,让数据投影后的方差最大;用前 k 个主成分近似原始数据。实现上就是对中心化后的协方差矩阵做特征分解,或者对数据矩阵做 SVD。sklearn的PCA(n_components=0.95)表示保留 95% 方差,比直接指定主成分个数更省心。
手写 PCA 的关键步骤是中心化、协方差、特征值排序、投影。下面是一个最小实现:
def simple_pca(X, k): """返回前 k 个主成分和投影后的数据""" X_centered = X - X.mean(axis=0) # 必须中心化,否则第一主成分被均值主导 cov = np.cov(X_centered, rowvar=False) # 协方差矩阵 eigvals, eigvecs = np.linalg.eigh(cov) # 对称矩阵用 eigh 更稳 idx = np.argsort(eigvals)[::-1] # 特征值从大到小 eigvecs = eigvecs[:, idx][:, :k] X_proj = X_centered @ eigvecs explained = eigvals[idx][:k] / eigvals.sum() return X_proj, eigvecs, explainedX.mean(axis=0)按列求均值,rowvar=False告诉np.cov每列是一个特征。np.linalg.eigh专门处理对称矩阵,比eig快且数值稳定。explained是方差解释率,累加看多少维能到 95%。白化(whiten=True)会把投影后的每个维度除以标准差,让各主成分方差为 1,适合后续接 SVM 或 KNN 这类对尺度敏感的模型,但会放大噪声方向,信噪比低时慎用。
4.2 SVM 核函数选择与参数影响
SVM 的核心是在高维空间找最大间隔超平面,核函数负责把低维不可分的数据映射到高维。线性核适合特征维度已经很高、样本量大的场景;RBF 核适合特征维度低、样本量中等、边界非线性的场景;多项式核现在用得少,因为参数多且数值不稳定。
RBF 核有两个关键参数:C和gamma。C控制对误分类的惩罚,越大越容易过拟合;gamma控制单个样本的影响半径,越大决策边界越扭曲。在 optdigits 手写数字分类这类任务上,我一般先用GridSearchCV在C=[0.1, 1, 10]、gamma=['scale', 0.01, 0.001]上粗搜,再在最优附近细搜。注意 SVM 对特征尺度极度敏感,PCA 白化或StandardScaler是标配,不做归一化直接上 RBF 核,结果基本是玄学。
from sklearn.svm import SVC from sklearn.model_selection import GridSearchCV from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline pipe = Pipeline([ ('scaler', StandardScaler()), ('svm', SVC(kernel='rbf')) ]) param_grid = { 'svm__C': [0.1, 1, 10], 'svm__gamma': ['scale', 0.01, 0.001] } grid = GridSearchCV(pipe, param_grid, cv=5, scoring='accuracy', n_jobs=-1) grid.fit(X_train, y_train) print(grid.best_params_, grid.best_score_)用Pipeline把归一化和 SVM 绑在一起,交叉验证时归一化只在训练折上拟合,避免数据泄漏。n_jobs=-1用满所有核。gamma='scale'是1/(n_features * X.var()),比固定值更自适应。如果数据量超过几万行,RBF 核的 O(n²) 复杂度会拖垮训练,这时候要么换线性核,要么用LinearSVC或 SGD 版本。
5. 避坑与排查:手写这些算法时最容易翻车的五件事
现象一:决策树训练集准确率 100%,测试集不到 60%。原因:树无限生长,每个叶子记住了一个样本,max_depth和min_samples_leaf没设或设得太大。 解决:分类任务max_depth从 5 起步,min_samples_leaf不低于 5;用验证集画深度-准确率曲线找拐点,别凭感觉。
现象二:随机森林的 OOB 分数比交叉验证高一大截。原因:OOB 计算时特征子空间索引和训练时不一致,或者自助采样和 OOB 索引用了两组不同的随机数。 解决:把每棵树的feat_idx和oob_indices存下来,预测和评估时严格复用;随机种子固定,保证可复现。
现象三:XGBoost 增益算出来是负数,树完全不分裂。原因:gamma设得太大,或者叶子权重公式里漏了负号,或者二阶梯度算错(逻辑损失下 h 应该是 p(1-p),不是 1)。 解决:先把gamma=0、lam=1跑通,确认增益为正再逐步加正则;打印每轮增益值,负数说明梯度或公式有问题。
现象四:PCA 降维后模型效果反而下降。原因:没做中心化,或者保留了太多噪声主成分,或者白化放大了噪声方向。 解决:确认X - X.mean(axis=0)这步没漏;用方差解释率累加曲线选 k,不要盲目保留 95%;信噪比低时关掉白化。
现象五:SVM 在 optdigits 上跑了几小时还没出结果。原因:RBF 核在几万样本上复杂度爆炸,或者C和gamma网格太密。 解决:先对数据做 PCA 降到 50 维以内,再用LinearSVC或SGDClassifier做基线;必须用 RBF 核时,GridSearchCV的n_jobs开满,网格先粗后细。
6. 贝叶斯回归的进阶用法:从固定超参到证据最大化
贝叶斯回归和普通最小二乘的区别在于,它不把权重 w 当成固定未知数,而是给它一个先验分布,通常取高斯先验 w ~ N(0, α⁻¹I),然后根据数据求后验。预测时对权重积分,得到的是预测分布而不是点估计,天然带不确定性。sklearn的BayesianRidge用证据最大化自动估计 α(噪声精度)和 λ(权重精度),不用交叉验证就能定超参,这是它比 Ridge 省事的地方。
我一般这样用:先跑BayesianRidge拿到 α 和 λ 的估计值,再把这些值喂给Ridge做大规模预测,因为BayesianRidge在预测阶段要算后验协方差,样本量大时慢。验证方法上,除了看 RMSE,还要看预测标准差——如果某些样本的预测区间特别宽,说明模型对那块区域没把握,值得单独排查。
from sklearn.linear_model import BayesianRidge import numpy as np model = BayesianRidge(compute_score=True) model.fit(X_train, y_train) y_mean, y_std = model.predict(X_test, return_std=True) # 找出预测不确定性最高的 10 个样本 uncertain_idx = np.argsort(y_std)[-10:] print("高不确定性样本索引:", uncertain_idx) print("alpha(噪声精度):", model.alpha_) print("lambda(权重精度):", model.lambda_)compute_score=True会记录每轮迭代的目标函数值,用来判断证据最大化是否收敛。return_std=True返回预测标准差,这是贝叶斯方法独有的。alpha_越大说明噪声越小,lambda_越大说明权重越接近零、正则越强。如果alpha_异常小,检查标签里有没有离群点;如果lambda_异常大,说明特征间共线性严重,考虑先做 PCA 或删特征。
一个具体技巧:把BayesianRidge的alpha_和lambda_作为Ridge的alpha初值,再在附近做小范围网格搜索,通常比盲搜快很多。这个习惯是我被贝叶斯回归的迭代收敛坑过几次之后养成的——它收敛慢的时候,先看compute_score曲线是不是震荡,震荡就调tol和max_iter,别急着换模型。希望帮到你。
本文还有配套的精品资源,点击获取