☰
机器学习算法从零手写:决策树到XGBoost原理与实战避坑指南
2026/9/26 11:21:54 网站建设 项目流程

简介:这份资源汇集了常用机器学习算法的简洁实现,涵盖决策树、随机森林、梯度提升、XGBoost、主成分分析、支持向量机、线性回归、逻辑回归、K近邻、朴素贝叶斯与独立成分分析等,面向希望从代码层面理解算法原理的数据科学初学者和开发者。压缩包共47个文件,以Python源码为主,附带编译后的pyc缓存、各算法模块的README说明文档及一个示例数据文件,整体大小约58KB。目录按算法分模块组织,每个算法均配有模型实现代码与可直接运行的示例脚本,便于对照学习。工具模块中还提供了数据操作、损失函数、核函数等底层组件,有助于理解算法间的公共部分和实现细节。资源目前已有185人学习,适合作为算法入门、面试准备或课程作业中的快速参考,无论是刚接触机器学习还是希望快速回顾常用算法,都能从中获得直观的代码参考。

1. 常用机器学习算法简洁实现:这份代码包把“调包”变成了“懂原理”

如果你和我一样,简历上写着“熟悉决策树、随机森林、XGBoost”,但面试官一问“XGBoost 相比 GBDT 到底改了什么”就心里发虚,那这份资源就是给你准备的。它不是 sklearn 的封装调用,而是把 10 个常用算法——决策树、随机森林、GBDT、XGBoost、PCA、SVM、线性回归、逻辑回归、朴素贝叶斯、KNN——全部用原生 Python + NumPy 从零写了一遍。每个算法目录下都有模型实现代码、可运行的示例脚本和 README,直接python xxx_example.py就能看到结果。适合三类人:准备算法面试的、期末复习机器学习原理的、以及想脱离黑匣子自己动手复现一遍的。

2. 决策树与随机森林:从单棵树的 CART 分裂到 Bagging 集成的完整链路

2.1 先看源码结构:决策树目录里到底有什么

打开decision_tree目录,你会看到三个核心文件:decision_tree_model.py是模型本体,decision_tree_classifier_example.py和decision_tree_regressor_example.py分别是分类和回归的示例脚本。这种“模型 + 示例”的拆法非常良心——你可以先跑示例脚本热个身,再钻进模型文件里看每一行实现。

decision_tree_model.py里没有用任何第三方库,树节点的分裂逻辑全靠数组运算完成。核心数据结构是一个字典类型的节点,包含特征索引、阈值、左子树、右子树、当前节点的预测值这几个键。分类树用基尼系数(Gini)选分裂点,回归树用均方误差(MSE)减少量来选。这和 sklearn 里criterion='gini'和criterion='mse'的底层逻辑是一一对应的。

# decision_tree_model.py 中的核心分裂逻辑(简化示意) def _best_split(self, X, y): best_gain = 0 best_idx, best_thr = None, None n_samples, n_features = X.shape # 当前节点的基尼系数或 MSE,作为分裂前的基准 if self.criterion == 'gini': base_impurity = self._gini(y) else: base_impurity = self._mse(y) # 遍历每个特征的每个取值,贪心找最优分裂点 for idx in range(n_features): thresholds = np.unique(X[:, idx]) for thr in thresholds: left_mask = X[:, idx] <= thr right_mask = ~left_mask if np.sum(left_mask) == 0 or np.sum(right_mask) == 0: continue left_y, right_y = y[left_mask], y[right_mask] # 加权杂质 = 左子树样本占比 * 左子树杂质 + 右子树样本占比 * 右子树杂质 weighted_impurity = (len(left_y) / n_samples) * self._impurity(left_y) + \ (len(right_y) / n_samples) * self._impurity(right_y) gain = base_impurity - weighted_impurity if gain > best_gain: best_gain, best_idx, best_thr = gain, idx, thr return best_idx, best_thr

这段代码的逻辑很直白:先算分裂前的杂质,再遍历所有特征的所有取值,计算分裂后的加权杂质,两者之差就是信息增益(分类)或方差减少量(回归),选最大的那个作为当前节点的分裂方案。注意thresholds = np.unique(X[:, idx])这一步——它天然去重了,但也会让连续特征的分裂点搜索变成纯暴力枚举,特征多、数据量大时非常慢。跑示例脚本没问题,跑到真实数据集上就要小心了。

2.2 随机森林的 Bagging 与随机特征选择在代码里怎么体现

random_forest目录里的random_forest_model.py很好地演示了“森林”的构建方式。和我见过的一些教学代码不同,这份实现没有直接调用自己写的决策树类,而是把树构建逻辑又写了一遍,然后在两个地方注入了随机性。

