news 2026/9/30 6:29:29

UCB1算法:多臂老虎机探索-利用、遗憾界与Python实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
UCB1算法:多臂老虎机探索-利用、遗憾界与Python实战

多臂老虎机(Multi-Armed Bandit,MAB)这个名字听着挺唬人,底子却是一个朴素到不能再朴素的场景:赌场大厅里摆着一排老虎机,每台机器中奖的概率你事先一无所知,兜里的硬币就那么几枚,怎么安排拉杆顺序才能让最后的收益尽量高。UCB1(Upper Confidence Bound, version 1)就是解决这类问题最经典、最干净的一个算法,公式短到能写在一张便利贴上,背后却压着 Hoeffding 不等式和一套相当漂亮的遗憾界证明。

如果你正在做推荐系统冷启动、广告素材轮换、A/B 测试的流量分配,或者在做超参搜索、临床试验分组,甚至只是想给游戏 AI 写一个会自己试错的决策模块,UCB1 基本都绕不开。它的好处是几乎零调参——显式超参数只有一个常数系数,剩下的全靠数据自己算。我从遗憾的定义开始,把 UCB1 的公式从头推一遍,然后手写一份能直接跑的 Python 实现,跑仿真看它比 ε-贪心好多少,最后聊聊实际落地时踩过的那些坑。

1. 先把问题框清楚:老虎机到底在优化什么

1.1 赌场原型与探索-利用的两难

先把符号定下来。假设有 K 根臂(老虎机),第 i 根臂每次被拉动,会以一个未知的固定分布吐出一个奖励,记它的期望是 μ_i。整个过程跑 T 轮,每一轮你只能选一根臂,拉完看到本次的实际奖励 r。目标很直白:让这 T 轮的奖励总和尽量大。

难点在于两件事同时发生。一是你不知道 μ_i 是多少,只能靠拉出来的样本去估计;二是你的估计本身要靠拉杆来获得,而拉杆又消耗了本该花在"已知好臂"上的预算。这就是所谓的探索-利用困境(exploration-exploitation tradeoff)。

拿推荐场景打比方:你手里有 200 条候选内容,第一天只能给用户推一条,推完看到用户点没点。你想知道哪条最受欢迎,就必须每条都试;但用户的时间是有限的,全拿去试,短期点击率就崩了。你要在多试和少试之间找一个动态平衡点。这个平衡点不是拍脑袋定的,它有一个数学上的最优节奏,而 UCB1 给的就是一个足够接近这个节奏、实现还特别简单的方案。

值得注意的是,老虎机问题和普通的监督学习有个本质区别:你拿到的数据不是随机分配的,而是被你的策略挑出来的。这意味着你收集到的样本天然带偏,越到后期越集中在少数几根好臂上,对这种偏置的估计必须靠额外的机制来纠正。UCB1 的 bonus 项,本质上就是那个纠正机制。

1.2 别盯着单次收益,盯"遗憾"

评价一个 bandit 策略,业界不太看总收益,而看遗憾(Regret)。定义很直接:假如你从第一轮就知道哪根臂最好,并且一直拉它,能得到的总收益是 T·μ*,其中 μ* = max_i μ_i;实际策略只拿到 Σ μ_{a_t},两者之差就是累积遗憾:

R(T) = T·μ* − Σ_{t=1}^{T} μ_{a_t}

为什么用遗憾而不用总收益?因为总收益的绝对值和具体问题绑定得太死——一个 μ* = 0.9 的问题和一个 μ* = 0.05 的问题,收益数字差几十倍,没法横向比较算法。遗憾把"最优解"当作基准线扣掉,剩下的部分纯粹反映你的策略有多浪费,这才是算法本身的能力。

遗憾还可以拆成两块。如果第 t 轮选了臂 i,这一轮的即时遗憾是 μ* − μ_i,记 Δ_i = μ* − μ_i,叫做这根臂的"间隙"(gap)。累积遗憾就是 Σ Δ_{a_t}。你希望这个量增长得越慢越好。如果策略是次优的,它会以线性速度增长,也就是平均每轮都亏一个固定量;如果策略是好的,遗憾只会以对数甚至更慢的速度增长,摊到每轮上趋近于零。

这个线性还是对数的区别,在工程上不是学术问题。T = 100 万次曝光,对数增长的遗憾可能只有几千,线性增长的遗憾就是几十万,直接对应真金白银的转化损失。所以看一个 bandit 算法值不值得用,第一眼就该看它的遗憾是 O(ln T) 还是 O(T)。

