Skip to content

机器学习设计模式

book cover

这次阅读的书籍,是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变换:一种更强大的变换,可以自动寻找最佳参数,将数据近似映射为 正态分布 。其原理是使变换后数据在各个区间的局部方差趋于一致。

image-20250220170252547

关键洞见:正态化 > 标准化

我感觉,对于模型 收敛速度的提升,最关键的是通过变换让特征分布变得更理想(如正态分布),而不仅仅是做数值上的标准化。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_valsattention_mask三个数值张量)

P1: Hashed Feature

挑战:高基数类别特征 (High-Cardinality Features)

当ID类特征的取值空间非常大时(例如用户ID、商品ID),直接使用One-hot会导致维度爆炸。Hashed Feature模式通过哈希函数将高维ID映射到固定的低维空间。

  • 优点:有效控制了特征维度。
  • 权衡:哈希冲突是不可避免的,但实践中,只要哈希空间设置得当,对模型效果的影响通常在可接受范围内。

P2: Embeddings

模式定义

Embedding是处理稀疏高维特征(尤其是ID类)的行业标准做法。它将高维稀疏的One-hot向量映射到一个低维、稠密的向量空间。

image-20250220171641922

关键洞见

  • 学习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个可能的取值。

  • 作用:通过特征组合,可以帮助模型学习非线性关系,加速收敛,其效果有时能媲美更深层的神经网络。

image-20250221151019081

挑战与解决方案

  • 挑战:直接组合(如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),可以还原出回归的期望值(点估计)。

image-20250225155723547