第一处是样本采样。每棵树训练前,用np.random.choice对训练集做有放回抽样,抽样数量和原始样本数一致——这就是 Bagging 的经典做法。第二处是特征选择。每个节点分裂时,不是遍历全部特征,而是先从总特征里随机抽一个子集(代码里max_features默认取sqrt(n_features)),再在这个子集里找最优分裂。这对应随机森林和袋装决策树的本质区别:Bagging 只在样本上做随机,随机森林在样本和特征两方面都做了随机。

# random_forest_model.py 中构建单棵树的抽样逻辑 n_samples = X.shape[0] for tree in range(self.n_estimators): # 有放回抽样,得到本棵树的训练索引 sample_idx = np.random.choice(n_samples, n_samples, replace=True) X_sample = X[sample_idx] y_sample = y[sample_idx] # 注意:这里可以加一个样本权重的调整,但当前版本没有实现 tree_model = self._build_tree(X_sample, y_sample) self.trees.append(tree_model)

这里的replace=True是关键。如果不做有放回抽样,每棵树的训练集都一样,森林就没有多样性,集成的方差削减效果直接归零。另一个细节是参数n_estimators在示例脚本里设为了 10,这个数偏小,实际用的时候至少要 50~100 棵才有稳定效果。你可以改random_forest_example.py里的参数重新跑,对比一下和默认结果的差异。

2.3 示例脚本跑通的三个前置要求

跑random_forest_example.py之前,先确认 Python 版本。项目里多处保留了cpython-35.pyc的缓存文件,说明作者开发环境是 Python 3.5 左右,代码里有些语法(比如print不带括号的兼容写法)在 Python 3.8+ 也能跑,但保险起见建议直接用 Python 3.8 或 3.9 建个干净环境跑。

依赖只有 NumPy。如果你本机没装,直接pip install numpy就行。数据是代码里内置的,不需要额外下载。示例脚本最后会把预测准确率打印出来,还会画一张简单的分类结果图(如果没报错的话)。如果报错,大概率是matplotlib没装,这个包并不在项目依赖里,需要手动补上。

3. GBDT 与 XGBoost:梯度提升家族的实现差异与调参边界

3.1 GBDT 的“负梯度 + 残差”在代码里是怎么落地的

gradient_boosting_decision_tree目录里有gbdt_model.py、gbdt_classifier_example.py和gbd_regressor_example.py三个文件。看完实现你会发现,GBDT 的核心就是“每一棵新树去拟合前面所有树的负梯度”。

回归问题里,负梯度就是残差(真实值减预测值);分类问题里,负梯度经过 sigmoid 转换后变成概率残差。gbdt_model.py里维护了一个self.trees列表,每轮迭代都新建一棵决策树回归器(注意:这里内部用的还是 CART 回归树,所以它依赖decision_tree_model.py),用当前模型的负梯度作为树的训练目标。

# gbdt_model.py 中每轮迭代的核心逻辑(简化) for _ in range(self.n_estimators): # 当前模型的预测值(累计所有树的结果) current_pred = self._predict_raw(X) # 回归任务:负梯度 = 真实值 - 当前预测值 if self.task == 'regression': residual = y - current_pred else: # 分类任务:先算 sigmoid 概率,再算概率残差 prob = self._sigmoid(current_pred) residual = y - prob # 用残差训练一棵新的回归树 tree = DecisionTreeRegressor(max_depth=self.max_depth) tree.fit(X, residual) self.trees.append(tree) # 每棵树只贡献 learning_rate 比例的修正 self.lr *= self.learning_rate

注意最后一行:self.lr *= self.learning_rate。这是一个很容易被忽略的实现细节——每一轮迭代后学习率都会再乘以初始学习率,作用是让后面的树贡献越来越小。这种衰减策略在经典 GBDT 论文里并没有强制要求,属于工程上的常见做法,目的是用更多树逼近目标时避免过拟合。

3.2 XGBoost 的“二阶近似 + 正则项”在源码里怎么体现

xgboost目录下的xgboost_model.py是目前网上能找到的教学实现里比较规矩的一版。和 GBDT 只对损失函数做一阶泰勒展开不同,XGBoost 用二阶泰勒展开来近似损失函数,同时在目标函数里显式加入了叶子节点数的惩罚项和叶子权重的 L2 正则项。

