机器学习设计模式
这次阅读的书籍,是O'Reilly的机器学习设计模式(Machine Learning Design Patterns),作者是三名Google工程师,我会做一个读书笔记,同时添加了一些个人项目经验和思考。
1、特征表示¶
本章重点:数据表示 (Data Representation)
机器学习模型的核心是一个数学函数,它要求输入的数据具有特定的、严格的格式(例如,数值型张量)。然而,现实世界的数据(如类别ID、文本、原始数值)并不能直接被模型用于计算。
本章将探讨一系列“数据表示”模式,它们的核心作用是将这些原始的、非结构化的数据,转换为模型可以理解和处理的标准化数学格式。
P0: 简单数值化方法¶
连续型数值特征 (Continuous Features)
- 常规缩放 (Scaling):这些方法只改变数值范围,并 不能解决 数据本身的偏态分布问题
Min-Max
:归一化到 [0, 1] 区间。Z-Score
:标准化为均值为0,方差为1的分布。
- 分布变换 (Transformation for Skewness):
- 对数变换:
log(x)
,适用于处理长尾分布。 - 分位数变换:使用百分位点 (Percentile) 将数据近似映射为 均匀分布 。
- Box-Cox变换:一种更强大的变换,可以自动寻找最佳参数,将数据近似映射为 正态分布 。其原理是使变换后数据在各个区间的局部方差趋于一致。
- 对数变换:
关键洞见:正态化 > 标准化
我感觉,对于模型 收敛速度的提升,最关键的是通过变换让特征分布变得更理想(如正态分布),而不仅仅是做数值上的标准化。z-score只是标准化,如果原始数据是偏态的,标准化后依然是偏态的。
离散型类别特征 (Discrete Features)
- One-Hot Encoding:最常用的方法,将每个类别映射为一个稀疏向量。
- Dummy Encoding:可以看作是比One-Hot更节省空间的方式,它会省略掉一个类别作为基准(base level),从而减少一个维度。
序列型特征 (Sequence Features)
无论是连续值序列还是离散值序列,都可以统一表示为一种 带权重的Multi-hot向量 ,[tag@weight, tag@weight]
的形式,并配合最大长度(max_length
)和填充(padding
)进行处理。
- 示例1:离散值序列
- 原始输入:
[tag1, tag2, tag2, ...]
- 统一表示:
[tag1@1.0, tag2@2.0, ...]
(权重可以是频次、TF-IDF等)
- 原始输入:
- 示例2:连续值序列
- 原始输入:
[val1, val2, ...]
- 统一表示:
[idx1@val1, idx2@val2, ...]
(将序列中的每个值看作是其位置索引上的权重)
- 原始输入:
- 示例3:文本序列
- 原始输入:
[tag1, tag2, tag2, ...]
- 统一表示:
[tag1@1.0@1, tag2@1.0@1, tag2@1.0@1, ...]
(一个文本序列被tokenizer切分后,表示为input_ids
,input_vals
和attention_mask
三个数值张量)
- 原始输入:
P1: Hashed Feature¶
挑战:高基数类别特征 (High-Cardinality Features)
当ID类特征的取值空间非常大时(例如用户ID、商品ID),直接使用One-hot会导致维度爆炸。Hashed Feature模式通过哈希函数将高维ID映射到固定的低维空间。
- 优点:有效控制了特征维度。
- 权衡:哈希冲突是不可避免的,但实践中,只要哈希空间设置得当,对模型效果的影响通常在可接受范围内。
P2: Embeddings¶
关键洞见
- 学习Embedding的过程,本质上是一个压缩空间变换的过程,它使得原本独立的ID之间可以计算相似度和距离。
- 扩展思路:我们可以将 预训练 的ID-Embedding作为位置编码(Position Embedding),与需要 从头训练 的Embedding向量相加,这可能会让注意力机制(Attention Mechanism)更好地发挥作用。
预训练方法
- 加载外部模型:直接使用成熟的预训练向量,如词向量(Word2Vec)、图像特征(CNN)或文本表示(BERT)。
- 自监督学习:我个人比较推荐的是使用自编码器(AutoEncoder)。例如,之前训练100万个博主向量就是用的VAE(变分自编码器)。Google在2020年提出的 TabNet 就是一个专门针对表格数据(DataFrame)的AE模型。通常比矩阵分解的建模更好。
- 维度选择:Embedding的维度通常建议在 集合大小的四次方根 到 平方根 之间进行尝试。
P3: 特征交叉(Cross Feature)¶
核心思想
特征交叉在统计学中被称为“交互项”(Interaction Term)。如果特征X1有2个取值,特征X2有3个取值,那么它们的交叉特征X1_X2就有6个可能的取值。
- 作用:通过特征组合,可以帮助模型学习非线性关系,加速收敛,其效果有时能媲美更深层的神经网络。
挑战与解决方案
- 挑战:直接组合(如Y=X1_X2)会面临严重的稀疏性问题。
- 解决方案:更通用的做法是使用 FM (Factorization Machines) 模型。它通过复用原始特征X1和X2的Embedding向量,并计算其内积,来高效地学习交叉特征的权重,从而克服稀疏性问题。
P4: 多模态¶
问题一:如何融合多组不同来源的特征?
答案:分别对不同模态的特征(如视频、文本、表格数据)进行Embedding,然后将这些Embedding向量拼接(concat)在一起,作为模型的输入。
问题二:如何在现有表格特征上衍生出更多特征?
答案:基于业务理解进行特征衍生。例如:
- 评分 (1-5分):可以衍生出一个二元特征,表示“高分”或“低分”。
- 地理位置:可以关联天气信息。
- 时间戳:可以提取出“星期几”、“是否周末”等信息。
衍生出的新特征也和原始特征拼接(concat)在一起,共同输入模型。
2、问题定义与模型架构¶
本章重点:问题定义与模型架构(Problem Representation)
在上一章(特征工程)探讨了模型的“输入”之后,本章将重点转向“问题定义”与“模型架构”。
P5: 问题重构(Reframe)¶
模式定义:问题重构 (Reframe)
将一个机器学习问题重新定义为另一种类型的问题。最常见的做法是将回归问题转换为分类问题( 点估计 转换为 分布的估计)。
- 做法:将连续的预测目标(如评分0-100)划分成若干个区间(bins),每个区间作为一个类别。
- 还原:模型预测每个类别的概率后,通过对类别概率进行加权求和(weighted sum),可以还原出回归的期望值(点估计)。
优势与好处(我的理解)
-
对噪声更鲁棒 (Robust to Noise)
- 回归模型对数据质量要求高,但现实数据充满噪声。例如,对于回归模型,从0.3预测到0.4,和从0.45预测到0.55,模型的损失可能是相同的。但实际上,
+/- 0.1
的差异很可能就是数据噪音,模型在学习这些噪音时,能力会下降。 - 通过分箱(binning)后,只要大类之间(如“低分区”和“高分区”)有区分度,标签就是准确的。转换成分类目标后,标注准确率更高,模型不易被噪声迷惑。
- 个人经验:使用Softmax(分类)通常比使用L2损失(回归)更容易收敛。
- 回归模型对数据质量要求高,但现实数据充满噪声。例如,对于回归模型,从0.3预测到0.4,和从0.45预测到0.55,模型的损失可能是相同的。但实际上,
-
处理多峰分布 (Multi-modal Distributions)
- 如果目标的真实分布是多峰的(例如,一个商品的价格可能在10元和100元两个点上都有高频交易),回归模型会很“迷惑”,可能会收敛到两个峰值之间的某个无意义的均值。
- 分类模型则没有这个问题,它可以清晰地在“10元区间”和“100元区间”上都输出高概率,完美捕捉多峰特性,同样减小了数据噪声的影响。
-
处理有界区间 (Bounded Outputs)
- 当目标是有界的取值区间时(例如,预测一个0-1之间的概率),将其分箱为分类问题天然就在界限内。如果使用回归,可能还需要额外的激活函数(如Sigmoid)来强行约束输出,或者面临预测值超出的风险。
我的项目经验
在我的项目中,基本上都把回归任务转化为了分类模型。例如:
- 用户年龄预估:实际上是预估“年龄段”,然后再还原回一个年龄期望值。
- 兴趣模型建模:将目标标签(如CTR)分成分数段1-5,而不是直接拟合0-100的连续分数。
P6: Multilabel¶
模式定义:多标签分类 (Multilabel Classification)
当一个样本可以同时属于多个类别时,就属于多标签分类问题。
- 核心改动:损失函数从 CrossEntropy (用于单选) 更换为 BinaryCrossEntropy (用于多选)。
P7: Ensembles¶
核心思想:集成学习 (Ensemble Learning)
集成学习通过组合多个模型来获得比单一模型更好的性能。它主要解决两个问题:
- 过拟合 (High Variance):模型在训练集上表现好,但在测试集上表现差。
- 欠拟合 (High Bias):模型在训练集和测试集上表现都差。
深度解析:Bias vs. Variance
“Variance”和“Bias”是理解Ensemble方法的理论基石。为了理解它们,可以做一个思想实验:
- 抽样数据:假设有一份非常大的原始数据。我们不只拿一个训练集,而是通过Bootstrap(有放回抽样)从原始数据中抽样出了 100个 略有不同的训练集(T1, T2, ..., T100)。
- 训练模型:使用 同一种算法 (比如,决策树)和 同一套超参数 ,在这100个不同的训练集上,分别训练出了 100个模型 (M1, M2, ..., M100)。
- 进行预测:现在,从测试集中拿 一个 样本
x_test
,让这100个模型分别对x_test
进行预测。于是,就得到了100个预测结果(p1, p2, ..., p100)。
此时,就可以清晰地定义Bias和Variance了:
1. 它衡量的是什么? Variance(方差) 就是这100个预测结果(p1, p2, ..., p100)的 方差(离散程度)。
2. High Variance (高方差)
- 现象:这100个预测结果 非常分散,互相差异很大 。
- 原因:这说明模型(比如一个很深的决策树) 极度敏感 。它在T1上学到的是T1的噪声,在T2上学到的是T2的噪声。训练数据的一点点不同,就会导致模型结构和预测结果发生巨大变化。
- 结论:这就是 过拟合 。模型把训练集里的 噪声 当成了规律来学习,导致它不具备泛化能力。
1. 它衡量的是什么?
Bias(偏差) 就是这100个预测结果的 “平均值” avg(p1...p100)
与 “ 真实答案 ” y_true
之间的 差距。
2. High Bias (高偏差)
- 现象:这100个模型的预测结果就算取了平均,离真实答案也 差得很远。
- 原因:这说明模型 系统性地出错了 。它 太简单 了(比如用直线去拟合S型曲线),根本没有能力捕捉数据中真正的规律。
- 结论:这就是 欠拟合 。模型在训练集上就表现很差,因为它连训练集的规律都学不到。
-
Bagging (例如 随机森林) 的核心目的就是 降Variance(降方差)。 它做的就是这个思想实验:训练100个高方差(过拟合)的深决策树,最后把它们的预测结果一 平均。通过平均,不同模型学到的不同噪声就被“抵消”了,最终得到一个稳定的、低方差的好模型。
-
Boosting (例如 XGBoost) 的核心目的就是 降Bias(降偏差)。 它串行地训练模型,后一个模型专门去学习前一个模型预测错的样本(即“残差”或“偏差”),一步一步地把这个系统性的错误给修正过来。
解决方法
Bagging,全称 Bootstrap Aggregating(话说以前一直以为是“打包”)。
- Bootstrap:这个词是关键,指的是每次从原始数据中(有放回地)抽样一部分数据,形成多个不同的训练子集,在每个数据子集上独立地训练一个模型。
- Aggregating:将所有子模型的结果进行平均(回归问题)或投票(分类问题)。
Stacking 简单来说是将Bagging的AveragePooling
换成了一个WeightedAveragePooling
。
- 在2016年,我做过一个项目:分别用标题、摘要、文本训练了SVM、朴素贝叶斯、逻辑回归三个模型,判断文章所属领域,然后用一个决策树来融合这三个模型的输出结果。这就是一个典型的Stacking案例。
P8: 级联模型¶
模式定义:级联模型 (Cascade/Chained Models)
级联模型指的是一个模型的输入特征依赖于另一个模型的输出。
- 示例1:使用预训练的InceptionV3模型提取图像Embedding,然后基于这个Embedding训练一个分类器。
- 示例2:推荐模型中,使用到预估的用户年龄、性别、留存率等作为输入特征。
核心建议:谨慎使用
书中建议 尽量避免 这样的强依赖关系,优先考虑用单一的端到端模型。如果确实需要使用级联模型,必须建立健壮的Pipeline工作流,确保上游模型更新后,下游模型能够及时、自动地重新训练和更新。
P9: 建立中立类别¶
核心思想:中立类别 (Neutral Class)
如果人类专家对某些样本也难以准确标注,那么就 不要强迫模型去区分它们。这样做反而会引入噪声,拉低模型的整体准确率。
- 建议:创建一个“其他”、“不确定”、“中间态”之类的中立类别,和其他明确的类别一起训练。
关键洞见
- 不要直接剔除模糊样本:剔除会导致模型看不到完整的样本空间(Entire Space),在预测时可能会盲目地将这些模糊样本分到某个已知的类别中。
- 不要强行划分模糊样本:将模糊样本随机或按表面相似性划分到某个类别,会严重干扰该类别的特征分布,拉低模型准确率。
这种中间态或中立类的设计,在制定标注规范时就应提前考虑进去。
应用案例
- 图片分类:我会预留一个“其他”类,用来存放一些“无意义”或“类别模糊”的图片(比如表情包),而不是将它们强行分到“美女”或“搞笑”类中。
- 股票预测:将股票走势划分为三类:“涨幅 > 5%”、“跌幅 > 5%”、以及“中间震荡态 [-5%, 5%]”。如果只学习“涨”和“跌”两种极端样本,模型会花费大量精力在区分无意义的[-5%, 5%]内的数据,拉低准确率。
P10: 数据不平衡¶
数据不平衡处理(Rebalance)
针对少数类样本过少的问题,有以下几种常规处理方法:
- 上采样 (Over-sampling):通过 数据增强(如图像旋转、文本替换)生成新的样本。案例是,我的作弊图片识别项目。
- 下采样 (Under-sampling):随机丢弃多数类样本。这会牺牲多数类的精度,但可能提升少数类的召回率和F1值。
- 书中技巧:可以结合Bagging,对多数类进行多次不同的下采样,训练多个模型,最后集成结果。这样可以更充分地利用数据。
- 类别权重 (Class Weight):在 损失函数 中,为少数类的样本赋予更高的权重,即预测错误的惩罚更大。
我的个人经验 (更有效的方法)
常规方法的效果往往有限,更像是一种 权衡。以下是我实践中总结的更有效的方法:
- 将产生偏置的因素,作为特征输入模型
- 背景:很多数据不平衡是系统性偏置(bias)导致的。例如,低频用户的兴趣标签天然就少;推荐系统中,位置靠后的物料点击率天然就低。
- 做法:将“用户活跃度”、“推荐位置”等偏置因素作为特征输入模型。模型在学习时会自动捕捉到这些偏置。在预测时,我们可以手动设定这些特征的值(例如,将所有位置都设为“1”),从而消除偏置的影响,得到更公平的预测。这类似于在训练朴素贝叶斯后,手动调整先验概率。
- 利用预训练和迁移学习
- 背景:少数类样本虽然少,但其包含的“知识”可能在其他大规模数据中存在。
- 做法:利用预训练模型进行迁移学习(Few-shot learning)。例如,预测一个敏感话题(样本少),可以先用大规模语料训练Word2Vec或BERT,模型已经学到了语言的基本结构,再用少量标注样本进行微调,就能很快收敛并取得好效果。FM模型通过向量共享来克服稀疏性,也是类似的思想。
特例:异常点检测
异常点检测是数据不平衡的一类特殊问题。除了 统计方法 外,书中给出了两种方案,
- 监督方法:训练一个模型来预测正常值。当新数据的实际值与预测值偏差过大时,判定为异常。
- 非监督方法:通过聚类。如果一个新数据点距离所有聚类中心都非常远,则判定为异常。
题外话
书中使用的 BigQuery ML 真的太好用了!感觉像是增强版的Hivemall,可惜我们公司没有。
3、训练过程¶
P11: Useful Overfitting¶
基本没用
P12: Checkpoints¶
略
P13: 迁移学习¶
略
P14: 分布式策略¶
数据并行 (Data Parallelism)
当单个模型可以放在一张卡上,但数据量太大时,使用数据并行。书里的策略主要是从Tensorflow的生态去聊的,类型还挺多的,但在pytorch下原生的是DP和DDP。
-
DP (DataParallel):单机多卡。
- 架构:中心化架构,类似Parameter Server。数据切分到各个GPU上计算梯度,然后所有梯度汇总到主GPU (GPU 0)进行参数更新,再将更新后的参数广播回所有GPU。
- 优势:简单,代码几乎不用改。
- 劣势:只能在单台机器上使用;主GPU有通信瓶颈。
-
DDP (DistributedDataParallel):多机多卡。
- 架构:去中心化架构,通过 All-reduce 机制。每个GPU在计算完梯度后,不依赖中心节点,而是通过环状通信等方式 互相 传递梯度,每个节点独立完成梯度的汇总和平均,并更新自己的参数。
- 优势:没有中心节点的通信瓶颈,扩展性更好。
- 劣势:代码改造相对复杂,每个GPU需要一个独立的Python进程。
模型并行 (Model Parallelism)
当模型本身太大,参数无法完全加载到单个GPU中时(例如推荐系统中上亿的用户ID Embedding),就需要使用模型并行。
- 推荐系统方案:Parameter Server (参数服务器)。将巨大的Embedding table等参数分片存储在多台服务器上。
- 大模型方案:不建议自己实现。推荐使用成熟的框架,如 PyTorch FSDP (Fully Sharded Data Parallelism) 或 FairScale。它们结合了数据并行和模型并行的思想,将模型的参数、梯度和优化器状态都进行分片。
P15: 超参数调优¶
传统方法的局限
网格搜索 (Grid Search) 等传统方法在现代大模型训练中已不适用。因为单次实验成本高昂,而超参数的组合空间巨大,时间成本会爆炸。
现代调参策略:组合拳
实际应用中,超参数调整已升级为如下的 组合拳:
- 随机搜索 (Random Search):先用随机搜索快速、大范围地探索超参数空间,找到一个大致的最优区域。
- 贝叶斯优化/进化算法:在随机搜索确定的较小范围内,进行更精细的搜索。
- 剪枝算法 (Pruning):在搜索过程中,尽早地识别并终止那些“没有希望”的超参数组合,节省计算资源。
- 小规模验证:先在小数据集或小模型上快速实验,找到有潜力的超参数范围,再到完整模型和大规模数据上进行微调。
调参工具案例:Optuna
Optuna 是一个非常流行的现代超参数调参框架。(书中给的是Keras Tune和Vizier的例子)
- 核心思想:将调参问题看作一个 探索与利用 (Explore-Exploit, EE) 问题。每一次模型训练(trial)都是一次对“老虎机”(bandit)的尝试,目标是在有限的尝试次数内找到最优的参数组合。
Optuna 的巧妙设计
-
Define-by-run API:
- 特点:遵循Python/PyTorch的设计哲学,超参数的搜索空间在代码运行时定义,非常灵活,无需繁琐的配置文件。
- 优势:由于超参数只是一个普通的Python变量,它可以出现在循环、条件判断等任何代码逻辑中,甚至可以用来调试与模型无关的广义超参数(如FFmpeg的压缩参数)。
-
剪枝 (Pruning):
- 原理:在每个trial的训练过程中,定期将其当前的性能指标(如验证集loss)与历史上其他trial在相同步骤的性能进行比较。如果当前trial的表现明显落后,就提前终止它。
- 效果:剪枝能将相同时间内的有效trial数量提升数十倍(例如从40次到1000次),极大地解决了Grid Search的最大痛点。
-
多进程/多节点并行:
- 实现:通过一个共享的SQL数据库后端(如SQLite, MySQL)来实现状态同步。你可以在不同进程甚至不同机器上启动同一个脚本,只要它们连接到同一个数据库和同一个study ID,框架就会自动分配不同的trial,实现异步并行。
-
智能采样算法:
- 超越随机:Optuna内置了多种比Grid/Random Search更智能的采样器,如 TPE (Tree-structured Parzen Estimator)、CMA-ES等。这些算法会根据已完成的trial结果,建立一个代理模型,来预测哪些超参数区域更有可能产生好的结果,从而更倾向于在这些有希望的区域进行采样。
选择那些“看起来像好的超参数”,和“不太像不好的超参数”的区域
- 超越随机:Optuna内置了多种比Grid/Random Search更智能的采样器,如 TPE (Tree-structured Parzen Estimator)、CMA-ES等。这些算法会根据已完成的trial结果,建立一个代理模型,来预测哪些超参数区域更有可能产生好的结果,从而更倾向于在这些有希望的区域进行采样。
-
可视化:
代码示例:使用 Optuna
import lightgbm as lgb
import optuna
# 1. 定义一个目标函数,Optuna会尝试最大化/最小化它的返回值
def objective(trial):
# ... (准备数据)
# 2. 使用 trial 对象建议超参数的值
param = {
'objective': 'binary',
'metric': 'binary_logloss',
'verbosity': -1,
'boosting_type': 'gbdt',
'lambda_l1': trial.suggest_float('lambda_l1', 1e-8, 10.0, log=True),
'num_leaves': trial.suggest_int('num_leaves', 2, 256),
'min_child_samples': trial.suggest_int('min_child_samples', 5, 100),
}
gbm = lgb.train(param, dtrain, valid_sets=[dvalid])
preds = gbm.predict(X_valid)
# accuracy = accuracy_score(y_valid, preds > 0.5)
# [可选] 向Optuna报告中间结果,用于剪枝
# trial.report(accuracy, step)
# if trial.should_prune():
# raise optuna.exceptions.TrialPruned()
# return accuracy
# 3. 创建一个 study 对象,并开始优化
# study = optuna.create_study(direction='maximize') # 目标是最大化 accuracy
# study.optimize(objective, n_trials=100) # 运行100次 trial
代码示例:多节点异步并行
# Setup: the shared storage URL and study identifier.
STORAGE_URL='sqlite:///example.db'
STUDY_ID=$(optuna create−study −−storage $STORAGE URL)
# Run the script from multiple processes and/or nodes.
# Their execution can be asynchronous.
python run.py ${STUDY_ID} ${STORAGE_URL} &
python run.py ${STUDY_ID} ${STORAGE_URL} &
python run.py ${STUDY_ID} ${STORAGE_URL}
4、服务架构¶
P16: 无状态服务(Stateless Serving)¶
模式定义:无状态服务
将训练好的模型封装成一个 REST API 服务。这是最常用、最灵活的部署方式。
- 优势:
- 语言解耦:调用方无需关心模型实现细节,可以是任何编程语言。
- 易于扩展:可以利用现成的DevOps生态(如Kubernetes, Docker)进行水平扩展、负载均衡。
- 更新方便:模型更新对调用方是透明的。
- 实现工具:TensorFlow Serving, TorchServe, ONNX Runtime, 或使用Flask/FastAPI等Web框架自行封装。
代码示例:封装前后处理逻辑
在服务中,通常需要将原始输入(如文本)的预处理逻辑和模型输出的后处理逻辑(如将logits转换为可读标签)也一并封装。
@tf.function(input_signature=[tf.TensorSpec([None], dtype=tf.string)])
def source_name(text):
# 标签定义
labels = tf.constant(['github', 'nytimes', 'techcrunch'], dtype=tf.string)
# 模型预测
probs = txtcls_model(text, training=False)
# 后处理
indices = tf.argmax(probs, axis=1)
pred_source = tf.gather(params=labels, indices=indices)
pred_confidence = tf.reduce_max(probs, axis=1)
# 返回结构化结果
return {'source': pred_source, 'confidence': pred_confidence}
P17: 离线批量预测(Batch Serving)¶
模式定义:批量服务
使用Map-Reduce框架(如Spark, BigQuery)加载模型,对海量离线数据进行批量预测。应用场景:
- 推荐系统的某些召回结果,可以提前算好
- 用户画像标签(人生状态)的批量更新
Lambda 架构
一个平台如果 同时支持 在线实时预测(Stateless Serving)和离线批量预测(Batch Serving),就被称为 Lambda架构。例如阿里的MaxCompute(ODPS)。
P18: 持续训练¶
线上用于预测的数据分布会随着时间发生变化(Data Drift),导致模型效果逐渐衰退。因此,持续地监控并重新训练模型是必要的。
关键洞见:监控指标 ≠ 训练标签
用于线上监控的“事实标准”(Ground Truth)与用于模型训练的“标注数据”(Labels)在目的和构建方式上有所不同:
- 训练标签:追求 全面性 (Coverage)。我们需要尽可能多地为样本打上标签,即使某些标签的置信度不高。
- 监控指标:追求 高可信度 (High Confidence)。我们不需要评估模型的每一个预测,而是要找到一个 高度可靠的信号 来判断模型的核心能力是否下降。
因此,监控指标可以基于 “局部但可信的事实” 来构建。我们优先保证评估的 精确性,而不是 覆盖率。
“局部但可信”的指标示例
- 强隐式反馈:只选择用户点击、互动行为 最集中的Top-K个标签,作为用户“确定感兴趣”的正样本。
- 强显式反馈:只使用用户明确的“不感兴趣”或“举报”等负反馈行为,作为“确定不感兴趣”的负样本。
- 触发:当监控指标下降到预设的阈值后,自动触发模型的重新训练流程。
- 简化版:如果无法建立完善的监控系统,也可以采用 定期重训 的策略,根据业务经验或指标衰减速度,设定一个固定的重训周期(如每周或每月)。
P19: 两阶段模型¶
模式定义:两阶段模型 (边缘端+云端)
这是一种“粗筛+精排”的架构,通常用于边缘计算场景。
- 小模型(阶段一):一个轻量级模型,部署在手机端或设备端。它在无网络或本地状态下运行,快速过滤掉大量明显无关的样本。
- 大模型(阶段二):一个复杂、精准的模型,部署在云端服务器。它只处理经过小模型过滤后的少量候选样本,进行精准预测。
核心优势:过滤掉大量简单、无效的数据,极大地减少服务端的计算压力和网络开销。
小模型的来源
- 模型简化:使用轻量级网络结构,如MobileNet。
- 目标简化:将复杂的多分类任务简化为二分类任务。
- 模型量化:将模型参数从FP32量化为INT8或FP16,减小模型体积。可以使用训练后量化(Post-training Quantization)或量化感知训练(Quantization-aware Training)。
应用案例
- 智能音箱的语音唤醒(小模型在本地判断唤醒词,大模型在云端处理具体指令)。
- 手机App在无网络状态下展示的个性化推荐页面。
- 谷歌离线翻译。
P20: Keyed Prediction¶
实用模式:键控预测
在分布式批量预测或异步mini-batch预测时,数据的原始顺序会被打乱。为了能将预测结果与原始输入对应起来,必须在输入和输出中包含一个唯一的 key。
- 建议:在开发预测函数时,就预先设计好key的透传机制。
输入格式示例:
{
"uid": "user_12345",
"input": "some_feature_data",
"extra": "metadata_to_be_passed_through"
}
输出格式示例:
{
"uid": "user_12345",
"prediction": "class_A",
"weight": 0.95
}
5、团队复用¶
核心原则:Reproducible
模型必须能够被团队中的每个开发人员方便地复现。开发者应该能够独立修改某个模块,并对整个流程进行测试,这对于提升团队整体效率至关重要。
P21: 特征变换¶
模式定义:将特征变换逻辑与模型绑定
将特征的预处理和变换逻辑作为模型的一部分,而不是放在数据ETL流程中,(见特征工程模式P26)。
优势与权衡
- 优势:
- 方便模型开发人员 随时调整 特征定义,处理逻辑也可以更复杂,不用修改ETL流程。
- 封装性好,预测时可以直接输入原始文本特征,而不是数值, 非常直观。
- 保证了训练和预测时特征处理逻辑的 绝对一致。
- 劣势:
- 可能会有性能开销,尤其是在大规模数据加载时。
在我们的兴趣模型中,T5类就是一个特征变换模块,它负责将一行原始文本数据转换为模型所需的张量字典。通过PyTorch的Dataset加载变换模块,完成特征和标签的数值化。
代码示例:PyTorch中的特征变换模块
class T5:
def __init__(self, feature_map, p=0):
self.feature_map = feature_map
self.p = p
def __call__(self, line):
data = self.transform(line)
if self.p == 0 or random.random() > self.p:
return data
return Compose([
Sequential([GaussianBlur("cat", radius=3, clamp_min=50, clamp_max=99), Sorted("cat")], p=0.4),
...
Translation("short_obj", p=0.8, alpha=0.5, beta=50, clamp_min=10, clamp_max=99),
GaussianBlur("expo_num", p=0.3, radius=0.5, clamp_min=0, clamp_max=99),
])(data)
def transform(self, line):
""" 把一行数据转化成dict
"""
try:
fields = line.strip("\r\n").split('\t')
uid = fields[0]
name = fields[1] if fields[1] != "NULL" else ""
cat = self.feature_map["cat"].encode(fields[2].split("|") if fields[2] != "NULL" else [], kind="code")
...
expo_30_cat = self.feature_map["expo_30_cat"].encode(fields[8].split("|"), kind="code")
gender = self.feature_map["gender"].encode(fields[10] if fields[10] != "NULL" else "", kind="code")
age = self.feature_map["age"].encode(fields[11] if fields[11] != "NULL" else "", kind="name")
click_30_tag = self.feature_map["click_30_tag"].encode(fields[13].split("|") if fields[13] != "NULL" else [], kind="code")
...
total_expo_30_cat = fields[9] if fields[9] != "NULL" else "0.0"
decay_expo_dt = str(decay_cat["input_vals"][0, -1].item() / 10)
unexpected_factor = 10 * random.random()
expo_num = self.feature_map["expo_num"].encode([total_expo_30_cat, decay_expo_dt, unexpected_factor], kind="code")
except:
print(line)
raise
return {"uid": uid, "name": name,
"cat": cat, "short_cat": short_cat,
...
"expo_num": expo_num,
}
Dataset
加载时会调用这个模块,
class StdinDataset(IterableDataset):
""" 从sys.stdin读取数据,用于hive的分布式预估,单线程即可
"""
def __init__(self):
super().__init__()
def __iter__(self):
for line in sys.stdin:
yield self.transform(line)
def __getitem__(self, index):
for i, line in enumerate(sys.stdin):
if i == index:
return self.transform(line)
def transform(self, line):
""" 把一行数据转化成dict
后续可以通过重载这个方法来实现特殊的转化
"""
return line
通过monkey patching组合到一起,
train_data = DirectoryDataset("../uncommitted/sv_sample_20230915_cat_8.train")
train_data.transform = T5(feature_map, p=1.0)
重要区别:Instance-level vs. Dataset-level
- Instance-level 变换:上面的T5模块是逐条数据独立处理的。
- Dataset-level 变换:某些变换需要先 统计整个数据集的信息 才能进行,例如z-score标准化需要计算全体数据的mean和stddev。这种变换通常需要实现类似fit_transform(在训练集上计算统计量并应用变换)和transform(在验证/测试集上应用已计算好的统计量)的逻辑。
我们在计算用户兴趣标签的时候,需要做两次zscore
- 用户对不同标签的点击量数值归一,这个是Instance-level的
- 归一化后的数值与全体用户进行比较(消除热点偏差),这个就是Dataset-level的;全体用户的mean和stddev需要提前算好
除了Pytorch的实现,书中给的例子是
- BigQuery的
TRANSFORM
层 - Keras的
tf.feature_column.numeric_column
和tf.keras.layers.Lambda
结合(一个作定义,一个写处理逻辑)
P22: 抽样模块¶
潜在问题:数据泄露
在划分训练集、验证集和测试集时,如果随机划分(即使固定种子值),可能会导致信息泄露,从而高估模型性能。
- 场景:
- 同一个用户的行为序列被分到了不同的数据集中。
- 文本相似度极高的两条样本,一条在训练集,一条在测试集。
- 同一天、同一个地点采集的样本。
核心原则:必须将 相关性高的样本 放入相同的划分中,即它们要么都出现在训练集,要么都出现在测试集。
解决方案:基于特征的哈希抽样
使用一个稳定的哈希函数(如FarmHash, MurmurHash)对能够标识样本相关性的特征进行哈希,然后对哈希结果取模,来决定样本被分到哪一份数据中。
- 示例:
hash(user_id) % 10
,可以将同一个用户的所有数据都划分到同一个桶中。- 如果有多列(比如日期和地点),可以将它们拼接成一个字符串再进行哈希。
P23: 特征定义变更¶
挑战:特征定义变更 (Schema Change)
模型迭代过程中,特征的定义会发生变化(新增、拆分、合并),如何处理包含旧版本特征的历史样本?
方案:静态填充 (Static Bridged Schema)
假设原有一个兴趣标签“颜值”,后被拆分为“美女”和“帅哥”。
- 新样本 (One-hot 编码):
美女
:[1, 0]
帅哥
:[0, 1]
- 旧样本 (对“颜值”进行“桥接”):
颜值
:[0.8, 0.2]
这里的[0.8, 0.2]
是对缺失值的一种 填充 (Imputation),其比例可以根据业务先验知识或新数据中的统计分布来确定。这可以让模型在训练时,同时利用新旧两个版本的数据。
我的疑惑:为何不使用版本列?
我感觉书里这种Imputation的方案未必好。在用户兴趣建模的实践中,我倾向于在样本中 增加一个“样本版本”或“日期分区”的 特征列 (例如dt=20230409
)。
- 新增的标签在旧样本中就是
NULL
。 - 模型可以自己学习到
dt
这个特征带来的偏置,通过自动的特征交叉,来判断在什么时间点应该使用哪些标签。 - 只要不同版本的特征 共享 同一套Embedding,模型就能利用所有版本的信息。在预测时,只需指定一个版本(如
dt=20240409
),就能引导模型输出对应的结果。
P24: 滑动窗口¶
模式定义
比较简单,主要指两种情况:
- 聚合特征计算:计算一个实体在过去一段时间内的统计特征,如“用户过去7天的平均点击率”。这在离线可以通过SQL的Window函数实现,在实时可以通过Flink等流计算引擎的Tumbling Window实现。
- 序列特征构建:将一个实体的连续行为拼接成一个序列,用于RNN、Transformer等模型。例如,将用户最近点击的10个商品ID拼成一个序列。
P25: 工作流¶
核心模式:工作流管道 (Workflow Pipeline)
将一个复杂的机器学习任务拆分成多个独立的步骤,用DAG(有向无环图)工作流引擎来编排和管理。我感觉是必选的机器学习模式。
离线工作流调度系统
优势
- 可插拔/可重跑:可以从头到尾运行,也可以只运行或重跑单个节点,便于调试和测试。
- 团队友好:团队成员可以独立修改各自负责的节点,而无需关心其他节点的实现细节。每个人都能 复现完整的流程 。
- 环境隔离:每个节点可以拥有独立的环境(如不同的Docker镜像、不同的资源配置、不同的权限),可以是Spark任务、Python脚本,也可以是调用REST API。
P26: 特征仓库¶
核心基础设施:特征仓库 (Feature Store)
特征仓库是一个集中式的、标准化的系统,用于存储、管理、发现和服务特征。
- 核心价值:
- 保证训练/预测一致性:解决训练时使用离线特征、预测时使用在线特征导致的数据不一致问题。
- 特征复用与协作:不同业务方可以在特征仓库中发现和复用已有特征,避免重复开发,提高规模效应。
- 统一管理:统一管理实时、小时级、天级等不同时效性的特征,以及它们的ETL流程。
我们公司的特征仓库系统
- 数据仓库:离线数据库使用Hive,在线KV数据库使用Redis/Pika
- 元数据后台:
- 提供特征的详细信息,如特征含义、负责人、数据类型、关联的离线表、生产任务、消费任务等。
- 提供数据质量监控,如调用量、空值率等。
- 业务方可以在系统上检索特征,申请特征的读写权限。
P27: 模型版本仓库¶
核心类比:模型的 Docker Hub/HuggingFace/Ollama
模型版本仓库 (Model Registry) 是一个用于存储、版本化、管理和部署已训练模型的中心化系统。
- 核心功能:
- 通过 模型名称+版本号 来索引和拉取模型。
- 记录与模型相关的元数据,如训练参数、评估指标、训练日志、代码版本等。
- 通常会提供一键部署功能,将模型部署为REST服务(实时)或封装成Spark UDF(离线)。
- 提供检索功能,可以按效果指标、时间等维度查找模型。
- 主流框架:MLflow, Google AI Platform, AWS SageMaker。
- 与Tensorboard对比:Tensorboard可以记录多次的运行结果,保存模型参数,但需要人工记录目录地址,关联版本和模型效果。不过, 最主要的区别是,模型仓库的管理和部署能力,可以让团队 其他成员 复用模型,一键使用。
代码示例:使用 MLflow 记录与注册模型
import mlflow
# 1. 设置MLflow Tracking Server的地址
mlflow.set_tracking_uri("http://127.0.0.1:8563")
# 2. 设置实验名称
mlflow.set_experiment("Apple_Sales_Prediction")
# 3. 在一个 run 的上下文中记录所有信息
with mlflow.start_run(run_name="RandomForest_v1") as run:
# 记录超参数
mlflow.log_params(params)
# 记录评估指标
mlflow.log_metrics(metrics)
# 记录模型,并指定其在仓库中的注册名称
# input_example 用于自动推断模型签名
# name 用于设定保存artifact的路径
mlflow.sklearn.log_model(sk_model=rf, input_example=X_val, name="rf_apples")
MLflow Serving 完整示例 (REST & Spark)
将注册在MLflow Registry中的模型一键部署为REST服务。
# 设置tracking server地址
export MLFLOW_TRACKING_URI=http://localhost:8563
# (可选)将模型REST服务打包成Docker镜像
# mlflow models build-docker -m "models:/rf_apples/1" --name "rf_apple-api"
# 部署模型'rf_apples'的第1个版本
mlflow models serve -m "models:/rf_apples/1" --port 8565
- 输入
serving_input_example.json
:{ "dataframe_split": { "columns": ["avg_temp", "rainfall", "weekend", "..."], "data": [ [22.1, 5.0, 1, "..."], [18.5, 4.5, 1, "..."] ] } }
curl
请求:curl -X POST http://localhost:8565/invocations \ -H "Content-Type: application/json" \ -d @serving_input_example.json
- 输出:
{"predictions": [1317.9, 1515.5]}
MLflow可以将任何Python模型(sklearn, torch, tf等)无缝封装成Spark UDF,用于分布式批量预测。从这个意义上,MLflow统一了所有Python模型的批量部署方式。
import mlflow
from pyspark.sql import SparkSession
spark = SparkSession.builder.appName("MLflow-Batch-Inference").getOrCreate()
# 1. 指定要加载的模型URI (从Registry或本地路径)
mlflow.set_tracking_uri("http://localhost:8563")
model_uri = "models:/rf_apples/1"
# model_uri = "/data0/weibo_bigdata_vf/yandi/als/mlflow/mlartifacts/240165992877979791/models/m-d4f23e98344f4ebe89b11d4e85bd881a/artifacts" # 本地artifacts路径
# 2. 将模型包装成Spark UDF
# MLflow会自动选择最高效的Pandas UDF
predict_udf = mlflow.pyfunc.spark_udf(spark, model_uri=model_uri)
# 3. 在Spark DataFrame上应用UDF
df = spark.read.parquet("path/to/inference_data")
# DataFrame[average_temperature: double, rainfall: double, weekend: bigint, holiday: bigint, price_per_kg: double, promo: bigint, previous_days_demand: double]
# UDF会作用于所有指定的列
df_with_predictions = df.withColumn(
"prediction", predict_udf(*df.columns)
)
# df_with_predictions.show()
6、对各方负责¶
本章重点:对其他参与者负责(Responsible)
模型不仅要效果好,还需要能被业务方理解、能保护用户隐私与公平性、并符合监管要求。
P28: 设立基准模型(Benchmark)¶
核心建议:使用统计模型做基线 (Baseline)
只给出一个模型的准确率(如95%)是没有意义的,业务方无法判断这个结果的好坏。必须提供一个简单、直观、难以出错的基线模型进行对比。
书中建议用简单的 统计模型 做基线,例如:
- 均值/众数:
- 预测用户兴趣:用用户过去30天点击过的兴趣标签的比例分布,作为基线。
- 预测股价:用历史移动平均值作为基线。
- 热点/TopK:
- 推荐系统:不使用复杂模型,而是给所有用户推荐全局热点内容;或者按照年龄、性别、时间段等维度,分层计算热点内容进行推荐。
- 人工评估:
- 将运营或专家手工标注的数据也计算一个“准确率”,与模型进行对比。这有时会发现,模型准确率难以提升的原因,可能在于标注标准本身存在巨大差异。
关键洞见:统计模型 驱动优化 机器学习模型
当有统计模型做基线之后,训练机器学习模型也不容易跑偏。
- 示例:
- 我们在做广告兴趣预测的时候,就同步优化两个模型:一个统计模型,一个机器学习模型。
- 除了对比整体效果,也会细分人群或者领域:例如,低频人群在教育领域中,统计模型更好,那么机器学习模型就有针对地调整。
P29: 可解释性¶
模式目的:模型学到了东西吗?
可解释性(Interpretability)旨在打开模型的“黑盒”,理解模型做出某个特定预测的内部逻辑。其作用主要有:
- 面向用户/监管:回答“为什么给我推荐了这条物料?”这类问题,既能作为“推荐理由”提升用户信任,也能满足监管部门的审查要求。
- 发现数据偏见 (Bias):帮助开发者调试模型。例如,如果模型严重依赖某个“本不重要”的特征来进行预测,这可能反映出训练数据中存在偏见,或者该特征的样本分布过于稀疏。
核心概念:贡献是相对于“基线”的
解释一个预测时,我们常会问:“对于这条样本,为什么模型会预测出0.8?”
归因方法会回答:“因为特征A贡献了+0.4,特征B贡献了-0.2”
但这些数字(+0.4或者-0.2)本身没有意义,除非我们知道它们是 “相对于什么?”
这个“什么”,就是 基线 (Baseline)。
- 基线是什么? 基线是一个“无信息”的、平均情况下的预测值。
- 如何计算基线? 通常是取整个训练集的特征中位数(median)或均值(mean),然后输入模型得到的一个“平均”预测结果(例如0.5)。
- 贡献的含义:所以,“特征A贡献+0.4” 的真正意思是: 特征A的出现,将模型的预测值从基线(0.5)拉高了0.4。
书中介绍了多种主流技术,如 SHAP、Integrated Gradients (IG) 和 What-If Tool。
SHAP (SHapley Additive exPlanations)
基于博弈论(Game Theory)的归因方法,是目前解释“为什么模型会做出这次预测?”的主流技术。
核心挑战:如何公平分配“贡献”?
在知道了“基线”之后,下一个问题是:如何将“总贡献”(即 最终预测值 - 基准值
)公平地分给每个特征?
这很困难,因为特征的贡献 依赖于“入场”顺序。
- 场景1:
- 先加入特征A(如“纬度”),模型得分从
基线(0.5) -> 0.6
。(A的贡献 = +0.1) - 再加入特征B(如“经度”),模型得分从
0.6 -> 0.8
。(B的贡献 = +0.2)
- 先加入特征A(如“纬度”),模型得分从
- 场景2:
- 先加入特征B(如“经度”),模型得分从
基线(0.5) -> 0.7
。(B的贡献 = +0.2) - 再加入特征A(如“纬度”),模型得分从
0.7 -> 0.8
。(A的贡献 = +0.1)
- 先加入特征B(如“经度”),模型得分从
那么,特征A的贡献到底是+0.1还是+0.2?
Q&A:技术上如何模拟特征“缺席”?
答:通过“替换为基线参考值”来实现。
在实际模型(如XGBoost)中,无法“只用A”来预测,模型需要所有特征的输入。SHAP的做法是:
- “特征在场”:使用这个样本的 实际值。
- “特征缺席”:用一个 参考值 (Reference Value,即用于计算 基线 的那个值,如训练集的中位数) 来替换它。
因此,计算 \(A\) 在 \({}\) 基础上的贡献, \(f(\{A\}) - f(\{\})\),实际上的计算是: $$ f(\text{A=实际值, B=参考值}) - f(\text{A=参考值, B=参考值}) $$ SHAP会通过巧妙的采样来估算所有这些组合的平均值。
Shapley的解决方案:公平分配“总差异”
Shapley Value 的巧妙之处在于:它不纠结于某一个特定顺序,而是通过数学方法,严谨地计算出该特征在 所有可能的“入场顺序”(排列组合) 下的 边际贡献,并取一个 加权平均值。这个最终的平均值,就被定义为该特征“最公平”的贡献。
关键特性:名字中的可加性 (Additivity)
这个方法最强大的特性是:它在计算原理上就天然保证了,所有特征的贡献值加起来, 正好等于 最终预测值 与 基线预测值 之间的差
$$
\text{模型最终预测值} - \text{模型基准值} = \sum (\text{每个特征的Shapley Value})
$$
这使得SHAP的解释非常完美和自洽。
Integrated Gradients (IG)
专为 深度学习 模型设计的归因方法。
- 适用范围:它适用于任何 可微分(differentiable) 的模型,例如基于TensorFlow的图像、文本或表格数据模型。
- 核心思想:IG通过计算模型输出相对于输入特征的梯度来归因。它会沿着从“基线”(如一张全黑的图片或一个全零的Embedding向量)到“实际输入”的路径上所有点的梯度进行 积分(Integrate),从而得到每个输入特征(如每个像素)的贡献值。