1. 从“硬分桶”到“软学习”:为什么我们需要AutoDis?
在推荐、广告、搜索这些以深度学习模型为核心的场景里,特征工程是决定模型效果上限的基石。其中,连续特征(Continuous Features)的处理,一直是个既基础又棘手的问题。比如用户的年龄、消费金额、浏览时长、物品的价格、历史点击率(CTR)等等。这些特征不像用户ID、物品ID这类离散特征,天然就有一个现成的Embedding表可以查。传统做法,也是很多工程师的第一反应,就是“分桶”(Bucketization)或者叫“离散化”(Discretization)。
简单来说,就是把一个连续的数值,比如年龄18岁,根据预设的阈值(比如[0, 20), [20, 30), [30, 40)...)硬生生地划归到某一个桶里,然后把这个桶当作一个离散的类别,再去学习它的Embedding。这个方法直接、有效,在很长一段时间里都是工业界的标准操作。但干过这活儿的同学都知道,这里面的坑太多了。
首先,阈值怎么定?凭经验?等频?等宽?对于年龄,等宽似乎合理;但对于消费金额,长尾分布严重,等宽分桶会让头部极少数样本独占多个桶,尾部大量样本挤在一个桶里,信息损失巨大。等频分桶能缓解分布问题,但桶的边界值可能没有业务意义(比如把100.3元和100.5元分到两个不同的桶),而且线上服务时,一个新来的数值(比如一个前所未有的高消费额)可能落不到任何一个桶里,需要兜底策略。
其次,信息损失不可避免。一旦分桶,18岁和19岁属于同一个“[0,20)”桶,它们在模型眼里就是完全相同的;而19岁和20岁,虽然只差1岁,却因为跨过了阈值,被分到了两个不同的桶,拥有了完全独立的Embedding。这种“桶内无差别,桶间硬切割”的特性,破坏了连续特征本身固有的序关系和数值相近性,模型学习起来非常别扭。
最后,超参数敏感。桶的数量是一个需要精心调校的超参数。桶太少,表达能力不足,模型学不到精细的差异;桶太多,不仅增加模型参数和计算量,还容易在小桶上过拟合。我们往往需要花费大量的精力在特征分桶的调参上。
那么,有没有一种方法,能让模型自动地、更优雅地从连续特征中学习到表征,既保留其连续性,又能像离散特征一样生成高质量的Embedding向量呢?这就是KDD 2021上提出的AutoDis框架要解决的核心问题。它不再进行“硬分桶”,而是转向一种“软学习”的范式,让Embedding的生成过程本身是可微的、可学习的。接下来,我们就深入这个框架的内部,看看它是如何巧妙设计的。
2. AutoDis核心三件套:元嵌入、自动相关性、聚合门控
AutoDis的整个设计非常模块化且直观,它主要包含三个核心组件,我习惯称之为“三件套”。理解了这三部分,你就掌握了AutoDis的精髓。
2.1 元嵌入:先准备好“候选素材库”
AutoDis首先承认,为连续特征预设一些“原型”或“基础元素”是必要的,这类似于分桶中的“桶”。但它不把数值硬分到某一个桶,而是准备一组可以灵活组合的“素材”。
具体来说,对于每一个连续特征字段(比如“年龄”),我们定义H个元嵌入。你可以把H想象成我们预设的“桶”的数量,比如H=10。每个元嵌入ME_h都是一个d维的向量,和最终我们想要的Embedding维度一致。这H个元嵌入就构成了这个特征的一个“可学习的嵌入字典”或“素材库”。
注意:这里
H的选择相对宽松。因为它不再是“分桶数”,而是“基向量的数量”。即使H设置得比较大(比如20、30),由于后续的聚合机制,也不会导致参数爆炸或过拟合,因为它并不直接对应样本的分配。这在一定程度上降低了对这个超参数的敏感性。
这一步对应论文中的Meta-Embeddings。所有样本共享这同一组H个元嵌入。对于一个具体的年龄值(比如25岁),模型的任务不是选择其中一个元嵌入,而是思考:“如何用这H个素材,组合出一个最能代表25岁的Embedding?”
2.2 自动相关性:计算数值与每个“素材”的亲和度
接下来,对于输入的具体连续值x(经过标准化等预处理),AutoDis需要计算这个x与每一个元嵌入ME_h的“相关性”或“亲和度”。这个计算是通过一个自动相关性模块实现的。
这个模块通常是一个简单的神经网络,比如一个多层感知机。输入是标量值x,输出是一个H维的向量,我们称之为相关性权重w。w的每个元素w_h代表了数值x与第h个元嵌入ME_h的相关程度。
w = softmax(MLP(x))
这里使用softmax是为了让所有权重归一化,形成一个概率分布,表示x的“注意力”在这H个元嵌入上的分配。这个过程是完全可微的,并且是数据驱动的。模型通过训练,自己学会什么样的数值(比如较小的年龄)应该与哪几个元嵌入有更高的相关性。
举个例子,假设H=5。对于x=25(年龄),模型可能学到的权重是[0.1, 0.6, 0.2, 0.1, 0.0]。这意味着25岁这个特征,与第2个元嵌入关系最密切,与第1、3、4个也有一定关联,与第5个无关。这就实现了“软分配”,25岁不是硬属于“20-30岁”桶,而是与多个“概念”以不同强度关联。
2.3 聚合门控:加权求和,生成最终Embedding
有了元嵌入ME(H个,每个d维)和相关性权重w(H维),最后一步就水到渠成了:生成最终的Embedding。
最终Embeddinge就是这H个元嵌入的加权和:e = sum_{h=1}^{H} (w_h * ME_h)
由于w是通过可微函数(MLP+softmax)从x计算出来的,因此整个从x到e的映射过程是端到端可训练的。梯度可以一路从损失函数反向传播,经过聚合层,通过权重w,更新自动相关性模块的MLP参数,同时也会更新元嵌入ME本身。
为什么这个设计是巧妙的?
- 保持连续性:输入
x的微小变化,会导致MLP输出的权重w发生连续、平滑的变化,从而使得生成的Embeddinge也连续变化。这保留了连续特征的内在性质。 - 可解释性:虽然不像线性变换那样直接,但我们可以通过分析权重
w的分布,来理解模型是如何“理解”这个连续特征的。比如,我们可能发现某个元嵌入总是对“高消费”数值有高权重,那它就可以被解释为“高消费模式”的基底。 - 表达能力强:最终的Embedding空间是
H个元嵌入张成的子空间中的一点。只要H足够,且元嵌入线性无关,这个空间可以表达非常丰富的模式,远超简单的线性变换或分段常数函数(硬分桶)。
下表对比了AutoDis与传统硬分桶的核心差异:
| 特性 | 传统硬分桶 | AutoDis |
|---|---|---|
| 处理方式 | 分段常数函数,硬分配 | 可微的软分配,加权聚合 |
| 连续性 | 破坏,桶内相同,桶间跳跃 | 保持,输出随输入连续变化 |
| 参数 | 每个桶一个独立Embedding | H个元嵌入 + 一个小型MLP参数 |
| 超参数敏感度 | 高(依赖桶数和边界) | 中低(H影响较小,MLP结构固定) |
| 外推能力 | 差,需兜底策略 | 较好,MLP可泛化到未见过的x |
| 可解释性 | 强(桶有明确边界) | 中等(通过权重分布解释) |
3. 手把手实现:将AutoDis集成到你的DNN模型中
理论很优美,但更重要的是落地。这里,我将以一个简化的点击率预测模型为例,展示如何用PyTorch实现AutoDis层,并把它嵌入到一个典型的深度推荐模型(如DeepFM、DCN等)中。
我们假设我们的特征包含稀疏特征(用户ID、物品ID)和稠密特征(年龄、消费额)。这里聚焦于如何用AutoDis处理“年龄”和“消费额”这两个连续特征。
3.1 定义AutoDis模块
首先,我们实现一个通用的AutoDis模块。
import torch import torch.nn as nn import torch.nn.functional as F class AutoDis(nn.Module): """ AutoDis: Automatic Discretization for Deep Learning with Continuous Features. 针对单个连续特征字段。 Args: meta_embedding_dim (int): 元嵌入的维度,也是最终输出Embedding的维度。 num_meta_embeddings (int): 元嵌入的数量 H。 temperature (float, optional): softmax的温度参数,用于控制权重分布的平滑程度。默认为1.0。 """ def __init__(self, meta_embedding_dim, num_meta_embeddings, temperature=1.0): super(AutoDis, self).__init__() self.meta_embedding_dim = meta_embedding_dim self.num_meta_embeddings = num_meta_embeddings self.temperature = temperature # 1. 定义H个元嵌入 self.meta_embeddings = nn.Parameter( torch.randn(num_meta_embeddings, meta_embedding_dim) * 0.02 ) # 形状: [H, d] # 2. 定义自动相关性网络 (一个简单的MLP) # 输入是标量x,输出是H维的logits self.correlation_net = nn.Sequential( nn.Linear(1, 64), nn.ReLU(), nn.Dropout(0.1), # 可选的Dropout,防止过拟合 nn.Linear(64, num_meta_embeddings) # 输出H个logits ) def forward(self, x): """ Args: x (Tensor): 输入的具体特征值,形状为 [batch_size, 1] 或 [batch_size]。 Returns: e (Tensor): 生成的Embedding,形状为 [batch_size, meta_embedding_dim]。 weights (Tensor): 相关性权重,形状为 [batch_size, num_meta_embeddings]。用于可解释性分析。 """ # 确保输入是二维的 [batch_size, 1] if x.dim() == 1: x = x.unsqueeze(-1) # [batch_size] -> [batch_size, 1] batch_size = x.size(0) # 通过相关性网络得到logits logits = self.correlation_net(x) # [batch_size, H] # 应用温度系数的softmax得到归一化权重 weights = F.softmax(logits / self.temperature, dim=-1) # [batch_size, H] # 加权聚合元嵌入 # self.meta_embeddings: [H, d] # weights.unsqueeze(-1): [batch_size, H, 1] # 广播相乘后求和 weighted_meta_embeds = self.meta_embeddings.unsqueeze(0) * weights.unsqueeze(-1) # [batch_size, H, d] e = weighted_meta_embeds.sum(dim=1) # [batch_size, d] return e, weights3.2 构建包含AutoDis的CTR模型
现在,我们构建一个简单的模型,它包含:
- 对稀疏特征(user_id, item_id)使用常规的Embedding层。
- 对连续特征(age, expense)使用我们刚实现的AutoDis层。
- 一个简单的多层感知机作为预测层。
class CTRModelWithAutoDis(nn.Module): def __init__(self, num_users, num_items, embedding_dim, auto_dis_dim, auto_dis_h): super(CTRModelWithAutoDis, self).__init__() self.embedding_dim = embedding_dim # 稀疏特征嵌入层 self.user_embedding = nn.Embedding(num_users, embedding_dim) self.item_embedding = nn.Embedding(num_items, embedding_dim) # AutoDis层处理连续特征 self.autodis_age = AutoDis(meta_embedding_dim=auto_dis_dim, num_meta_embeddings=auto_dis_h) self.autodis_expense = AutoDis(meta_embedding_dim=auto_dis_dim, num_meta_embeddings=auto_dis_h) # 假设我们还有两个普通的稠密特征(非AutoDis处理),比如历史点击数和曝光数 self.dense_fc = nn.Linear(2, embedding_dim) # 将2维稠密特征映射到embedding_dim维 # 预测层 # 输入:user_emb, item_emb, age_emb, expense_emb, 其他稠密特征emb # 总共 5 * embedding_dim 维 total_emb_dim = embedding_dim * 5 self.predictor = nn.Sequential( nn.Linear(total_emb_dim, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, 64), nn.ReLU(), nn.Dropout(0.2), nn.Linear(64, 1), nn.Sigmoid() ) def forward(self, user_id, item_id, age, expense, other_dense_features): # 1. 稀疏特征嵌入 user_emb = self.user_embedding(user_id) # [batch_size, embedding_dim] item_emb = self.item_embedding(item_id) # [batch_size, embedding_dim] # 2. AutoDis处理连续特征 age_emb, _ = self.autodis_age(age) # [batch_size, auto_dis_dim] expense_emb, _ = self.autodis_expense(expense) # [batch_size, auto_dis_dim] # 3. 处理其他普通稠密特征 other_dense_emb = self.dense_fc(other_dense_features) # [batch_size, embedding_dim] # 4. 拼接所有特征向量 # 注意:需要确保auto_dis_dim和embedding_dim一致,或者通过一个线性层统一维度。 # 这里假设我们设置 auto_dis_dim = embedding_dim concat_emb = torch.cat([user_emb, item_emb, age_emb, expense_emb, other_dense_emb], dim=1) # 5. 通过预测层得到点击概率 click_prob = self.predictor(concat_emb).squeeze(-1) # [batch_size] return click_prob3.3 训练与数据准备
在训练时,数据的预处理需要特别注意连续特征。
import numpy as np from torch.utils.data import Dataset, DataLoader # 假设我们的原始数据 # user_id, item_id, age, expense, other_feat1, other_feat2, label # 其中 age, expense 是原始连续值 class CTRDataset(Dataset): def __init__(self, data_array): self.data = data_array def __len__(self): return len(self.data) def __getitem__(self, idx): sample = self.data[idx] # 假设数据列顺序为上述 user_id = int(sample[0]) item_id = int(sample[1]) age = float(sample[2]) expense = float(sample[3]) other_dense = np.array([sample[4], sample[5]], dtype=np.float32) label = int(sample[6]) return { 'user_id': user_id, 'item_id': item_id, 'age': age, 'expense': expense, 'other_dense': other_dense, 'label': label } # 关键步骤:连续特征归一化 # AutoDis的MLP输入是原始值,但数值范围差异过大会影响训练稳定性。 # 建议对 age 和 expense 进行标准化或归一化。 def normalize_features(data): ages = data[:, 2].astype(np.float32) expenses = data[:, 3].astype(np.float32) age_mean, age_std = ages.mean(), ages.std() expense_mean, expense_std = expenses.mean(), expenses.std() # 标准化: (x - mean) / std data[:, 2] = (ages - age_mean) / (age_std + 1e-8) data[:, 3] = (expenses - expense_mean) / (expense_std + 1e-8) return data, (age_mean, age_std, expense_mean, expense_std) # 加载和预处理数据 raw_data = np.loadtxt('your_data.csv', delimiter=',') # 示例 normalized_data, norm_stats = normalize_features(raw_data) dataset = CTRDataset(normalized_data) dataloader = DataLoader(dataset, batch_size=256, shuffle=True) # 初始化模型、损失函数、优化器 model = CTRModelWithAutoDis(num_users=10000, num_items=5000, embedding_dim=32, auto_dis_dim=32, # 设置为和embedding_dim相同 auto_dis_h=10) criterion = nn.BCELoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) # 训练循环 for epoch in range(10): total_loss = 0 for batch in dataloader: user_id = batch['user_id'] item_id = batch['item_id'] age = batch['age'] expense = batch['expense'] other_dense = batch['other_dense'] label = batch['label'].float() optimizer.zero_grad() pred = model(user_id, item_id, age, expense, other_dense) loss = criterion(pred, label) loss.backward() optimizer.step() total_loss += loss.item() print(f'Epoch {epoch}, Avg Loss: {total_loss/len(dataloader):.4f}')提示:在实际工业场景中,
age和expense的归一化参数(均值和标准差)需要在训练集上计算,并保存下来,用于对验证集、测试集以及线上实时请求的数据进行同样的变换,保证数据分布的一致性。
4. 实战中的调优策略与避坑指南
把AutoDis的代码跑通只是第一步。要让它在你的业务数据上真正发挥出超越硬分桶的效果,还需要一些实战经验和技巧。下面是我在几个真实项目中应用AutoDis后总结出的关键点。
4.1 元嵌入数量H与维度d的选择
H(num_meta_embeddings):这个参数不再像分桶数那样敏感。论文和实验表明,
H在10到20之间通常就能取得很好的效果。设置得太小(如<5),可能限制模型的表达能力;设置得太大(如>50),虽然不会像硬分桶那样直接导致过拟合,但会增加相关性网络的学习负担,可能让权重分布变得过于平均化,失去区分度。建议从H=10开始,作为一个稳定的默认值。如果你的特征非常复杂,可以尝试增加到15或20,并通过验证集AUC/Bias等指标观察变化。d(meta_embedding_dim):这个维度决定了最终连续特征Embedding的表达能力。它应该与你模型中其他稀疏特征的Embedding维度对齐或相当。例如,你的
user_idEmbedding是32维,那么AutoDis的输出也设为32维是合理的。如果维度不匹配,可以在拼接所有特征后,通过一个线性投影层统一维度。一个经验法则是,d的取值可以与你的主要稀疏特征的Embedding维度相同。
4.2 相关性网络的结构与温度系数
网络结构:论文中使用了一个简单的两层MLP。在实践中,这个网络不宜过于复杂。一个包含1到2个隐藏层、激活函数为ReLU的MLP已经足够。过于复杂的网络可能会让模型过度关注如何从单个数值
x计算权重,而忽略了元嵌入本身的学习,甚至可能引入过拟合。保持简洁有效是关键。可以加入Dropout(如0.1-0.3)来增强泛化能力。温度系数(Temperature):这是控制权重分布“尖锐”或“平滑”的重要超参数。在softmax中,温度
T越低(logits/T),输出的概率分布越尖锐(接近one-hot);温度越高,分布越平滑(接近均匀分布)。- 如果温度太低,权重会接近one-hot,AutoDis就退化成了另一种形式的“硬分配”(虽然分配是可学习的),可能失去软聚合的优势。
- 如果温度太高,权重趋于均匀,所有元嵌入贡献几乎相同,最终Embedding会趋近于所有元嵌入的均值,丢失了输入
x的特异性信息。 - 建议从T=1.0(标准softmax)开始尝试。如果你发现模型学习缓慢或效果不佳,可以尝试稍微调高温度(如1.5)让训练初期更平滑,或者调低温度(如0.7)让模型做出更“果断”的权重分配。这是一个值得微调的超参数。
4.3 连续特征的预处理:标准化与非线性变换
AutoDis的输入是原始连续值。数据的尺度直接影响相关性网络MLP的学习。
标准化/归一化是必须的:像“消费金额”这种可能从0到数万的特征,如果不做处理,巨大的数值范围会导致MLP的梯度不稳定。务必对每个连续特征进行标准化(减均值除方差)或归一化(缩放到[0,1]或[-1,1]区间)。这能大幅提升训练速度和模型稳定性。
考虑非线性变换:对于一些具有特定分布的特征(如幂律分布),直接对原始值
x建模可能效率不高。可以尝试先对x进行非线性变换,如log(1+x),将大范围的值压缩到一个较小的区间,再输入给AutoDis。这相当于给了模型一个更强的先验,有时能加速收敛。这是一个特征工程的技巧,可以与AutoDis结合使用。
4.4 与模型其他部分的协同
AutoDis生成的Embedding最终要和其他特征的Embedding拼接在一起,送入后续的深度网络。这里有几个细节:
- 维度对齐:确保AutoDis的输出维度
d与其他Embedding维度一致,方便拼接。如果不一致,可以添加一个nn.Linear层进行投影。 - 梯度流:AutoDis的整个通路都是可微的,梯度会同时更新元嵌入
ME和相关性网络MLP的参数。要关注这两部分参数的学习率是否合适。通常使用统一的优化器(如Adam)即可,它们会自动适应。 - 可视化分析:在训练后期,可以取出
autodis_age和autodis_expense的权重w,对一批样本进行统计分析。例如,画出不同年龄x对应的权重分布(H个权重随x变化的曲线)。这能帮你直观理解模型学到了什么样的“软分桶”模式,也是一种模型可解释性的体现。
4.5 可能遇到的“坑”与解决方案
问题:训练初期不稳定,loss震荡大。
- 排查:首先检查连续特征是否做了标准化。其次,检查元嵌入
ME的初始化。代码中使用的是torch.randn * 0.02,这是一个较小的随机初始化。如果问题依旧,可以尝试使用Xavier或Kaiming初始化。 - 解决:尝试调高softmax的温度系数
T(如设为2.0),让初始权重分布更均匀,降低梯度方差。也可以在第一个训练周期使用较大的T,然后逐渐退火到1.0。
- 排查:首先检查连续特征是否做了标准化。其次,检查元嵌入
问题:模型效果提升不明显,甚至略低于精心调参的硬分桶。
- 排查:AutoDis的优势在于自动化和对连续性的保持。如果你的硬分桶是业务专家经过大量实验得出的“黄金分桶”,那么它本身已经编码了很强的先验知识。AutoDis需要从数据中重新学习这个结构。
- 解决:给模型更多的时间和数据。AutoDis的潜力在于其灵活性,可能需要在更大的数据集上训练更长时间才能超越强先验的硬分桶。也可以尝试用硬分桶的边界信息来初始化相关性网络(例如,让MLP初始输出在对应桶的权重更高),进行“热启动”。
问题:线上推理延迟增加。
- 分析:相比硬分桶(一次查表),AutoDis需要做一次小型MLP的前向计算(几层全连接)和一次加权求和。这会增加一些计算开销。
- 优化:这个MLP非常小(输入1维,输出H维,H通常<=20),其计算开销在现代CPU/GPU上几乎可以忽略不计,尤其是在批量推理时。如果确实成为瓶颈,可以考虑将MLP的计算合并或查找表化,但绝大多数场景下无需担心。
5. 效果对比与业务场景适配性分析
为了更直观地感受AutoDis的价值,我们可以在一个公开数据集(如Criteo Display Ads)上,设计一个对比实验。我们构建三个结构相同的DNN模型,唯一区别在于处理连续特征的方式:
- 模型A(基准):使用等频分桶(比如100个桶),然后接Embedding层。
- 模型B(改进):使用对数变换后等频分桶。
- 模型C(AutoDis):使用AutoDis层,
H=10,d=32,输入为标准化后的连续值。
在相同的训练集、验证集划分下,使用相同的优化器、学习率和迭代轮数进行训练。我们关注两个核心指标:验证集AUC(衡量排序能力)和模型收敛速度(达到稳定AUC所需的epoch数)。
在我的复现实验中,通常观察到以下模式:
- 初期:模型B(带手工变换的硬分桶)可能因为先验知识强而领先。
- 中期:模型C(AutoDis)开始快速追赶,因为它的“软学习”机制能更精细地捕捉特征信息。
- 后期:模型C的AUC往往会持平或略微超越模型B(0.5%到2%的AUC提升是常见的),并且收敛曲线更平滑稳定。模型A(简单分桶)通常表现最差。
业务场景适配性分析:
AutoDis并非银弹,它在以下场景中优势最为明显:
- 特征价值密度高,需要精细建模:如金融风控中的用户收入、信贷评分;广告系统中的实时出价(bid);内容推荐中的视频完播率。这些特征的微小差异可能带来业务指标的显著变化,AutoDis的连续性保持能力至关重要。
- 缺乏先验知识或分桶规则复杂:对于新兴业务或难以定义清晰阈值的新特征(如某种复杂的用户行为指数),人工设计分桶规则成本高、效果差。AutoDis的“自动学习”特性可以节省大量特征工程时间。
- 线上特征分布动态变化:如果特征的分布随着时间漂移(如人均消费水平逐年上涨),硬分桶的边界可能需要定期调整。AutoDis的MLP具有一定的外推和自适应能力,对分布变化的鲁棒性更强。
而在以下场景,简单的硬分桶可能更合适:
- 特征本身具有明确的业务分段:如“用户等级”(青铜、白银、黄金),这本身就是离散的,无需AutoDis。
- 极度追求线上推理效率:虽然AutoDis开销很小,但在某些超低延迟(<1ms)的极端场景,一次查表仍比一次微型MLP计算更有优势。
- 数据量非常小:AutoDis相比硬分桶有更多的参数(
H*d + MLP参数)。在数据量不足时,可能更容易过拟合。此时,强先验的硬分桶作为正则化手段,可能效果更好。
总而言之,AutoDis为我们处理深度学习中的连续特征提供了一个强大、优雅且自动化的新选择。它将我们从繁琐且不优雅的“调桶”工作中解放出来,把如何从连续值中学习有效表征这个任务,交给了模型本身。在实际项目中,我建议可以将它作为处理连续特征的默认方案进行尝试,并与精心优化的硬分桶方案进行A/B测试,让数据来决定最终的选择。它的出现,无疑是特征工程迈向自动化、智能化方向上的坚实一步。