
简介这是一套面向MATLAB初学与强化学习入门者的多臂赌机问题程序包聚焦勘探与利用权衡这一核心难题适合在课程实验、毕业设计或项目实践中对照学习。压缩包内共9个m文件大小约4KB按三种求解策略分目录存放e-greedy策略对应的e_greedy.m、softmax策略对应的softmax.m以及时变e-greedy策略对应的tce_greedy.m每个策略均配有findmax.m与Slotmachine5.m分别承担最大动作选择和环境仿真功能。通过运行和对比这些代码可以直观看到不同策略在奖励积累与探索机制上的差异理解参数调节对学习效果的影响也可在此基础上修改老虎机数量、奖励分布或探索率进一步观察算法收敛特性。目前已有2997人学习下载对于希望快速上手强化学习经典案例的MATLAB用户来说是一份轻量且完整的参考实现。1. 为什么值得先把多臂老虎机玩明白很多想入门强化学习的朋友上来就去啃 DQN、PPO结果在搭环境、调 reward、等训练收敛这条路上就被劝退了。多臂赌机问题Multi-Armed Bandit现在更多叫多臂老虎机MAB是另一种风格它没有状态转移也没有时序信用分配只有一个动作和一个立即反馈的奖励但恰恰把强化学习里最核心的“探索与利用”矛盾单独拎了出来。我最近在 MATLAB 里整理了一个多臂老虎机程序包既用于给研究生上课演示也用来做控制系统参数在线选择的前期原型。这篇就把这个包的设计思路、核心代码和调参经验完整拆开讲一遍。1.1 一个拉杆问题为什么够格叫“学习”多臂老虎机的问题设定一句话就能说清你面前有 K 个拉杆臂每个拉杆按一个固定但未知的分布给出奖励你每一步只能拉一个目标是让累计奖励尽量大。听起来简单难在哪难在你不知道哪个臂最好又只能在“继续摸清不确定的臂”和“去拉目前看起来最好的臂”之间做权衡。这和现实中的选外卖、选广告素材、选 PID 参数本质上是同一个问题。评判一个算法好坏标准不是平均奖励而是“遗憾”regret。定义是Regret(T) T × μ* − Σ r_t其中 μ* 是最优臂的期望奖励r_t 是每一步实际拿到的奖励。这个指标相当于拿你的累计收益去和“上帝视角下永远选最优臂”的收益比差了多少。MAB 研究里几乎所有理论结论都在讨论遗憾的上界因为只有这个指标能公平地比较不同策略。1.2 为什么我选 MATLAB 而不是 Python现在做强化学习的大多涌向 Python这没错但 MAB 这种问题不一样。它本身逻辑不重不需要深度学习框架也不需要大规模并行采样。用 MATLAB 的好处很实际很多控制、通信、运筹方向的工程师和学生机器上本来就装了 MATLAB为一个 200 行的演示去配 Python 虚拟环境不划算。MATLAB 的矩阵写法和内置绘图让实验结果可视化非常快改个参数重新跑一遍也很顺手。后续想接 Simulink、做系统辨识或者 PID 整定MATLAB 的链路是最短的。这不是说 MATLAB 比 Python 好而是说在 MAB 这个规模的问题上工具本身不构成瓶颈。反而是把算法逻辑直接摊开写在 .m 文件里每一步都能看清楚学习效果更好。1.3 这个程序包要解决什么问题我希望拿到这个包的人能在 5 分钟内跑出一张策略对比图而不是先读一堆文档。所以设计目标有三个策略可插拔、环境可配置、一键出图。下面两节就按这个目标来拆。2. 程序包骨架目录、类和调用方式2.1 为什么用 MATLAB Package 目录MATLAB 里为了避免函数名冲突可以用包目录。只要文件夹名以开头里面所有函数和类都归到这个包名下调用时写成mab.xxx就行。我的程序包结构是这样mab/ problem.m runExperiment.m epsilonGreedy.m ucb1.m thompsonNormal.m thompsonSelect.m scripts/ demo_mab.mmab是包目录scripts外面放一个演示脚本。这样做的好处是以后你再写一个plotRegret.m放在包内也不会和系统函数或其他项目冲突。2.2 环境类 problem环境类负责定义“有多少个臂、每个臂的奖励分布是什么”。我默认使用高斯分布因为工程场景里的奖励大多是连续量。同时也保留一个 Bernoulli 类型方便大家复现经典论文里的 0/1 奖励实验。classdef problem handle properties K mu sigma type end methods function obj problem(mu, sigma, type) if nargin 3 type gaussian; end obj.K numel(mu); obj.mu mu; obj.sigma sigma; obj.type type; end function r pull(obj, k) switch obj.type case bernoulli r double(rand obj.mu(k)); otherwise r obj.mu(k) obj.sigma(k) * randn; end end function b bestIdx(obj) [~, b] max(obj.mu); end end end这里用handle类而不是值类是因为后续你可能会在对象上加一些记录字段比如每个臂被拉次数、历史奖励等handle保证所有引用操作的是同一个对象。pull方法返回奖励策略不需要知道奖励是怎么生成的这样环境和策略就解耦了。2.3 实验引擎 runExperiment实验引擎负责跑完整条时间线更新策略状态并记录累计遗憾。这个函数的设计原则是策略本身只是一个函数句柄输入当前状态返回一个动作。function stats runExperiment(policy, p, T, reps) RegAll zeros(reps, T); RAll zeros(reps, T); for rep 1:reps Q zeros(1, p.K); N zeros(1, p.K); R zeros(1, T); Reg zeros(1, T); for t 1:T a policy(Q, N, t); r p.pull(a); N(a) N(a) 1; Q(a) Q(a) (r - Q(a)) / N(a); R(t) r; Reg(t) t * p.mu(p.bestIdx()) - sum(R(1:t)); end RegAll(rep, :) Reg; RAll(rep, :) R; end stats.meanReg mean(RegAll, 1); stats.seReg std(RegAll, 0, 1) / sqrt(reps); stats.RegAll RegAll; end注意Q的更新是增量式样本均值Q(a) Q(a) (r − Q(a)) / N(a)这一步看着简单但它保证你不需要保存所有历史奖励每次只用一个数字就能维护当前估计值。Reg的计算用了sum(R(1:t))严格来说每一步 O(t) 复杂度但 T 在几千的量级时完全无所谓代码可读性更重要。2.4 演示脚本的调用方式策略作为函数句柄传入这是整个包“可插拔”的关键。要新加一个策略只需要写一个相同签名的函数rng(2024); mu linspace(0.2, 0.8, 10); sigma 0.1 * ones(1, 10); p mab.problem(mu, sigma); T 2000; reps 50; epsPolicy (Q, N, t) mab.epsilonGreedy(Q, N, t, 0.1); sEps mab.runExperiment(epsPolicy, p, T, reps); sUcb mab.runExperiment((Q, N, t) mab.ucb1(Q, N, t), p, T, reps); figure(Color, w); plot(sEps.meanReg, LineWidth, 1.5); hold on; plot(sUcb.meanReg, LineWidth, 1.5); legend({epsilon 0.1, UCB1}, Location, northwest); xlabel(t); ylabel(Cumulative Regret); exportgraphics(gcf, mab_regret_demo.png, Resolution, 300);exportgraphics是在 R2020a 之后推荐用的导出方式比print和saveas更不容易出现字体和边框问题。如果你还在用老版本换成print(gcf, -dpng, mab_regret_demo.png, -r300)也行。3. 三个策略的 MATLAB 实现与选择逻辑3.1 epsilon-Greedy最直接也最容易写错epsilon-Greedy 的思路是以 epsilon 的概率随机探索以 1−epsilon 的概率选择当前估计均值最大的臂。代码本身很简单但这里有一个十个人里九个会踩的 MATLAB 坑max只返回第一个最大值下标。function a epsilonGreedy(Q, N, t, epsilon) if rand epsilon a randi(numel(Q)); else bestVal max(Q); candidates find(Q bestVal); a candidates(randi(numel(candidates))); end end如果 Q 向量是[0.3, 0.3, 0.2]直接写[~, a] max(Q)会永远返回 a1。在算法初期多个臂的估计值完全可能相同这种隐性的偏好会让策略一直选同一个臂而且很难被发现。所以只要涉及“取最大”并需要随机打破平局就一定要用find randi。epsilon 的选择也是有讲究的。固定 0.1 在大多数情况下不会太差但其实最优值和问题难度有关。臂数多、均值差距小的时候需要更多探索epsilon 可以设大一些如果臂数少且差距明显epsilon 设成 0.05 就够了。一个更稳妥的做法是让 epsilon 随时间衰减比如epsilon_t min(0.2, 1 / sqrt(t))但非平稳环境里不要衰减到 0否则环境一变算法就完全失灵。3.2 UCB1用置信上界自动控制探索UCB1 的核心是给每个臂算一个“乐观估计”样本均值加上一个置信区间宽度。拉得越少的臂置信区间越宽会被自动多试几次拉得多的臂区间越来越窄逐渐收敛到真实均值。function a ucb1(Q, N, t) unplayed find(N 0); if ~isempty(unplayed) a unplayed(randi(numel(unplayed))); return; end upper Q sqrt(2 * log(t) ./ N); candidates find(upper max(upper)); a candidates(randi(numel(candidates))); end前几行先处理“还没拉过的臂”这一步很容易漏。如果 N 里有 0log(t) ./ N会得到 Infmax会永远选中那个没拉过的臂这倒不算致命但行为变得不可控。显式把未拉过的臂随机选一个逻辑更干净。公式里的log(t)会随时间增长但增长得很慢这保证了 UCB1 不会因为时间推移就无限探索而是把探索集中在“可能被低估”的臂上。这个策略的遗憾上界是对数级的工程上一般够用。3.3 Thompson Sampling从后验分布里抽一个“最优臂”Thompson Sampling 的直觉是“概率匹配”每个臂维护一个后验分布每步从这些分布里抽样然后选样本值最大的臂。奖励是 0/1 时用 Beta-Bernoulli 模型非常自然function a thompsonSelect(alpha, beta) theta betarnd(alpha, beta); candidates find(theta max(theta)); a candidates(randi(numel(candidates))); end更新时如果选中臂 a 得到奖励 r就执行alpha(a) alpha(a) r; beta(a) beta(a) (1 - r);因为 Beta 分布是 Bernoulli 奖励的共轭先验所以后验还是 Beta 分布更新就是简单加法。但注意我的程序包默认环境是高斯奖励beta 更新就不成立了。为了在统一实验引擎里跑 Thompson我在包内写了一个基于正态近似的版本function a thompsonNormal(Q, N, t) unplayed find(N 0); if ~isempty(unplayed) a unplayed(randi(numel(unplayed))); return; end theta normrnd(Q, 1 ./ sqrt(N)); candidates find(theta max(theta)); a candidates(randi(numel(candidates))); end这个近似把样本均值当后验中心1/sqrt(N)当后验标准差。它不是我严格贝叶斯意义上的 Thompson Sampling但在工程演示里效果已经很接近而且能和runExperiment无缝配合。如果想严格一点可以去查 Normal-Inverse-Gamma 共轭先验的推倒把方差也作为未知量估计。3.4 三种策略怎么选策略探索来源主要优点主要弱点适用场景epsilon-Greedy固定概率随机实现最简单参数直观固定 epsilon 会产生线性遗憾探索不区分“值不值得”快速原型、非平稳环境兜底UCB1置信上界宽度理论保证好自动分配探索需要奖励方差有界对分布假设敏感稳定环境、臂数中等Thompson Sampling后验抽样小样本和随机性下表现好后验更新计算稍重分布假设要匹配奖励分布已知、样本量小没有绝对最优的策略。UCB1 和 Thompson Sampling 在平稳环境里通常明显优于固定 epsilon但如果你想做在线参数选择而且环境会漂移保留一个最小 epsilon 往往是更稳的选择。4. 实验结果能告诉我们什么4.1 复现实验设置我建议的演示参数是臂数 K 10每个臂的真实均值linspace(0.2, 0.8, 10)奖励标准差sigma 0.1总步数 T 2000重复次数 reps 50为什么重复 50 次因为单次 MAB 实验的随机性很大就像是给一段心电图你很难从毛刺里看出趋势。重复 50 次取平均累计遗憾才能得到稳定结论。这也是很多人实验做得不专业的原因跑一次就画图结果换个随机种子结论就反过来了。4.2 应该画什么图不要画什么图最值得画的是“累计遗憾随时间变化”的曲线并带上标准误或者置信带。另一个可选的图是每个臂被拉次数能直观看到策略是否把更多采样分配给了最优臂。但我不建议只画“平均奖励随时间变化”的曲线因为每一步奖励是随机变量平均奖励看起来非常抖而且它没法反映策略选错臂的代价有多大。画置信带在 MATLAB 里没有现成的一行函数通常用patch实现或者干脆先只画均值线正文里给标准误数字。对课程演示来说均值线已经够了。4.3 我在这组参数下看到的典型结论在rng(2024)这个种子下我得到的结果大致是固定epsilon 0.1的累计遗憾在 2000 步结束时大约在 60 到 100 之间UCB1 通常在 25 到 45 之间Thompson Sampling 的近似版本和 UCB1 接近少数随机种子里能更低。这个对比说明一个道理固定 epsilon 的线性遗憾不是算法“不努力”而是它把探索的预算平均撒到了每一个臂上哪怕是明显很差的臂也持续浪费机会。UCB1 和 Thompson 的聪明之处在于它们会自动把更多采样投给“可能最优”的臂。还有一个容易被忽略的结论是如果只看平均奖励三个策略在 2000 步后差距可能不到 5%但累计遗憾差距可能是两倍。所以做实验对比一定要用累计遗憾作为主指标否则很多细节被平均掉。4.4 epsilon 衰减值得单独做一次实验你可以先不改策略逻辑只把 epsilon 从固定值改成衰减函数比如eps_t min(0.2, 1 / sqrt(t));再跑一遍实验大概率会发现前期探索多、后期收敛好累计遗憾明显下降。这个观察比记住任何结论都值钱。不过要提醒一句如果你的系统是非平稳的后期 epsilon 太小就会让策略失去对环境变化的响应能力所以工程里我一般给衰减设一个下界比如 0.02。5. 我在实现和调试中踩到的五个坑5.1 max 只返回第一个下标我在第 3.1 节已经提过这里再展开说。MATLAB 的max对向量返回第一个最大值的位置这不是 bug但很多从其他语言转过来的人会默认它返回所有位置。结果就是算法在早期会莫名其妙偏向编号小的臂。修复方式就是bestVal max(Q); candidates find(Q bestVal); a candidates(randi(numel(candidates)));同样的坑也出现在upper max(upper)和theta max(theta)里。只要你的策略里有“平局随机选”的需求就统一用这个模式。5.2 UCB 里的除零问题UCB1 的公式里有1 ./ N如果某个臂没被拉过N0得到 Inf。你可能会想Inf 不就会被选中吗理论上确实会被选中但问题是这样选出来的臂没有随机性而且如果你有多个未拉过的臂它们之间无法比较。我建议显式处理unplayed find(N 0); if ~isempty(unplayed) a unplayed(randi(numel(unplayed))); return; end这样前 K 步会保证每个臂至少被探索一次之后的 UCB 公式才稳定。5.3 随机种子放错了位置如果把rng(2024)放在reps循环内部那么每次重复实验都会重播同一段随机序列相当于这 50 次重复完全不独立算出来的标准误会严重偏小。正确做法是把rng放在最外面然后循环内部自然消耗随机序列。如果想让结果可复现就固定同一个种子如果想看鲁棒性可以在循环外部对每个 rep 设置不同的种子但千万不要放在循环里。5.4 策略的分布假设和奖励分布不匹配Thompson Sampling 的 Beta-Bernoulli 版本只适用于 0/1 奖励。如果你把高斯奖励硬塞进 Beta 更新比如把连续值当成 1 或 0 来处理就会丢失大量信息甚至产生完全错误的排序。我在程序包里提供了thompsonNormal作为高斯奖励的近似方案但它的理论保证比严格贝叶斯要弱。实际工程里奖励往往是连续量我的建议是要么把问题建模成 0/1 奖励比如“是否成功达到指标”要么就用 UCB1别让奖励类型和策略假设错配。5.5 忽略了非平稳环境MAB 经典理论假设每个臂的奖励分布固定但真实系统没有这么客气。信道会拥塞、设备会老化、用户偏好会漂移。Q(a) Q(a) (r − Q(a)) / N(a)这种用 1/N 做学习率的更新会让旧数据的影响永远存在均值一旦漂移就追不上了。工程上我常用的改法是换成固定学习率alpha 0.1; Q(a) Q(a) alpha * (r - Q(a));这样最近的奖励对估计值的贡献更大环境漂移时能跟上。代价是估计方差会变大所以 alpha 不能太大。如果你有足够内存也可以保存一个滑动窗口对窗口内的奖励求均值。这个改动非常小但会极大提升策略在真实系统里的存活率。6. 从老虎机到实际项目的三个迁移思路6.1 PID 参数在线整定多臂老虎机最常见的工程化例子之一就是 PID 参数自动选择。把每一组 Kp、Ki、Kd 候选参数看成一个臂奖励定义为仿真闭环系统的性能指标比如 ITAE 的负值或者超调量和调节时间的加权组合。在 MATLAB 里很容易和 Simulink 对接function reward evaluatePID(kp, ki, kd) simOut sim(pid_plant_model, StopTime, 10); y simOut.yout{1}.Values.Data; t simOut.tout; e 1 - y; dt 0.01; reward -sum(abs(e) .* dt); end然后把“策略选臂”和“仿真评估”串起来策略选一组参数Simulink 跑一次闭环仿真返回奖励更新策略状态继续下一轮。实际使用时要注意单次 Simulink 仿真可能耗时几秒甚至更久而 MAB 的决策更新只是毫秒级所以瓶颈在仿真不在算法。这种情况下可以先离线并行把候选参数都评测一遍再用 MAB 在预算内做在线选择避免每轮在线等待。6.2 通信和试验设计里的探索利用通信系统里的盲信道选择、波束选择、功率等级选择本质上都是在有限观测下做动作选择。奖励可以是吞吐量、误码率或者信号强度这类指标通常带噪声正好落在 MAB 的射程内。UCB1 在这里特别合适因为它不需要手动调节 epsilon也不会在已经确定的好信道上浪费太多探索。试验设计和 A/B 测试也是同理。传统 A/B 测试先按固定比例分流等积累一定样本再做假设检验但 MAB 可以边学习边调整流量分配把更多用户分给表现更好的方案。这个思路对推荐冷启动、广告素材筛选、活动页面排序都适用。6.3 从单步决策走向完整强化学习多臂老虎机是一个单步决策问题没有状态转移。如果给策略加上一个可观测的上下文特征就变成了上下文老虎机如果再加上状态转移和长期回报就进入 Q-learning、DQN 的世界。很多人直接学 DQN 觉得概念太多其实可以把学习路径拆成“多臂老虎机 → 上下文老虎机 → MDP → DQN”每一步只引入一个新概念理解起来会顺畅很多。我自己做这套包的时候最深的体会是算法代码都不长难的是把“探索和利用的代价”这件事用实验讲清楚。与其急着往包里堆功能不如把 epsilon 衰减函数单独拎出来反复调整看它对累计遗憾曲线的形状影响有多大。等这一步想透了后面接 Simulink、接数据流、接深度网络思路都会清晰很多。本文还有配套的精品资源点击获取