1.3 贪心和 ε-贪心为什么不够好

最直接的想法是纯贪心:每一轮都选当前平均收益最高的臂。这个策略的问题太致命了——只要某根臂运气好,前几次拉出来几个高奖励,它的均值就会短暂飙高,然后被无限拉下去,永远没有翻身的机会。哪怕它其实是全场最差的臂,只要样本量够小,它就能把自己"伪装"成最优解。这种早期随机性导致的锁死现象,是贪心策略的经典翻车方式。

稍微改一下就是 ε-贪心:以 1−ε 的概率选当前最优臂,以 ε 的概率随机挑一根。它能逃出锁死,但引入了新的浪费——那 ε 的探索是盲目的,均匀撒在所有臂上,包括早已确认很差的那些。你已经知道第 7 根臂成功率只有 2%,却还要按固定比例继续给它流量,这就是纯粹的浪费。

更麻烦的是 ε 怎么定。ε 设大了长期遗憾下不来,设小了早期探索不够、锁死风险还在。有人用 ε 随时间衰减,比如 ε_t = 1/t,效果会好很多,但衰减曲线又成了一个需要自己调的超参。我在早期做广告轮换时就吃过这个亏:为了保守把 ε 定在 0.1,跑了一个月后发现大约 10% 的曝光永远浪费在垫底素材上,白扔的量相当可观。

UCB1 的思路是彻底换一个角度:不再固定探索预算,而是让探索量由"不确定性"自动决定。一根臂没被拉过几次,它的不确定性就大,bonus 就高,自然会被优先探索;拉得多了,bonus 缩小,探索自动退火。整个过程不需要你手调衰减率,bonus 的数值直接从置信区间的宽度算出来。

2. UCB1 公式是怎么推出来的

2.1 乐观主义:给不确定的东西加成

UCB 的核心哲学叫"面对不确定性时保持乐观"(optimism in the face of uncertainty)。具体做法是:对每根臂,不算它的平均收益,而算它的"最乐观可能收益"——也就是在统计意义上,真实均值最高可能有多高。然后挑这个乐观值最大的那根去拉。

这个逻辑其实很好理解。一根已经拉了几千次的臂,它的均值很稳,乐观值和均值几乎一样;一根只拉过两次的臂,均值可能很低,但样本太少,真实均值完全可能高得多,所以它的乐观值可以很高。于是算法会自然地把"有潜力的新人"和"稳定的老人"放在同一个尺度上比较,谁的乐观值大谁上。

这个乐观值的学名就叫上置信界(Upper Confidence Bound)。它等于样本均值加上一个"置信半径":均值是当前最好的猜测,半径是"我可能猜错了多少"的量化。半径越大,说明越不确定,就越值得再试一次。

2.2 Hoeffding 不等式:把"均值有多可信"量化

要让半径有数学依据,得用一个工具:Hoeffding 不等式。先做归一化假设,奖励取值落在 [0, 1] 区间内(不是的话自己缩放,后面会讲)。设臂 i 的真实均值为 μ_i,已经独立拉了 n_i 次,观测到的样本均值为

\hat μ_i = (1/n_i) · Σ_{s=1}^{n_i} r_s

Hoeffding 不等式告诉我们,样本均值高出真实均值超过 ε 的概率有个上界:

P(\hat μ_i − μ_i ≥ ε) ≤ exp(−2 n_i ε²)

这个式子值得多看一眼。它说的是:样本量 n_i 越大,样本均值偏离真值的概率衰减得越快,而且是按 e 的负二次方衰减,非常快。它不要求奖励服从正态分布,只要求有界且独立同分布,这比中心极限定理的适用范围宽得多——伯努利奖励、均匀奖励、有界连续奖励都能用,这一点在工程上特别重要,因为线上的点击奖励就是个 0/1 伯努利变量。

现在把这个不等式反过来用。我不想问"给定 ε 求概率",我想问"给定一个我很小的犯错概率 δ,ε 该取多大"。令 exp(−2 n_i ε²) = δ,两边取对数:

2 n_i ε² = ln(1/δ)

ε = sqrt( ln(1/δ) / (2 n_i) )

这就是置信半径的雏形。你可以看到它的结构:分母是拉杆次数 n_i,次数越多半径越小;分子是对数形式的置信水平,你想要越高的把握,半径就要放得越宽。