# xgboost_model.py 中目标函数近似计算的示意 def _calc_gain(self, g_left, h_left, g_right, h_right, reg_lambda): # 分裂后的增益 = 左子树增益 + 右子树增益 - 分裂前增益 gain_left = (g_left ** 2) / (h_left + reg_lambda) gain_right = (g_right ** 2) / (h_right + reg_lambda) gain_parent = ((g_left + g_right) ** 2) / (h_left + h_right + reg_lambda) return gain_left + gain_right - gain_parent

这里的g是损失函数的一阶导(gradient),h是二阶导(hessian),reg_lambda是 L2 正则系数。窗口期你自己试试就知道,reg_lambda设成 0 的话,XGBoost 就退化成了和 GBDT 差不多的东西。源码里还有一个细节:分裂点的搜索不是暴力遍历,而是对特征值做分位点预排序,这直接减少了搜索次数,是 XGBoost 比 GBDT 快的一个重要原因。

3.3 模型对比实验:一份数据跑出性能差异

xgboost_example.py里自带了一份二分类数据,直接跑完会输出准确率。建议你顺手做一个对照实验:在同一个脚本里调用gradient_boosting_decision_tree里的 GBDT 模型,设置相同的n_estimators、max_depth、learning_rate,对比两边的验证集准确率。我的经验是,小数据上两者差距不大,但 XGBoost 收敛更快——同样 50 棵树,XGBoost 的验证集误差已经平稳,GBDT 可能还在下降。原因就在二阶导数信息量更大,每一步迈得更准。

学习率这个超参数在两边的影响方式不太一样。GBDT 的学习率衰减写在代码里了,调参时要留意:初始学习率设 0.1,跑 50 轮,实际最后一轮的贡献权重已经衰减到0.1^50,几乎可以忽略。XGBoost 的默认实现不做这种衰减,更依赖正则项来控制复杂度,所以调 XGBoost 时优先动reg_lambda和max_depth,而不是盲目调低学习率。

4. PCA 与 SVM:降维、分类边界与核函数的选择逻辑

4.1 PCA 的实现是特征分解还是 SVD:两种路线差别在哪

pca目录下只有pca.py和 README,例子很克制。打开pca.py,实现的路线是“中心化 + 协方差矩阵特征分解”,不是 SVD(奇异值分解)。这两种路线在结果上等价,但数值稳定性差了很远。协方差矩阵是d x d(d 为特征维度),当特征维度很高时,协方差矩阵的求解会非常慢,而且可能出现数值病态。SVD 直接对原始数据矩阵做分解,不需要构造协方差矩阵,对大维度数据更稳健。

# pca.py 中的核心降维逻辑 def transform(self, X, k): # 1. 中心化:每个特征减去均值 X_mean = np.mean(X, axis=0) X_centered = X - X_mean # 2. 计算协方差矩阵 cov_matrix = np.cov(X_centered.T) # 3. 特征值分解 eigenvalues, eigenvectors = np.linalg.eig(cov_matrix) # 4. 按特征值降序排列,取前 k 个特征向量 idx = np.argsort(eigenvalues)[::-1][:k] projection_matrix = eigenvectors[:, idx] return X_centered.dot(projection_matrix)

这里有个典型的坑:np.linalg.eig返回的特征向量列向量不是按特征值大小排列的,必须手动排序取前 k 个,源码里这个操作做了。如果你改代码改成 SVD 路线,记得np.linalg.svd返回的Vt行向量才是特征向量方向,不用再转置。

4.2 SVM 的软间隔实现:TempLinkoping2016.txt 数据是拿来干嘛的

support_vector_machine目录里有一个TempLinkoping2016.txt文件,这是瑞典林雪平市 2016 年的逐日温度记录,被用作 SVM 回归(SVR)的示例数据——预测当天的平均气温。选这个数据集挺聪明的:温度序列有明显的趋势和周期性,线性核搞不定,必须上 RBF 核,这正好演示了核函数的必要性。

svmModel.py实现的是软间隔 SVM,目标函数里带松弛变量惩罚系数C。kernels.py里提供了线性核、多项式核和 RBF 核三种。最有意思的是,这个实现没有用经典的 SMO 序列最小优化算法,而是用梯度下降直接优化原始问题。

# svmModel.py 中梯度下降更新权重的示意 def _optimize(self, X, y): n_samples, n_features = X.shape w = np.zeros(n_features) b = 0 lr = 0.001 for epoch in range(self.n_epochs): for i in range(n_samples): # 计算当前样本的决策值 decision = np.dot(w, X[i]) + b # 距离边界较远的样本,梯度只来自正则项 if y[i] * decision >= 1: w_grad = w b_grad = 0 # 违反间隔约束的样本,梯度同时来自正则项和合页损失 else: w_grad = w - self.C * y[i] * X[i] b_grad = -self.C * y[i] w -= lr * w_grad b -= lr * b_grad

