
简介基于NumPy实现常见机器学习算法的实践代码包面向机器学习初学者和希望深入算法原理的开发者。项目围绕西瓜书中的典型模型使用NumPy手工实现线性回归、逻辑回归、K近邻、朴素贝叶斯、支持向量机、AdaBoost、决策树与神经网络等每个算法都配有独立脚本可直接运行并观察结果。资源共11个文件包含8个Python实现文件、2个CSV数据集西瓜数据集和鱼类数据集以及1张决策树示意图压缩包仅364KB结构清晰、轻量易用。已有168人学习下载。代码不仅展示算法核心公式的落地写法还覆盖数据标准化、梯度下降、交叉验证、评估指标计算等环节适合对照教材动手调试也可作为课程设计或面试复习的参考模板。1. 为什么我劝你别一上来就调 sklearn用 numpy 复现常见机器学习算法到底值不值还在对着 loss 曲线和混淆矩阵发愁的同学多半都搜过这个标题基于numpy实现常见机器学习算法。它解决的不是“缺一个库”的事而是帮你把黑匣子拆成矩阵乘法和导数。我的经验是用 numpy 复现一次线性回归、逻辑回归、K-Means 和 PCA比调三个月 sklearn 更能理解为什么模型要归一化、什么是共线性、softmax 为什么要减最大值。这套思路适合面算法岗要手撕代码的人也适合做毕设想展示算法细节的人以及那些参数调来调去总是不收敛、想回头补底层基础的一线工程师。这篇笔记不整理某个现成 zip 包的目录而是按实际跑通的顺序把三类最常见算法的依赖理论、最小实现代码和踩过的坑摆出来。2. 线性回归的最小复现正规方程、批量梯度下降和 R² 验收2.1 正规方程闭式解numpy.linalg 里的三个陷阱正规方程是线性回归最直接的解法核心思路是让残差平方和的梯度等于零直接一步求出最优 theta。公式是theta (X^T X)^(-1) X^T y用 numpy 写出来连十行都不到但对工程实现来说真正的坑往往藏在“怎么加偏置”和“矩阵不可逆”两个细节里。import numpy as np def normal_equation(X, y): # X: (m, n) 原始特征矩阵m 是样本数n 是特征数 # y: (m,) 一维目标值 # # 第一坑不拼这一列 1模型就没法学习偏置 b X_b np.hstack([np.ones((X.shape[0], 1)), X]) # 第二坑用 inv 而不是 pinv遇到共线性矩阵直接抛 LinAlgError # pinv 基于 SVD对奇异矩阵能算出最小二乘意义下的伪逆 theta np.linalg.pinv(X_b.T X_b) X_b.T y return theta第一坑是漏拼偏置列。数组X只包含特征线性回归要学一个y w1*x1 ... b那个b在矩阵形式里就是全 1 列对应的权重。不拼这一列模型被迫强制过原点性能会掉一大截尤其当标签均值明显不为零时训练出来 R² 直接烂掉。第二坑是pinv和inv的选择。np.linalg.inv在X.T X奇异时会抛异常也就是当特征之间存在强线性相关时。np.linalg.pinv不会抛异常它用 SVD 求伪逆牺牲一点点精度换来鲁棒性。更稳妥的做法是只依赖数据本身先用np.corrcoef(X.T)看特征相关系数大于 0.99 的列建议手工删掉其中一列。第三坑是特征缩放。正规方程虽然一步求出精确解但矩阵求逆在数值上天然偏向大尺度特征。举个例子第一列在 0~1第二列在 100000~200000计算X_b.T X_b时会得到跨越十几个数量级的元素pinv的 SVD 为了保住精度可能额外付出代价最后解出的权重也极度不平衡。下面这张表是我的选择习惯新手可以参考不用照抄数据规模 / 情况做法样本几千、特征十几个优先正规方程代码短、无调参样本几十万、特征上百优先梯度下降内存友好特征强共线先用pinv 检查相关性必要时删特征数据流式到达 / 线上更新梯度下降正规方程没法增量更新正规方程看起来不用调学习率但代价是计算量对样本量和特征量都很敏感(X.T X)自己就是(n1, n1)矩阵求逆是 O(n^3)特征维度超过一千以后会非常难受。所以业内最常见的做法是小数据用解析解验证其他实现大数据直接走梯度下降。2.2 手写批量梯度下降从公式到 numpy 向量化当数据量变大解析解要计算的X.T X求逆越来越费时我一般就换成梯度下降。批量梯度下降这个名字听着吓人其实就是每一轮用所有样本算梯度再沿负梯度方向迈一步。线性回归对 theta 求导的结果是grad (1/m) * X.T (X theta - y)把这个公式翻译成 numpy 就得到训练函数。def batch_gradient_descent(X, y, lr0.01, epochs500): # X 必须已经拼好全 1 列shape (m, n) # y 是 (m,) 一维数组 m, n X.shape theta np.zeros(n) loss_history [] for epoch in range(epochs): y_hat X theta # 前向计算矩阵乘一次 grad (X.T (y_hat - y)) / m # 向量化梯度的标准写法 theta - lr * grad loss np.mean((y_hat - y) ** 2) loss_history.append(loss) if epoch % 100 0: print(fepoch {epoch:4d}, loss {loss:.6f}) return theta, loss_history代码里最值得琢磨的是X.T (y_hat - y)。这一行的效果是把(n, m)和(m,)做矩阵乘法最后累加成(n,)的梯度。如果写成for i in range(m): grad X[i] * (y_hat[i] - y[i])速度会慢至少一个数量级。所有基于 numpy 的机器学习算法实现都应该遵守这条铁律能用矩阵乘法解决的绝不写 Python 循环。参数方面lr是最大的调参对象。我把lr0.01当作默认因为它在标准化特征上通常能在一百轮内看到明显下降。如果你用原始特征且没有缩放学习率必须主动缩小反之特征缩放后学习率可以放大。epochs并不是越大越好因为梯度下降会在极小点附近震荡最后一万轮可能只是在零点零零几的范围内反复横跳。我建议判断标准看 loss 曲线前一百轮下降明显后面基本不动那这五百轮就够了如果最后几轮还在线性下降说明还可以再加 epochs。另一个容易踩的细节是 bias 的初始化。np.zeros(n)初始化没问题线性回归是凸问题不同初始值最终收敛到同一个最优解这点与神经网络不同。所以初期调试线性回归时不需要担心随机种子。2.3 从 batch 到 mini-batch内存与收敛速度的折中批量梯度下降的缺陷是每轮要用全体样本算梯度数据量过百万时单次X.T X就可能吃掉几个 G 内存。一个自然的替代是小批量梯度下降mini-batch GD它每次只取一小批样本估算梯度一轮下来更新多次收敛更快代码也只多了一层循环。def mini_batch_gradient_descent(X, y, batch_size32, lr0.01, epochs50): # X 已拼接全 1 列shape (m, n) # y 是 (m,) 一维数组 m, n X.shape theta np.zeros(n) loss_history [] for epoch in range(epochs): # 每轮打乱样本顺序防止样本排列带来的周期性梯度 perm np.random.permutation(m) X_shuffled, y_shuffled X[perm], y[perm] for start in range(0, m, batch_size): end min(start batch_size, m) X_batch X_shuffled[start:end] y_batch y_shuffled[start:end] y_hat X_batch theta grad X_batch.T (y_hat - y_batch) / len(y_batch) theta - lr * grad loss np.mean((X theta - y) ** 2) loss_history.append(loss) if epoch % 10 0: print(fepoch {epoch:3d}, loss {loss:.6f}) return theta, loss_history逻辑上说permutation打乱索引是为了避免数据集中同类样本全部聚在一起否则梯度会跑偏。batch_size是这里的核心参数32 到 128 是常见区间。batch 太小梯度噪声大loss 曲线像毛刺batch 太大又退回批量梯度下降的内存问题。实际使用一般先取 32如果收敛太慢就改成 64 或 128。把batch_size设为 1就是随机梯度下降适合在线学习但训练曲线会非常吵。模型训练完我习惯先看测试集上的 R²而不是只看训练 lossdef r2_score_manual(y_true, y_pred): ss_res np.sum((y_true - y_pred) ** 2) ss_tot np.sum((y_true - np.mean(y_true)) ** 2) return 1 - ss_res / ss_tot这段代码没有用任何库直接按 R² 定义计算。R² 值是回归任务验收时的第一眼信号训练 R² 0.99、测试 R² 0.7说明过拟合两边都低成负数说明模型比“取均值”还差这时候先查数据顺序、泄漏列再怀疑实现。把 R² 和 loss 曲线放在一起看线性回归基本的调试闭环就完成了。3. 逻辑回归与多分类用 numpy 让分类任务告别黑匣子3.1 sigmoid 和交叉熵分类为什么不用最小二乘逻辑回归的输出是概率但本质上它还是在做线性变换加一个非线性激活。把线性输出z X theta通过 sigmoid 压到 (0,1)再根据阈值 0.5 判断类别。sigmoid 的公式很简单实现的第一步却是防溢出np.exp(-z)在 z 小于 -700 时会变成无穷大结果直接 nan。def sigmoid(z): # 限制输入范围不是拍脑袋float64 能表示的最小正数约为 5e-324 # exp(-745) 已经下溢clip 到 -500 能覆盖绝大多数正常数据 z np.clip(z, -500, 500) return 1.0 / (1.0 np.exp(-z)) def binary_cross_entropy(y_true, y_pred): eps 1e-12 # log 前加 eps防止真实标签为 1 但预测概率为 1.0 时出现 log(0) return -np.mean(y_true * np.log(y_pred eps) (1 - y_true) * np.log(1 - y_pred eps))第二个要理解的是损失选择。如果你用均方误差训练逻辑回归损失函数不是凸的可能出现多个局部极小点梯度下降容易陷进去而交叉熵配合 sigmoid得到的梯度表达非常干净grad X.T (probs - y) / m。这个式子越看越像线性回归的梯度本质上都是“残差乘特征”。二分类逻辑回归的最小训练循环建议直接照着写def train_logistic_binary(X, y, lr0.01, epochs300): # X 已拼接全 1 列y 标签必须取 0/1 m, n X.shape theta np.zeros(n) for epoch in range(epochs): probs sigmoid(X theta) # 二分类交叉熵的梯度恰好等于 (probs - y) 的线性投影 grad X.T (probs - y) / m theta - lr * grad if epoch % 50 0: loss binary_cross_entropy(y, probs) acc ((probs 0.5).astype(int) y).mean() print(fepoch {epoch:3d}, loss {loss:.4f}, acc {acc:.4f}) return theta参数上唯一需要小心的是标签必须是 0 和 1。数据加载时如果用字符串 yes 和 no 而没有转成 0/1probs - y这一步直接报 TypeError或者把字符串自动转成 bool 造成隐式类型错误那种 bug 最难查。建议在第一行加一句断言assert set(y) {0, 1}成本很低但能救你一命。3.2 多分类 softmax 的 numpy 实现细节二分类只需要输出一个概率多分类则需要为每个类别输出概率。softmax 把任意实数向量归一化成概率分布但朴素实现有严重数值问题exp(1000)会溢出为 infinf / inf直接 nan。解决办法是每行先减去该行最大值这个操作不改变概率值。def softmax(logits): # logits 形状为 (m, num_classes)每一行是一个样本在所有类别上的得分 # 减最大值softmax 的分子分母同时除以 exp(max)结果不变 logits_shifted logits - logits.max(axis1, keepdimsTrue) exp_logits np.exp(logits_shifted) return exp_logits / exp_logits.sum(axis1, keepdimsTrue)keepdimsTrue是关键。如果没有它logits.max(axis1)的形状是(m,)广播到(m, num_classes)时会沿着行方向做减法结果完全错误。每次写广播之前的规约操作我都会在心里默念一句输出形状到底对不对。这件事看着小实际排查起来非常费时间因为矩阵形状不出错时模型可能只在个别样本上出错。多分类的训练循环比二分类多两步构造 one-hot 标签和计算 softmax 梯度。def train_softmax(X, y, lr0.01, epochs300, num_classesNone): m, n X.shape if num_classes is None: num_classes int(y.max()) 1 # 权重太小会学不动太大会让初始 logits 过大softmax 接近均匀分布 W np.random.randn(n, num_classes) * 0.01 b np.zeros((1, num_classes)) # one-hot 编码y 里的类别号放在对应列上其余为 0 Y_onehot np.zeros((m, num_classes)) Y_onehot[np.arange(m), y] 1 for epoch in range(epochs): logits X W b probs softmax(logits) # 多分类交叉熵梯度的闭式表达式预测值减 one-hot再投影到特征空间 grad_W (X.T (probs - Y_onehot)) / m grad_b np.sum(probs - Y_onehot, axis0, keepdimsTrue) / m W - lr * grad_W b - lr * grad_b if epoch % 50 0: pred probs.argmax(axis1) acc (pred y).mean() print(fepoch {epoch:4d}, acc {acc:.4f}) return W, b梯度公式probs - Y_onehot是 softmax 配交叉熵的经典结论。建议自己推导一遍对某个样本来说正确类别的梯度是probs - 1错误类别是probs所以整体写成一列就是一减 one-hot。num_classes显式传参比y.max()1更稳因为如果测试集里缺少某个类别y.max()1会少算一类如果你的业务标签是从 1 开始的在做 one-hot 前必须先把 y 减 1否则数组索引越界。参数上多分类比二分类要更保守。lr0.01是安全起点但特征维度高或者类别多时建议降到 0.001 再观察。W 的初始化尺度取 0.01不要取大值初始权重太大会让所有类别的 softmax 概率趋于均匀训练曲线在前几十轮几乎不动。3.3 log-sum-exp 与训练曲线的判断标准softmax 减去最大值只是 log-sum-exp 技巧的一种简化形式。如果你真的需要计算 log softmax更稳妥的写法是def log_softmax(logits): shifted logits - logits.max(axis1, keepdimsTrue) logsumexp np.log(np.exp(shifted).sum(axis1, keepdimsTrue)) return shifted - logsumexp在实际分类器的梯度计算里log-sum-exp 的意义远不止防溢出。在极端的特征尺度下np.exp(shifted)仍然可以把一些小概率直接下溢成 0再对 0 做 log 就成了 -inf。为了避免这个情况用上面的log_softmax直接算 logits 的概率对数再去减 one-hot就不会在 0 上取对数了。训练曲线怎么看代码里每 50 轮打印一次准确率你要能在前 50 轮看到准确率有一个明显爬升然后慢慢趋于稳定。如果准确率从 0.4 忽然跳到 0.9 又掉回 0.4多半是学习率太大权重跨过了最优区域如果准确率一直卡在 0.1 上下先去查 one-hot 和标签映射标签错位会让模型学到完全无意义的方向。0.1 这个数字很有代表性因为如果有 10 个类别随机猜的概率大约就是 0.1看到这个值基本说明实现有 bug。4. 无监督与降维K-Means 和 PCA 的 numpy 实现及参数敏感度4.1 K-MeansK 值、初始化与收敛判断K-Means 是理解算法输出依赖初始状态的教科书。它的目标是把样本分成 K 个簇让每个样本到所属簇中心的距离平方和最小。实现上只需要两行核心逻辑交替执行按当前中心分配样本按样本均值更新中心。def kmeans(X, k, max_iter100, tol1e-6, seedNone): # X 是 (m, n) 二维数组 if seed is not None: np.random.seed(seed) # 初始化从样本里随机抽 k 个不重复的点 idx np.random.choice(len(X), sizek, replaceFalse) centers X[idx].copy() for _ in range(max_iter): # 分配步骤广播计算欧氏距离shape 从 (m,n) 和 (k,n) 变 (m,k,n) distances np.linalg.norm( X[:, None, :] - centers[None, :, :], axis2 ) labels distances.argmin(axis1) # 更新步骤每个簇取均值 new_centers np.array([ X[labels c].mean(axis0) if np.any(labels c) else centers[c] for c in range(k) ]) # 中心点几乎不动提前收敛 if np.linalg.norm(new_centers - centers) tol: break centers new_centers return centers, labels这版实现有几个隐藏细节。第一个是空簇处理某个中心点距离所有样本都很远时分配步骤可能没有任何样本被分给它X[labels c].mean(axis0)会对空数组求均值numpy 会返回 nan 并且打印警告。我在这里做了个兜底如果某个簇为空就保留上一轮的中心点下一轮再尝试更激进的做法是把它移动到距离所有中心点最远的样本上但学习阶段先用保守策略。第二个是 K 的选择。手写 K-Means 不提供自动定 K业界常见做法是画肘部图对 K 从 2 到 10计算每个 K 下的总惯性inertia np.sum(distances.min(axis1))画出来后找“拐点”。实际数据的肘部通常不锋利这时我一般结合业务语义确定簇数再用两到三个附近候选值跑一遍对比稳定性。如果 K 设小了簇内距离会偏大K 设大了会出现一个簇里只有零星几个样本的空洞结构看每个簇的样本数分布就能发现。第三是随机初始化带来的不稳定性。同样的数据、同样的 K随机抽到的初始中心不一样最终结果可能完全不一样。我曾经在同一个数据集上连续跑五次 K-Means每次准确率差 5 个百分点。工程上的标准解法是 K-Means它的思路很直观第一个中心随机选之后每个新中心都偏向于选离现有中心较远的样本。用 numpy 实现也不复杂def kmeans_plusplus_init(X, k, seedNone): if seed is not None: np.random.seed(seed) centers [X[np.random.randint(len(X))]] for _ in range(1, k): # 对每个样本计算到已选中心的最小距离 dist np.array([ min(np.linalg.norm(x - c) for c in centers) ** 2 for x in X ]) probs dist / dist.sum() centers.append(X[np.random.choice(len(X), pprobs)]) return np.array(centers)这段代码保留了三重循环只为演示思路在小数据上跑没问题生产环境直接用 scikit-learn 的initk-means。理解它的价值在于你会明白所谓“算法效果不好”不一定是损失函数错了初始化策略、K 值、空簇处理都能决定结果。4.2 PCA 的 SVD 路线协方差矩阵和奇异值分解怎么选PCA 的目标是找出一组正交方向让数据投影在这些方向上的方差从大到小排列取前 K 个方向作为新的特征轴。实现有两种路线对协方差矩阵做特征分解或对中心化数据做 SVD。绝大多数书籍推荐协方差矩阵路线因为概念上更直接但我更常用 SVD因为数值更稳而且不需要显式构造(n,n)协方差矩阵。def pca(X, k): # X 是 (m, n)先中心化 X_mean X.mean(axis0) X_centered X - X_mean # full_matricesFalse 让 SVD 只返回 (m, min(m,n)) 的 U避免内存爆炸 U, S, Vt np.linalg.svd(X_centered, full_matricesFalse) # Vt 的行是主方向按奇异值从大到小排列取前 k 行作为投影矩阵 components Vt[:k] # shape (k, n) X_pca X_centered components.T # shape (m, k) return X_pca, components, S代码逻辑是先让每个特征减去均值让数据中心在原点SVD 把矩阵分解成U diag(S) Vt其中Vt的行恰好是协方差矩阵的特征向量方向奇异值S的平方对应各方向的方差。components就是 PCA 里的主成分向量X_centered components.T得到低维投影。选 K 的标准有一个非常常用的工具累计解释方差比。奇异值的平方正比于对应主成分的方差所以要算保留了多少信息看累计方差比即可explained_ratio np.cumsum(S ** 2) / np.sum(S ** 2) # 找第一个满足 0.95 的索引 k np.argmax(explained_ratio 0.95) 1 print(f保留 95% 方差需要 {k} 个主成分)这行代码会自动帮你选 K但别机械地用。如果前两个主成分已经占了 80% 方差第三个才开始衰减那 K2 常常是可视化最能解释的选择如果方差比是平滑下降没有断崖说明这件事本身就不适合用线性 PCA 压缩硬压到二维会有很大信息损失。PCA 的另一个常见坑是不做标准化。当特征量纲差异很大时方差大的维度会在 SVD 中被放大主成分大概率被那些数值大的特征主导所以特征尺度不一样时先标准化再 PCA 几乎是一种约定俗成。标准化后每个维度的方差都是一PCA 找的方向反映的是“相关性结构”而不是“绝对数值差异”这样解释起来才合理。重建误差也是检验 PCA 建模质量的手段把投影结果乘回转置加回均值再算均方误差可以用来判断 K 是多少时已经足够好。X_recon X_pca components X_mean rmse np.mean((X - X_recon) ** 2)如果这个 rmse 远大于数据本身的方差说明 K 选小了。反之如果 K 只取了一两个维度rmse 已经接近 0那说明原始数据本来就在一个极低维的流形上。4.3 大矩阵的内存问题与批量处理思路手写 PCA 最大的限制在np.linalg.svd。完整的np.linalg.svd(X)默认会返回形状为(m,m)的 U 矩阵当样本上到十万时这个矩阵就有 100 亿个元素内存直接爆掉。full_matricesFalse一个参数就能把 U 压缩到(m, min(m,n))这是我的第一个记忆点。如果数据大到X_centered本身都装不进内存常见做法是随机抽样一个子集先做主成分再对全部数据投影。原理上这是随机化 SVD 的简化版先对数据做随机线性组合得到一个小矩阵再在这个小矩阵上做精确 SVD。def pca_randomized(X, k, seed0): # 简化版随机化 PCA用高斯随机矩阵把数据压缩到低维 rng np.random.default_rng(seed) Omega rng.standard_normal((X.shape[1], k 10)) Y X Omega # 把样本压到 (m, k10) Q, _ np.linalg.qr(Y) # 对 Y 做 QR得到正交基 B Q.T X # 小矩阵 B再对 B 做 SVD _, _, Vt np.linalg.svd(B, full_matricesFalse) components Vt[:k] return (X - X.mean(axis0)) components.T, components这个版本牺牲了一点精确度但把 SVD 的计算对象从(m, n)变成了(k10, n)内存和速度都好很多。这里不展开随机化 SVD 的理论你只需要知道在“内存装不下”的场景里可以先拿这套思路顶上去。真实生产环境我一般直接用成熟的加速库但理解了这个简化版再看 sklearn 的源码就不会觉得头大。5. numpy 实现机器学习的避坑与排查5 个高频翻车现场手写算法时 90% 的翻车发生在数据进入算法之前和损失函数下降那几步。下面五条是我在帮别人 review 代码时最常看到的类型按“现象 → 原因 → 解决”展开碰到相同的症状可以直接照着查。5.1 loss 曲线像心电图先别调学习率去查特征范围现象训练 loss 曲线像锯齿一样上下跳动降低学习率后跳幅变小但收敛变慢继续训练又恢复震荡。原因特征没做尺度统一。线性回归的梯度里包含特征本身当第一列特征在 0~1、第二列在几千到几万时梯度向量里第二列的权重更新量远大于第一列学习率取小了第一列学不动取大了第二列震荡。这跟学习率无关是优化问题的条件变差了。解决方式是先做标准化而且必须把均值和标准差的计算严格限定在训练集上mu X_train.mean(axis0) std X_train.std(axis0) 1e-8 # 加极小值防止除零 X_train_std (X_train - mu) / std X_test_std (X_test - mu) / std # 测试集只用训练集的统计量注意最后一行很多人把完整数据集X一次性标准化后再划分测试样本的分布信息已经进了训练过程测试误差估计偏乐观这在 5.2 条会引发连锁反应。标准化的正确执行顺序是先划分再取训练集的 mu 和 std最后应用到两个集合上。5.2 训练 acc 99%、测试 acc 只有 70%先断泄漏再谈过拟合现象训练集准确率接近 100%测试集准确率大幅下跌反复调参没有改善。原因大概率是数据泄漏小概率是过拟合。常见来源有三个一是 5.1 提到的先标准化再划分二是特征矩阵里混入了和标签强相关的“未来信息”比如预测用户是否违约时把“是否已逾期 30 天”当成特征三是随机划分时没有打乱训练集和测试集分布了完全不同的时间段或批次模型只是在背训练集的时间分段。解决的第一步是把训练测试的划分代码单独抽出来确保所有预处理都在划分之后执行第二步是打印训练集和测试集的标签分布比如样本类别的平均值如果差异非常大说明抽样有问题改用分层采样按类别比例挑索引。第三步再考虑正则化手写逻辑回归可以给损失函数加 L2 项但一般要等排查完前两步再说否则只是在掩盖症状。5.3 loss 突然变 nanlog(0) 和梯度爆炸的排查顺序现象loss 前几轮正常某轮突然变成 nan后面全部 nan。原因最常见是 log(0)sigmoid 的输出在训练到后期会被推到非常接近 1.0对 1-y 取 log 时得到 -inf再乘 0 就 nan。另一个是学习率过大权重一步更新过头后续所有梯度都被爆调。排查方法是从第一轮开始打印 loss看看 nan 出现在第几轮。如果第二轮就是 nan学习率改小到 0.001 或 0.0001 再试。如果前面跑得好好的突然在某个 epoch 变 nan打印该轮的probs.max()和probs.min()大概率是最大值已经到 1.0、最小值到 0.0解决方法是去损失函数里加epsnp.log(probs eps)同时确保 sigmoid 和 softmax 都用到了前面提到的防溢出写法。真正玄学的 nan 很少大多数都是这两类可复现的问题。5.4 正规方程报 Singular matrix共线性让权重异常巨大现象np.linalg.inv抛出LinAlgError: Singular matrix换成pinv后权重值动辄几千且无法解释。原因特征共线性。当两个特征高度线性相关比如温度摄氏度和华氏度同时入模时X.T X的某个特征值接近零矩阵求逆被无穷放大导致权重的绝对值大得离谱。这时的模型权重没有可解释性一个特征的估计值可能是 5000另一个是 -4999两者相互抵消后刚好拟合目标。解决分三步第一步把inv换成pinv至少让它不报错第二步直接用np.corrcoef(X.T)算出相关矩阵找到绝对值大于 0.95 的列对删掉其中一列第三步如果删了还不够给X.T X加一个很小的对角矩阵实现岭回归的雏形theta np.linalg.inv(X.T X 0.1 * np.eye(n)) X.T y。先后顺序不要反先删特征再谈正则化否则模型复杂度上去了解释性很差。5.5 数据量大一点手写代码像死机把三层循环换成矩阵广播现象样本量上到十万手写的 K-Means 或者逻辑回归跑一宿都没结束CPU 还占不满。原因代码里全是 Python 层循环。比如 K-Means 距离计算写成了for i in range(m): for j in range(k): np.linalg.norm(X[i] - centers[j])在 10 万样本、10 个中心点的时候这一轮要对 100 万对向量做 Python 级循环必然慢到无法接受。解决方法是先把循环改成矩阵广播。以距离计算为例# 慢两层 Python 循环 # fast一行广播 distances np.linalg.norm(X[:, None, :] - centers[None, :, :], axis2)X[:, None, :]把 X 扩展成(m,1,n)centers[None,:,:]扩展成(1,k,n)相减后自动广播成(m,k,n)再沿最后一维求范数。这一步替代了最耗时的两层循环10 万样本也从几小时降到几秒钟。如果你需要处理的中间矩阵实在太大就分块算每次只对 1 万行做广播把结果累积到一个(m, k)的预分配数组里不要一次性把中间结果全部展开。6. 一个值钱的小习惯用数值梯度检查验证你写的每个梯度手写算法最怕的不是参数不对而是推导错了但代码不报错。我的血泪教训是曾把 softmax 求导里的(probs - Y_onehot)错写成转置训练 loss 一直在降准确率就是上不去。从那以后我每次自定义损失函数都会先跑数值梯度检查再拿去拟合数据。数值梯度用差分近似导数把某个维度的参数分别往正负方向挪动一个 eps一减一除就得到这个维度的近似梯度。def numerical_gradient(f, theta, eps1e-6): # f 是只接受参数向量 theta 的损失函数返回标量 grad np.zeros_like(theta) for i in range(len(theta)): theta_plus, theta_minus theta.copy(), theta.copy() theta_plus[i] eps theta_minus[i] - eps grad[i] (f(theta_plus) - f(theta_minus)) / (2 * eps) return grad使用方式是把自己手写的解析梯度函数和它对比np.random.seed(0) X np.random.randn(20, 3) y X np.array([1.0, 2.0, 3.0]) 0.1 theta np.random.randn(3) * 0.1 f lambda t: np.mean((X t - y) ** 2) grad_numeric numerical_gradient(f, theta) grad_analytic 2 * X.T (X theta - y) / len(y) rel_error np.linalg.norm(grad_numeric - grad_analytic) / ( np.linalg.norm(grad_numeric) np.linalg.norm(grad_analytic) ) print(f相对误差: {rel_error:.2e}) # 小于 1e-6 即认为实现正确相对误差小于1e-6解析梯度基本没写错量级在1e-3上下回去查有没有漏除 m 或漏乘 2完全对不上就用循环逐项对比。数值梯度要跑 O(n) 次前向计算不适合训练时用但调试阶段几秒就能跑完成本非常低。第二个同样重要的习惯是固定随机种子K-Means、mini-batch、随机化 PCA 全都要np.random.seed(0)不然你会把初始化运气当成调参效果。这两个习惯组合起来就是一个最小验证闭环先证梯度对再固定随机性最后才看效果。做完这步把手写实现和 sklearn 的相同算法做一次对比比如在同一数据上比较权重方向和 loss 曲线你就能清楚看到别人写的库到底在默认参数里加了哪些猫腻。希望帮到你。本文还有配套的精品资源点击获取