4.4 大规模数据处理 本节摘要:当数据大到单机内存装不下时,Scikit-learn 默认那种"一次性读进内存再 fit"的玩法就失效了。这一节讲的是在内存受限的前提下照样训练模型:用 增量学习逐块喂数据、用生成器做外存流式读取、用特征哈希省掉词表、用小批量聚类和抽样压缩规模,再用 并行与直方图梯度提升把时间压下来。核心不是换一台更大的机器,而是改变"读数据"的方式。 本节导航 阅读完本节,你应当能够: 说清增量学习与外存学习的区别,以及它们各自解决什么问题 用 和生成器写出一段逐块训练的最小例子 用特征哈希把文本转成固定维度向量,并理解哈希冲突的代价 在增量学习、抽样、特征哈希、并行之间按场景做取舍 知道哪些算法支持 、哪些不支持 一、先跑通:一百万个样本,用很少的内存训练
本节摘要:当数据大到单机内存装不下时,Scikit-learn 默认那种"一次性读进内存再 fit"的玩法就失效了。这一节讲的是在内存受限的前提下照样训练模型:用
partial_fit增量学习逐块喂数据、用生成器做外存流式读取、用特征哈希省掉词表、用小批量聚类和抽样压缩规模,再用n_jobs并行与直方图梯度提升把时间压下来。核心不是换一台更大的机器,而是改变"读数据"的方式。
阅读完本节,你应当能够:
partial_fit 和生成器写出一段逐块训练的最小例子partial_fit、哪些不支持先别急着讲概念。看一段能跑的最小例子:数据假设有 100 万个样本、20 个特征,我们不想一次性生成再塞进内存,而是用一个生成器每次吐 1000 行,训练一个逻辑回归(用 SGDClassifier 实现)。
import numpy as np from sklearn.linear_model import SGDClassifier def data_gen(n_samples, chunk_size=1000): for i in range(0, n_samples, chunk_size): X = np.random.rand(chunk_size, 20) y = np.random.randint(0, 2, chunk_size) yield X, y clf = SGDClassifier(loss='log_loss', random_state=42) for X_chunk, y_chunk in data_gen(100000): # 第一次要声明全部类别,之后可省略 clf.partial_fit(X_chunk, y_chunk, classes=np.array([0, 1]))
关键就在 partial_fit。普通 fit 一调用就假设你手里有完整数据,它从头算一遍;partial_fit 则假设数据是一块块来的,每次只在这一块上继续更新模型参数。于是内存里始终只留"当前这一块 + 模型参数",而不是整份数据。
这就是增量学习的全部思想。它跟"把数据切成几份分别 fit 再平均"不一样——后者每一份都训练一个独立模型,最后还要想办法合并;partial_fit 维护的是同一个模型的连续更新状态。
💡 关键直觉:
fit是"看完全卷再答卷",partial_fit是"边看边答,看到哪答到哪"。数据永远流不完的场景里,前者根本没法用。
传统流程是 X, y = load(...),然后 model.fit(X, y)。这套动作默认 X 和 y 整个躺在内存里。当数据量超过内存时,第一处崩的不是模型,而是加载这一步——load 直接内存溢出。
退一步讲,就算勉强加载进去了,后续还有两个瓶颈:一是矩阵运算要反复拷贝中间结果,内存峰值可能是原始数据的好几倍;二是很多算法(比如精确求解的 SVM、暴力 KNN)时间复杂度随样本数非线性增长,数据翻一倍,训练时间可能翻几倍。所以"换台更大内存的机器"只能延后问题,不能根治。
外存学习(out-of-core learning)的思路正好反过来:数据一直留在硬盘上,程序只按需把一小块读进内存,处理完这一块就释放,再读下一块。Scikit-learn 自己没有一个完整的"外存引擎",它靠两样东西拼出这个能力:生成器负责按块读,partial_fit 负责按块学。下面这张图就是这条流水线。
这里有个容易忽略的细节:预处理器的状态也要跟着数据流走。比如 StandardScaler 要算均值和标准差,如果每块都重新 fit_transform,每块的标准都不一样,特征就被"漂移"了。正确做法是第一块 fit_transform 算出统计量,之后所有块都用这同一套统计量 transform。
scaler = StandardScaler() first = True for X_chunk, y_chunk in data_gen(100000): if first: X_scaled = scaler.fit_transform(X_chunk) first = False else: X_scaled = scaler.transform(X_chunk) clf.partial_fit(X_scaled, y_chunk, classes=np.array([0, 1]))
⚠️ 常见坑:预处理器的拟合状态(均值、方差、编码映射)必须在第一块定下来,之后全程复用。每块重新拟合,等于在特征层面制造了分布漂移,模型会学得莫名其妙。
文本或高基数类别特征有个老问题:要把词转成向量,通常得先扫一遍全量数据,建立一张"词到列号"的词表,再回头转换。这步在流式场景里很尴尬——新词不断冒出来,词表永远建不完。
特征哈希绕开了词表。它用一个哈希函数,把任意一个词直接映射到一个固定长度的向量下标上,同一词永远落在同一个位置,词频作为值累加。HashingVectorizer 就是干这个的:
from sklearn.feature_extraction.text import HashingVectorizer from sklearn.linear_model import SGDClassifier vec = HashingVectorizer(n_features=2**18) X_train = vec.transform(docs_train) # 没有 fit,直接 transform clf = SGDClassifier(loss='log_loss') clf.fit(X_train, y_train)
注意它没有 fit 方法。因为不需要学词表,transform 直接就能算。向量维度由 n_features 固定,不管来多少新词,维度都不变,内存占用完全可预测。
代价是哈希冲突:两个不同的词可能被哈希到同一个下标,它们的词频就叠加在一起,模型分不清谁是谁。冲突概率跟维度成反比,维度越大冲突越少,但内存也越大。实践中 2**18 到 2**20 是常见取值,够用但别指望它精确。另一个代价是特征不可解释——哈希后你看到的只是"第 327681 列",没法对应回某个具体词。
💡 关键直觉:特征哈希是"用一点点精度换无限扩展能力"的典型交易。词表方案精确但上限被内存锁死;哈希方案有损但上限是无穷的。
增量学习解决的是"装不下",但还有一类痛点是"装得下、跑不动"。这时候要先问一句:真的需要全部数据吗?
抽样是最直接的一刀。很多模型在几十万样本和几百万样本上的效果差异并不大,因为信息早就饱和了。用 resample 或直接 train_test_split 抽一个子集先跑,往往几分钟就能告诉你这条路通不通,值不值得上全量。
小批量是抽样的另一面:数据全用,但每次只取一小撮。聚类里的 MiniBatchKMeans 就是这么干的——它每轮随机取一个 mini-batch 更新簇中心,而不是每次扫全部样本。Birch 也是类似思路,先增量建一棵树,再在树的层次上聚类。对几十万、上百万样本的聚类,它们比标准 KMeans 快一个量级,效果通常差得不多。
from sklearn.cluster import MiniBatchKMeans km = MiniBatchKMeans(n_clusters=8, batch_size=1000, random_state=42) km.fit(X) # X 可以是几十万行
抽样还有一个细节值得单独拎出来:分层。数据里有稀有类别时,随机抽样可能让某个类几乎抽不到,训练出的模型直接无视它。分层抽样按类别比例抽取,能保住每个类的代表性。train_test_split 的 stratify 参数就是干这个的——不止切分要用,抽样同样要用。举个例子,一个二分类里正样本只占 1%,随机抽 10 万条可能只抽到几百条正样本,模型学会"全猜负类"也能拿 99% 的准确率;分层抽样则强制维持这个 1:99 的比例,正样本至少不会缺席。
选择哪条路,取决于你的瓶颈在"内存"还是"时间",以及精度能牺牲多少。下表把这几种策略摆在一起。
| 策略 | 解决什么 | 代表工具 | 主要代价 |
|---|---|---|---|
| 增量学习 | 数据流、装不下的数据 | partial_fit 系列算法 |
只对部分算法有效 |
| 外存流式读取 | 内存装不下整份数据 | 生成器 + 分块 | 要自己管理预处理状态 |
| 特征哈希 | 高维文本、不断来新特征 | HashingVectorizer |
哈希冲突、不可逆 |
| 抽样 / 小批量 | 训练或聚类太慢 | 子集抽样、MiniBatchKMeans |
可能丢失尾部信息 |
| 并行 / 直方图 | CPU 时间太长 | n_jobs、HistGradientBoosting |
并行有调度开销上限 |
内存问题解决后,时间问题还在。Scikit-learn 里最省事的一招是 n_jobs=-1,它让交叉验证、网格搜索、随机森林这类"天然可并行"的任务把多核 CPU 用满。
from sklearn.model_selection import GridSearchCV from sklearn.ensemble import RandomForestClassifier grid = GridSearchCV(RandomForestClassifier(), param_grid, cv=5, n_jobs=-1) grid.fit(X_train, y_train)
但要清醒:并行不是免费的午餐。每开一个任务都要拷贝数据、调度进程,数据小的时候这些开销反而拖慢整体。所以并行加速有个"先吃亏后占便宜"的临界点,小数据集上别乱开。
真正的大杀器是 HistGradientBoostingClassifier / HistGradientBoostingRegressor。传统梯度提升在每次分裂时要对每个特征的每个候选切分点都算一遍,样本一多就慢得惊人;直方图版本先把连续特征分箱成直方图,在"箱"的粒度上找切分点,训练速度能快一个数量级,还天然支持缺失值,不需要先做缺失值填充。
from sklearn.ensemble import HistGradientBoostingClassifier clf = HistGradientBoostingClassifier(max_iter=200, random_state=42) clf.fit(X_train, y_train)
⚠️ 常见坑:
n_jobs=-1在数据小、任务轻的时候可能比串行还慢,因为进程调度和内存拷贝的开销盖过了并行收益。先拿小数据测一下加速比,再决定开不开。
对于单机实在扛不住的数据,还可以把 Scikit-learn 的估计器交给 Dask 这类分布式框架,把数据分片放到多台机器上。Scikit-learn 本身不做分布式,但它的接口足够统一,Dask 生态里有一批 partial_fit 兼容的封装,可以把训练拆到集群上。这是"外存"思路在集群维度的延伸。
增量学习不是没有代价的,动手前要把这三笔账算清楚。
第一笔是顺序敏感性。partial_fit 是"边看边学",如果数据本身有顺序——按时间排列、按来源分块、按设备编号——模型会先被前面的数据带偏。喂数据前尽量打乱顺序,或者至少不要让同一批数据在时间上过度集中,否则先学到的模式会霸占模型,后面的数据很难纠正它。
第二笔是分布漂移。全量训练假设数据分布稳定,可数据流场景里分布常常在变:用户行为、市场行情、日志格式都在漂移。partial_fit 会持续跟进新数据,这本是优点,但如果你希望模型对老数据保持记忆,就要盯住学习率和正则化——这两个参数定得太激进,模型就会"学新忘旧",把早期的重要信号冲掉。
第三笔是评估难。没有一份固定的全量数据,就不能套"训练集/测试集"那套。常见的做法有两种:一是预留一个固定时间窗口的数据当验证集,绝不参与训练;二是用"在线评估",每预测一条,等真实标签到了再累计误差。评估集必须与训练流隔离,否则分数虚高。这里最常见的错误,就是把"最后一块训练数据"当测试集——模型刚见过这块数据,分数自然好看,可换到真正没见过的新数据上就现原形。
partial_fit 逐块更新同一个模型,数据永不进全量内存,适合数据流与超大数据集。partial_fit 按块学,处理完一块释放一块。StandardScaler 等转换器要在第一块 fit,之后全程复用同一套统计量,否则特征漂移。MiniBatchKMeans 用小批量加速。n_jobs 加速可并行任务但有临界点;直方图梯度提升在大数据上比传统梯度提升快一个量级。数据处理到位了,模型也训练出来了,可一个新的问题冒了出来:这个模型到底为什么这么预测?下一节我们就把镜头从"训练"转向"解释",看看如何读懂一个黑箱模型的决策依据。