Thompson采样原理与实践:推荐系统冷启动与EE难题的经典解法 做过推荐算法的朋友应该都有这种经历离线AUC涨了0.3线上AB一开新用户、新物料冷启动照样垮。模型再强遇到那些从未出现在训练样本里的东西本质上是瞎子。我在不少项目里反复试过之后结论越来越明确推荐算法要处理的不只是拟合用户兴趣还得处理如何在信息不足时做决策。Thompson汤普森采样就是解决这类问题最经典、也最被低估的武器之一。这篇文章我从原理讲到代码再到真实业务里的落地姿势和那些坑争取一次说透。适合正在做推荐、搜索、广告算法的朋友也适合准备面试时想把Bandit算法讲明白的同学。1. 为什么离线AUC涨了冷启动照样翻车1.1 推荐系统的EE问题探索与利用的日常博弈推荐系统本质上是一个持续和用户交互的决策系统。用户看到内容点还是不点这个反馈既在评估推荐质量又变成下一轮模型训练的数据。问题就出在这个闭环上如果一个策略只敢推荐算法认为最优的内容那系统很快就被困在局部最优里。举个最常见的例子。信息流里上线了一批新文章没有历史点击数据。ID类特征没有学到Embedding模型只能给一个随机预估。你按pCTR排序这批新文章几乎永远排不出去。没有曝光就没有点击没有点击就没有训练样本没有样本Embedding就永远学不对。这就是典型的探索与利用Exploration and ExploitationEE困境也叫冷启动死循环。深度学习解决的是利用部分——在已有样本里找到最优映射。但探索部分通常被忽略或者只靠给新内容加个随机初始分来解决效果很差。Thompson采样就是专门补这个缺口的。1.2 多臂老虎机的形式化描述学术界把EE问题抽象成一个经典模型多臂老虎机Multi-Armed Bandit。假设你面前有N台老虎机每台机器吐钱的概率未知你每轮只能拉一台目标是让总收入最大化。映射到推荐系统里每台老虎机就是一个臂可以是一个新物料、一个策略组合、一个广告素材甚至是一个排序模型。每一轮推荐就是一次拉杆用户点击就是收益Reward。但问题是你怎么知道哪个臂更好只能通过反复试验来估计。试验得越少估计越不确定试验得越多估计越准但试错成本也越高。这里有个关键概念叫遗憾Regret如果你的最优臂每轮期望收益是0.5但你选了一个期望只有0.2的臂这轮就产生了0.3的遗憾。整个策略的累计遗憾越低说明探索越高效。1.3 Thompson采样在这个谜题里的位置Thompson采样最早是1933年提出的至今快一百年了但在推荐算法里依然是个非常能打的方案。它属于随机化策略核心思路是为每个臂维护一个收益概率的后验分布每一轮从分布里采样一个数然后选最大的那个。它不像epsilon-greedy那样用固定概率瞎试也不像UCB那样每次都算一个上界然后选确定性的值。它把不确定性本身变成了决策依据。这个思路在推荐场景里特别有用因为推荐系统面对的信号噪声大、环境非平稳、反馈链路长一个过于确定性的策略往往容易死板而一个完全随机的策略又太浪费流量。Thompson采样更适合作为决策层的EE模块插在粗排、精排之后或者代替原本的确定性排序。它能帮系统回答一个问题在当前信息下哪个选择最值得再试一次。2. Thompson采样的贝叶斯内核用Beta分布给不确定建模2.1 把点击率当成随机变量而不只是点估计传统模型给一个物料的pCTR预估是0.02这只是一个点估计。但0.02这个数字背后有两种截然不同的情况一种是看了1000次点击了20次估计很可靠另一种是只看过10次点击了0.2次如果可能的话估计完全是噪声。点估计把这俩混为一谈Thompson采样不会。TS的视角是每个臂的真实收益概率不是一个固定但未知的数而是我们头脑中一个随着证据变化的概率分布。初始时什么都不知道分布很平很宽看的样本越多分布就越尖集中到一个值附近。这个视角和推荐系统的冷启动场景天然契合。新物料没样本分布极宽采样出来可能很高也可能很低——这就给了它被探索的机会但又不会因为它某一次被选中后表现平平就立刻放弃它。2.2 Beta先验与后验更新共轭带来的便利Thompson采样里最常用的分布是Beta分布原因是数学上有个共轭先验的便利性质。Beta分布由两个参数决定α和β。可以通俗地理解为伪成功次数和伪失败次数。Beta(1, 1)就是均匀分布表示完全不确定。期望值公式是期望 α / (α β)如果某臂被选中了10次其中3次点击7次未点击那这个臂的后验就是Beta(13, 17) Beta(4, 8)期望约等于0.333。这个数字直观反映了点击率。为什么Beta分布舒服因为伯努利收益点击/未点击的似然是二项分布Beta分布是它的共轭先验。意思是说先验用Beta更新之后的后验还是Beta只是参数变了。这意味着线上服务时更新极便宜就是两个整数加一的操作不需要重新训练任何模型。更新规则也很简单被选中并产生点击α ← α 1被选中但未点击β ← β 1没有学习率没有复杂的梯度就是加法。2.3 三个步骤采样、执行、更新整个Thompson采样的线上流程可以用三个步骤说清采样对每个臂j从当前后验分布Beta(α_j, β_j)中采样一个随机数θ_j。执行选择所有臂中θ_j最大的那个把对应的内容/策略推给用户。更新观察用户反馈。点击则α_j 1未点击则β_j 1。这三个步骤每轮推荐都执行一遍。整个过程没有人工设置的探索概率探索强度完全由后验分布的宽度自动控制。一个臂越不确定采样值的波动范围越大就越有机会被选中一旦被多次验证表现差后验分布整体左移采样值几乎不可能超过别的臂探索自然就停了。这个自动控制探索强度的特性是TS区别于几乎所有启发式策略的核心优势。2.4 概率匹配TS不自私的原理学术界把TS背后的逻辑叫概率匹配。每轮选择臂j的概率等于臂j真的是当前所有臂中最优的那个的后验概率。说白了就是谁最有可能是最优就以相应概率试谁。什么时候会选一个当前均值看起来不是最高的臂当这个臂的估计方差特别大时。举个例子臂A只被试过1次且点击了后验Beta(2,1)期望0.667臂B被试过100次点击率0.05后验Beta(6,96)期望0.059。如果只看期望A远高于B。但A只观察了一次它的后验分布还很宽所以每次采样值可能非常高也可能很低。TS会时不时选中它但这正是理性决策者该做的事——样本量太小你不能断言A就是最好的。一句话总结TS把没见过变成了值得试但又不会永远给已经证明不行的东西机会。这个平衡是推荐系统真正需要的东西。3. 和UCB、epsilon-greedy放在同一张桌子上比较3.1 评估口径累计遗憾是试金石要对比策略好不好光看探索得顺不顺没有量化标准实操中大家一般看累计遗憾。模拟环境里假设每个臂有真实的未知收益概率p_j最优臂是p*。每轮策略选了臂j拿到奖励x_t那一轮的遗憾就是p* - p_j。累计遗憾就是所有轮次遗憾之和。我在本地做了个标准的5臂伯努利老虎机实验真实概率设为[0.1, 0.2, 0.3, 0.4, 0.5]一共跑10000轮每种策略独立重复多次取平均。代码后面会给先看结论累计遗憾越低说明策略越早锁定最优臂浪费的试错越少。3.2 三张牌的底牌对比三类策略代表三种完全不同的探索哲学。epsilon-greedy最简单——每轮以ε概率随机选一个臂否则选当前均值最高的臂。它的问题是固定概率探索太无脑探索时不区分臂的潜力已经确定很差的臂和刚出现的新臂获得完全相同的机会。UCB1置信上界是确定性策略每轮给每个臂算一个上界值当前均值加上一个探索奖励项奖励项和臂被选次数相关被选得越少bonus越大。公式是μ_j sqrt(2 * log(t) / n_j)其中t是全局轮次n_j是臂j被选的次数。Thompson采样则随机地从每个臂的后验分布采样然后选最大值。对比下来各自的性格差异很明显策略探索机制是否随机探索衰减速度代码复杂度适合场景epsilon-greedy固定概率随机选是永不衰减极低快速验证、流量便宜UCB1确定性置信上界否对数衰减低臂数少、环境平稳Thompson采样贝叶斯后验采样是自动收敛、每臂独立衰减低冷启动、非平稳、延迟反馈我实测下来的感觉是epsilon-greedy如果ε设大了浪费的流量比例是恒定的一万轮之后遗憾还在稳定增加UCB和TS都能快速收敛到最优臂但UCB在臂数变多、噪声变大时固定公式的bonus项有时候会显得偏保守。3.3 非平稳环境里TS的隐式自适应能力真实推荐系统几乎没有平稳环境。用户兴趣在漂移商品库在变化一个当前表现最好的臂可能三周后就变差了。TS在这种环境下有个隐式优势。因为每个臂的后验分布只依赖它自己的历史反馈一旦一个臂最近表现变差失败次数持续累积β变大后验分布自然右移到低值区间采样值很难再超过别人。而UCB的bonus项和全局轮次t挂钩已经被大量验证过的老臂其历史累积会把均值撑住变化反应慢半拍。当然你可以给UCB加滑动窗口但那又是额外参数了。另外TS的随机性本身对线上系统也是个缓冲。一个严格的确定性策略容易被用户感知到同质化TS每次从分布里抽数同样的输入可能得到不同的输出这天然带来多样性不需要额外再套打散逻辑。4. 从公式到能跑的代码复现一个Thompson采样器4.1 代码结构怎么设计写实验代码时我习惯把策略类统一成两个接口select() 负责选臂update(arm, reward) 负责用反馈更新内部状态。这样模拟器可以无差别地跑任何策略。这里用numpy就够不需要深度学习框架。关键是np.random.beta(a, b)这个函数它每次调用都会从Beta分布里抽一个样本。4.2 核心代码采样与更新逻辑import numpy as np class ThompsonSampling: def __init__(self, n_arms, alpha1.0, beta1.0): self.n_arms n_arms self.alpha np.full(n_arms, alpha) self.beta np.full(n_arms, beta) self.trials np.zeros(n_arms) self.success np.zeros(n_arms) def select(self): # 从每个臂的后验Beta分布采样一个theta选最大的 samples np.random.beta(self.alpha, self.beta) return int(np.argmax(samples)) def update(self, arm, reward): self.trials[arm] 1 self.success[arm] reward if reward 1: self.alpha[arm] 1.0 else: self.beta[arm] 1.0核心逻辑就这么多。select里std的细节是np.random.beta返回的是一个数组长度等于臂数分别从各臂的分布采样。np.argmax取最大下标。update就是前面讲的加法更新。注意一点Beta参数α和β必须是正数而且在你还没获得任何反馈时Beta(1,1)均匀分布已经能保证每个臂都被抽到这比UCB里对未试臂用无穷大bonus的写法要干净。4.3 模拟实验5个臂10000轮3个策略我把epsilon-greedy、UCB1和TS放到同一个模拟器里跑了一遍。class EpsilonGreedy: def __init__(self, n_arms, epsilon0.1): self.n_arms n_arms self.epsilon epsilon self.trials np.zeros(n_arms) self.success np.zeros(n_arms) def select(self): if np.random.rand() self.epsilon: return int(np.random.randint(self.n_arms)) means self.success / np.maximum(self.trials, 1) return int(np.argmax(means)) def update(self, arm, reward): self.trials[arm] 1 self.success[arm] reward class UCB1: def __init__(self, n_arms): self.n_arms n_arms self.trials np.zeros(n_arms) self.success np.zeros(n_arms) self.t 0 def select(self): self.t 1 values np.zeros(self.n_arms) for arm in range(self.n_arms): if self.trials[arm] 0: values[arm] float(inf) else: mu self.success[arm] / self.trials[arm] bonus np.sqrt(2.0 * np.log(self.t) / self.trials[arm]) values[arm] mu bonus return int(np.argmax(values)) def update(self, arm, reward): self.trials[arm] 1 self.success[arm] reward def simulate(true_prob, strategy, rounds10000): n_arms len(true_prob) best_arm int(np.argmax(true_prob)) total_regret 0.0 for _ in range(rounds): arm strategy.select() reward np.random.binomial(1, true_prob[arm]) regret true_prob[best_arm] - true_prob[arm] total_regret regret strategy.update(arm, reward) return total_regret true_prob np.array([0.1, 0.2, 0.3, 0.4, 0.5]) for strategy in [ EpsilonGreedy(n_arms5, epsilon0.1), UCB1(n_arms5), ThompsonSampling(n_arms5) ]: regrets [simulate(true_prob, type(strategy)(n_arms5, **({ epsilon: 0.1 } if isinstance(strategy, EpsilonGreedy) else {}))) for _ in range(50)] print(type(strategy).__name__, np.mean(regrets))跑出来的结果大致是这个量级具体数值随随机种子浮动但排序关系稳定策略10000轮累计遗憾均值epsilon-greedyε0.1约 900UCB1约 600Thompson采样约 5004.4 实验结论应该怎么读这个结果说明两件事。第一epsilon-greedy因为用10%的流量做纯随机探索遗憾是线性增长的轮数越长落后越多。第二TS和UCB的差距在10000轮时没有拉开到悬殊但如果把臂数增大到几十个、或者有些臂收益方差特别大时TS的优势会更明显——因为它会把更多的探索机会分配到更不确定的臂上而不是按固定公式统一补贴。我的理解是UCB是在每个臂都值得尊重的假设下工作TS是在不确定性越大的臂越值得试错的假设下工作。推荐系统里新老物料的不确定性差异极大后者的假设更贴近现实。5. 推荐系统里的落地姿势不只是跑通Demo5.1 场景一新物料冷启动这是TS应用最舒服的场景。我在内容推荐项目里做冷启动时把新发布24小时内的内容单独放进一个候选池池子里每篇内容就是一个臂初始Beta(1,1)。每次用户请求从池里选1篇内容插入到信息流。关键细节是不要永远让TS管理这些内容。设置一个毕业标准曝光次数达到某个阈值比如50或200后内容从TS池挪到常规排序池由主模型接管。这个设计避免了一个内容在TS池里反复被探索但主模型完全没见过它的样本。实测下来这个方案比单纯给新内容加随机初始化分数有效得多。原因在于随机分数没有记忆——下一轮又是一次新的随机而TS有后验分布的累积它会自动记住这个新内容之前试了10次表现不佳先少给点流量也不会因为某次运气差就完全封杀一个潜力内容。5.2 场景二与深度学习模型结合的探索层很多团队不可能为了做EE就把主排序模型换掉这时可以把TS作为一个旁路探索层叠加在模型之上。常见做法有两种。第一种是探索位替换主模型排出Top K结果后把第K个位置替换成从TS池里采样的结果。它的优点是对主流程影响小、代价可控缺点是探索位永远在尾部刚开始探索速度偏慢。第二种是分数融合把TS的采样值θ_j和主模型的pCTR融合成最终排序分final_score pCTR lambda * (theta_j - pCTR_mean)lambda控制探索强度业务初期可以设大一点观察大盘CTR和水位之后逐步调小。这种做法的好处是新物料有一定的机会冲到中高位冷启动更快坏处是如果lambda太大容易把主模型的排序效果带崩必须配合流量监控。5.3 场景三Contextual TS的实用变体纯TS假设每个臂的收益概率固定且独立但真实推荐里新用户在A类内容上的点击率和B类内容完全不同。想要引入上下文信息直接上完整版的Bayesian Logistic Regression是可行的但工程复杂度高。工程上更划算的方案是分桶独立TS——按用户画像或场景维度分桶每个桶内部独立维护TS参数。比如新用户桶、老用户桶、品类偏好A桶、品类偏好B桶。每个桶里跑一个独立的Beta分布组开销就是多存几组α和β完全可控。如果嫌分桶粒度太粗或者太细都不合适还有一个中间做法特征哈希桶。把用户和Item的特征拼接后哈希到K个桶每个桶跑独立TS。这个思路实现简单但引入了新的超参K需要你根据自身业务流量权衡。5.4 流量预算与业务约束怎么加直接裸跑TS存在流量风险。TS理论上会不断挑最优臂但如果某个臂因为某个阶段的优异表现被过度集中曝光会带来两个问题一是用户审美疲劳这个内容本身的质量可能只是阶段性的二是头部物料集中度过高生态不健康。实际落地时我至少会加两个约束单臂流量封顶单个臂的曝光占比不能超过本策略总流量的20%超过后强制转给次优采样值。探索请求比例控制比如全流量中只有10%的请求走TS探索通道其余90%完全走主排序这样即便探索策略激进对大盘的冲击也被限制住了。这两条规则可以用配置中心动态调整上线初期把探索比例放低跑几天看清楚大盘各项指标后再放宽。6. 工程化阶段我踩过的坑6.1 Beta先验参数不是随便填的很多TS教程默认Beta(1,1)起步这个在纯学术实验里没问题但真实业务里会翻车。原因是Beta(1,1)的期望是0.5意味着把一个完全没有历史数据的物品预估成50%的点击率。这远远高于绝大多数推荐场景的真实点击率可能只有1%-5%。TS会在探索初期给新臂很大的采样值导致新物料突然获得大量曝光但如果真实质量一般紧接着的大规模负面反馈会把后验迅速拉低造成流量波动。更稳的做法是均值对齐初始化。假设你经验上知道新物料平均点击率大概在1%设定虚拟样本量pseudo count为100alpha 0.01 * 100 1 beta 0.99 * 100 99这样Beta(1, 99)的期望正是0.01开局对大盘影响小。虚拟样本量越大后验更新越慢探索越保守越小则探索越激进。想要快速发现爆款就调小一点想要稳就调大一点。6.2 延迟奖励带来的点击率高估推荐系统里有个特别容易忽略的细节曝光和点击之间有时间差。用户看到一条推荐内容可能隔了半小时才点甚至过了一天才点。如果日志实现不严谨你可能会在已曝光但未点击时就把β加1等延迟点击来了又加α。这就相当于一次曝光既算失败又算成功后验参数被污染。我在项目里的做法是先只记录曝光时间点和臂ID进入归因窗口比如30分钟到24小时按业务节奏定窗口结束后再统一更新α和β。窗口期内这批曝光对策略来说就是悬置状态先不参与后验更新TS的随机性会帮系统扛过这一小段不确定性。6.3 别用离线AUC评估TS这是项目合作里反复出现的一个认知冲突。TS是一种在线决策策略它的目标是最大化累计收益而不是对已有日志的拟合能力。用历史日志做离线评估时很多TS做出的探索动作在日志里根本不存在评估无从谈起硬评估出来的数字也没有意义。正确做法是小流量在线AB观察周期可以短到3-5天核心指标除了大盘CTR、GMV之外还要单独看新物料冷启动成功率比如曝光后24小时内获得点击的物料占比和头部流量集中度。这两个指标比AUC更能反映探索策略的价值。6.4 一次线上事故探索位放错了位置最后分享一个我自己经历过的反面案例。有一版内容推荐上线了TS探索策略连续两天大盘点击率掉了百分之十几紧急回滚后排查原因探索位固定在了信息流第2位。第2位是全屏位置里用户注意力最高的用户对这里的内容期待很高。TS在探索早期给了很多低质量新内容这个位置体验伤害非常大。后来我把探索位挪到第4位之后并且单次请求最多只允许一个TS探索候选。同样一批新内容冷启动速度确实变慢了一点但大盘指标稳住了。这个教训告诉我在真实推荐产品里探索策略放在哪个位置、占多少比例往往比探索算法本身的选择更影响最终效果。我个人现在做EE方案时第一件事已经不是选算法而是和产品经理一起画清楚哪些位置可以承受探索代价哪些位置绝对不行。在这个约束下Thompson采样作为核心探索引擎效果和稳定性都会好很多。如果你正准备在自己的推荐系统里加探索能力我的建议是从这三个旋钮开始调探索位的位置、Beta先验均值、探索请求占比。先把这三个参数跑稳了再去调更复杂的变体。