
简介这份资源面向希望夯实机器学习底层原理的Python学习者与算法入门者以NumPy为核心工具从零实现常见机器学习算法帮助摆脱对现成库的依赖、真正理解模型内部计算过程。包内共11个文件以8个py脚本为主体覆盖线性回归、逻辑回归、决策树、支持向量机、K近邻、朴素贝叶斯、AdaBoost与神经网络等算法实现另含2个csv数据集与1张决策树示意图压缩包约364KB结构轻量便于逐文件研读。内容围绕数组与广播、矩阵运算、标准化与归一化、梯度下降、核函数、聚类与评估指标等关键知识点展开读者可结合西瓜数据集等实例动手复现训练与预测流程在编码中体会损失函数优化与模型评估的细节。目前已有168人学习适合作为算法原理练习与课程实验的参考素材。1. 用 numpy 手搓机器学习算法为什么我劝你别急着调 sklearn很多人第一次接触机器学习是从from sklearn.linear_model import LogisticRegression开始的。三行代码跑通鸢尾花分类成就感拉满。但真到了线上排查问题模型不收敛、梯度爆炸、维度对不上你连从哪下手都不知道——因为黑匣子太厚了。我见过太多人numpy安装完就直奔框架结果连矩阵乘法里哪个维度该转置都说不清。这个标题讲的事情很朴素只用 numpy把线性回归、逻辑回归、KNN、KMeans、PCA 这些常见机器学习算法从零实现一遍。它解决的不是能不能跑而是你到底懂不懂。适合两类人刚学完numpy教程想找练手项目的新手以及用了几年框架、想回头补底层直觉的老手。python安装numpy库的方法网上一搜一大把但装完库之后拿它干什么才是分水岭。接下来我按先立住原理、再动手复现、最后讲坑的顺序把这条路走一遍。2. 先把地基打牢numpy 实现机器学习算法的四个核心操作2.1 为什么选 numpy 而不是直接上框架框架帮你做了三件事自动求导、参数更新、数值稳定。手写 numpy 版本这三件事全得自己扛。听起来是倒退但收益很实在。第一你会被迫想清楚每个算法的损失函数长什么样、梯度怎么推、更新公式里哪一项对应哪一步。第二调试能力会质变——当 loss 变成 nan你知道去查学习率、查 log 里的零、查除零而不是对着框架报错发呆。第三numpy 的向量化写法是后面读任何框架源码的基础PyTorch 的 tensor 操作和 numpy 几乎一一对应。选型上有个边界要说清楚numpy 适合实现参数量小、结构简单的算法用来学习和验证。真要做深度学习或者大规模数据还是得回到框架。别拿 numpy 去硬刚百万级特征的模型那是跟自己过不去。2.2 四个必须刻进肌肉记忆的操作广播broadcasting。这是 numpy 最容易翻车也最省事的地方。形状(100, 3)的矩阵减去形状(3,)的向量numpy 会自动把后者广播到每一行。手写算法时偏置项b的更新几乎全靠广播写对了代码短一半写错了维度对不上直接报错。向量化替代循环。新手最容易写出双重 for 循环算距离矩阵1000 个样本就是一百万次循环慢到怀疑人生。向量化之后一行搞定import numpy as np def pairwise_dist(X, Y): # X: (n, d), Y: (m, d) - 返回 (n, m) 的距离平方矩阵 # 利用 (a-b)^2 a^2 - 2ab b^2 展开避免显式循环 X_sq np.sum(X ** 2, axis1, keepdimsTrue) # (n, 1) Y_sq np.sum(Y ** 2, axis1, keepdimsTrue).T # (1, m) cross X Y.T # (n, m) dist_sq X_sq - 2 * cross Y_sq # 数值误差可能让对角线出现极小负数裁剪到 0 return np.maximum(dist_sq, 0)逻辑说明把欧氏距离平方展开成三项全部用矩阵运算完成。keepdimsTrue是关键它保证求和后维度不塌缩才能正确广播。参数上axis1表示按行求和得到每个样本的模长平方。最后np.maximum是血泪经验——浮点误差会让本该为 0 的值变成-1e-12开根号直接 nan。axis 的方向。记住一句话axis0沿着行方向压结果是列axis1沿着列方向压结果是行。算均值、算梯度、算 loss 全靠它搞反了结果形状对但数值全错这种 bug 最难查。原地操作与内存。a b是原地修改a a b会新建数组。在迭代上千次的训练循环里这个差别会体现在内存占用上。但原地操作有风险如果a是被别处引用的视图改它会污染源数据。我一般只在确认独占的累加器上用。2.3 环境准备与版本对齐numpy版本不匹配是高频问题。老代码里np.float、np.int、np.bool这些别名在 numpy 1.20 之后陆续被移除直接报AttributeError。解决办法是全局替换成内置的float、int、bool或者用np.float64这类明确类型。# 查看当前版本 python -c import numpy; print(numpy.__version__) # 建议锁一个较新的稳定版避免新旧 API 混用 pip install numpy1.24,2.0参数说明上限卡在 2.0 以下是因为 numpy 2.x 有一批破坏性变更学习阶段没必要踩。装完用np.show_config()能看到底层 BLAS 库如果显示的是 openblas 或 mkl矩阵运算会快很多如果是默认的慢库大矩阵乘法会明显拖后腿。3. 从线性回归到逻辑回归把梯度下降写明白3.1 线性回归的闭式解与梯度解线性回归是最适合入门的因为它有闭式解可以和梯度下降的结果互相验证。闭式解就是最小二乘w (X^T X)^{-1} X^T y。但直接求逆数值不稳定实践中用np.linalg.solve或lstsq。import numpy as np def linreg_closed_form(X, y): # X: (n, d)已拼接偏置列或单独处理 # 加一列全 1 作为偏置项 Xb np.hstack([np.ones((X.shape[0], 1)), X]) # 用 lstsq 而非显式求逆数值更稳 w, residuals, rank, sv np.linalg.lstsq(Xb, y, rcondNone) return w def linreg_gd(X, y, lr0.01, epochs1000): n, d X.shape Xb np.hstack([np.ones((n, 1)), X]) w np.zeros(d 1) losses [] for _ in range(epochs): pred Xb w # (n,) err pred - y # (n,) grad (2.0 / n) * (Xb.T err) # (d1,) w - lr * grad loss np.mean(err ** 2) losses.append(loss) return w, losses逻辑说明lstsq内部用 SVD 分解求解即使X^T X奇异也能给出最小范数解比np.linalg.inv稳得多。梯度版本里grad的推导是 loss 对 w 求导系数2/n是均方误差带来的。参数上lr太大直接发散太小收敛慢线性回归一般0.01到0.1之间试。epochs给 1000 通常够配合losses曲线看是否还在下降。3.2 逻辑回归sigmoid 与交叉熵的数值陷阱逻辑回归在线性输出后接 sigmoid损失用交叉熵。这里有个经典翻车点直接写np.log(sigmoid(z))当z是很小的负数时sigmoid 趋近 0log 趋近负无穷loss 直接变 inf 或 nan。def sigmoid(z): # 分正负两支避免 exp 溢出 out np.zeros_like(z, dtypenp.float64) pos z 0 neg ~pos out[pos] 1.0 / (1.0 np.exp(-z[pos])) # z0 时用等价变形 exp(z)/(1exp(z))指数不会溢出 ez np.exp(z[neg]) out[neg] ez / (1.0 ez) return out def logreg_train(X, y, lr0.1, epochs2000): n, d X.shape Xb np.hstack([np.ones((n, 1)), X]) w np.zeros(d 1) for _ in range(epochs): p sigmoid(Xb w) # 交叉熵梯度形式上和线性回归一样简洁 grad (1.0 / n) * (Xb.T (p - y)) w - lr * grad return w逻辑说明sigmoid 分两支是为了数值稳定正数走标准式负数走等价变形两边指数都不会溢出。梯度Xb.T (p - y)是逻辑回归最漂亮的地方——交叉熵配 sigmoid导数化简后和线性回归的残差形式一致。参数上逻辑回归的lr可以比线性回归大一些0.1起步epochs给 2000 是因为收敛比线性回归慢。提示如果 loss 曲线出现剧烈震荡先降学习率如果 loss 一直不降检查标签是不是 0/1 而不是 -1/1这是新手最常见的标签格式坑。3.3 怎么判断自己写对了三个验证手段。第一和闭式解对比线性回归的梯度解最终 w 应该和lstsq的结果接近到小数点后几位。第二和 sklearn 对比同样数据跑一遍LogisticRegression系数应该基本一致注意 sklearn 默认带 L2 正则要设penaltyNone。第三看 loss 单调性正常训练 loss 应该整体下降偶尔波动正常但持续上升一定是学习率或梯度符号错了。4. KNN 与 KMeans距离度量和初始化的门道4.1 KNN 的向量化与 k 值选择KNN 逻辑简单但朴素实现慢得离谱。核心是前面写的pairwise_dist拿到距离矩阵后取每行最小的 k 个。def knn_predict(X_train, y_train, X_test, k3): dist pairwise_dist(X_test, X_train) # (n_test, n_train) # argpartition 比 argsort 快只需要前 k 个 idx np.argpartition(dist, k, axis1)[:, :k] # 取出这 k 个的标签投票 neighbor_labels y_train[idx] # (n_test, k) # 沿 axis1 做多数投票 preds [] for row in neighbor_labels: vals, counts np.unique(row, return_countsTrue) preds.append(vals[np.argmax(counts)]) return np.array(preds)逻辑说明argpartition只保证前 k 个是最小的不保证内部有序但投票不需要有序所以比argsort快。参数k是核心太小对噪声敏感太大边界模糊。常见做法是取训练集样本数的平方根附近再用交叉验证微调。距离度量上欧氏距离适合连续特征但特征量纲差异大时必须先标准化否则量纲大的特征会主导距离。4.2 KMeans 的初始化随机 vs KMeansKMeans 最大的坑是初始化。纯随机选中心点可能选到两个挨得很近的点导致收敛到很差的局部最优。KMeans 的思路是让初始中心尽量分散。def kmeans_plusplus_init(X, k, rng): n, d X.shape centers [X[rng.integers(n)]] for _ in range(1, k): # 计算每个点到最近已选中心的距离平方 dist_sq pairwise_dist(X, np.array(centers)).min(axis1) # 按距离平方做概率采样远的点更容易被选中 prob dist_sq / dist_sq.sum() idx rng.choice(n, pprob) centers.append(X[idx]) return np.array(centers) def kmeans(X, k, epochs100, seed42): rng np.random.default_rng(seed) centers kmeans_plusplus_init(X, k, rng) for _ in range(epochs): dist pairwise_dist(X, centers) # (n, k) labels np.argmin(dist, axis1) # 每个点归属最近中心 new_centers np.array([ X[labels j].mean(axis0) if np.any(labels j) else centers[j] for j in range(k) ]) if np.allclose(new_centers, centers): break centers new_centers return centers, labels逻辑说明KMeans 用距离平方作为采样概率距离越远的点越可能成为新中心这样初始中心天然分散。参数seed固定随机种子保证可复现这是工程习惯。空簇的处理很关键——某个中心没有任何点归属时保留原位置而不是让mean对空数组报错。epochs给 100 通常足够配合np.allclose提前退出。4.3 收敛判断与评估指标KMeans 的收敛条件是中心点不再移动但浮点下很难完全相等所以用np.allclose加容差。评估用惯性inertia也就是所有点到其中心距离平方和越小越好但 k 越大惯性必然越小所以不能只靠它选 k。实践中用肘部法看惯性下降的拐点或者用轮廓系数。轮廓系数计算每个点与同簇距离和最近异簇距离的差范围 -1 到 1越接近 1 越好。5. 避坑与排查手写 numpy 算法最容易翻的五个车5.1 现象loss 训练几轮后变成 nan原因指数溢出或 log 零。sigmoid、softmax、log 都是重灾区输入稍大就 infinf 参与运算变 nan。解决所有涉及 exp 和 log 的地方都做数值稳定处理sigmoid 分正负支softmax 先减去每行最大值log 里加极小值1e-12。5.2 现象维度对不上报 shapes not aligned原因矩阵乘法要求左矩阵列数等于右矩阵行数广播规则没吃透。解决养成打印形状的习惯在每个矩阵运算前print(a.shape, b.shape)。转置用.T拼接用np.hstack横向和np.vstack纵向别混。numpy nchw这类维度顺序问题在图像数据里尤其常见手写算法时统一约定好是(样本, 特征)还是(特征, 样本)全篇一致。5.3 现象结果每次运行都不一样原因没固定随机种子。初始化、KMeans 采样、数据打乱都涉及随机。解决用np.random.default_rng(seed)创建独立的随机数生成器比全局的np.random.seed更干净不会互相干扰。所有涉及随机的函数都把 rng 作为参数传进去。5.4 现象梯度下降不收敛loss 来回跳原因学习率太大或者特征没标准化。解决先把学习率降一个数量级试如果还跳就检查特征量纲。线性回归和逻辑回归对特征尺度敏感标准化到均值 0 方差 1 之后收敛会稳很多。标准化用训练集的均值和方差再应用到测试集别用全量数据算那是数据泄漏。5.5 现象AttributeError: module numpy has no attribute float原因numpy版本不匹配老代码用了已移除的别名。解决全局搜索np.float、np.int、np.bool、np.object替换成float、int、bool、object或对应的np.float64等明确类型。这是升级 numpy 后最常见的兼容性问题没有之一。6. 进阶技巧用梯度检验和向量化把代码质量拉满写到这一步算法能跑了但怎么确认梯度推导没错靠梯度检验。核心思想是数值梯度和解析梯度应该几乎相等。数值梯度用中心差分(f(weps) - f(w-eps)) / (2*eps)比单边差分精度高一个量级。def grad_check(f, grad_f, w, eps1e-5, tol1e-6): # f: 标量损失函数, grad_f: 返回解析梯度的函数 num_grad np.zeros_like(w) for i in range(len(w)): w_plus w.copy(); w_plus[i] eps w_minus w.copy(); w_minus[i] - eps num_grad[i] (f(w_plus) - f(w_minus)) / (2 * eps) ana_grad grad_f(w) # 相对误差避免量纲影响 diff np.abs(num_grad - ana_grad) / (np.abs(num_grad) np.abs(ana_grad) 1e-12) return diff.max()逻辑说明对每个参数维度单独扰动算数值梯度再和解析梯度比。eps取1e-5是精度和舍入误差的折中太大截断误差大太小浮点误差大。返回最大相对误差一般小于1e-6算通过1e-4量级要警惕大于1e-2基本可以确定梯度推错了。这个技巧救过我很多次尤其是逻辑回归加正则、softmax 多分类这种梯度容易推错的场景。第二个技巧是彻底向量化。手写算法时先写循环版本验证逻辑再改向量化版本对比结果两者数值一致就说明向量化没写错。向量化之后用np.einsum处理复杂的多维求和会让代码更清晰比如np.einsum(ij,ik-jk, X, X)算协方差矩阵比手写转置乘法可读性好。第三个习惯是给每个算法写一个__main__里的自测造一份小数据跑一遍打印形状、loss 曲线、和 sklearn 的对比。这份自测脚本就是你的后悔药改代码之后跑一遍几秒钟就知道有没有改坏。我现在的习惯是任何手写算法落地前梯度检验和 sklearn 对比这两关必须过过不了就不往下走。这套流程不花哨但能让你在别人还在对着 nan 发呆的时候已经把算法调通了。希望帮到你。本文还有配套的精品资源点击获取