一次我到一座南方城市出差,晚上九点落地,打开某在线旅行平台的App想找口热汤。首页推荐清一色的连锁火锅和网红甜品店,评分都不低,可我那天嗓子发炎,只想吃一碗清淡的汤面。反复翻了三页,愣是没找到一家合适的。那一刻我突然意识到:这个以酒店、机票见长的平台,美食频道本质上还是“分高就往前排”的全局热门逻辑,榜单背后没有“个人”这个概念。
后来我们项目组花了小半年时间,基于深度学习做了一个个性化美食数据推荐系统,把美食频道信息流从“千篇一律的热门榜”改成“千人千面的动态排序”。项目内部代号叫“觅食V2”,标题里那串d7fq1jtw只是第一次打包上传的版本号,随手写的,没想到一直留了下来。
这篇内容不会只讲模型结构,我会把为什么做、数据怎么准备、模型怎么分工、冷启动怎么做、上线评估怎么搞,以及落地时踩过的几个大坑都拆开聊。适合的人群大概是三类:想入门推荐系统但不想只看论文的开发者,正在做搜索/信息流排序的工程师,以及那些被“离线指标很高、线上纹丝不动”折磨过的算法同学。
1. 旅行者在陌生城市找吃的,为什么热门榜不够用
1.1 一次出差吃饭踩坑引出的项目动机
我开头那个例子不是段子,是真实的项目触发点。平台美食频道的旧版推荐逻辑很简单:统计每个商家的星级评分、评论数、收藏数,按一个加权的热门分从高到低排。这个东西在“旅游打卡”场景下不算全错,因为第一次来一座城市的人确实需要知道“大家都去哪吃”。但它有两个天生的盲区。
第一个盲区是没有状态。用户是刚落地饿了一天,还是已经吃过正餐想来份甜品;是带着小孩,还是跟朋友聚会;当天是暴雨天还是大晴天——这些信息旧版排序一概不感知。第二个盲区是没有差异。两个口味完全相反的用户,打开同一个城市的美食页,看到的前10家店一模一样。这放在电商场景里是不可想象的,但出行美食场景里大家都习惯了。
我们拉了一组数据验证这个问题:把同一时间内、同一个城市页面的推荐结果按用户分桶做相似度分析,发现旧版首页前10条推荐的平均重合度超过85%。换句话说,所谓“推荐”其实是个榜单。这也直接给了我们立项的理由——不是要做一个更复杂的排序,而是要做一个真正能区分人的推荐系统。
1.2 出行美食推荐的三个技术难点:跨域、稀疏、强时效
立项之后我们做的第一件事不是选模型,而是把问题拆开。出行美食推荐跟电商推荐最大的区别,在于它同时踩中了三个难点。
第一个难点是跨域。一个用户的稳定偏好是在常住城市形成的,但推荐行为发生在目的地城市。他在家爱吃粤菜,不代表出差到一座川菜城市就该继续推粤菜——这时候他可能恰恰想试试本地菜。跨域迁移不是简单地把用户向量搬过来,而是要理解“用户在当前城市的探索意愿”和“对熟悉口味的依赖程度”。
第二个难点是稀疏。用户可能一整年只在某个城市有过两三次美食浏览记录,本地生活App上积累的行为远不如电商丰富。加上美食决策周期极短,用户在App里从打开到下单往往不超过十分钟,能用来学习的有效行为窗口非常窄。
第三个难点是强时效。商家是否在营业、距离远不远、排队情况、时段是早餐还是夜宵,这些因素对美食选择的影响有时比口味偏好更大。晚上十一点想吃夜宵的人,不会因为一家店评分高就愿意走两公里。这些动态信号必须进入模型决策,而不是只做后置过滤。
这三个难点决定了后续所有的技术选型:数据侧要做多模态融合,模型侧要召回和精排分工,线上侧必须保证实时特征可用。我们后面每一步,其实都是围绕这三个难点展开的。
2. 数据侧的关键设计:特征划分与行为序列处理
2.1 结构化特征、文本化口味、图片氛围的三路输入
很多人做推荐系统一上来就建模,但我们的经验是先把数据当成一个多模态问题来设计。一个美食商家身上有三类信息值得利用。
第一类是结构化特征。菜系、人均价格、评分、评论数、经纬度、所在城市/商圈,这些像电商里的商品属性一样是基础骨架。注意这里有个很多人会犯的错:人均价格和评分要分位数归一化而不是直接用原始值,否则高价位商家的向量会被价格主导。
第二类是文本化的口味特征。评论区的自由文本是真正的金矿,用户会写“番茄汤头浓郁”“辣得跳脚”“清淡适合带老人来”。我们把这些评论按商家聚合,先分词抽关键词,再用预训练语言模型做句向量编码,最后压缩成64维的口味向量。这比单纯用菜系标签要细得多——同样是火锅店,川渝火锅和潮汕牛肉火锅的用户群完全不一样。
第三类是图片氛围特征。门店环境图、菜单图、菜品图里隐含了“适合约会”“适合聚餐”“快餐性质”这类感受性信息,文本未必写得出。我们用了图像分类模型提取特征后压缩到64维,跟文本向量做同样的下游处理。
三路特征在模型里会各自承担不同任务,这个后面说。这里只想强调一个设计原则:不要试图把所有信息都揉成一个向量,分模态保持独立性,方便后面做注意力加权。
2.2 用户行为序列的截断、加权与跨城市隔离
用户在美食频道的行为基本是三类:曝光、点击、下单(或者收藏)。我们要把这些行为按时间组织成序列,用来刻画“最近想吃什么”的瞬时偏好。
具体做法上有一个关键参数:序列长度。我们实验了10、30、50三档,最后定在最近30条行为。太短表达不了口味演化,太长会把几周前甚至几个月的偏好当噪声带进来。每条行为不是简单地存一个商家ID,而是把当时的行为类型、距当前时间、所在城市一起编码进去。行为类型加权很讲究:下单行为权重最高,收藏次之,点击最弱。时间衰减用指数形式,半衰期设为30天。
这里最容易被忽略的是跨城市隔离。我们在训练初期没有做隔离,把用户在家乡的所有行为一股脑塞进序列,结果发现模型严重偏向用户常住城市的菜系——出差用户到了目的地,推的全是他平时吃的东西,探索性为零。后来改成“近期目的地城市行为优先,常住城市行为只作为泛化背景特征”,效果立刻不一样了。这个细节直接对应前面说的跨域难点。
2.3 美食评论的分词与口味标签抽取
评论区文本的处理,我们前后迭代了三版。第一版直接用预训练模型对整个商家评论池做句向量平均,向量表示有了,但解释性很差,而且会被高频词主导。第二版加了一套口味词典,把“清淡、辣、微辣、偏甜、重油、锅气、排队、上菜快”这类词做成标签体系,用分词结果做关键词匹配,得到一个口味分布向量。
最终线上用的是两路拼接:一边是文本语义向量(64维),一边是口味标签分布(约30维)。为什么要保留语义向量?因为口味词典覆盖不了一切,比如“这家店让我想起小时候外婆做的面”这种句子对标签体系没有贡献,但对语义向量是有价值的。两边互补,实验下来比任何单一路线都稳。
抽取流程里还有个经验:中文分词要小心“不”字带来的反义,比如“不是很辣”直接抽“辣”就是反的。我们最后在标签聚合时加了否定判断,对“不辣、微辣、不是很辣”做了区分处理。这种小细节对美食场景尤其重要,因为用户对辣度的表达非常敏感。
3. 模型架构:双塔召回与精排融合的实际分工
3.1 召回阶段:双塔结构、向量检索与多路召回
用过深度学习做推荐的都知道,候选商家不是几十个,而是几十万个,精排模型不可能直接吃全量。所以我们把整个系统拆成“召回+精排”两段,每一段负责不同的目标。
召回阶段用的是经典双塔结构。用户塔输入用户ID、行为序列池化向量、当前城市编码、时段编码,输出128维用户向量;商家塔输入商家ID、菜系、人均、口味向量、图片向量、商圈编码,输出同样128维商家向量。训练目标不是单纯的点击,而是同时预测曝光->点击、点击->收藏、点击->下单三个行为,共享底层Embedding,多目标加权得到最终得分。
双塔结构训练好之后,把所有商家的128维向量灌进ANN索引库,线上拿到用户向量后毫秒级召回几千个候选。这里我特别想提醒:双塔召回阶段不要盲目堆太深的网络。两个塔一旦变深,向量空间容易被某些高维特征带偏,召回率反而下降。我们最终两个塔都只用了两层MLP,效果远好于一开始的四层版本。
除了双塔主召回,我们还保留了规则召回作为兜底:用户当前定位500米内的营业中商家、用户历史点击过的同菜系商家、以及城市热门榜前50。这样做的目的是防止双塔推荐的“探索性不足”把一些本来应该出现的候选漏掉。多路召回的分数先做归一化再合并,避免某一路分数天然偏高吃掉其他路。
3.2 精排阶段:DeepFM加多模态注意力的融合方式
召回给出的候选集合可能是几百上千条,精排要做的就是从里面挑出最合适的二三十条。这个阶段我们放弃了纯向量内积的打分方式,改用更细粒度的特征交叉模型。
特征方面,我们把用户向量、商家向量、用户行为序列的最近偏好向量、距离分桶、人均是否在偏好区间、营业状态、时段、天气、商圈消费水平全部拼接起来,过DeepFM。为什么选DeepFM?因为它用FM层显式建模二阶特征交叉,又用Deep层捕捉高阶非线性关系,对高维稀疏特征非常友好。相比纯深度学习模型,DeepFM在特征数量有限的场景下更不容易过拟合,而且训练快、调参成本低。
多模态融合上,文本向量和图片向量没有简单拼接,而是加了一层注意力机制。注意力的key是当前请求的上下文向量(由城市、时段、天气、浏览意图编码而来),query是文本/图片特征本身。这样在下雨天,模型会自动调高“室内环境好”这类图片特征的权重;在夜宵时段,又会更看重“口味标签”中的路边摊气质。第一次跑通这个注意力层的时候,我能明显感觉到精排结果在场景上“讲道理”了。
精排的输出是点击概率和下单概率两个头,最终排序分按如下方式融合:
final_score = ctr_prob * pow(cvr_prob, alpha) # alpha 是下单行为的权重系数,冷启动期间设低一点,促进探索;稳定期设高一点,促进转化alpha这个系数很关键。我们一开始固定为1.0,结果系统过度倾向推低价高频商家,因为下单概率高但体验未必好。后来改成按用户当日浏览深度动态调整:用户翻页越深,alpha越大,代表他确实有明确消费意图,这时候下单权重可以提高。
3.3 这套架构在离线评估中的表现
模型搭完第一步永远是对基线。我们跟旧版热门榜和一套手工特征加LR做了对照,离线指标上提升是明显的:AUC从0.86提到0.889,GAUC从0.62提到0.68,NDCG@10提升了8个点左右。这里想给个清醒的判断:离线指标的提升幅度,不代表线上体感就有这么明显。AUC是全局排序能力,GAUC是按用户分组的平均,NDCG@10看头部排序质量。
三套模型在离线表现得分的差异,很大程度上来自特征设计的改进,而不是模型结构本身。DeepFM相对LR的优势只贡献了大概四分之一,更多收益来自行为序列池化、文本口味向量和场景注意力这些“数据侧”的改进。做推荐系统的人必须接受一个事实:多数时候,数据决定上限,模型只是逼近上限的方式。
4. 新用户、新店、新城市:冷启动与时空动态怎么处理
4.1 新用户冷启动:注册画像加探索反馈
出行美食场景里新用户比例比电商高得多——很多人到了新城市才会装旅游类App,或者直接用平台搜餐厅。冷启动不做,推荐系统就是给老用户的玩具。
我们的方案分三层。第一层是注册画像,在用户首次进入美食频道时采集口味偏好、忌口信息和常居城市,形成一个粗糙的先验向量。第二层是人群相似映射,把常居城市+年龄+消费档位相近的老用户向量做平均,作为新用户启动向量。第三层是快速反馈更新,用户只要有几次浏览行为,就立刻用贝叶斯方式更新偏好分布的参数。
这里有一个工程上的东西值得说:不能等到离线任务跑完再更新用户向量,要做一个轻量的在线版本。我们给每个新用户维护一个本地偏好缓存,每次请求后实时更新,三天内的冷启动阶段完全走这个在线缓存,等用户行为攒够了才切换到正式模型向量。这样冷启动收敛速度从原来平均30次曝光缩短到8次左右。
4.2 新店冷启动:影子特征与试探流量
新店是另一个难啃的骨头。一家刚上线几天的店,没有点击历史,按正常模型打分永远排不过老店,但这不代表它不值得推荐。我们的做法是给新店构造影子特征再做试探流量。
影子特征的具体思路是:把同一商圈、同菜系、同人均价位段的老店群体做一个聚合统计,把平均评分、平均点击率、平均下单率这些值复制给新店作为初始特征。相当于让新店“继承”一群相似老店的先验表现。试探流量则是给新店一个固定的探索配额,让它们有机会曝光给部分用户,等积累了真实反馈之后逐步替换掉影子特征。
新店策略的成败在于试探流量怎么释放。我们一开始直接随机展示,结果点击率被拉得很低。后来改成“相似偏好定向试探”:把影子特征和用户历史偏好做匹配,只把新店展示给口味、价位都匹配的用户,既收集反馈,又不伤害体验。这个改动让新店的七日留存率提升了将近两倍。
4.3 距离、时段、天气的动态权重设计
出行场景里,位置和时间的影响比口味更直接。一个人不可能为了吃一碗评分高0.2分的面多走一公里。所以我们在精排里单独做了一套时空动态加权。
距离不能直接用原值,我们做了分桶处理:300米内、1公里内、3公里内、3公里以上几个桶,再把距离分桶和时段组合成交叉特征。早餐桶的接受距离最小,正餐桶稍大,夜宵桶介于两者之间。模型能学到“深夜时段用户更愿意接受500米内的高分店,而不是2公里外的网红店”这类规律。
天气因素我们也接入了。数据源不复杂,就是每个城市的天气API,但效果比想象中明显。下雨天系统会更倾向于推距离近、室内环境好、有线上线下排队信息的店;大热天则会降低火锅、烤肉这类重口味商家的权重。有一次后台看到某城市下暴雨,系统把原本排在20名开外的一家500米内的砂锅粥推到了第2位,当天该店的下单转化率是全城排名第一——那一刻你会觉得推荐系统是真的在替用户考虑问题。
5. 模型训练、评估与上线的完整链路
5.1 样本采样、正负比例与多目标损失
推荐系统的训练样本不是天然存在的,构造方式直接决定模型学到的目标是什么。我们的样本以曝光日志为骨架,用户看到了什么、点击了什么、点击后有没有下单,按时间顺序拼接成一条样本。正样本就是有点击或有下单的行为,负样本是曝光但未点击的商家。
负采样这里必须多说两句。直接拿曝光未点击当负样本没错,但由于热门商家曝光多,它们被标记为负样本的机会也更多,模型容易因此压制所有热门商家。我们的解决办法是在全量负样本中混合一部分随机负样本和一部分“热门但用户未点击”的负样本,比例大致10:1。随机负样本让模型学习基础区分能力,曝光负样本让模型学习用户真实的反馈差异。
损失函数是多目标交叉熵:点击损失和下单损失之间按0.7:0.3加权。同时我们还加了一个辅助的pairwise损失,目标是让用户真正下单的商家分数比同时曝光但未点击的商家分数高出一个margin。多目标加pairwise的组合,比单纯用AUC导向的损失更贴近真实业务目标。
5.2 GAUC、NDCG这些离线指标的局限
离线指标不能只看一个AUC,这是个老生常谈的问题,但具体到出行美食场景,它还有特殊含义。AUC衡量的是“正样本分数高于负样本”的概率,本质上是全局排序能力。可是推荐系统是按用户请求来组织列表的,用户只关心自己看到的这几十条。所以我们加了GAUC——按用户请求分组计算AUC再平均,它更贴近“每个用户自己的排序效果”。
NDCG@10则用来衡量头部位置的准确性,因为用户真正会点开看的主要是前两屏。但这里也有陷阱:如果某家店确实不错,但用户上周已经连续三天看到它了,NDCG再高也代表体验差。所以我们在离线评估指标之外,还专门计算了一个“重复曝光率”和“推荐多样性”。这两个不是排序指标的组成部分,但它们是用户满意度的守门员。
5.3 AB实验与人工回归的评估流程
线下再准都要过线上AB。我们的分流方案是10%实验组、10%对照组,剩余流量正常走旧版。核心观察指标是点击率、收藏率、下单率,但还有两个容易被忽略的负向指标:首屏无点击率和快速跳出率。如果用户第一屏全滑完一个都没点,或者点完立刻退出回到首页,说明推荐结果跟预期明显不符合。
人工回归也保留了,每天固定抽一批case,让运营和产品同学直接看推荐列表是不是合理。人工智能跑得好不好,最终还要过“人觉得像话不像话”这一关。我们曾经有一次模型分数全面上涨,但人工回归发现推荐结果里连续七个商家都是整条街同一菜系的店,多样性崩了,这个就是离线指标看不出来的问题。
6. 落地过程踩过的坑与修复记录
6.1 特征穿越导致离线指标虚高:完整排查链路
这个坑值得单独写一整节,因为排查过程很有代表性。项目上线第一版时,离线评估结果非常漂亮:AUC 0.88,GAUC 0.68,比基线涨了一大截。但上了线上AB,点击率几乎没有任何变化,焦虑了整整一周。
排查的第一步是对比训练样本和线上实时输入的特征分布。我们用分位数统计逐字段检查,发现一个叫“当日累计曝光次数”的特征分布差得离谱:训练样本里这个字段的值普遍偏高,但线上实时请求里这个值在一天前几个小时内就是很小的数。
第二步回溯特征工程代码,发现问题出在特征定义上。离线训练时,我们从T+1报表里取了当天的累计曝光数;但线上实时计算时,这个值只能取自请求时刻为止的累计数。一个取的是“全天最终结果”,一个取的是“实时过程值”,分布天然不一致。模型在训练时学到的规律是“当天曝光次数多=受欢迎”,可实时阶段根本没有这个完整信息,当然失效了。
第三步修正:把离线特征改成T-1天的历史累计值,同时把线上实时累计值分桶编码,让两端的口径尽量对齐。重新训练后离线AUC降到0.81,看似变差了,但上线AB之后点击率反而提升了2.3%。这个案例我之后每次分享都会提:离线指标异常高的时候,先怀疑特征穿越,而不是急着加模型复杂度。
6.2 经纬度直接入模带来的记忆偏差
第二个坑是坐标特征直接进模型。我们最初把用户当前位置的经纬度、商家的经纬度作为连续特征放入精排模型,结果出现了很诡异的case:同一家连锁店,用户在不同分店之间收到的排序分差异巨大,甚至有些店只在特定位置附近才被推荐。
排查后发现,模型并没有学到“位置远近”这个通用规律,而是把某些经纬度值本身记住了。几十万维的稀疏经纬度编码会让神经网络产生位置记忆偏差,一旦用户移动到新地点,推荐立刻失真。
修复方案是把原始经纬度替换成三层构造特征:一是距离分桶,二是用户所在区域聚类ID,三是商家所在商圈ID。区域聚类ID和商圈ID做Embedding入模,距离分桶继续保留连续特征。这样模型既能学到“用户更愿意去最近的商圈”,又不会死记硬背具体坐标。修复之后,换城出差的推荐效果明显改善,AUC虽然只提高了1个多点,但线上差评投诉量下降了三分之一。
6.3 从全量日更到小时级增量的改造
最后一个坑是模型更新节奏。第一版模型每天凌晨全量训练一次,每小时上线一次快照。这在很多推荐场景里够了,但出行美食不行。中午十二点、下午六点、晚上十一点的用户意图完全不同,而且商家的营业状态、排队情况也在实时变化。模型一天只更新一次,就像一个人只看了早报就决定全天吃什么,太迟钝。
我们改成增量训练架构:全量训练仍然每天跑,但每过一个小时,用最近一小时的增量样本对模型做几百步微调,再把更新后的Embedding和DeepFM参数热更新到线上。由于增量步数少,模型不会剧烈漂移,但能快速感知到当天天气异常、某家店突然爆火这类新信息。
这个改造给系统带来最直接的变化是召回结果的“新鲜度”上来了。用户下午四点搜晚餐,系统已经能感知到傍晚会下雨,并把室内店权重拉高;本来要等到第二天才能学到的规律,现在两个小时内就能生效。工程上多了一个小时级流水线,听起来简单,但把特征一致性、训练稳定性、上线回滚机制都重做了一遍。
项目做完之后再回想,我最大的感受是:这套系统里没有特别前沿的模型,双塔、DeepFM、注意力机制都是工业界验证过很多年的组件。真正花掉大量时间的,是特征口径的对齐、冷启动策略的打磨、上线链路的稳定性这些不入论文的“脏活”。但恰恰是这些脏活,决定了用户打开App时看到的第一屏推荐像不像话。指标会骗人,模型会过拟合,只有把细节抠到底,系统才算真正在替用户的口味考虑。