简介:这是一套基于Python实现的机器学习算法源码集合,涵盖逻辑回归、SVM、KMeans、DBSCAN、岭回归、BP、MeanShift、矩阵分解、随机森林、协同过滤等十余种经典算法,并配有对应训练/测试数据与说明文档,适合具备一定Python基础、希望系统学习算法原理与实现细节的开发者,也可作为高校机器学习课程的辅助教学材料。整个压缩包共54个文件,以31个Python源文件为核心,配合19个文本数据文件及少量专用测试数据,总大小仅323KB,目录按算法分章组织,定位清晰,方便按需查阅。目前已有361人学习使用。通过这份源码,读者可以对照代码理解每种算法的内部计算流程与适用场景,借助自带的数据集快速运行验证,省去自行准备数据的麻烦,还能从项目结构中学到如何组织一个多算法共存的学习型代码库,是理论与实践结合的实用参考资料。
1. 基于Python的机器学习算法设计源码:为什么“手写一遍”比调包更值得
很多人入门机器学习的第一件事,是打开 sklearn 敲一句 import。模型是跑通了,但一被问到“梯度下降每一步到底在更新什么”,或者特征高度相关时闭式解为什么直接崩掉,就答不上来。而标题里说的“基于 Python 的机器学习算法设计源码”,本质上不是把别人的仓库拖下来改两行,而是把常见算法从数学公式翻译成可运行、可测试、可扩展的 Python 代码。这篇笔记适合这么一批人:已经会用 sklearn、看得懂 Python 语法,但觉得模型是个黑匣子的调包工程师,以及需要把算法改造成自定义损失函数或小规模嵌入式场景的开发者。下面会从统一接口设计讲起,依次手写线性回归、KNN、决策树,最后给出交叉验证和性能剖析的方法,并重点说明手写源码时最容易翻车的几个细节。
2. 先把算法统一成 fit/predict 接口:源码骨架与评价函数设计
2.1 为什么接口先行
我见过不少人自己写算法时,每个文件各写各的:线性回归定义一个 train 函数,KNN 又定义 distance 和 vote 两个函数。看着很灵活,一旦要做多模型对比,测试代码就得写好几份;想写个自动调参脚本,函数签名又对不上。sklearn 把 fit(X, y) 和 predict(X) 定为通用接口,我们在手写源码时也应该照做,只是不依赖 sklearn,纯 numpy 实现。
下面这个基类完全可以作为整个算法库的公共父类,后续每个模型都继承它:
# base.py import numpy as np class BaseModel: """统一接口:所有手写模型都继承这个基类。""" def fit(self, X, y): raise NotImplementedError def predict(self, X): raise NotImplementedError def score(self, X, y): pred = self.predict(X) return np.mean(pred == y) # 分类默认用 accuracy,回归模型可覆盖fit 负责学习参数,predict 负责把学到的规律应用到新样本。score 先默认写成分类准确率,回归模型后面可以覆盖成 R2 或 RMSE。继承带来的好处非常直接:后面写交叉验证、网格搜索时,只需要传入“模型对象”这一个抽象,不需要为每个算法单独写测试入口。
| 接口风格 | 优点 | 缺点 |
|---|---|---|
| sklearn 风格 fit/predict | 统一评估、方便交叉验证、便于扩展 | 需要稍微约束一下函数签名 |
| 各自为政 | 写起来随意 | 对比和调参时到处打补丁 |
2.2 数据生成与评价函数
源码设计不需要一上来就上真实数据集。自己造一个数据生成器,能快速验证算法有没有 bug。下面是常用的回归数据生成函数和几个评价函数:
# utils.py import numpy as np def make_regression(n_samples=200, n_features=3, noise=0.5, random_state=42): rng = np.random.default_rng(random_state) X = rng.standard_normal((n_samples, n_features)) w = rng.standard_normal(n_features) * 2.0 y = X @ w + rng.normal(0, noise, n_samples) return X, y def mse(y_true, y_pred): return np.mean((y_true - y_pred) ** 2) def rmse(y_true, y_pred): return np.sqrt(mse(y_true, y_pred)) def accuracy(y_true, y_pred): return np.mean(np.asarray(y_true) == np.asarray(y_pred))n_samples 默认 200,对源码调试来说足够快;noise 控制信噪比,想验证模型抗噪能力就把 noise 调大。random_state 传随机种子,让每次生成的数据可复现。这里用np.random.default_rng(random_state)而不是np.random.seed,是为了避免污染全局随机状态。mse 对离群点更敏感,rmse 会把误差拉回原始量纲,方便脑内评估;accuracy 专门给分类算法用。
2.3 数据划分:先写一个不带泄漏的 train_test_split
评估算法前必须把训练集和测试集分开。很多设计得比较随意的源码,直接把整个数据集丢给 fit,再拿同一份数据评估,训练误差当然好看,部署到真实数据上立刻现原形。手写一个划分函数:
# utils.py def train_test_split(X, y, test_size=0.2, random_state=None): rng = np.random.default_rng(random_state) idx = rng.permutation(len(X)) cut = int(len(X) * (1 - test_size)) train_idx, test_idx = idx[:cut], idx[cut:] return X[train_idx], X[test_idx], y[train_idx], y[test_idx]rng.permutation把索引整体打乱,切出互不重叠的两份。test_size 默认 0.2,也就是常见的 8:2 划分。注意这里没有做特征缩放,因为缩放必须在划分之后单独拟合训练集统计量,否则测试集信息会通过均值、标准差泄漏进模型,这一点后面避坑章节会专门讲。
3. 用纯 NumPy 写线性回归源码:最小二乘、梯度下降与收敛判断
3.1 最小二乘闭式解的实现
线性回归是十大机器学习算法里最容易从零写起的一个。目标是找到权重 w,让残差平方和最小。当特征数不多、矩阵可逆时,可以直接解正规方程。为了避免 X^T X 奇异导致权重爆炸,通常加一个很小的 l2 正则项。下面是一个同时支持闭式解和梯度下降的完整实现:
# linear_regression.py import numpy as np class LinearRegression: def __init__(self, solver="normal", l2=1e-4, lr=0.01, max_iter=1000, tol=1e-6): self.solver = solver self.l2 = l2 self.lr = lr self.max_iter = max_iter self.tol = tol self.w = None def fit(self, X, y): X = np.asarray(X, dtype=float) y = np.asarray(y, dtype=float) X = np.c_[np.ones(len(X)), X] # 加一列 1,用来学偏置 if self.solver == "normal": A = X.T @ X + self.l2 * np.eye(X.shape[1]) self.w = np.linalg.solve(A, X.T @ y) elif self.solver == "gd": self.w = np.zeros(X.shape[1]) last_loss = None for i in range(self.max_iter): pred = X @ self.w loss = np.mean((pred - y) ** 2) grad = X.T @ (pred - y) / len(X) self.w -= self.lr * grad if last_loss is not None and abs(last_loss - loss) < self.tol: break last_loss = loss else: raise ValueError("solver 只支持 normal 或 gd") return self def predict(self, X): X = np.asarray(X, dtype=float) X = np.c_[np.ones(len(X)), X] return X @ self.w闭式解用np.linalg.solve而不是np.linalg.inv,数值稳定性高一个档次,而且速度更快。梯度下降部分,grad 是均方误差对 w 的导数,除以 len(X) 是为了让梯度不受样本量影响;学习率 lr 默认 0.01,实际使用时常需要降到 0.001 或更低。迭代终止条件是连续两轮 loss 变化小于 tol,这里 tol 是绝对变化量,如果数据本身量纲很大,可以适当放宽到 1e-4。
| 参数 | 默认值 | 作用 | 什么情况调 |
|---|---|---|---|
| solver | normal | 选闭式解还是梯度下降 | 特征数过万时考虑 gd |
| l2 | 1e-4 | 正则化强度 | 特征共线时调到 1e-2 |
| lr | 0.01 | 梯度下降步长 | loss 震荡时下调到 0.001 |
| max_iter | 1000 | 最大迭代轮数 | loss 仍在下降时加大 |
| tol | 1e-6 | 收敛阈值 | 数据量纲大时放宽到 1e-4 |
3.2 特征缩放:闭式解无所谓,梯度下降容易被量纲带崩
当一个特征范围是 0 到 1,另一个是 0 到 100000,梯度下降的更新方向基本被大数值特征主导,loss 曲线会像锯齿一样震荡,甚至直接发散。所以在梯度下降前做标准化是源码里的常规操作:
def zscore(X, mean=None, std=None): X = np.asarray(X, dtype=float) if mean is None or std is None: mean = X.mean(axis=0) std = X.std(axis=0) std[std == 0] = 1.0 return (X - mean) / std, mean, stdzscore 把每个特征变成均值 0、方差 1。关键点在于:fit 训练集时得到一个 mean 和 std,测试集转换时必须复用同一组统计量,绝不能重新计算。实际写源码时很多人在这里偷偷引入泄漏,后面避坑章节会展开。闭式解不需要特征缩放,因为正规方程对量纲不敏感;梯度下降则强烈建议先做这一步。
3.3 收敛判断与 loss 轨迹
判断训练是否正常,光看最终 loss 不够,最好把每轮 loss 记录下来。可以在 fit 里加一行:
if i % 100 == 0: print(f"iter {i:4d}, loss {loss:.4f}")配合 3.2 的特征缩放,lr 从 0.01 起步通常能稳定下降。如果 loss 一开始就在变大,说明步长太大,把 lr 除以 10 再试。还有一种常见情况:loss 在某个值附近反复横跳但不再下降,这时不一定是代码 bug,而是 lr 偏大导致一直在最优点附近振荡,把 lr 调小即可。我一般的做法是先用 solver="normal" 跑通一遍拿到参考权重,再切到梯度下降对照 loss 曲线,这样能快速确认梯度计算没错。
4. 从零实现 KNN 和决策树源码:分类算法的两套不同思路
4.1 KNN:fit 只是记住数据,predict 才需要算距离
KNN 是最直观的有监督算法:新样本和训练集里每个样本算距离,取前 k 个近邻投票。它没有显式的训练过程,fit 阶段只是把数据存下来。手写源码的核心在 predict 的距离矩阵计算:
# knn.py import numpy as np class KNN: def __init__(self, k=5): self.k = k def fit(self, X, y): self.X = np.asarray(X) self.y = np.asarray(y) return self def predict(self, X): X = np.asarray(X) # 广播计算欧氏距离:X: (m,d),训练集 self.X: (n,d) dists = np.sqrt(((X[:, None, :] - self.X[None, :, :]) ** 2).sum(axis=-1)) idx = np.argsort(dists, axis=1)[:, :self.k] knn_y = self.y[idx] preds = [] for row in knn_y: preds.append(np.bincount(row).argmax()) return np.array(preds)X[:, None, :]把形状变成 (m, 1, d),self.X[None, :, :]变成 (1, n, d),广播之后得到 (m, n, d) 的差矩阵,最后求和开根号。np.bincount要求标签是非负整数,用于统计每个近邻类别出现次数,argmax 取出票数最多的类。k 太小预测容易翻车,k 太大又把远距离样本拉进投票。实际参数选择建议从 k=5 开始,用交叉验证看曲线再调整。如果特征量纲差异大,欧氏距离会被大数值特征主导,所以 KNN 前同样要做 zscore。
4.2 决策树:按基尼指数做最优切分
决策树分类的每个节点做一件事:找一个特征和阈值,把样本分成左右两组,让两组内部纯度提升最大。基尼指数越小越纯,公式是 1 - Σ p^2。实现时先写分裂评估函数:
def gini(y): _, counts = np.unique(y, return_counts=True) p = counts / counts.sum() return 1 - (p ** 2).sum() def best_split(X, y): n = len(y) parent_gini = gini(y) best_gain, best_feat, best_thr = -1, None, None for feat in range(X.shape[1]): vals = np.unique(X[:, feat]) # 候选阈值取相邻取值的中位点 for thr in (vals[:-1] + vals[1:]) / 2: mask = X[:, feat] <= thr if mask.sum() == 0 or (~mask).sum() == 0: continue weighted = (mask.sum() / n * gini(y[mask]) + (~mask).sum() / n * gini(y[~mask])) gain = parent_gini - weighted if gain > best_gain: best_gain, best_feat, best_thr = gain, feat, thr return best_feat, best_thr候选阈值取相邻特征值的均值,np.unique自带排序,保证阈值从小到大。跳过所有样本都落到同一侧的分裂方式,因为这种分裂没有意义。基尼增益越大,说明分裂后纯度提升越多。
有了 best_split,再套一个递归建树的类:
class DecisionTree: def __init__(self, max_depth=5, min_samples_split=2): self.max_depth = max_depth self.min_samples_split = min_samples_split def fit(self, X, y): X = np.asarray(X) y = np.asarray(y) self.tree_ = self._build(X, y, depth=0) return self def _build(self, X, y, depth): if (len(np.unique(y)) == 1 or depth >= self.max_depth or len(y) < self.min_samples_split): return {"leaf": True, "class": int(np.bincount(y).argmax())} feat, thr = best_split(X, y) if feat is None: return {"leaf": True, "class": int(np.bincount(y).argmax())} mask = X[:, feat] <= thr return { "leaf": False, "feature": feat, "threshold": thr, "left": self._build(X[mask], y[mask], depth + 1), "right": self._build(X[~mask], y[~mask], depth + 1), } def predict(self, X): X = np.asarray(X) return np.array([self._predict_one(x, self.tree_) for x in X]) def _predict_one(self, x, node): if node["leaf"]: return node["class"] if x[node["feature"]] <= node["threshold"]: return self._predict_one(x, node["left"]) return self._predict_one(x, node["right"])max_depth 默认 5,能有效防止无限生长;min_samples_split 表示节点样本数少于该值时不再分。四个停止条件分别是:纯节点、超深、样本太少、找不到合法分裂。树节点用 dict 实现,直观但内存占用偏高,真到大规模场景可以改成数组索引或链表。对决策树来说,不是深度越大越好,源码里把这两个默认参数透出,就是为了让你在项目里通过网格搜索找到甜点区。
4.3 把两个分类模型放进同一个框架里对比
接口统一之后,评估代码缩到很短。先生成一个二分类数据:
def make_classification(n_samples=200, n_features=2, random_state=42): rng = np.random.default_rng(random_state) X = rng.standard_normal((n_samples, n_features)) y = ((X[:, 0] ** 2 + X[:, 1] ** 2) < 4).astype(int) return X, y然后用同一个训练流程测两个模型:
from utils import make_classification, train_test_split, accuracy from knn import KNN from decision_tree import DecisionTree X, y = make_classification() X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0) for model in [KNN(k=5), DecisionTree(max_depth=5)]: model.fit(X_train, y_train) acc = accuracy(y_test, model.predict(X_test)) print(type(model).__name__, f"acc={acc:.3f}")KNN 对这种圆形分界通常适应良好,决策树用轴平行切分则需要更深才能逼近。这个对比不是为了证明谁强,而是验证刚写的源码没有低级 bug。如果某个模型准确率只有 0.5 左右,先检查数据是否泄露、标签是否错位,再怀疑算法实现。
5. 手写算法源码最容易踩的五个坑:数据泄漏、梯度爆炸与复现问题
5.1 数据泄漏:归一化在划分前做,测试集信息被提前看到
现象:模型在“测试集”上准确率很高,甚至比调出来的 sklearn 结果还高,但一到真实场景就明显下滑。
原因:手写源码时图省事,对全量 X 先做 zscore 或 min-max 缩放,再划分训练测试。这样一来,测试集的均值和标准差已经参与过特征变换,相当于模型提前看到了测试集的分布信息。这种泄漏不报错,结果又好看,最容易骗到自己。
解决:先划分,再单独算训练集统计量。
X_train, X_test, y_train, y_test = train_test_split(X, y) mean, std = X_train.mean(axis=0), X_train.std(axis=0) X_train = (X_train - mean) / std X_test = (X_test - mean) / std测试集只能用训练集的 mean/std,不能用自己重新算的。这个习惯要刻进源码模板里。
5.2 梯度下降 loss 变成 NaN 或一直涨
现象:前几轮 loss 正常,突然变成 nan;或者每轮 loss 一轮比一轮大。
原因:学习率太大,权重更新一步越过了最优点,梯度越来越大,最后溢出。特征没做归一化时,即使 lr=0.01 也可能在量纲大的特征上爆炸。
解决:先做 zscore,把 lr 从 0.01 开始往下试。写个判断经验:loss 波动但整体下降,问题不大;loss 一路向上,立刻把 lr 除以 10。训练循环里加一行:
if np.isnan(loss): raise ValueError("loss is nan, try smaller lr or zscore")5.3 闭式解在特征共线时出现极大权重
现象:训练集里存在高度相关的两列,比如 x2 = 2 * x1,闭式解算出的权重数值极大,预测值异常。
原因:X^T X 接近奇异,np.linalg.solve虽然比求逆稳,但接近奇异时结果仍然不可靠,会把噪声放大。
解决:源码里给正规方程加 l2 正则项,l2 从 1e-4 调到 1e-2 通常能压住权重。更彻底的做法是检查特征相关性并删掉冗余列。在线性回归源码实现里默认带 l2,这是我很坚持的一个习惯。
5.4 决策树过拟合:max_depth 设太大,叶子全是纯节点
现象:训练集准确率接近 100%,测试集准确率明显更低,而且树的结构很深。
原因:树不停分裂到每个叶子只剩一两个样本,把噪声当成规律记了下来。手写源码时停止条件如果只写“纯节点才停”,几乎必然过拟合。
解决:设 max_depth=3 到 7,min_samples_leaf=5 起。观察测试集准确率随深度先升后降,取峰值附近的深度。这个“深了反而差”的反直觉现象,在决策树源码里几乎必现。
5.5 复现不了别人的结果,不一定是算法写错
现象:公式一样、数据一样,跑出来的精度差几个点,反复检查也找不到明显问题。
原因:随机种子不一致、数据切分顺序不同、初始化权重不同、归一化方式不同,甚至是评价指标不一样,有人用 accuracy 有人用 F1。
解决:把整个实验流程固定成可复现设置。数据生成统一走 utils 里的函数并传 random_state;模型初始化不读全局随机状态;记录训练集 mean/std。我现在的做法是每个项目根目录放一个 experiments.py,所有核心实验只通过它进入,参数不散落在各个脚本里,这样下次回头看还能原样跑通。
6. 给手写源码做基准验证:交叉验证、超参数搜索与性能剖析
6.1 手写 k 折交叉验证
单次训练测试划分太看运气。交叉验证把样本切成 k 份,轮流拿其中一份做验证集,其余训练,最后取均值。代码量不大:
import copy def kfold_cv(model, X, y, k=5, random_state=42): rng = np.random.default_rng(random_state) idx = rng.permutation(len(X)) scores = [] for i in range(k): val_idx = idx[i::k] tr_idx = np.setdiff1d(idx, val_idx) m = copy.deepcopy(model) # 避免上一个 fold 的状态残留 m.fit(X[tr_idx], y[tr_idx]) scores.append(accuracy(y[val_idx], m.predict(X[val_idx]))) return np.mean(scores), np.std(scores)返回值是均值加减标准差。标准差大说明模型对数据划分敏感,不稳定。deepcopy 很重要,因为线性回归 fit 后 w 会被覆盖,不复制对象第二次训练就带着上一个 fold 的初始化状态。
6.2 超参数搜索
手写源码时不一定要引入网格搜索库,循环就够。比如 KNN 选 k:
for k in [1, 3, 5, 9, 15]: acc, std = kfold_cv(KNN(k=k), X, y) print(k, round(acc, 3), round(std, 3))看趋势而不是单个点:k=1 在验证集上方差很大,k 太大偏差上升,合适的区间通常在两头的某个稳定段。决策树同理,把 max_depth 从 2 扫到 10,找测试分数开始下降的位置。
6.3 用 timeit 找出 KNN 的瓶颈
KNN 预测要算每个样本到全部训练集的距离,复杂度 O(mnd),样本量上来后明显变慢。用性能计数器测量最直接:
import time start = time.perf_counter() pred = knn.predict(X_test) elapsed = time.perf_counter() - start print(f"predict elapsed: {elapsed:.3f}s")实测中几千样本、几十维特征,numpy 向量化还能扛;上万样本就开始卡。真要优化,下个方向是 KD-Tree 或 BallTree,而不是继续优化当前的距离矩阵循环。我现在写源码有个固定习惯:每个模型文件夹里放一份干净版和一份带 debug 日志的版本,改了算法先跑基准数据,再跑小规模业务数据,确认行为一致再上全量。手写机器学习算法常见的坑,最后都指向“输入数据范围没留意”这一件事。希望这篇笔记对你手写 Python 机器学习算法源码有帮助。
本文还有配套的精品资源,点击获取