优势与好处(我的理解)

  1. 对噪声更鲁棒 (Robust to Noise)

    • 回归模型对数据质量要求高,但现实数据充满噪声。例如,对于回归模型,从0.3预测到0.4,和从0.45预测到0.55,模型的损失可能是相同的。但实际上,+/- 0.1 的差异很可能就是数据噪音,模型在学习这些噪音时,能力会下降。
    • 通过分箱(binning)后,只要大类之间(如“低分区”和“高分区”)有区分度,标签就是准确的。转换成分类目标后,标注准确率更高,模型不易被噪声迷惑。
    • 个人经验:使用Softmax(分类)通常比使用L2损失(回归)更容易收敛。
  2. 处理多峰分布 (Multi-modal Distributions)

    • 如果目标的真实分布是多峰的(例如,一个商品的价格可能在10元和100元两个点上都有高频交易),回归模型会很“迷惑”,可能会收敛到两个峰值之间的某个无意义的均值。
    • 分类模型则没有这个问题,它可以清晰地在“10元区间”和“100元区间”上都输出高概率,完美捕捉多峰特性,同样减小了数据噪声的影响。
  3. 处理有界区间 (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方法的理论基石。为了理解它们,可以做一个思想实验:

  1. 抽样数据:假设有一份非常大的原始数据。我们不只拿一个训练集,而是通过Bootstrap(有放回抽样)从原始数据中抽样出了 100个 略有不同的训练集(T1, T2, ..., T100)。
  2. 训练模型:使用 同一种算法 (比如,决策树)和 同一套超参数 ,在这100个不同的训练集上,分别训练出了 100个模型 (M1, M2, ..., M100)。
  3. 进行预测:现在,从测试集中拿 一个 样本 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:将所有子模型的结果进行平均(回归问题)或投票(分类问题)。

典型的算法是 随机森林。深度学习中的 Dropout 也可以被看作是Bagging思想的一种体现。 image-20250225154721896

Stacking 简单来说是将Bagging的AveragePooling换成了一个WeightedAveragePooling

  • 在2016年,我做过一个项目:分别用标题、摘要、文本训练了SVM、朴素贝叶斯、逻辑回归三个模型,判断文章所属领域,然后用一个决策树来融合这三个模型的输出结果。这就是一个典型的Stacking案例。

image-2.png

Boosting 是一系列串行训练的模型。每个新模型都专注于解决前面模型没有解决好的问题(即拟合残差)。典型例子就是XGBoost。 image-20250225154640744

P8: 级联模型

模式定义:级联模型 (Cascade/Chained Models)

级联模型指的是一个模型的输入特征依赖于另一个模型的输出。

  • 示例1:使用预训练的InceptionV3模型提取图像Embedding,然后基于这个Embedding训练一个分类器。
  • 示例2:推荐模型中,使用到预估的用户年龄、性别、留存率等作为输入特征。

核心建议:谨慎使用

书中建议 尽量避免 这样的强依赖关系,优先考虑用单一的端到端模型。如果确实需要使用级联模型,必须建立健壮的Pipeline工作流,确保上游模型更新后,下游模型能够及时、自动地重新训练和更新。

P9: 建立中立类别

核心思想:中立类别 (Neutral Class)

如果人类专家对某些样本也难以准确标注,那么就 不要强迫模型去区分它们。这样做反而会引入噪声,拉低模型的整体准确率。

  • 建议:创建一个“其他”、“不确定”、“中间态”之类的中立类别,和其他明确的类别一起训练。

关键洞见

  • 不要直接剔除模糊样本:剔除会导致模型看不到完整的样本空间(Entire Space),在预测时可能会盲目地将这些模糊样本分到某个已知的类别中。
  • 不要强行划分模糊样本:将模糊样本随机或按表面相似性划分到某个类别,会严重干扰该类别的特征分布,拉低模型准确率。

这种中间态或中立类的设计,在制定标注规范时就应提前考虑进去。

应用案例

  1. 图片分类:我会预留一个“其他”类,用来存放一些“无意义”或“类别模糊”的图片(比如表情包),而不是将它们强行分到“美女”或“搞笑”类中。
  2. 股票预测:将股票走势划分为三类:“涨幅 > 5%”、“跌幅 > 5%”、以及“中间震荡态 [-5%, 5%]”。如果只学习“涨”和“跌”两种极端样本,模型会花费大量精力在区分无意义的[-5%, 5%]内的数据,拉低准确率。

P10: 数据不平衡

数据不平衡处理(Rebalance)

针对少数类样本过少的问题,有以下几种常规处理方法:

  • 上采样 (Over-sampling):通过 数据增强(如图像旋转、文本替换)生成新的样本。案例是,我的作弊图片识别项目。
  • 下采样 (Under-sampling):随机丢弃多数类样本。这会牺牲多数类的精度,但可能提升少数类的召回率和F1值。
    • 书中技巧:可以结合Bagging,对多数类进行多次不同的下采样,训练多个模型,最后集成结果。这样可以更充分地利用数据。
  • 类别权重 (Class Weight):在 损失函数 中,为少数类的样本赋予更高的权重,即预测错误的惩罚更大。
我的个人经验 (更有效的方法)

常规方法的效果往往有限,更像是一种 权衡。以下是我实践中总结的更有效的方法:

  1. 将产生偏置的因素,作为特征输入模型
    • 背景:很多数据不平衡是系统性偏置(bias)导致的。例如,低频用户的兴趣标签天然就少;推荐系统中,位置靠后的物料点击率天然就低。
    • 做法:将“用户活跃度”、“推荐位置”等偏置因素作为特征输入模型。模型在学习时会自动捕捉到这些偏置。在预测时,我们可以手动设定这些特征的值(例如,将所有位置都设为“1”),从而消除偏置的影响,得到更公平的预测。这类似于在训练朴素贝叶斯后,手动调整先验概率。
  2. 利用预训练和迁移学习
    • 背景:少数类样本虽然少,但其包含的“知识”可能在其他大规模数据中存在。
    • 做法:利用预训练模型进行迁移学习(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进程。

image-20250303114356672 image-20250303114527027

模型并行 (Model Parallelism)

当模型本身太大,参数无法完全加载到单个GPU中时(例如推荐系统中上亿的用户ID Embedding),就需要使用模型并行。

  • 推荐系统方案Parameter Server (参数服务器)。将巨大的Embedding table等参数分片存储在多台服务器上。
  • 大模型方案:不建议自己实现。推荐使用成熟的框架,如 PyTorch FSDP (Fully Sharded Data Parallelism)FairScale。它们结合了数据并行和模型并行的思想,将模型的参数、梯度和优化器状态都进行分片。

P15: 超参数调优

传统方法的局限

网格搜索 (Grid Search) 等传统方法在现代大模型训练中已不适用。因为单次实验成本高昂,而超参数的组合空间巨大,时间成本会爆炸。

现代调参策略:组合拳

实际应用中,超参数调整已升级为如下的 组合拳

  1. 随机搜索 (Random Search):先用随机搜索快速、大范围地探索超参数空间,找到一个大致的最优区域。
  2. 贝叶斯优化/进化算法:在随机搜索确定的较小范围内,进行更精细的搜索。
  3. 剪枝算法 (Pruning):在搜索过程中,尽早地识别并终止那些“没有希望”的超参数组合,节省计算资源。
  4. 小规模验证:先在小数据集或小模型上快速实验,找到有潜力的超参数范围,再到完整模型和大规模数据上进行微调。

调参工具案例: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结果,建立一个代理模型,来预测哪些超参数区域更有可能产生好的结果,从而更倾向于在这些有希望的区域进行采样。 image-20250304120213151

    选择那些“看起来像好的超参数”,和“不太像不好的超参数”的区域

  • 可视化

    • 提供了类似TensorBoard HParams的丰富的可视化界面。 image-20250304120435990
代码示例:使用 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个标签,作为用户“确定感兴趣”的正样本。
  • 强显式反馈:只使用用户明确的“不感兴趣”或“举报”等负反馈行为,作为“确定不感兴趣”的负样本。

image-20250812173627607

  • 触发:当监控指标下降到预设的阈值后,自动触发模型的重新训练流程。
  • 简化版:如果无法建立完善的监控系统,也可以采用 定期重训 的策略,根据业务经验或指标衰减速度,设定一个固定的重训周期(如每周或每月)。

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)。

优势与权衡

  • 优势:
    1. 方便模型开发人员 随时调整 特征定义,处理逻辑也可以更复杂,不用修改ETL流程。
    2. 封装性好,预测时可以直接输入原始文本特征,而不是数值, 非常直观
    3. 保证了训练和预测时特征处理逻辑的 绝对一致
  • 劣势:
    1. 可能会有性能开销,尤其是在大规模数据加载时。

在我们的兴趣模型中,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_columntf.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: 滑动窗口

模式定义

比较简单,主要指两种情况:

  1. 聚合特征计算:计算一个实体在过去一段时间内的统计特征,如“用户过去7天的平均点击率”。这在离线可以通过SQL的Window函数实现,在实时可以通过Flink等流计算引擎的Tumbling Window实现。
  2. 序列特征构建:将一个实体的连续行为拼接成一个序列,用于RNN、Transformer等模型。例如,将用户最近点击的10个商品ID拼成一个序列。

P25: 工作流

核心模式:工作流管道 (Workflow Pipeline)

将一个复杂的机器学习任务拆分成多个独立的步骤,用DAG(有向无环图)工作流引擎来编排和管理。我感觉是必选的机器学习模式。

image-20251014154348252

离线工作流调度系统

优势

  • 可插拔/可重跑:可以从头到尾运行,也可以只运行或重跑单个节点,便于调试和测试。
  • 团队友好:团队成员可以独立修改各自负责的节点,而无需关心其他节点的实现细节。每个人都能 复现完整的流程
  • 环境隔离:每个节点可以拥有独立的环境(如不同的Docker镜像、不同的资源配置、不同的权限),可以是Spark任务、Python脚本,也可以是调用REST API。

P26: 特征仓库

核心基础设施:特征仓库 (Feature Store)

特征仓库是一个集中式的、标准化的系统,用于存储、管理、发现和服务特征。

  • 核心价值
    1. 保证训练/预测一致性:解决训练时使用离线特征、预测时使用在线特征导致的数据不一致问题。
    2. 特征复用与协作:不同业务方可以在特征仓库中发现和复用已有特征,避免重复开发,提高规模效应。
    3. 统一管理:统一管理实时、小时级、天级等不同时效性的特征,以及它们的ETL流程。
我们公司的特征仓库系统
  • 数据仓库:离线数据库使用Hive,在线KV数据库使用Redis/Pika
  • 元数据后台
    • 提供特征的详细信息,如特征含义、负责人、数据类型、关联的离线表、生产任务、消费任务等。
    • 提供数据质量监控,如调用量、空值率等。
    • 业务方可以在系统上检索特征,申请特征的读写权限。

P27: 模型版本仓库

核心类比:模型的 Docker Hub/HuggingFace/Ollama

模型版本仓库 (Model Registry) 是一个用于存储、版本化、管理和部署已训练模型的中心化系统。

  • 核心功能
    • 通过 模型名称+版本号 来索引和拉取模型。
    • 记录与模型相关的元数据,如训练参数、评估指标、训练日志、代码版本等。
    • 通常会提供一键部署功能,将模型部署为REST服务(实时)或封装成Spark UDF(离线)。
    • 提供检索功能,可以按效果指标、时间等维度查找模型。
  • 主流框架MLflow, Google AI Platform, AWS SageMaker。
  • 与Tensorboard对比:Tensorboard可以记录多次的运行结果,保存模型参数,但需要人工记录目录地址,关联版本和模型效果。不过, 最主要的区别是,模型仓库的管理和部署能力,可以让团队 其他成员 复用模型,一键使用。

工具案例:MLflow

MLflow是一个开源的机器学习全生命周期管理平台,其Model Registry是核心组件之一。 image-20250827151707823

代码示例:使用 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)旨在打开模型的“黑盒”,理解模型做出某个特定预测的内部逻辑。其作用主要有:

  1. 面向用户/监管:回答“为什么给我推荐了这条物料?”这类问题,既能作为“推荐理由”提升用户信任,也能满足监管部门的审查要求。
  2. 发现数据偏见 (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

书中介绍了多种主流技术,如 SHAPIntegrated 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)
  • 场景2
    • 先加入特征B(如“经度”),模型得分从 基线(0.5) -> 0.7。(B的贡献 = +0.2)
    • 再加入特征A(如“纬度”),模型得分从 0.7 -> 0.8。(A的贡献 = +0.1)

那么,特征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的解释非常完美和自洽。 image-20251022180251885

全局特征重要度

把每个样本的特征贡献值的进行汇总,就得到了 全局特征重要度 image-20251023122024847

Integrated Gradients (IG)

专为 深度学习 模型设计的归因方法。

  • 适用范围:它适用于任何 可微分(differentiable) 的模型,例如基于TensorFlow的图像、文本或表格数据模型。
  • 核心思想:IG通过计算模型输出相对于输入特征的梯度来归因。它会沿着从“基线”(如一张全黑的图片或一个全零的Embedding向量)到“实际输入”的路径上所有点的梯度进行 积分(Integrate),从而得到每个输入特征(如每个像素)的贡献值。

应用案例:显著性图 (Saliency Map)

  • 在图像模型中,IG(及其变种如XRAI)的输出结果是一张“显著性图”(saliency map),用 高亮像素 的方式指出模型在做出判断时(如识别为“猫”)到底“看”了图像的哪些区域。

image-20251022193700353

Comments