2.3 从置信半径到 UCB1 的完整推导

剩下唯一的问题是 δ 取多少。δ 不能是常数,因为整个 T 轮里你会做 T 次判断,如果每次犯错的概率都是固定的 δ,累积起来的总犯错概率会随 T 线性膨胀,遗憾分析就做不出来了。正确做法是让 δ 随轮数 t 一起衰减,并且衰减得足够快,使得对 t 求和后是个收敛的级数。

Auer 等人在 2002 年那篇经典论文里取的方案是 δ_t = t^{−4}。为什么是四次方?因为要对所有轮次做并集界(union bound),需要 Σ_{t=1}^{∞} t^{−4} 收敛。这个级数的极限是 π⁴/90 ≈ 1.0823,是个很小的常数,所以"整个过程中至少出现一次覆盖失败"的总概率被牢牢压在常数级别,遗憾的每一块都能加起来。

把 δ = t^{−4} 代回半径公式,注意 ln(1/δ) = ln(t⁴) = 4 ln t:

ε = sqrt( 4 ln t / (2 n_i) ) = sqrt( 2 ln t / n_i )

于是臂 i 的上置信界就是:

UCB_i(t) = \hat μ_i + sqrt( 2 ln t / n_i )

每一轮,算完所有臂的这个值,选最大的那根:

a_t = argmax_i [ \hat μ_i + sqrt( 2 ln t / n_i ) ]

这就是 UCB1 的完整公式。没有别的了。整个推导里用到的假设只有三条:奖励有界在 [0,1]、各轮奖励独立同分布、臂之间相互独立。三条假设在绝大多数业务场景里都是成立的。

顺手补一个历史背景:Agrawal 在 1995 年提出的早期 UCB 指数里含有一项需要用动态规划算出来的复杂积分,工程上根本没法实时算。Auer 团队用上面这个 sqrt(2 ln t / n) 把它替换掉了,指数变成闭式可算,这才是 UCB1 真正流行起来的原因。"1"这个编号来源于它是那篇论文里提出的 UCB 家族中最简单的第一个版本,后面还有 UCB2 和 UCB-Tuned。

2.4 系数 2 的来历,以及改它会发生什么

经常有人问那个 2 能不能换成别的。能,但你要清楚换掉的是什么。

系数 2 完全来自 ln(t⁴)/2 = 2 ln t。如果你把 δ 从 t^{−4} 改成 t^{−α},半径就变成 sqrt(α ln t / (2 n_i)),α 越大半径越大、探索越激进、遗憾上界里的常数也越大。工程代码里通常把它写成一个显式的系数 c,bonus 项写成 c·sqrt(2 ln t / n_i)。c = 1 是理论值,实际调参时在 0.5 到 2 之间试都算合理。

调 c 的直觉是这样的:如果你的奖励噪声特别大,或者奖励分布偏离假设比较严重(比如重尾),理论半径偏窄,可以适当把 c 调大一点,多给探索一些空间;如果你的奖励很干净、臂之间的差距又很大,喜欢快速收敛,把 c 调小一点能更快锁定最优臂。不过说实话,UCB1 对 c 的敏感度远低于 ε-贪心对 ε 的敏感度,我见过很多线上系统就直接用 c = 1 没动过,效果也够用。

注意:把 c 调到 0.1 以下时,UCB1 实际上退化成了近似贪心策略,早期锁死的风险会回来。这不是理论问题,是我在压测里亲眼见过的事——c 太小的配置在高噪声环境下,前 200 轮就锁死在一根次优臂上,之后再也爬不出来。

2.5 遗憾上界:为什么它值得信赖

光有公式不够,得知道它有多好。Auer 等人证明的 UCB1 期望遗憾上界大致是:

E[R(T)] ≤ 8 · Σ_{i: Δ_i>0} (ln T / Δ_i) + C · Σ_{i: Δ_i>0} Δ_i

其中 C 是个由收敛级数产生的常数(约 4.29 量级)。第一项是对数主项,第二项是与 T 无关的常数项。把它粗略化简一下,就是 O(K · ln T / Δ_min),其中 Δ_min 是最小的那个非零间隙。

