ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

推荐系统特征编码实战:类别、数值与序列特征全解析

2026/9/8 15:16:08 拓冰建站 浏览量
推荐系统特征编码实战:类别、数值与序列特征全解析 不知道你有没有遇到过这种情况模型结构换了好几种超参也调了一轮又一轮离线指标就是上不去。前面排查了一遍数据管道最后发现问题的根源不是模型而是喂进模型的特征编码方式出了问题。特征编码是推荐系统里最基础、也最容易被轻视的一个环节。推荐系统的基础链路通常是“数据 - 特征 - 模型 - 排序”特征编码就发生在“数据 - 特征”这一步。它的核心任务是把原始日志、用户画像、物品属性这些非结构化的业务数据转换成模型能直接消费的数值向量。原始数据里大量是“菜名”“口味”“食材”“浏览序列”这类文本或者ID模型不认识这些只认识数字。编码方式不同模型看到的信息结构就完全不同——是保持类别独立还是表达相似性是保留原始量纲还是按区间建模非线性全靠编码这一步来决定。这篇文章我从三类最常见的特征类别特征、数值特征、序列特征出发分别讲清楚每种编码方式的原理、适用场景和工程落地时的坑最后用一个“养生食谱推荐系统”的完整案例把整个过程串起来。适合刚入门的推荐算法工程师也适合那些已经在跑模型但效果总差口气、怀疑是特征问题的同学参考。1. 特征编码为什么是推荐系统的“隐形胜负手”1.1 模型只认识数字但业务数据是另一套语言推荐系统拿到的原始数据长什么样我们看几条典型的“养生食谱”场景日志用户IDU123456浏览序列[“红枣枸杞汤”, “银耳莲子羹”, “山药排骨汤”]食谱标签“温补”, “润肺”, “甜口”用户年龄32最近一次购买距今45天这些字段里有字符串、有列表、有整数各自表达的业务含义完全不同。模型尤其是深度学习模型的输入必须是固定维度的数值张量所以我们需要一套规则把这些数据翻译成数字向量。这个翻译过程就是特征编码。这一步看着简单实际上它对模型能力的上限有决定性影响。一个很直观的说法是特征编码决定了模型能“看到什么”模型结构决定了模型能“学到什么”。如果编码阶段丢了信息后面网络结构再复杂也补不回来。1.2 不同的编码方式给模型传递不同的先验知识为什么说编码方式会影响模型学习效果原因在于不同的编码会隐式地告诉模型“这些特征之间是什么关系”。以类别特征为例One-Hot编码隐含的假设是所有类别之间相互独立没有相似性。比如“温补”和“滋补”是两个完全独立的维度模型无法从“温补”的样本中学到什么信息迁移到“滋补”上。Embedding编码隐含的假设是类别之间存在可学习的相似性。“温补”和“滋补”如果经常出现在相似的食谱上它们的Embedding向量就会在训练过程中逐渐靠近。Hash编码介于两者之间它把类别随机映射到固定维度空间如果两个类别的Hash值落到同一个桶里它们就共享同一个特征维度在某种程度上产生隐式的特征共享。数值特征也一样直接喂原始数值模型只能学到线性关系。体重80公斤和40公斤的用户对推荐的影响被简化为“翻倍”的关系。做分桶编码后每桶对应一个独立维度模型可以自由学习每个区间的独立权重——这等于给了模型拟合非线性的能力。所以特征编码从来不是简单的格式转换本质上是你在替模型做了一部分特征工程。你的先验知识、业务理解全部藏在这些编码规则里。1.3 特征编码的三个核心评判标准我在实际项目里判断一种编码方式好不好通常看三个方面保序性原始特征中带顺序含义的信息编码后不能被破坏。比如用户的会员等级、食材的热量等级这些是有序的编码后模型应该仍然能感知到大小关系。保相似性语义上相近的类别编码后距离应该更近。对于推荐系统来说这一点尤其重要因为推荐本质上就是“找相似”。稀疏度可控编码后的特征不能过度稀疏否则梯度传播效率会大幅下降模型训练慢还容易过拟合。后面讲到的每种编码方法都可以用这三条标准去衡量它是否适合你的场景。2. 类别特征编码从One-Hot到Hash再到Embedding的选型逻辑类别特征是推荐系统中最常见的一类特征。用户ID、物品ID、类目、标签、城市、渠道全是类别特征。这一节我把四种主流编码方式拆开讲清楚包括它们各自的适用边界。2.1 One-Hot与Multi-Hot最直白但稀疏性隐患不小One-Hot编码的逻辑很简单假设一共有N个类别构造一个N维向量当前样本属于第i个类别时第i维置1其他维度置0。Multi-Hot是One-Hot在多值场景下的推广。比如一个食谱打了多个标签“温补”、“润肺”就把这些标签对应的位置全部置1。import pandas as pd def one_hot_encode(values, categories): 最简单的一版One-Hot编码实现实际项目一般直接用sklearn vector [0] * len(categories) for v in values: if v in categories: vector[categories.index(v)] 1 return vector这个方案的优点是简单、可解释性极强、上线调试非常方便。但问题也很突出第一维度爆炸。假设食谱库里有一万个食材ID每个食材一维这一万维里绝大部分是0。训练时梯度只会在非0维度上更新其他维度完全闲着计算和存储都被浪费。第二特征之间完全割裂。One-Hot编码的每个类别是独立的超平面方向模型无法知道“山药”和“淮山”其实是同一个东西很多用户在菜谱里混着叫。你必须在编码之前自己做好别名归并和层级归一让One-Hot才能正常工作。所以One-Hot真正适合的场景是类别基数小记不太住一般少于几百、类别之间关系不重要的特征。比如“性别”、“渠道来源”、“是否会员”这类。一旦类别基数上万我必须给你后面的方案。2.2 Hash编码工程上的维数灾解药代价是冲突Hash编码也叫Hashing Trick思路很巧妙用一个Hash函数把字符串类别映射到一个固定大小的整数区间。比如设定桶数为10000用MurmurHash3算出“山药排骨汤”的哈希值再对10000取模得到它落在第几个桶这个桶的维度就置1。import mmh3 def hash_encode(value, num_buckets): # MurmurHash3是工业界最常用的hash函数之一分布均匀且速度快 return mmh3.hash(value, signedFalse) % num_buckets这样一来不管原始类别有多少个输出维度固定。Embedding层可以直接建一个[num_buckets, embed_dim]的向量表训练成本可控工程实现简单。但Hash编码有一个必须直面的问题哈希冲突。两个不同的类别可能映射到同一个桶。这到底是好事还是坏事争论很多。从我实际踩坑的经验来看一定程度的冲突反而是件好事。假设“红枣”和“枸杞”经常出现在同一个养生食谱场景里如果它们被哈希到同一个桶模型就会隐式地把这两个特征做共享某种程度上自动完成了“相似性学习”。这也是为什么很多工业级推荐系统会把Hash当成Embedding的降维前置方案来用。但冲突率太高的风险在于某个频繁出现的类别把桶占满了其他被映射到同一桶的类别完全学不到自己的表征。实操中的解法是桶数设置要足够大。经验值一般是类别基数预估的2到4倍保证冲突率在10%以内。对高频类别单独做白名单白名单内的值不参与Hash直接分配独立Embedding维度。对极低频类别出现次数小于阈值直接统一归入一个UNKNOWN桶避免Hash噪声。2.3 低频类别清洗优先于编码这是很多项目最容易忽视的一环。类别的长尾分布非常严重——大量的类别只出现了一两次这些低频类别无论用Hash还是Embedding都学不到可靠表征。我的标准做法是先做频次统计再做编码决策频次区间处理方式原因频次 100保留独立编码样本量足够学到稳定表征10 频次 100保留但做Embedding时降低学习率防止噪声样本干扰频次 10归入UNKNOWN学不稳定且容易引入噪声从未出现在训练集编码为UNKNOWN新类别冷启动必须走这个分支这个阈值的设定要结合样本量动态调整。如果全集有1亿样本频次100只是起步线如果只有10万样本那频次阈值可以放宽到5。2.4 Embedding编码当类别基数大到需要“学习相似性”One-Hot的维度太高Hash的随机映射不可控那就让模型自己学一个低维稠密向量。这就是Embedding。Embedding本质上就是一个查找表先给每个类别分配一个编号然后定义一张[类别数, 维度]的矩阵训练过程中这个矩阵和其他网络参数一起被梯度更新。Embedding的适用前提是类别有足够样本量去支撑向量学习而且类别之间确实存在相似性结构。用户ID、物品ID这类高基数特征用Embedding几乎是标配。但也别一上来就无脑Embedding它有两个需要你注意的工程点Embedding维度怎么定经验公式是min(100, ceil(log2(类别数)))起步再按效果上调。类别数10万设64维就是一个比较稳的起点。Embedding表很大怎么存储推荐系统全量物品ID动辄千万级一张百万行乘128维的Embedding表光存储就好几个GB。常规解法是借助Hash Embedding先用Hash把千万类别压到百万桶再做Embedding然后用辅助Hash把少数冲突列单独分开。class HashEmbedding(nn.Module): def __init__(self, num_buckets, embed_dim): super().__init__() self.embedding nn.Embedding(num_buckets, embed_dim) def forward(self, raw_feature): # 先用hash压缩再查表 buckets raw_feature % self.embedding.num_embeddings return self.embedding(buckets)分享一个我的筛选心得当类别基数小于1万且业务上“类别间相似性”不是核心关注点的时候用Hash比Embedding更省事当类别基数大、且“相似的类别应该靠近”这件事是模型的关键能力时直接上Embedding。3. 数值特征编码归一化只是及格线分桶才是真正的杀器类别特征讲完了接下来是数值特征。数值特征看起来最“省心”——因为本来就是数字直接喂进去不就行了吗实际没那么简单。3.1 为什么直接喂原始数值经常效果不好直接喂原始数值有两个隐患量纲问题和线性假设问题。量纲问题很好理解。“年龄32”和“最近购买距今天数45”不在一个量级但更麻烦的其实是“最近观看时长3600秒”和“点击次数2”这种差距悬殊的特征。模型在梯度下降时量纲大的特征会主导损失函数的梯度量纲小的特征学习被压制。线性假设问题更隐蔽。用户的购买意愿和“距上次购买天数”之间大概率不是一个线性关系——刚买完1天和买完30天的人意愿通常差距很大但买完30天和买完31天的人差距几乎可以忽略。如果你直接把天数作为原始数值喂给模型模型只能学到一个全局系数w这个w要么被第1天的用户带偏要么被第30天的用户带偏。3.2 分桶给模型一把非线性的钥匙分桶的本质是把连续值离散化成有限个区间每个区间对应一个One-Hot或Embedding维度。这样模型就能为每个区间学习独立的权重等于隐式地拥有了拟合非线性关系的能力。三种主流分桶方式等宽分桶按数值范围均匀切分。比如年龄0-100每10岁一桶。实现最简单但数据分布不均匀时很多桶是空的。等频分桶按分位数切分每个桶的样本量大致相等。这个方法对抗长尾分布非常有效。卡方分桶ChiMerge基于目标变量做有监督分桶把“对目标影响相似”的区间合并。效果最好但实现复杂度高且只在训练集上做。import pandas as pd # 等频分桶按分位数切成5桶 ser pd.Series([1, 2, 3, 4, 5, 6, 7, 8, 9, 1000]) pd.qcut(ser, q5, labelsFalse, duplicatesdrop) # 输出: 0 0 0 1 1 2 2 3 3 4我个人的实践经验是先把特征分布画出来如果分布接近正态或者均匀等宽就行如果长尾严重优先等频如果有明确的业务阈值比如会员等级T1/T2的消费分界线以业务阈值为准先手工切分再结合分位数调整边界。3.3 归一化与标准化的线上线下一一致性坑分桶并不排斥归一化两者经常一起用。归一化就是把数值压到[0,1]区间Min-Max或者缩放到均值0方差1Z-Score。这里有一个非常常见的坑很多人用全量数据的均值和标准差做归一化模型离线效果很好上线后线上预测值全部偏掉。原因在于统计量本身存在“数据穿越”归一化时使用的均值来自全体数据训练集中每一条样本都“偷看”了全局分布信息。更严重的是线上的实时数据分布会随时间漂移训练集的均值和上线后的实际均值对不上编码结果就系统性偏移。正确做法是这样的只用训练集的统计量做归一化验证集和测试集都复用同一个统计量。把训练集的均值、标准差、Min、Max固化成一个独立的统计文件随模型一起发布上线。上线后持续监控线上特征分布如果均值偏移超过阈值就要考虑重新训练或在训练集中加入近期数据。# 正确做法只在训练集上fit然后对全部分区应用同一个scaler from sklearn.preprocessing import StandardScaler scaler StandardScaler() train_norm scaler.fit_transform(train[[feature]]) val_norm scaler.transform(val[[feature]]) test_norm scaler.transform(test[[feature]])3.4 数值缺失的处理顺序先分析缺失原因再选填充方式很多项目一遇到缺失值就“均值填充”或者“0填充”这其实是个懒办法。我踩过坑后的处理顺序是先查缺失比例。缺失率超过70%的特征直接考虑删除或者加一个“是否缺失”的独立标志位。再查缺失机制。如果是“无购买行为导致的缺失”你填充0反而是对的因为它有业务含义如果是“数据上报链路断了导致的缺失”0填充就是在引入脏数据。最后选择填充值。数值型的一般用中位数而不是均值因为中位数对异常值鲁棒。填充完之后强烈建议额外加一列“was_missing”标志位让模型自己学会缺失时的默认行为。4. 序列特征编码用户行为的“顺序”里藏着下一单推荐系统里还有一类特征地位特殊用户行为序列。这类特征编码的复杂度比前两类高很多因为要处理的不是一个点而是一串行为事件。4.1 序列特征的本质一张用户兴趣快照行为序列的一般表现形式是用户在某个时间窗口内交互过的物品ID列表比如behavior_seq [10234, 10891, 10234, 20567, 30912] # 食谱ID列表这个序列用最朴素的方式记录了用户兴趣的变化轨迹。用户在推荐系统里留下的每一次点击、收藏、下单都是兴趣信号而它们的顺序则记录了兴趣转移的过程。序列编码的核心任务就是把这串ID变成模型能消费的固定维度特征向量同时尽量保住顺序信息和上下文关系。4.2 序列长度不一致截断、补齐与掩码用户的交互序列长度天然不同新用户可能只有1条行为重度用户可能有几百条行为。但模型的输入维度是固定的这就需要截断和补齐。截断策略有一个需要你想清楚的问题保留最近的还是保留最久的我建议保留最近的N条因为推荐场景中用户的近期兴趣往往比长期兴趣对下一单的影响更大。序列长度N如何确定先统计一下线上用户序列长度的分布取P80或者P90长度作为截断上限不是拍脑袋定。补齐策略一般用0作为padding值。注意这里有个容易被坑的点padding出来的0和其他真实物品ID混在一起模型会误以为“0”也是一个真实的物品。所以序列输入必须配套一个mask让模型知道哪些位置是真实行为哪些是padding出来的。# 序列截断 padding同时输出mask def pad_sequence(seq, max_len): if len(seq) max_len: seq seq[-max_len:] # 保留最近的 mask [1] * len(seq) seq seq [0] * (max_len - len(seq)) # 0是padding位 mask mask [0] * (max_len - len(mask)) return seq, mask4.3 序列Embedding化从平均池化到注意力加权有了截断、补齐和mask之后序列可以做Embedding化。最朴素的做法是把序列里每个物品ID查表得到Embedding然后做平均池化得到一条定长的用户兴趣向量。平均池化有个潜在问题序列里的行为权重被平均了。“看了三秒就划走”和“反复看了十遍”是同一个权重。更进阶的做法是引入注意力机制让模型自己学习每个行为的重要性。我在实际项目里发现一个规律序列嵌入的收益在某些场景特别明显但有些场景用了反而跌点。一个典型的反例是“新手引导类推荐”中用户序列极短平均序列长度只有2-3这个时候学出来的序列嵌入非常不稳定单纯用用户画像特征反而更稳。所以如果你的场景用户行为稀疏不要强行上序列模型。4.4 序列特征里的“时间衰减”细节除了物品ID序列本身我一直会加一个时间衰减因子。原理很简单离当前越远的行为参考价值越低。具体落地时用不着复杂的模型一个指数衰减系数就够了decay exp(-alpha * (current_time - event_time))把这个系数乘到每个行为的Embedding上再做池化就能让模型“更关心最近的行为”。alpha的取值一般参考业务节奏对生鲜/食品这类快消品alpha可以设置得大一点比如0.01让近3天的行为主导兴趣对耐用消费品alpha要小得多用户三个月前的偏好仍然有效。5. 完整实战养生食谱推荐系统的特征编码落地前面四节把三类特征的编码原理讲完了这里我用一个“养生食谱推荐系统”的案例把整套编码流程串一遍方便你完整落地。这个场景的好处是特征类型齐全有画像、有内容属性、有用户行为序列覆盖了推荐系统的大部分典型字段。5.1 业务场景与数据概况场景设定一个面向中青年用户的养生食谱App首页需要给每个用户推荐可能感兴趣的热菜谱。候选池有约5万道食谱每道食谱有标题、食材、功效标签、口味类别、制作难度、预计耗时等属性用户侧有性别、年龄段、常驻地区、健康偏好行为侧有点击、收藏、下单长短期序列。这个场景的特征编码选型直接决定推荐效果的上限。下面是我最终的选型表特征名特征类型编码方式选型理由食谱ID高基数类别Embedding(128维)5万基数需要表达食谱间相似性食材列表多值类别Multi-Hot 窄Embedding食材之间天然相关如“山药”和“淮山”功效标签多值类别Hash(1024桶)标签总量约3000Hash降维后够用用户ID高基数类别Embedding(64维)用户量百万级必须Embedding性别/年龄段低基数类别One-Hot类别少One-Hot简单可解释偏好标签多值类别Hash 频次过滤用户自定义标签长尾严重预计耗时数值等频分桶(5桶)耗时与偏好非线性等频防长尾热量值数值卡方分桶有目标监督分桶边界更有效点击序列序列Embedding 时间衰减反映实时兴趣变化距今N天活跃度数值Z-Score归一化连续值线性关系合理5.2 编码实现过程中的三个典型坑这套编码在一次实际落地中踩过的坑我记录一下都是可以直接避免的典型错误。坑一食谱的“食材列表”和“功效标签”直接拼接成一个Multi-Hot导致模型无法区分“红枣”是食材还是功效来源。解决办法是把两类特征分别编码后再拼接而不是简单把ID合并。坑二用户ID的Embedding在冷启动场景下严重过拟合。新用户只有1-2条行为记录学出来的Embedding基本是噪声。我的处理方式是对于行为数少于10的用户直接不参与ID Embedding训练用一个统一默认向量代替等行为积累够了再动态切换。# 冷启动用户模型内做embedding替换 def get_user_embedding(user_id, behavior_count, embedding_layer): if behavior_count 10: return DEFAULT_USER_VECTOR # 全局统一冷启动向量 return embedding_layer(user_id)坑三点击序列里存在大量“无效短点击”。用户点开食谱详情页不足3秒就退出这种行为的兴趣信号极其微弱。我做序列清洗时的规则是过滤掉停留时长小于3秒的点击对同一天内对同一道食谱的重复点击只保留最近一次。这一步清洗之后序列特征带来的离线AUC提升明显更稳定。5.3 离线评估对比不同编码方案的差异这套编码做完以后我用同一份训练集和同一个DeepFM模型对比了三组特征编码方案的离线效果。结果很能说明问题方案特征组合离线AUC说明方案A原始数值 One-Hot直接拼接0.712基线效果稀疏且无量纲统一方案B数值分桶 类别Hash0.739离散化带来明显提升方案C完整方案Embedding 分桶 序列编码0.768序列特征和Embedding贡献最大提升可以看到只是把特征编码从方案A升级到方案BAUC就有接近3个点的提升——这一步完全不需要改模型结构。方案C的额外提升主要来自序列特征和ID类特征的Embedding化。6. 上线之前必须自查的5个特征编码细节线上效果经常和离线评估对不上原因很多都出在“线下编码逻辑”和“线上编码逻辑”不一致。我整理了一份自查清单每次上线前按着过一遍。6.1 训练集与线上预测的编码一致性这是第一号杀手。训练时很多新手会用全部数据一起fit编码器然后切分训练集测试集线上预测时单独加载一个模型编码逻辑完全另起炉灶。这就直接导致了离线很好、线上崩盘。正确的做法是编码器的参数Hash桶数、分桶边界、归一化统计量、Embedding映射表全部在训练阶段固化序列化成一个独立的“特征编码配置”文件发布。线上服务加载这个配置用同一套规则做编码。训练和预测用到的每一个类别的映射关系都应该能在同一个配置里找到。6.2 编码表的版本管理与回滚特征编码配置是有生命周期的业务新增类目后Hash桶的统计边界会变化Embedding表会随模型重训而更新。所以编码配置也要和模型一样做版本管理。我的做法是把“编码配置版本号”作为模型产物的一部分和模型参数一起打包。每次重训生成一个新版本线上同时保留上一版本一旦新版本出现指标异常随时可以回滚到旧版本。如果编码逻辑和模型参数分开管理回滚时还要同时处理两份产物出问题的概率会大很多。6.3 特征穿越用未来信息编码当前样本特征穿越是指样本的某个特征里面包含了它不该看到的信息。最常见的情况是用全量数据的统计量做归一化或分桶边界训练集里每一条样本都“偷看”了全局分布包括未来数据的分布。这一点在第3节已经重点讲过。另一个容易忽略的场景是序列特征用“用户整个周期的行为”做序列而不是用“截至当前样本发生时刻之前的行为”。这在离线构造训练样本时极其常见看似的确用了用户行为实际上混入了未来信息。必须按时间切分构造训练样本时只取该样本发生时间之前的行为这个检查一定要做。6.4 空值处理要分特征类型不要全量“填0”不同特征的空值含义完全不同。用户“未登录”导致ID为空和一个合法ID不存在对业务的影响是不同的。我的建议是每一类特征单独制定空值方案并增加“是否缺失”标志位。在代码层面空值可以在特征签名里明确标记防止线上和线下走了不同的分支。6.5 监控特征分布漂移而不是只盯线上AUC特征编码上线后不是一劳永逸的。用户行为习惯在变食谱内容池在更新任何变化都会导致线上特征分布和训练集的分布逐渐偏移。比如新上了几十道“秋冬润燥”食谱它们的功效标签高频出现导致某些Hash桶的点击率发生明显变化。我现在的做法是每天对Top特征做分布监控和训练集基线对比。如果某个分桶的样本占比偏移超过阈值或者归一化后的均值漂移超过3倍标准差就会触发告警。告警之后人工判断是重新训练还是调整规则。我自己在实际项目里养成了一个习惯每次拿到一个新数据集先花半天把特征分布画一遍做频次统计和缺失分析再开始动手搭模型。特征编码的部分看起来不“高级”但它往往才是决定模型最终效果的那块短板。希望这篇整理能帮你少走一些我走过的弯路。