简介:这份资源面向希望夯实机器学习底层原理的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, axis=1, keepdims=True) # (n, 1) Y_sq = np.sum(Y ** 2, axis=1, keepdims=True).T # (1, m) cross = X @ Y.T # (n, m) dist_sq = X_sq - 2 * cross + Y_sq # 数值误差可能让对角线出现极小负数,裁剪到 0 return np.maximum(dist_sq, 0)逻辑说明:把欧氏距离平方展开成三项,全部用矩阵运算完成。keepdims=True是关键,它保证求和后维度不塌缩,才能正确广播。参数上axis=1表示按行求和,得到每个样本的模长平方。最后np.maximum是血泪经验——浮点误差会让本该为 0 的值变成-1e-12,开根号直接 nan。
axis 的方向。记住一句话:axis=0沿着行方向压,结果是列;axis=1沿着列方向压,结果是行。算均值、算梯度、算 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 "numpy>=1.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, rcond=None) return w def linreg_gd(X, y, lr=0.01, epochs=1000): 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) # (d+1,) 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 趋近 0,log 趋近负无穷,loss 直接变 inf 或 nan。
def sigmoid(z): # 分正负两支,避免 exp 溢出 out = np.zeros_like(z, dtype=np.float64) pos = z >= 0 neg = ~pos out[pos] = 1.0 / (1.0 + np.exp(-z[pos])) # z<0 时用等价变形 exp(z)/(1+exp(z)),指数不会溢出 ez = np.exp(z[neg]) out[neg] = ez / (1.0 + ez) return out def logreg_train(X, y, lr=0.1, epochs=2000): 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 正则,要设penalty=None)。第三,看 loss 单调性,正常训练 loss 应该整体下降,偶尔波动正常,但持续上升一定是学习率或梯度符号错了。
4. KNN 与 KMeans:距离度量和初始化的门道
4.1 KNN 的向量化与 k 值选择
KNN 逻辑简单,但朴素实现慢得离谱。核心是前面写的pairwise_dist,拿到距离矩阵后取每行最小的 k 个。
def knn_predict(X_train, y_train, X_test, k=3): dist = pairwise_dist(X_test, X_train) # (n_test, n_train) # argpartition 比 argsort 快,只需要前 k 个 idx = np.argpartition(dist, k, axis=1)[:, :k] # 取出这 k 个的标签,投票 neighbor_labels = y_train[idx] # (n_test, k) # 沿 axis=1 做多数投票 preds = [] for row in neighbor_labels: vals, counts = np.unique(row, return_counts=True) preds.append(vals[np.argmax(counts)]) return np.array(preds)逻辑说明:argpartition只保证前 k 个是最小的,不保证内部有序,但投票不需要有序,所以比argsort快。参数k是核心,太小对噪声敏感,太大边界模糊。常见做法是取训练集样本数的平方根附近,再用交叉验证微调。距离度量上,欧氏距离适合连续特征,但特征量纲差异大时必须先标准化,否则量纲大的特征会主导距离。
4.2 KMeans 的初始化:随机 vs KMeans++
KMeans 最大的坑是初始化。纯随机选中心点,可能选到两个挨得很近的点,导致收敛到很差的局部最优。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(axis=1) # 按距离平方做概率采样,远的点更容易被选中 prob = dist_sq / dist_sq.sum() idx = rng.choice(n, p=prob) centers.append(X[idx]) return np.array(centers) def kmeans(X, k, epochs=100, seed=42): 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, axis=1) # 每个点归属最近中心 new_centers = np.array([ X[labels == j].mean(axis=0) 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 都是重灾区,输入稍大就 inf,inf 参与运算变 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(w+eps) - f(w-eps)) / (2*eps),比单边差分精度高一个量级。
def grad_check(f, grad_f, w, eps=1e-5, tol=1e-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 发呆的时候,已经把算法调通了。希望帮到你。
本文还有配套的精品资源,点击获取