这个结论有两层含义。第一,遗憾随 T 只按对数增长,这是 bandit 算法能做到的好结果,因为 Lai-Robbins 下界证明了任何"一致好"的策略遗憾至少是 Ω(ln T),所以 UCB1 在 T 的量级意义上是达到了最优的。第二,它对 Δ_min 非常敏感——如果两根臂几乎一样好,间隙趋近于零,遗憾上界会被放大得很厉害。这不是 UCB1 的缺陷,而是问题本身的性质:两根几乎一样好的臂,你很难区分,多花一些代价是必然的。

不过也要公平地说,UCB1 的常数偏松。Lai-Robbins 下界里最优系数是 1/KL(μ_i, μ*),用 KL 散度度量的;UCB1 的分母是 Δ_i²,对于伯努利分布来说比 KL 大,所以高估了所需样本量。这个差距就是后面 UCB-V、KL-UCB 这些改进版本的动机所在。

3. 手写一份能跑的 UCB1

3.1 接口与数据结构怎么定

真要把 UCB1 写成代码,需要维护的状态其实非常少,每根臂只需要两个数字:被拉的次数 counts[i] 和奖励的累加和 sums[i]。均值不单独存,用的时候现算,这样能避免浮点累积误差——如果你每轮都按 \hat μ ← \hat μ + (r − \hat μ)/n 增量更新,跑个几百万轮下来误差是能被看见的。

每轮决策分两个阶段。第一阶段是冷启动:只要还有臂没被拉过,就按顺序把没拉过的臂各拉一次。这一步是必须的,因为 sqrt(2 ln t / n_i) 在 n_i = 0 时除零,而且从统计上看,不给每根臂至少一个样本,你连它属于哪个数量级都不知道,谈不上比较。第二阶段才是正常的指数计算和 argmax。

有些实现会把冷启动那一步写成"初始化时给每根臂的 counts 填 1、sums 填一个乐观初值",这也行,但我不太推荐。原因是它的行为等价于给每根臂加了一个人为的先验,而这个先验的强度你在后面调参时很容易忘掉,出问题不好排查。老老实实写个冷启动分支,逻辑清楚得多。

3.2 完整实现代码

下面这份实现只有二十来行,我把它写成了一个独立的类,方便你直接贴到 notebook 里跑。

import numpy as np class UCB1: """奖励需归一化到 [0, 1]。c 是探索系数,理论值取 1。""" def __init__(self, n_arms, c=1.0): self.n_arms = int(n_arms) self.c = float(c) self.counts = np.zeros(self.n_arms, dtype=np.int64) self.sums = np.zeros(self.n_arms, dtype=np.float64) @property def total(self): return int(self.counts.sum()) def select(self): # 阶段一:未被拉过的臂各拉一次,顺便避免除零 unseen = np.flatnonzero(self.counts == 0) if unseen.size > 0: return int(unseen[0]) # 阶段二:算上置信界,取最大 avg = self.sums / self.counts bonus = self.c * np.sqrt(2.0 * np.log(self.total) / self.counts) return int(np.argmax(avg + bonus)) def update(self, arm, reward): self.counts[arm] += 1 self.sums[arm] += reward

这段代码里有两个细节容易写错。一个是np.log(self.total),t 用的是全局总轮数而不是该臂的次数,这个很关键——如果误写成 log(counts[i]),bonus 的结构就完全变了,会退化成一种奇怪的自适应采样。另一个是avg + bonus里 avg 必须是浮点,如果你的 sums 用了 int 类型,除出来会被截断,这个坑我在 C++ 版本里踩过。

3.3 仿真跑起来:把参数拧一拧

光看代码没有手感,得跑个数。下面这段仿真是我最常用的验证模板:造一组伯努利臂,跑 T 轮,统计累积遗憾,用多组随机种子取平均消除单次波动。

def run_ucb1(probs, T, seed, c=1.0): rng = np.random.default_rng(seed) K = len(probs) best = max(probs) policy = UCB1(K, c=c) cum_regret = 0.0 for _ in range(T): arm = policy.select() reward = 1.0 if rng.random() < probs[arm] else 0.0 policy.update(arm, reward) cum_regret += best - probs[arm] return cum_regret, policy.counts probs = [0.15, 0.25, 0.35, 0.45, 0.55, 0.65] T = 20000 regrets, all_counts = [], [] for s in range(50): reg, cnt = run_ucb1(probs, T, seed=s) regrets.append(reg) all_counts.append(cnt) print("平均累积遗憾:", np.mean(regrets)) print("各臂平均拉杆次数:", np.mean(all_counts, axis=0).round(0))