这版实现是给教学用的,收敛速度比 SMO 慢,如果你直接在svm_example.py上改数据集,要做好训练耗时十倍以上增长的准备。路径上的TempLinkoping2016.txt是原始数据,示例脚本会读它,你不需要手动打开这个文件。

4.3 核函数的选择对分类边界的影响

kernels.py里三个核函数的写法非常简洁。线性核就是标准内积;多项式核多了一个degree参数;RBF 核的核心是gamma参数,控制单个样本的影响半径。拿温度回归这个任务来说,gamma太小,所有样本的高斯影响范围叠在一起,预测曲线被拉平;gamma太大,预测曲线跟着每一个训练样本剧烈震荡,直接过拟合。

实践中我会建议跑项目自带的示例先看一遍gamma默认值的分类效果,然后把gamma调大 10 倍和调小 10 倍各跑一次,观察边界变化。这个动作的成本很低,但对理解 RBF 核的行为帮助明显。

5. 线性回归到贝叶斯回归:损失函数、条件与常见排查

5.1 从线性回归的解析解到逻辑回归的梯度下降

linear_regression目录下只有linear_regression.py和 README,而logistic_regression目录下有模型和示例。线性回归走的是正规方程(解析解),逻辑回归走的是梯度下降,两者的代码风格完全不同。

# linear_regression.py 中正规方程求解的示意 def fit(self, X, y): # 给 X 加一列全 1 的特征,作为偏置项 X_b = np.c_[np.ones((X.shape[0], 1)), X] # 正规方程:theta = (X^T X)^-1 X^T y theta = np.linalg.inv(X_b.T.dot(X_b)).dot(X_b.T).dot(y) self.theta = theta

正规方程的优势是无需调学习率、不用迭代,一步算出全局最优解。但它有个致命前置条件:X^T X必须可逆。如果你的数据特征之间存在高度相关性(比如同时放了摄氏温度和华氏温度),或者特征数量大于样本数量,np.linalg.inv就会直接报错或返回数值上极不稳定的结果。看到这种报错,不要怀疑是代码写错了,先去查特征是否冗余。

逻辑回归的logistic_regression.py用的是批量梯度下降。损失函数是交叉熵,每一轮迭代都遍历所有样本计算梯度。这里有一个工程上的关键细节:如果你直接拿原始特征去跑,特征尺度差异大的话(比如一个特征范围 0~1,另一个是 0~10000),梯度下降会非常慢,甚至不收敛。我拿到所有数据集的第一件事就是先做标准化。

# 注意:跑逻辑回归示例前先做特征标准化(这是我踩坑后的习惯) from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)

5.2 朴素贝叶斯的独立假设与概率连乘的下溢问题

naive_bayes目录下只有naive_bayes.py和 README,实现的是高斯朴素贝叶斯。它假设每个特征在给定类别下服从高斯分布,且特征之间相互独立。这个“独立假设”在真实数据上几乎从不成立,但对很多文本分类、垃圾邮件识别场景,即便假设不成立,它的效果也够用——这就是朴素贝叶斯最反直觉的地方。

代码里有一个极容易翻车的点:概率连乘。当特征维度很高时,多个小概率连乘的结果会极度接近 0,Python 浮点数直接下溢。教学中常见的补救方式是在概率连乘的循环里对每个概率取对数,把连乘变成连加。这份代码没有做这个处理,所以如果你拿高维数据直接跑,大概率会看到预测结果全部指向同一个类别——不是算法不行,是数值计算崩了。

# naive_bayes.py 中需要手动修补的下溢问题(现象:所有样本都被分到同一类) # 把概率连乘改为对数概率连加 log_prob = 0.0 for i in range(n_features): # 原来:prob *= self._gaussian_pdf(x[i], mean[i], var[i]) log_prob += np.log(self._gaussian_pdf(x[i], mean[i], var[i]) + 1e-9)

5.3 KNN 的实用经验:距离度量与特征尺度

k_nearest_neighbors目录里有knnModel.py和knnExample.py。KNN 本身没有训练过程,但它的“训练”其实发生在预测时——每次预测都要计算新样本与所有训练样本的距离。knnExample.py里默认使用欧氏距离,k值设成 5。

KNN 最大的坑不在代码里,而在数据准备上。欧氏距离对特征尺度极其敏感。假设你有两个特征,一个是年龄(范围 20~60),一个是年收入(范围 5 万~50 万),那么年龄的差异在距离计算中被完全淹没,模型约等于只看年收入。这个项目没有自带 KNN 的数据预处理示例,但使用前一定先手动加一步标准化,否则你的 KNN 基本等于没调参。

