先说一个我去年踩过的大坑。有一段时间,我们同时维护着四五个场景的推荐模型,每个场景一套特征、一套模型、一套独立上线流程。平时还好,一到促销季就炸:新场景要冷启动没数据,老场景改个特征要对齐半天,CTR涨了CVR跌了,产品过来问为什么搜索的模型不能直接用发现页的排序策略。当时我就意识到,继续靠"每场景一套模型"这种堆人力模式走不下去了。后来我们开始认真研究多场景多任务(Multi-Scenario Multi-Task)方向,结合元学习(Meta Learning)的思路,落地了M2M方案。这篇帖子就是把我在这个项目里的设计思路、实现细节、调参经验和踩过的坑完整写一遍,希望能给正在做推荐系统、做广告投放、做用户增长的同学一些参考。
1. 为什么需要M2M:多场景多任务不是"多个模型放一起"
1.1 多场景问题的本质是数据分布偏移
先说场景。一个典型的电商App里,首页信息流、搜索结果页、商品详情页的推荐位、购物车页凑单推荐,甚至活动页的会场推荐,这些都是场景。每个场景的流量大小不一样、用户意图强弱不一样、商品密度不一样,用户行为模式也完全不一样。首页刷出来的东西用户可以随便点,但搜索结果页用户是要找特定品类的东西,详情页推荐位用户更可能在看同款和配件。
如果把这些数据直接混在一起训练一个模型,会遇到一个很尴尬的问题:模型学到的是一锅乱炖的"平均行为"。它会偏向数据量最大的那个场景,牺牲掉小场景的效果。如果说得再技术一点,多场景问题的本质是不同场景间存在明显的covariate shift——特征分布不同,label分布也不同,甚至特征到label的映射关系都不同。
那退一步,每个场景单独一个模型行不行?行,但代价很大。第一,维护成本是线性的,每加一个场景就要重新做一遍特征工程、模型调参、上线监控。第二,数据利用不均,小场景样本少,单独建模很容易过拟合,冷启动更是无解。第三,跨场景知识完全隔离,首页模型学到的用户兴趣迁移不到搜索排序上。
所以多场景建模的核心诉求是:能不能有一个统一的主干网络,让所有场景共享大部分参数,同时保留每个场景自己的差异化能力?这也是M2M里"Multi-Scenario"一半的戏份。
1.2 多任务为什么要和场景绑在一起
多任务(Multi-Task)在多场景场景里几乎是必须的。想想一个真实推荐链路:用户看到了一个商品位(曝光),产生了点击(CTR),然后进一步发生了加购/成交(CVR)。同时你可能还想优化浏览时长、关注、分享等互动指标。每个业务目标单独建模型,不仅重复建设,还会在线上打分时出现分数不可比的问题——你拿CTR模型的pCTR和CVR模型的pCVR去乘,得到的期望成交概率是有偏的。
多任务建模就是让模型同时预测这几个目标,底层共享学习到的向量表示,各自的任务塔负责最终输出。经典做法如ESMM能统一估计pCTR和pCTCVR,MMoE和PLE则用多门控机制把任务间的联系和区别都建模出来。这些方案的核心洞察是:点击和转化不是孤立的两个事件,它们之间有关联顺序,共享表示能帮助两个任务都学得更好。
但当多任务和前面的多场景叠加在一起,问题就变了:同一个任务在不同场景里的行为模式是不同的。首页的点击可能很多都是好奇型点击,搜索页的点击有强意图支撑,详情页推荐位的点击则更多是货比三家。如果你只是简单地把一个多任务模型加到所有场景数据上,那模型又会退化成"平均场景、平均任务"的平庸模型。场景和任务之间是交叉耦合的,需要一种更灵活的结构来建模这种"场景-任务"组合的关系。
1.3 元学习能给多场景多任务带来什么
这里就轮到Meta Learning出场了。元学习的核心思想是"学习如何学习",它关心的不是某一个任务上做到多好,而是模型能不能在见过一系列任务之后,遇到一个新任务时快速适应。
把元学习用到多场景多任务上,角度其实很自然:把每一个场景当成一个task,元学习器通过在所有场景上学到的元知识,来指导模型在具体某个场景上更快、更好地拟合。这就是M2M——Multi-Scenario Multi-Task Meta Learning的本质。
为什么元学习能解决前面说的那些问题?我自己理解有这么几点:
- 场景自适应:元学习让模型在每个场景上都有一个"内循环"的快速适配过程,相当于给模型装了一个场景感知的开关,数据分布变了,模型参数能迅速跟着调整。
- 知识共享:外循环的元学习器把各个场景的共性部分提炼成一份可迁移的初始化参数,新场景即使数据很少,也能从这份初始化中继承先验知识。
- 任务协同:在多任务设置下,元学习器学到的"如何平衡多个任务"的经验,能迁移到新场景的多任务优化上,避免新场景从头摸索loss权重。
所以M2M本质上不是发明了什么全新的网络结构,而是引入了一种训练范式:在统一的共享架构下,用元学习的inner-update模拟场景切换,用meta-objective约束模型学到具备跨场景泛化能力的表示。
2. M2M的整体设计与思路拆解
2.1 整体架构:共享主干 + 场景自适应层 + 多任务塔
我先说我最终采用的baseline结构,它不是唯一解,但我认可它的合理性,实际效果也确实对得起预期。
整个M2M可以拆成三层来看:
第一层:共享Embedding和共享主干网络。用户特征、物品特征、上下文特征经过embedding层,进入一个共享的全连接网络。这一层的参数是全场景、全任务完全共享的,用来提取通用的、跨场景可迁移的表示。这里我用了类似DIN的attention结构来处理用户历史行为序列,attention的参数也是共享的。
第二层:场景自适应模块。这是M2M最重要的差异化设计。场景信息(scene_id + 场景统计特征)经过一个小的场景编码器,生成场景向量scene_embedding。这个scene_embedding会作用于共享主干输出的上层特征——具体做法是把特征向量和scene_embedding做一次逐元素融合(比如门控加权),再喂到后面的层。这样主干输出的每个维度都能根据地场景被放大或抑制。
第三层:多任务塔。经过场景自适应后的特征,会分发到不同任务的塔里。我一开始用的是最简单的share-bottom,后来对比了MMoE之后改成了带有任务门控的Expert模块:几个expert网络共享中间层,每个任务学习自己的门控权重来挑选expert。每个任务塔独立输出,比如pCTR、pCVR、pComment等等。
这个结构最核心的设计哲学是:场景负责控制特征的"口径",任务负责决策"用什么指标"。两者解耦,通过共享表示有效级联。
2.2 元学习训练的数学直觉
咱们用最流行的MAML来做例子,理解M2M的元学习训练方式是怎么转起来的。MAML的优化目标可以直觉地理解成:找到一个初始参数theta,使得模型在任何场景的任务上,都能通过少量梯度更新就取得好表现。
把它拆成内循环(Inner Loop)和外循环(Outer Loop)来看:
- 内循环:针对某个具体场景s,从该场景的数据中抽出support set,在当前参数theta的基础上做k步梯度下降,得到场景专属参数theta_s'。这个步骤模拟的是"如果在场景s上微调,模型会变成什么样"。
- 外循环:再用场景s的另一批数据query set来评估theta_s'的loss,对原始的theta计算梯度(注意是对原始theta求导,而不是对theta_s'),更新元学习器。这一步等于在说:如果我在场景s上能做到这个效果,说明我的初始参数还需要往哪个方向调整。
代码级的伪代码长这样,看起来就是几行,但真正能跑通且稳定收敛,是我调了很久之后才做到的:
for iteration in range(max_iter): scene_meta = sample_a_scene_batch(meta_batch_size) meta_loss = 0.0 for scene in scene_meta: support_data = sample_from_scene(scene, support_size) query_data = sample_from_scene(scene, query_size) # 内循环: 在support上快速适配 theta_scene = theta for step in range(k_inner): loss_support = multi_task_loss(support_data, theta_scene, scene) theta_scene = theta_scene - lr_inner * grads(loss_support, theta_scene) # 外循环: 在query上计算元损失 loss_query = multi_task_loss(query_data, theta_scene, scene) meta_loss += loss_query theta = theta - lr_outer * grads(meta_loss, theta)这里面有一个很容易被忽略的细节:在内循环更新时,场景信息必须参与到前向计算里。因为M2M里每个场景都有scene embedding,内循环微调要同时更新主干参数和场景编码器里的部分参数。我在第一版实现里偷懒,内循环只更新主干参数,结果场景自适应模块完全没有学到场景差异,效果跟共享模型没区别。
2.3 为什么这套设计能避免"平均场景陷阱"
回到最开始说的问题——数据量悬殊。假设首页feed流有1000万样本,搜索页有100万样本,购物车有10万样本。普通多任务模型做梯度下降时,总loss是各场景loss的加权平均,首页梯度占绝对主导,模型自然偏向首页行为。
M2M不一样的地方在于,它的每次参数更新是按场景(task)为单位计算meta-loss的。每个场景先在自己的support上做内循环适配,这个适配本来就把场景差异拉平了;然后元学习的更新把所有场景的query loss相加,每个场景都被同等对待(或者你显式加权重)。这就打破了大场景压制小场景的惯性。
一句话总结:元学习把"样本"做了一次场景维度的重排,优化目标从"拟合所有数据"变成了"拟合所有场景"。样本多的大场景确实在采样的概率上可能仍然偏高,但你可以通过显式的场景均衡采样来修正。这一点在实操部分我会细说。
3. 核心细节解析与实操要点
3.1 场景特征怎么设计
M2M的场景自适应依赖scene embedding,它不能只有一个scene_id的embedding拉倒,会学得比较浅。我实践的结论是,场景特征至少包含以下几类:
- 场景标识:scene_id的embedding,最简单的场景身份表示。
- 场景统计特征:比如该场景近7天平均曝光量、点击率均值、转化率均值、供给侧的平均价格带。这些特征刻画了场景当下的状态,比如大促期间活动页的转化率均值会明显偏高,这能帮助模型感知到场景的动态变化。
- 场景上下文特征:包括用户在进入该场景前的来源页面、停留时长、访问路径等。同一场景在不同上下文下的用户意图也是有差异的,比如用户从直播间进到商品详情页,和从搜索结果进到商品详情页,对推荐位的敏感度完全不同。
场景特征和样本特征一起输入一个场景编码器(通常就是一个MLP),输出一个固定维度的scene embedding。我会限制scene embedding的维度,一般取64维或者128维,不宜过高,因为场景本身数量有限,维度太高容易过拟合到场景ID上。
3.2 场景门控如何作用到共享特征上
拿到scene embedding之后,怎么让它在模型中生效?我试过三种方式,说一下各自的感受:
第一种是简单拼接。把scene embedding直接拼接到共享主干输出的特征后面,喂给任务塔。这种方法最简单,但效果一般,因为拼接只相当于给模型提供了场景信息,模型并不强制用它来做特征变换,学成什么样完全靠随机梯度自己探索。
第二种是权重调制(FiBiNet风格)。把scene embedding经过一个小网络生成逐元素的缩放向量gamma,然后对主干输出特征做:h = h * (1 + gamma)。这相当于让场景去"校准"特征的每一维。这种方式效果明显比拼接好,因为它是直接在特征表示上加了一个场景敏感的变换。
第三种是生成式门控(Hypernetwork风格)。让scene embedding生成场景自适应层的一部分权重参数。比如场景自适应层是一个线性层g(h) = W_s h + b_s,其中W_s由scene embedding经过一个系数矩阵变换而来。这种方式表达能力最强,但参数暴增,容易过拟合,训练也慢。我只在大促前的活动新场景上用了一次,效果还OK,但得不偿失的感觉比较强。
最终我落在第二种和第三种的折中上:先用门控方式做逐元素的feature gating,再用一个很小的场景专属bias。实践证明这个组合既能快速收敛,又能比较充分捕捉场景差异。
3.3 多任务loss的平衡策略
多任务最经典的问题就是loss怎么平衡。直接相加,很可能CTR任务收敛得很快,把CVR任务的loss淹没掉。我踩过的坑包括:初期直接把三个任务的BCE loss直接相加,发现CVR任务几乎不更新,模型整体偏向点击率,加购预估明显低估。
后来试了三种平衡策略:
- 动态加权(Uncertainty weighting):用任务相关的可学习权重sigma_i,把loss变成 sum_i(1/2*sigma_i^2 * L_i + log(sigma_i^2))。这方法解读起来简单:方差大的任务自然权重低。实际训练里sigma比较难收敛,但总体是有效的。
- GradNorm:根据梯度幅度动态调整权重,让不同任务的梯度保持在一个数量级。这个在场景差异比较小的时候效果不错,但遇到冷启动场景时梯度噪声大,会不稳定。
- 分阶段训练:先让CTR这类数据充足的显性任务训几个epoch,再开放其他任务的梯度更新。这个偏工程手段,简单粗暴能用,但理论上对这个任务会有些暴力。
最后我采用的方案是"不确定性加权 + 手动上限约束"的组合。具体是:初始权重设成1:1:1,训练时允许sigma自主学习,但给每个任务的权重一个合理的范围限制,避免一个任务权重过大。这个做法在多个任务相对同质的场景下都能获得稳健的效果。
3.4 元学习的任务采样:场景均衡有多重要
M2M的训练,采样策略直接影响模型学到的元知识质量。如果你用常规dataloader按样本量采样,1000万样本的场景和10万样本的场景根本不是一个量级,那内循环里每个step算出来的loss几乎都被大场景主导。
我采用的做法是场景均衡采样器:每个iteration里,先从场景集合中随机均匀采样meta_batch_size个场景(如果场景太多,就按照权重采样,但权重上限要做平滑),然后在每个场景内部分别采样support和query数据。
这个设计有一个工程上的细节:为了高效训练,要提前把数据按场景partition成多个独立的TFRecord/Parquet分片,每个场景有自己的数据流。训练时并行读取分片,组成一个"场景batch"去跑内循环和外循环。这样才能保证采样逻辑真的是按场景来走。
3.5 参数设置与训练策略
我直接给一份经过实验的参考配置,不一定是最优,但作为起点比较稳:
| 参数 | 参考值 | 说明 |
|---|---|---|
| embedding维度 | 64 | 用户/物品ID类特征 |
| 场景编码器 | 2层MLP(128->64) | 输入场景特征,输出scene embedding |
| 共享主干层 | 3层MLP(512, 256, 128) | 每层后加BN和ReLU |
| 场景门控 | h * (1 + gamma),gamma由scene embedding生成 | 作用在主干第3层输出 |
| 任务塔 | 每任务1层MLP(64) + 输出层 | 塔尽可能浅,参数主要共享 |
| 内循环步数 k | 3 | 1-5之间,太少了学不到适配,太多了训练太慢 |
| 内循环学习率 | 0.01 | 要比外循环大10-100倍 |
| 外循环学习率 | 1e-4 | Adam优化器,warmup 2000步 |
| 场景batch大小 | 8 | 每次外循环采8个场景 |
| support/query数量 | 每场景各256/256 | 满足内循环梯度估计的稳定需求 |
这里我想特别强调内循环步数。步数太小,内循环的适配能力不足,场景差异会被吞掉;步数太大,相当于每个场景已经收敛到局部最优点,外循环的"学习如何快速适配"这个信号被稀释。实测3-5步是甜点区。我试过10步,训练时间翻倍,效果反而略降。
4. 实操过程与核心模块代码级解析
4.1 数据准备与特征工程
数组准备上,多场景数据分片首先要保证特征口径一致。不同场景的特征如果命名不同、归一化方式不同、缺失值填充策略不同,到了共享主干网络里就是灾难。我们在项目里建了一套统一的特征配置文件(schema.json),所有场景统一走同一套特征工程pipeline。
统一特征之后,把全量数据按场景ID分片存储。每个分片内再按时间排序,保证内循环的support和query都取自同一时段的分布,这样可以降低分布漂移的影响。我这边是每个场景分片预留最近3天的数据作为query验证集,用来做每epoch的元评估。
4.2 模型搭建的核心片段
下面给出我认为最重要的几块代码逻辑。框架用的是TensorFlow 2.x,但换PyTorch也是同一套思路。
第一块是场景编码器和门控:
def scene_encoder(scene_feat): # scene_feat: [batch_size, scene_feat_dim] with tf.name_scope("scene_encoder"): h = tf.keras.layers.Dense(128, activation="relu")(scene_feat) h = tf.keras.layers.Dense(64, activation="relu")(h) return h # scene embedding [batch_size, 64] def scene_gate(shared_feat, scene_embedding): # shared_feat: [batch_size, 128] gate = tf.keras.layers.Dense(128, activation="tanh")(scene_embedding) # 对特征逐元素调制 gated_feat = shared_feat * (1.0 + gate) return gated_feat这里用(1.0 + gate)而不是gate,是为了保证门控默认不会把特征压到0。tanh的输出范围是[-1, 1],1+gated的范围是[0, 2],这样模型在初期没有学到场景差异时,门控处在一个近似恒等变换的位置,训练会更稳定。这个细节是我在一次复现论文时发现的,很多同学直接写shared_feat * gate,结果初始化阶段特征尺度突然变小,收敛慢很多。
第二块是多任务门控专家网络(简化版的MMoE):
def task_tower(gated_feat, n_experts=4, n_tasks=3): experts = [] for e in range(n_experts): expert_out = tf.keras.layers.Dense(64, activation="relu")(gated_feat) experts.append(expert_out) # each [batch_size, 64] task_outputs = [] for t in range(n_tasks): # 每个任务学习门控权重,组合专家 gate_w = tf.keras.layers.Dense(n_experts, activation="softmax")(gated_feat) expert_stack = tf.stack(experts, axis=1) # [batch, n_experts, 64] gate_stack = tf.expand_dims(gate_w, axis=-1) # [batch, n_experts, 1] mix_out = tf.reduce_sum(expert_stack * gate_stack, axis=1) task_logit = tf.keras.layers.Dense(1, activation="sigmoid")(mix_out) task_outputs.append(task_logit) return task_outputsMMoE的Expert门控是让不同任务可以按需挑选不同的expert组合,多任务共享底层的同时又保留各自任务的差异化。在多场景设置下,我会让scene embedding也拼到门控的输入里,这样同一个任务在不同场景可以使用不同的专家组合——这等于把场景和任务的交叉耦合建模到了门控权重中。
4.3 内循环+外循环的完整训练伪代码
训练逻辑类比我之前给的伪代码再加一些M2M专属细节:
for iteration in range(total_iter): # 均衡采样: 随机取 batch_size 个场景 scene_list = balanced_sampler.sample(batch_size) meta_loss_list = [] for scene_id in scene_list: scene_emb = scene_encoder(scene_feat_dict[scene_id]) # 内循环: 复制一份参数用于局部适配 with tf.GradientTape() as tape_inner: support_loss = compute_task_loss( support_data[scene_id], shared_model, task_towers, scene_emb, trainable=True) grads_inner = tape_inner.gradient(support_loss, shared_model.trainable_variables) # 更新一份副本 inner_model = copy_model_with_delta( shared_model, grads_inner, inner_lr) # 外循环: 用query数据算元损失 with tf.GradientTape() as tape_meta: total_meta_loss = 0 for scene_id in scene_list: query_loss = compute_task_loss( query_data[scene_id], inner_model, task_towers_copy, scene_emb, trainable=False) total_meta_loss += query_loss grads_meta = tape_meta.gradient(total_meta_loss, shared_model.trainable_variables) optimizer.apply_gradients(zip(grads_meta, shared_model.trainable_variables))这里有个实现要点:内循环更新的是副本参数,不能直接改主模型的参数再把梯度带到外循环。最容易出的bug是内循环的tape_inner和外循环的tape_meta叠加导致梯度二阶甚至三阶导数,既慢又不稳定。用一种detach/stop_gradient的思路把内循环的计算图隔离开,外循环只对初始参数的梯度做更新,这是最稳的工程实践。
5. 常见问题与排查技巧实录
5.1 新场景冷启动,元学习器能直接迁移吗?
M2M一个很诱人的卖点是"新场景快速上线"。实际操作上,我建议分两步走:
- 先把新场景的统计特征和历史行为序列特征尽量补齐,哪怕样本量只有几千条。把这批数据作为support,做内循环微调,看看元学习器给的初始化参数能不能快速收敛。我遇到的最好的情况是,新场景只用正常数据量的1/5,就能达到单独训练模型的80%+效果。
- 如果新场景连support都凑不齐,那就退一步,用元学习器的初始化直接跑推理。此时模型对新场景的适配基本靠scen embedding在线推算,效果会比老场景差一些,但不能说完全不可用,至少比用其他场景硬带要好。
5.2 Loss在初期冲高然后崩溃怎么办?
M2M训练初期特别容易遇到一个现象:第一个epoch loss莫名冲高,然后直接NaN。排查基本围绕三点:
- 数值稳定性:内循环梯度回传到外循环,二阶梯度数值会比较大,学习率稍微高一点就爆。把外循环学习率降到1e-5到1e-4区间,梯度裁剪到5.0以内,能解决大部分崩溃问题。
- BN层:如果共享主干里有BatchNorm,它统计的是当前batch的均值和方差。内循环用support算统计量,外循环用query算,两边统计量不一致会让BN不稳定。我在共享主干里直接换成了LayerNorm,效果更省心。
- 任务loss权重:不确定性加权的sigma初始值如果太小,loss会前期偏大。建议初始权重直接用1:1:1,sigma每50个iteration更新一次,而不是每个step都更新。
5.3 内循环步数到底调几?
我前面提到3-5步是甜点区,但这里有个前提:场景数据的"任务难度"。如果场景之间分布差异很大,比如一个首页一个实时竞价,内循环可能需要更多步才能适配;如果场景间差异小,内循环可以只做1-2步。建议的做法是做一个ablation:固定其他参数,内循环k分别取1/3/5/8跑一版,对比query loss的下降速度和新场景的验证指标,找到自己业务数据下的最优值。
5.4 场景特征变化频繁,模型会不会跟着抖?
这是上线后才暴露的问题。某个场景的流量结构发生变化(比如首页改版),统计特征均值变了,scene embedding输入端就漂了,门控输出也随之变化,模型输出波动明显。这里有一个技巧:对场景统计特征做平滑。不要直接用当天均值,而是用近7天的滑动平均,并且对scene embedding输出的范数做一个约束(比如限制L2 norm在固定范围)。这样既保留场景动态信息,又不至于让模型因为单日统计抖动而大幅波动。
5.5 内循环和外循环的显存与成本控制
M2M在训练时对显存有额外要求。每个场景的内循环都要复制一份模型参数做更新,场景batch=8的话,等于同时在内存里有8份模型副本在跑前向和反向,GPU显存压力肉眼可见。
我优化后的方案是折叠内循环的更新方式:不真正复制完整模型,而是只在计算support loss时保存一份参数的delta,外循环计算query loss时,把delta加到主模型的forward里。这样内循环的反向不再单独保计算图,显存能省接近一半。代价是代码会复杂一些,但对工业化训练来说很值得。
6. 最终效果与我的个人经验
M2M这套方案在我们线上跑了两个多月,覆盖首页feed流、搜索、购物车三个主要场景,同时优化CTR、CVR、加购三个任务。对比基线(每场景独立的多任务模型),整体效果是这样的:
- 三个场景平均的CTR提升约3.8%,CVR提升约4.6%,购物车场景的提升最明显,因为它的样本量最少,从共享知识里获益最大。
- 新场景冷启动时间从原来的一到两周缩短到两三天就能达到可用水平。
- 维护负担大幅下降,所有场景共用一套特征schema和一套训练流程,新增场景只是加一个scene_id和对应的特征分片。
说到底,M2M的价值不只是一个模型结构,它提供了一种新的思考角度:不是把所有场景当成一大锅数据去炖,而是把它们当成一组有关联的任务去学习。每个场景有自己的个性,但也共享了一套通行的规律,元学习就是把这两件事统一起来的那个框架。
最后分享一个我自己的体会:M2M这类方案,最大的收益往往不在于单个场景的效果暴涨,而在于它把多场景从"一个场景一个模型"的粗放模式,升级成了"一套系统统一调度"的精细模式。这样的系统在业务扩张期价值最大——新增一个场景的成本从以周计降到以天计。如果你也正在被多场景多任务的维护成本和冷启动问题折磨,M2M这条路值得认真走一遍。