跑完之后你应该会看到两个现象。第一个是各臂的拉杆次数极度不均衡——最优那根臂(0.65)大概会吃掉一万五千次以上,最差那根(0.15)可能只有几十次。这个分布形态就是 UCB1 该有的样子:一旦某根臂被确认很差,它的 counts 涨得极慢,bonus 也就涨不起来,从此基本被冷藏。第二个是累积遗憾的绝对数值会落在几百的量级,而不是上千——理论上是几千,但那个上界包含了一个宽松的 8 倍常数,实际表现通常好得多。

再做一组对比实验,把 T 从 2000 拉到 20000,你会看到遗憾只增加了大约一倍出头(ln 20000 / ln 2000 ≈ 9.90 / 7.60 ≈ 1.30),而不是增加十倍。这个对数级的增长曲线,是判断一个 bandit 实现有没有写错的最直接证据。如果你改完代码发现遗憾随 T 线性增长,那基本可以确定 bonus 项写错了,或者 counts 的更新逻辑有问题。

3.4 和 ε-贪心、Thompson 采样横向比一比

单看 UCB1 没意思,得有参照物。我在同一组臂、同一批随机种子上跑了三个策略,把结果整理成了下面这张表。

策略关键机制累积遗憾(相对量级)需要调的超参确定性
纯贪心永远选当前均值最高高,且波动极大无是
ε-贪心(ε=0.1)固定比例随机探索中等,约为 UCB1 的 2 至 3 倍ε否
ε-衰减ε_t = 1/t较低,接近 UCB1衰减曲线否
UCB1均值 + 置信半径低c(通常取 1)是
Thompson 采样从后验采样后取最大通常略优于 UCB1先验参数否

表里的"相对量级"是我多次跑下来得到的经验排序,不同参数下具体数值会变,但大方向很稳。有几点值得展开说。

ε-贪心最大的问题是它的探索是"无记忆"的。跑了十万轮之后,它仍然会以 10% 的概率去拉那根成功率 0.15 的臂,这 10% 的浪费是永久性的、不会随经验减少的。而 UCB1 的 bonus 会随着 counts 增长持续缩小,探索是自动退火的,跑得越久浪费越少。这就是对数遗憾和线性遗憾在实测里的直观差别。

Thompson 采样在伯努利奖励下经常能压 UCB1 一头,因为它是贝叶斯方法,用了完整的后验分布而不只是一个二阶矩的界。但它需要你指定先验(Beta(a, b)),先验选得不合适早期会跑偏,而且它本质上是随机策略,同样的输入跑两次结果不一样,线上排查问题时这一点挺烦人的。UCB1 的确定性在工程上是个被低估的优点:你能精确复现任何一次决策。

提示:做 A/B 对比实验时,务必让三个策略共用同一组随机种子生成的奖励序列。不然你看到的差异里混进了噪声,很容易得出错误结论。我早期对比时忘了这事,结论反复横跳了好几次。

4. 落地踩坑与排查手册

4.1 冷启动、奖励尺度与非平稳

奖励尺度是第一杀手。UCB1 的推导建立在奖励落在 [0,1] 的前提上。如果你直接把 GMV、停留时长、点击转化金额这种原始数值塞进去,均值可能落在几千的区间,而 bonus 项里的 sqrt(2 ln t / n) 最大也就几,两者的量纲差了三个数量级,bonus 完全被均值淹没,算法直接退化成纯贪心。正确的处理方式有两种:把奖励除以一个上界 R 归一化到 [0,1],或者把 bonus 整体乘以 R 保持量纲一致。哪种都行,关键是不能忘。我在一个电商项目里见过有人直接把客单价塞进去,跑了两周发现策略一直只推一个爆款,排查半天才发现是这个量纲问题。

非平稳环境要加窗口。UCB1 的 \hat μ_i 是对全部历史求的平均,counts 也是累积的。一旦环境变了——比如某条素材的热度随季节过去了——老数据会拖住均值,而 counts 又很大导致 bonus 很小,算法几乎没有动力去重新验证。表现就是反应迟钝,眼看着效果掉下去也不调整。解决办法是把累积统计换成滑动窗口(只统计最近 w 次)或者指数加权衰减,这就是 Sliding-Window UCB 和 Discounted UCB 的动机。窗口长度 w 怎么定?我的经验是取你业务里"环境显著变化的时间尺度"的两三倍,比如素材热度大概三天一轮,w 就取一周左右的曝光量。