6. 避坑清单:这份代码包的五个常见翻车点与排查方案

6.1 Python 版本兼容问题

现象:运行示例脚本时出现SyntaxError或ModuleNotFoundError,最常见的是print语法报错和__pycache__的版本不匹配。原因:作者开发环境是 Python 3.5,部分语法与高版本 Python 不兼容。

解决:用 Python 3.8 或 3.9 新建虚拟环境,先手动跑一个最简单的脚本,比如linear_regression.py,确认环境没问题后,再跑其他依赖较多文件的算法。不要试图在最新版 Python(3.12+)上硬跑老代码,大概率会碰上 NumPy API 变更导致的兼容错误。

6.2 NumPy 版本导致np.float报错

现象:导入xgboost_model.py时提示module 'numpy' has no attribute 'float'。原因:NumPy 1.24 及以上移除了np.float、np.int等旧别名,而这份早期代码里有类似的旧写法。

解决:安装 NumPy 1.23.x 版本,比如pip install numpy==1.23.5。如果你不想降级 NumPy,也可以手动把所有np.float替换成float、np.int替换成int,但这个工程量大,降级省事得多。

6.3 多分类任务直接跑崩

现象:用决策树或随机森林跑一个三分类数据集,模型训练时报错或预测结果全变成同一个类别。原因:部分模型实现只为二分类设计,比如svmModel.py的合页损失函数假设标签为{-1, +1},而你的数据标签是{0, 1, 2}。

解决:在使用 SVM、逻辑回归相关实现前,先检查数据集的类别数。KNN、决策树、随机森林可以支持多分类,但 SVM 和逻辑回归的实现需要先做 One-vs-Rest 包装,或者干脆只用于二分类任务。

6.4 特征不标准化导致梯度下降不收敛

现象:跑logistic_regression.py时,迭代了几百轮,损失函数不降反升,或者准确率始终在 50% 附近徘徊。原因:特征尺度差异太大,梯度下降的更新步长对某些维度过大或过小,导致震荡。

解决:所有跑梯度下降的模型(逻辑回归、SVM)训练前都做 StandardScaler 标准化。做完之后再跑一次,你会发现损失函数下降曲线立刻变得顺滑。这个问题在此类手写实现里几乎必现,不是特例。

6.5 数据文件路径写死导致换机器跑不了

现象:换一台电脑或者换目录后,svm_example.py报FileNotFoundError。原因:脚本里用了相对路径TempLinkoping2016.txt,而当前工作目录不是support_vector_machine。

解决:运行时先cd到对应算法目录,或者用os.path.join(os.path.dirname(__file__), 'xxx.txt')方式读取文件。这是早期教学代码的通病,不算 bug,但足以让你浪费十几分钟。

7. 进阶验证:如何用自己的数据系统验证这批手写实现

跑完示例脚本只是热身。真正让这份代码产生价值的是用它做“手写实现 vs sklearn 基线”的对比实验。我自己的做法是固定一个数据集(比如加州房价或鸢尾花),把 hand-written 模型和 sklearn 的同名模型放在同一个脚本里,设置完全相同的超参数,然后对比训练时间、预测精度和推理速度。差距大的地方,就是你对算法理解不够深的地方。

拿决策树举例。手写实现里没有对特征预排序,也没做任何缓存优化,在大数据集上训练时间可能是 sklearn 的 10 倍以上——这并不代表实现差,而是说明 sklearn 里做了大量的工程优化。知道差距在哪个环节,比单纯会用.fit()有价值得多。

一个更实用的验证方案:用utils目录里的data_operation.py和data_manipulation.py现有工具,自己造一份带噪声的回归数据,分别跑线性回归、决策树回归、随机森林回归和 GBDT 回归,画出四条预测曲线。你会直观看到:线性回归在非线性数据上彻底失效,决策树能拟合但极度毛糙,随机森林平滑了一个档次,GBDT 甚至能逼近复杂曲线——整个过程不依赖任何“调包”,全是你自己控制代码的每一步。

从那以后,我每次上手机器学习项目,都会强制走一遍这个流程:选一份干净数据 → 手写实现跑通 → sklearn 跑同参数 → 对比训练时间和效果差异。这个习惯帮我筛掉了无数“看起来懂了其实没懂”的知识盲区。这份资源最大的价值不在于代码质量,而在于它给了你一个可以肆意改动、随时打断、逐行打印中间结果的实验台——希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询