尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

手写Python机器学习算法:从线性回归到决策树的完整实现指南

发布时间:2026/9/18 21:27:52

资讯中心
01
ARTICLE

手写Python机器学习算法:从线性回归到决策树的完整实现指南

手写Python机器学习算法:从线性回归到决策树的完整实现指南
简介面向机器学习初学者的实战型PDF教程围绕Python生态讲解如何从零实现经典算法。内容基于scikit-learn与Keras两大库系统覆盖KNN、朴素贝叶斯、逻辑回归、SVM、决策树、随机森林、感知机、多层前向网络及卷积神经网络等模型帮助读者理解监督学习与神经网络的基本原理。教程同时详解问题定义与评价指标选择、数据清洗、缺失值处理、归一化、特征工程、PCA降维、交叉验证以及超参数调优等完整流程并给出Numpy与PIL在数值计算和图像预处理中的应用实例与实际项目衔接紧密。整份资源为1个PDF文件压缩包仅35KB轻量便携便于随时查阅目前已有2424人浏览学习。对于想快速入门机器学习、希望用Python动手实践算法的读者这份PDF能帮你构建从数据准备到模型优化的系统框架是开启项目实战的良好起点。1. 为什么说“手写 Python 机器学习算法”比调库更重要看到 Python 和机器学习算法放在一起很多人第一反应是打开 sklearn 写两行 fit。真正把算法从零实现一遍是另一件事只看 NumPy从损失函数出发把梯度推导成矩阵运算再看着 loss 掉到与官方库同一量级。这个能力对应两类真实需求——面试现场手撕梯度下降以及模型上线出现 NaN 时你知道该查哪一行而不是重建数据。按常见做法把回归、分类、聚类、树模型依次实现一遍拿 sklearn 做对拍能覆盖大多数实现型教程想讲的东西。适合读到这篇文章的人群很具体调库很顺、遇到数据形状不匹配就慌的工程师。后面内容的轨迹是先用手写线性回归跑通最小闭环再解决分类和聚类的数值问题最后用梯度检查和对拍收尾。2. 先用线性回归打通全流程NumPy 手写梯度下降与最小可运行框架回归适合做第一个手写对象数据生成简单、损失函数光滑、梯度可以直接写成矩阵乘法。许多教程直接拿真实房价数据开跑但更稳的路线是先生成一个可控的二维数据把“梯度算错”和“数据没洗干净”这两类问题分开。等手写代码在自己的合成数据上收敛再换真实数据算法本身的问题才好定位。2.1 为什么第一站选线性回归矩阵、损失与梯度是一次完整的“算法实现循环”线性回归的目标是学一组权重 w 和偏置 b使预测值近似真实目标。手写时最省心的写法是把 b 合并进 w在特征矩阵 X 左侧拼一列 1模型变成一次矩阵乘法梯度公式也随之统一。不少手写代码在 b 的梯度上漏了求和或者少除一个样本数都是因为没做这一步。损失函数对 w 求导的常见结果是 dw (1/n) * X^T (预测值 - 真实值)。有的资料写成 2/n那是直接把平方项求导的 2 留下来了我们定义的损失是“平均”均方误差不是误差平方和所以导数里的 2 被消掉。写代码时如果发现梯度始终差一倍先检查损失是 mean 还是 sum。选线性回归当第一站还有一个原因它的损失是凸函数只要学习率选取合理多次运行会收敛到同一组解。这意味着你可以把偏差完全归因于“梯度实现错误”而不是“优化器运气不好”。后面实现逻辑回归时你会发现损失函数一换问题性质立刻不同所以先用线性回归把框架搭稳。2.2 手写梯度下降十行核心代码与两个容易写错的地方下面是一个可直接运行的最小实现偏置合并进权重核心训练循环只有三条矩阵运算。import numpy as np def mse(y_true, y_pred): return np.mean((y_true - y_pred) ** 2) class LinearRegressionGD: def __init__(self, lr0.01, epochs1000): self.lr lr # 学习率 self.epochs epochs # 迭代轮数 self.w None def fit(self, X, y): n X.shape[0] # 在 X 左侧拼一列 1把偏置 b 合并进权重向量 X_b np.hstack([np.ones((n, 1)), X]) self.w np.zeros(X_b.shape[1]) for epoch in range(self.epochs): y_pred np.dot(X_b, self.w) # 前向一次矩阵乘法 grad np.dot(X_b.T, (y_pred - y)) / n # 梯度转置矩阵乘误差 self.w - self.lr * grad # 参数更新 if epoch % 200 0: print(fepoch {epoch:4d}, loss {mse(y, y_pred):.6f}) return self def predict(self, X): X_b np.hstack([np.ones((X.shape[0], 1)), X]) return np.dot(X_b, self.w)逻辑说明y_pred 的形状是 (n,)grad 是 (d1,)X_b.T 与误差向量做点积等价于对每个特征求“该特征值与误差的内积”再除以样本数。这里必须用 X_b.T 而不是 X_b因为矩阵乘法要求维度对齐写反会直接报 shape 错误。参数说明lr 从 0.01 起步epochs 设 1000观察 loss 不再下降再停。两个高频错误一是忘记拼接 1 列导致偏置永远学不到二是更新步写成self.w self.w self.lr * grad符号错了会让 loss 单调上升这时不要怀疑公式先去看减号。用一段合成数据验证方便你复制后立刻看到收敛曲线rng np.random.default_rng(0) X rng.uniform(-1, 1, (200, 3)) true_w np.array([2.0, -1.0, 0.5]) y np.dot(X, true_w) 0.1 * rng.normal(size200) model LinearRegressionGD(lr0.1, epochs500).fit(X, y) print(model.predict(X[:5]))这段代码里真实权重是 [2, -1, 0.5]加了 0.1 的噪声。如果手写实现正确收敛后的权重会接近真实值误差基本来自噪声而非模型偏差。用这个方法可以在十秒内确认梯度方向和更新逻辑都没错。2.3 学习率、轮数与特征缩放一张参数表讲完越界行为手写线性回归最常见的调试循环是“loss 爆炸了”而原因通常落在下面这张表里。参数常用区间越界表现排查方向lr 学习率0.001 ~ 0.1loss 震荡或爆升降 lr或先做特征标准化epochs 迭代轮数200 ~ 2000loss 未收敛打印每 100 轮 loss看是否仍在下降特征尺度各特征量级接近大尺度特征主导梯度用 z-score (x - mean) / std经验是特征范围相差 100 倍以上时学习率往往对窄特征太小、对宽特征爆炸。对特征做标准化之后学习率选择会宽容很多。标准化的代码可以写在 fit 之前用训练集的均值和标准差去变换测试集避免数据泄漏。如果你是用 VSCode 写这段代码环境配置这一步经常比算法本身更耗时间。建议先确认当前解释器是哪个 python 环境再把 numpy 装进这个环境而不是全局装了一份、终端里又跑的是另一份。print 输出看不到通常不是代码问题而是解释器选错或输出面板没打开。3. 分类别照抄回归逻辑回归与多分类 Softmax 的数值稳定写法线性回归跑通后下一个自然动作是处理分类。常见错误是把 MES 直接套到 sigmoid 输出上发现 loss 半天不动。原因在于分类问题的目标不是拟合数值而是估计概率损失函数必须换成交叉熵。这一章从损失函数差异讲起再给一个可直接抄的 Softmax 多分类实现。3.1 交叉熵而不是均方误差分类损失函数为什么必须换逻辑回归把线性输出 z Xw b 送进 sigmoid得到 (0,1) 区间的概率。如果照搬线性回归的均方误差损失函数对 w 的梯度会带一个 s(z) 因子。sigmoid 在两端的导数接近 0导致误差很大时梯度反而很小这就是梯度饱和。更麻烦的是 MSE 对 sigmoid 的输出非凸梯度下降容易停在不理想的位置。交叉熵在二分类下的形式是 loss -mean(y * log p (1 - y) * log(1 - p))。对它求导得到的梯度形式恰好是 dw X^T (p - y) / n和线性回归长得一模一样只是把“预测值”换成了“预测概率”。这个简洁结果是逻辑回归在实际中好用的原因之一只要中间没有数值溢出梯度方向天然正确。实现时建议直接从线性输出 logits 出发计算概率不要先算 sigmoid 再算 log更不要对概率做 log 后又做除法。代码层面保留 logits 这条通路后续加正则化、换成 softmax 都会更顺手。3.2 向量化的 Softmax 分类器先减最大值再算 exp多分类是逻辑回归的自然推广。下面这个实现直接支持任意类别数偏置仍用拼接列处理并加了 L2 正则化。import numpy as np def softmax_safe(z): # 减去每行最大值避免 exp 溢出 z z - np.max(z, axis1, keepdimsTrue) e np.exp(z) return e / np.sum(e, axis1, keepdimsTrue) class SoftmaxClassifier: def __init__(self, lr0.5, epochs500, reg1e-3): self.lr lr self.epochs epochs self.reg reg self.classes None def fit(self, X, y): self.classes np.unique(y) C len(self.classes) n X.shape[0] X_b np.hstack([np.ones((n, 1)), X]) # one-hot 目标矩阵每行只有真实类别位是 1 Y np.zeros((n, C)) for i, c in enumerate(self.classes): Y[y c, i] 1 self.W np.zeros((X_b.shape[1], C)) for epoch in range(self.epochs): probs softmax_safe(np.dot(X_b, self.W)) # 梯度p - Y 是误差项偏置列不参与正则化 dw np.dot(X_b.T, probs - Y) / n reg_grad self.reg * np.vstack([np.zeros((1, C)), self.W[1:]]) self.W - self.lr * (dw reg_grad) loss -np.mean(np.sum(Y * np.log(probs 1e-12), axis1)) if epoch % 50 0: print(fepoch {epoch:3d}, loss {loss:.4f}) return self def predict(self, X): X_b np.hstack([np.ones((X.shape[0], 1)), X]) probs softmax_safe(np.dot(X_b, self.W)) return self.classes[np.argmax(probs, axis1)]逻辑说明softmax_safe 里“减最大值”不影响概率分布但能把 exp 的输入压到 0 以下从根上避免浮点溢出。预测时取 argmax 后要用 self.classes 映射回原始标签因为类别顺序在 fit 里被 np.unique 重排过。参数说明lr 在类别较少、特征不大时可取 0.5收敛速度快reg 是 L2 系数偏置列梯度被显式置零防止正则把偏置也往 0 拉。loss 里加的 1e-12 只是防 log(0)正规做法也可以用 logsumexp 一次性算 loss但手写阶段这种简化足够安全。3.3 多分类、正则化与类别不平衡的三个检查点多分类实现常见的问题集中在 one-hot 编码、梯度符号和数值稳定三处。写作时把下面这张表贴在 fit 函数旁边能少走很多弯路。检查点症状常见原因one-hot 对齐准确率恒等于 1/Cy 的类别与 Y 的列没有一一对应梯度符号loss 单调上升更新写成加号而不是减号数值稳定出现 inf 或 NaNsoftmax 没减最大值或 log(0)正则化强度训练集分数低于预期reg 过大或偏置也被正则类别不平衡是真实任务里绕不开的问题比如音乐风格分类这类多分类任务几个主流风格样本量可能差几十倍。常见的做法是在 loss 里给每个样本加一个与类别频次成反比的权重对类别 c 设 w_c n / (k * n_c)其中 n_c 是该类别样本数。此时损失函数改成对每个样本的交叉熵乘以它的 w_c。手写这个加权要小心权重矩阵的形状必须与 Y 一致或者在Y * np.log(probs)之后按行乘权重否则 NumPy 广播会把权重错位到每一个类别分量上。4. 无监督与树模型K-Means 收敛判定和决策树信息增益的实现细节回归和分类跑通后无监督和树模型是另一片自有坑区的领域。K-Means 的核心难点在收敛判定和空簇处理决策树的难点在分裂标准的实现细节。这两个模型都不需要梯度下降但代码里藏着更多“数值上能跑、语义上错了”的情况。4.1 K-Means 的 E-step 与 M-step距离矩阵与空簇处理K-Means 每次迭代先计算每个样本到所有簇中心的距离再取最近中心作为标签最后用簇均值更新中心。这两步分别叫期望步和最大化步。样本量不大时用广播直接展开距离矩阵代码最清晰。import numpy as np def kmeans(X, k, max_iter100, tol1e-4, seed42): rng np.random.default_rng(seed) centers X[rng.choice(len(X), k, replaceFalse)] n X.shape[0] for it in range(max_iter): # E-step广播成 (n, k, d) 后求欧氏距离 dist np.linalg.norm(X[:, None, :] - centers[None, :, :], axis2) labels np.argmin(dist, axis1) # M-step簇内均值更新中心空簇用随机样本重初始化 new_centers [] for c in range(k): if np.any(labels c): new_centers.append(X[labels c].mean(axis0)) else: new_centers.append(X[rng.choice(n)]) new_centers np.array(new_centers) shift np.linalg.norm(new_centers - centers) centers new_centers if shift tol: break return centers, labels逻辑说明X[:, None, :] 的形状是 (n, 1, d)centers[None, :, :] 是 (1, k, d)广播后得到 (n, k, d)再对最后一维求范数就得到距离矩阵。空簇处理是手写 K-Means 最容易漏的一环某个簇没有任何样本时mean 会返回 NaN后续所有距离都会变成 NaN最终结果全废。参数说明tol 取 1e-4 表示中心点平均位移小于该值时停止k 需要外部指定一般用“手肘法”看整体距离平方和随 k 的下降折点。注意 K-Means 聚类结果的簇编号是任意的拿它与 sklearn 对比时必须先做标签重排否则准确率会异常低。4.2 决策树分裂信息增益计算与连续特征切分决策树需要定义分裂标准常见的有基尼指数和信息熵。手写时基尼指数计算量更小下面以基尼增益为例实现分裂得分。def gini_impurity(y): _, counts np.unique(y, return_countsTrue) p counts / counts.sum() return 1 - np.sum(p ** 2) def split_gain(y, left_mask): n len(y) if left_mask.sum() 0 or left_mask.sum() n: return 0.0 left_y, right_y y[left_mask], y[~left_mask] gain gini_impurity(y) gain - (len(left_y) / n) * gini_impurity(left_y) gain - (len(right_y) / n) * gini_impurity(right_y) return gain逻辑说明split_gain 返回分裂前后基尼不纯度的差值越大表示分得越纯。left_mask 是布尔数组用于把样本分成左右两支。写这一层时最容易出错的不是信息增益公式而是两个分支中某一个为空此时 gain 可能被算成负数应在入口处用条件直接拦截。构建递归树时常见做法是对每个特征枚举它出现过的值作为阈值取增益最大的“特征-阈值”对。这样做能处理连续特征代价是分裂候选点多时计算较慢但手写版本优先保证正确性。终止条件一般设三个节点样本全为同一类、达到 max_depth、样本数小于 min_samples_split。4.3 手写分类树最容易踩的三个坑第一个坑是叶子标签用 np.bincount 选众数但 bincount 要求标签是非负整数。如果原始标签是字符串或负数必须先用 np.unique 做映射或在预测层通过 self.classes 做转换。否则叶子节点一多报错位置会离源头很远。第二个坑是递归不终止。当某个特征上所有样本都相同、切分后左右子集与父集完全相同递归会无限进行。解决方法是每个递归入口都检查增益是否大于 0增益等于 0 直接生成叶子。这个条件和 max_depth 必须同时存在缺一不可。第三个坑是预测时的阈值边界。训练时用的是X[:, f] t预测也必须沿用小于等于的规则。如果手写预测时写成小于那么恰好在阈值点上的样本会走入不同的分支训练分数与预测分数不一致。建议把切分阈值和比较符号存进节点字典而不是在预测函数里重新写一遍。提示决策树对数据顺序敏感同一份数据打乱后可能得到不同的树因为最大信息增益存在并列时取的是第一个候选。这并不是 bug但调试时不要用“两次结果不一致”来判断代码错误。K-Means 和树模型的共同特征是它们没有梯度下降那种一眼可见的 loss 曲线验证正确性只能靠最终指标和人工检查簇中心或树结构。因此写完后立刻进入对拍比对着公式再读一遍代码更高效。5. 用手写模型和 sklearn 对拍梯度检查、参数边界与调试手段手写代码“看起来正确”和“数值正确”之间有一段距离收尾阶段要做的不是反复读公式而是用三种手段把错误暴露出来对拍、梯度检查、中间量观察。5.1 对拍的正确姿势对拍要在同一份数据、同样的随机种子下进行。以线性回归为例用 sklearn 的 LinearRegression 作为参考比较预测值的最大绝对误差。from sklearn.datasets import make_regression from sklearn.linear_model import LinearRegression X, y make_regression(n_samples200, n_features5, noise0.1, random_state42) sk_model LinearRegression().fit(X, y) my_model LinearRegressionGD(lr0.1, epochs2000).fit(X, y) diff np.max(np.abs(sk_model.predict(X) - my_model.predict(X))) print(max diff:, diff)逻辑说明make_regression 生成回归数据random_state 固定后数据可重现。手写模型收敛后预测应与 sklearn 的解非常接近若 diff 大于 1e-3 量级优先检查特征是否标准化再检查偏置是否被正确学习。参数说明对拍时的容差由数据尺度决定不要在数据值域大到 1e4 时仍期望差 1e-8。更稳定的做法是看相对误差即 diff 除以 y 的标准差。5.2 梯度检查用中心差分梯度检查适用于所有用梯度下降更新的模型对每个参数 w_i 做微小扰动用中心差分估算梯度与解析梯度比较。def numerical_grad(f, x, eps1e-6): # 中心差分比单向差分误差小一个数量级 return (f(x eps) - f(x - eps)) / (2 * eps) # 使用方式f 是只接受当前参数、返回损失的函数 # grad 是 fit 里算出的解析梯度 # 对每个参数分量比较 numerical_grad(f, w, eps) 与 grad[i]逻辑说明中心差分的截断误差与 eps 的平方成正比而单向差分是与 eps 成正比所以中心差分是通用做法。eps 取 1e-6 通常足够过小会引入浮点舍入误差过大则逼近效果变差。梯度检查通过的标准是数值梯度与解析梯度的最大相对差小于 1e-4。检查时只取一小批样本比如前 50 条数据并把正则项暂时置零这样梯度公式更纯错误也更易定位。5.3 把调试钩子留在 fit 里最后养成的习惯是在 fit 循环里长期保留三样东西每轮或每若干轮的 loss 打印、probs 或预测值的最小最大打印、权重更新的最大绝对值打印。这些钩子在正常训练时占不了多少开销但模型出现 NaN 时能帮你十分钟内定位到是 exp 溢出、学习率过大还是数据未标准化。把这些打印留在代码里而不是调通后再删掉下次换数据集时你会感谢它们。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。