大规模臂数的冷启动成本。UCB1 要求每根臂至少拉一次,臂数 K 大的时候这一步很贵。如果 K 是几百,无所谓;如果 K 是几百万条候选商品,先全试一遍根本不可能。这种场景有两条路:一是给 counts 设虚拟初值,把冷启动"软化"成先验;二是先做一层粗粒度聚类,在簇级别跑 bandit,簇内再用别的逻辑挑。后者是工业界最常见的做法,树的每一层跑一个小规模的 UCB,整体复杂度从 O(K) 降到 O(log K)。

4.2 常见问题速查表

下面这张表是我和同事们在排查时实际用过的,按症状查比从头读代码快得多。

症状最可能的原因排查动作
策略长期只拉一根臂,其余几乎不碰bonus 被均值淹没,量纲不一致检查奖励是否归一化;试把 c 放大到 1 以上观察行为变化
遗憾随轮数线性增长bonus 计算错误,或 counts 未正确更新打印每轮的 avg 和 bonus,确认 bonus 随 total 缓慢上升、随 counts 下降
前期锁死在次优臂上c 过小,或冷启动阶段被人为缩短把 c 调回 1;确认每根臂都拿到了至少一次初始拉杆
环境变化后长期不调整累积统计导致对新数据不敏感换成滑动窗口或指数衰减统计
相同输入两次运行结果不同你在 update 里加了随机化,或臂间均值打平时 argmax 不稳定检查是否有随机成分;打平时用固定的 tie-break 规则
线上效果远差于离线仿真反馈延迟,counts 更新滞后于决策引入影子计数,把决策与更新解耦成异步流水线
后期完全不再探索业务侧用了很小的 c,或 log 项被截断确认 log 用的是全局轮数而不是该臂次数

第五条特别值得说。UCB1 的 argmax 在两根臂完全打平时,numpy 会返回下标较小的那个。这在仿真里无所谓,但在线上意味着流量会系统性地偏向编号靠前的臂,久而久之形成一种隐蔽的不公平。如果你的臂对应不同的内容创作者,这就是个业务问题而不只是技术问题。显式加一个随机 tie-break 更稳妥。

4.3 几条不太写在文档里的心得

先跑离线回放,再上在线。线上流量是有成本的,拿真实用户去验证一个还没调好的算法风险太大。我的做法是先用历史日志做离线评估——每天的曝光日志里,每条臂都有被曝光和被点击的记录,用这些数据构造一个"如果当时选别的臂会怎样"的反事实估计(IPS 加权是常见做法),先把 c 的取值范围缩到一个窄区间,再拿去线上小流量试。

日志里一定要记 bonus。出问题时最想知道的就是"这个决策当时的置信半径是多少"。把 avg、bonus、total、counts 一起打点到日志里,排查效率能提高一个数量级。很多团队只记了最终选了哪根,事后完全无法复现推理过程,只能靠猜。

注意反馈延迟带来的 counts 滞后。在推荐场景里,点击可能在曝光后几分钟甚至几小时才回来。如果决策时读到的是还没更新的 counts,你会在短时间内反复选同一根臂——因为它的 counts 迟迟不涨,bonus 一直很高。这是线上 UCB1 最容易出现的隐性 bug,表现是某个时段流量异常集中。解决办法是决策和计数分成两条路径,决策读影子计数,计数在反馈回来后异步写入。

别用 UCB1 去解决它不擅长的问题。如果臂数固定但候选池每天在变,如果奖励不是独立同分布而是有时间相关性,如果用户之间有强差异需要个性化,UCB1 都不是合适的选择。这些情况下该上的是上下文老虎机,或者干脆回到常规的监督学习排序模型,把探索交给一个轻量的随机扰动去做。

5. UCB 家族的其他成员与适用边界

5.1 UCB-V、KL-UCB、滑动窗口 UCB

标准 UCB1 只用了样本均值和样本量,完全忽略了一个信息:奖励的方差。如果一根臂的奖励波动极小(比如稳定在 0.5 附近),另一根波动极大(一会儿 0 一会儿 1,均值也是 0.5),UCB1 会给它们完全相同的 bonus,但显然第一根的不确定性更低,真正需要的探索更少。UCB-V 通过把经验方差带进置信半径修正了这一点,它的 bonus 形式大致是 sqrt(2 σ̂² ln t / n) 加上一个随 n 衰减的修正项。奖励方差差异大的场景下,UCB-V 能明显省下探索预算。

KL-UCB 走的是另一条路:不用 Hoeffding 的二次型界,而用 Chernoff 界导出基于 KL 散度的置信区间。这在伯努利奖励下特别合适,因为伯努利的 KL 散度有闭式表达。它的理论性质更好,渐近常数能逼近 Lai-Robbins 下界,实测在臂数多、间隙小的场景里优势比较明显。

滑动窗口 UCB 和折扣 UCB 是针对非平稳环境的两个变体。前者只保留最近 w 轮的观测,后者给历史观测加指数衰减权重。两者本质上是同一个思路的两种实现,选择哪个主要看你的数据结构——用队列维护窗口容易,但更新成本 O(w);用衰减因子是 O(1) 更新,但要额外存一个权重和。

5.2 上下文来了怎么办:LinUCB 与 GP-UCB

纯粹的 UCB1 是"无上下文"的,它对所有用户群体给同一个答案。真实的推荐和广告场景里,同一个臂对不同用户的收益天差地别,这时候需要上下文老虎机。

LinUCB 是最常被拿来用的一个。它假设收益是特征向量的线性函数,μ = xᵀθ,把标量的均值和方差换成线性回归的估计和协方差:

UCB_i = xᵀθ̂_i + α · sqrt( xᵀ A_i^{−1} x )

这里的 A_i 是臂 i 的特征二阶矩矩阵,α 就是 UCB1 里那个 c 的角色。结构上完全是同一个思想——线性预测加一个不确定性的加成,只是"不确定性"从标量变成了由特征空间几何形状决定的量。实践中 LinUCB 的 α 通常要调,因为它同时承担了置信水平和模型正则化的双重角色,不再有理论值可以直接用。

GP-UCB 则是把线性假设换成高斯过程,适合特征维度低、需要平滑性假设的场景。贝叶斯优化里那套 GP-UCB 采集函数,本质上和这里是同一个东西,只是名字换了。

5.3 什么时候不该用 UCB1

最后说说边界。UCB1 有几个明确不适用的情况。

第一,臂的数量极大且动态变化。前面说过冷启动成本的问题,百万级候选池上用 UCB1 需要额外做分层。

第二,奖励不满足有界独立同分布。特别是奖励之间存在强时间相关性的时候(比如某类内容的整体热度在上升),Hoeffding 的独立性假设不成立,bonus 的理论保证就失效了。这不是说不能用,而是说你要知道理论保证没了,得靠滑动窗口这类工程手段兜底。

第三,延迟反馈非常长的场景。如果一次转化的反馈需要一周才能确定,UCB1 的 counts 更新会严重滞后于决策节奏,整个算法的收敛速度被反馈链路卡死。这种情况更适合用短期信号的代理指标(比如点击代替成交)先跑起来。

第四,需要严格可解释性和审计的场景。UCB1 本身是确定性的、可复现的,这点是加分项,但它的探索行为在业务侧看起来会很反直觉——明明某条素材数据最差,算法还在给它流量。如果业务方难以接受这种解释,你可能需要准备一套话术,把"为了长期收益正在付出的探索成本"讲清楚,或者把探索额度做成显式的预算池,让它变得可见可控。

我在实际项目里最常用的一套组合是:全局层用 UCB1 做粗粒度的资源分配,保证探索量自适应;用户层用轻量的上下文模型做个性化排序,探索交给 UCB1 输出的臂级权重来控制。这套分层结构的好处是两层的职责很清晰,出了问题容易定位是哪一层的问题,而且 UCB1 那层几乎不需要维护,跑几年都不会出什么状况。如果你也想上手,建议就从那份二十行的实现开始,先在一个小的流量池里跑通全流程,把日志打全,再考虑往上下文方向扩展。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 6:29:27

STM32CubeIDE 安装、汉化、主题调优与故障排查指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:29:26

2026广州工业现场EtherCAT总线驱动器选型实测指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:29:02

WebSphere Application Server部署决策链:下载安装与运行时契约解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:28:57

嵌入式Linux驱动开发实战:从设备树到中断并发的完整流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:28:24

循环神经网络RNN详解:从序列建模到梯度消失的解决之道

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:27:57

ICCAVR与Proteus联合调试:COFF实现AVR源码级仿真

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华