真实业务数据下的强化学习落地:从日志构造到BCQ离线优化
1. 为什么真实的业务数据里强化学习往往不是你想的那样1.1 仿真环境里练出来的“本事”到了真实数据上很容易失灵接触过强化学习的读者绝大多数第一步都是在某个仿真环境里跑通一个 Demo。干干净净的状态维度、明确定义的奖励函数、随时可以 reset 的环境接口只要把 Q 网络调通几万步迭代下去曲线总会照常上升。这套流程熟练之后很多人——包括我自己——都会有一个惯性思维强化学习嘛不就是把环境换一下数据喂进去就能跑直到我第一次把算法放到一段真实的业务数据上才发现事情没那么简单。真实的运营日志不会像仿真环境那样给你一个干净的(state, action, reward, next_state)四元组也不会告诉你这个动作做完之后世界进入了哪个确定的下一个状态。你拿到的可能只是大量用户行为记录、投放流水、订单信息一切都需要自己做转换。而一旦涉及真实数据原本在仿真里不痛不痒的几个问题会被瞬间放大数据分布偏了怎么办动作空间怎么定义才符合业务逻辑历史策略只探索了一部分动作空间没有出现过的动作组合要怎么处理这些是每一个做真实数据强化学习项目的人都要面对的坎。这篇文章我想用一个非常典型、计算量又可控的场景——电商促销预算分配——把完整链路拆开讲一遍。我会从业务理解、数据构造、环境建模、算法选型到离线评估尽量把你真正动手时会踩的坑提前踩一遍。1.2 为什么我选了“促销预算分配”这个场景选择业务场景是真实数据强化学习项目里第一个也是最重要的决策。预算分配这个场景有三个天然优势。第一状态和动作的定义非常接近结构化数据。预算分配的核心是决定给哪类用户、在什么状态下投放多少预算。状态就是用户特征最近访问频次、历史转化率、客单价区间等动作就是预算档位奖励就是投放带来的收益增量。这个映射关系清晰直接不需要做 CV 或 NLP 那种非结构化特征提取非常适合作为第一个真实数据 RL 项目。第二反馈延迟相对可控。很多真实场景里一个动作做下去可能要好几天甚至几周才能看到完整效果。但预算投放的收益在用户浏览点击后的转化漏斗里几个小时内就能大体显现。这意味着我们有机会用日志直接构造完整的轨迹数据而不需要做复杂的延迟归因。第三试错成本有边界。预算档位通常是离散的、有上限的即使策略给某个状态分配了不合理的高预算损失也有限。这对我来说很重要因为真实的在线试错永远有成本我们必须先在离线数据上尽可能把策略评估准确。在确定这个场景后我拿到的原始数据其实很朴素就是一张运营日志流水表里面记录了用户每次访问时的属性特征、当时系统给他投放的预算档位、以及后续一段时间的点击、加购和成交信息。它的字段长这样字段说明示例user_segment用户分群编号3高价值用户browse_freq_7d近7天访问次数9avg_order_value历史平均客单价元268days_since_last_order距离上次下单天数4budget_level系统实际投放档位2ctr点击率0.031conversion_revenue该用户本次贡献的成交额128cost投放成本4.2这张表就是整个项目的原料。听上去很平常但后面的每一个环节都要回到这张表上做文章。2. 原始日志到可训练数据集状态、动作和奖励的“翻译”过程2.1 先把业务问题翻译成强化学习问题的三要素强化学习的标准范式是智能体观察状态选择动作获得奖励环境转移到新状态。在真实数据的项目里你的第一项任务就是把原始日志中的字段硬生生地“翻译”成这三个东西。这个翻译过程看起来简单但每个选择都会直接影响训练结果。状态State我选了四个特征拼成一个向量user_segment用户分群、browse_freq_7d近7天访问次数、avg_order_value历史平均客单价、days_since_last_order距上次下单天数。为什么挑这四个因为这个场景的转化逻辑很直白——一个用户最近来得勤、买得贵、又有好几天没下单了那么这时候给他一点预算刺激转化的概率会明显上升。这四个特征刚好覆盖了“用户价值”“活跃度”“购买意愿窗口”这几个关键维度而且是日志里现成的不需要额外加工。动作Action预算档位。我把它离散化成 4 档0 表示不投放1/2/3 分别表示低/中/高三档预算。为什么非要离散化两个考虑一是业务上预算本来就是分级申请的连续预算没有实操意义二是离散动作空间对 DQN 及其变体是最友好的Q 网络输出层每个节点对应一个动作的 Q 值训练起来简单直接。后面你会发现动作怎么定义直接决定了你用什么算法。奖励Reward这个需要认真想。直接拿成交额当奖励显然不对因为钱不是白来的投放是有成本的。我用的奖励定义是reward conversion_revenue - cost * penalty_factor这里的penalty_factor我取 1.5意思是成本按 1.5 倍计入惩罚。为什么要这样因为转化收入是毛收入预算投放产生的真实利润还要扣除商品成本、物流成本等用一个高于 1 的系数去惩罚成本是在向模型传达“省着点花”的信号。这个系数在训练里影响非常大后面调参部分会专门讲。2.2 奖励归一化和缺失状态的处理真实数据里有一个隐蔽的坑奖励的数值分布非常不均。大多数用户不转化奖励就是负的投了成本没回报少数高价值用户一次成交就是几百块。如果不做任何处理直接训练Q 网络会被那几个极端的正奖励样本主导导致学出来的策略过度激进拼命在少数高价值用户身上砸钱。我的处理方式是做一个温和的裁剪把奖励值压缩到[-5, 5]区间超过边界的截断同时保证 0 值附近有足够的梯度。这一步我试过 Z-Score 标准化也试过 Min-Max最后发现还是简单截断的效果最稳。原因也简单低保真度的日志数据里奖励的精确数值意义远不如“好坏方向”重要截断能保留方向信息又不会让极端值破坏损失函数的平衡。状态特征同样需要归一化。user_segment是分类变量我用 One-Hot 编码其余三个连续特征都做了 Min-Max 归一化。注意归一化的统计量只能从训练集计算不能混入验证集和测试集的信息否则会造成轻微的数据泄漏。这个细节很多人不在意但实际上会让你的离线评估结果虚高。2.3 构造轨迹的切分连贯性真实数据和仿真环境的另一个关键差异是它没有天然的“回合episode”边界。每个用户的访问日志是连续的你截图取一段算一个状态过几天他再来了又产生了一个新状态这两个状态之间并没有“结束—重新开始”的关系。我的处理方式是把每个用户的一段时间窗口内的数据视为一条独立轨迹的多个时间步在这段窗口结束的位置设置doneTrue。这里的窗口我选择的是 14 天——也就是最多取 14 天的日志来推断一个用户的短期状态变化。超过 14 天没有行为就认为这条轨迹结束了重新开始。这是对真实业务的一种近似虽然不能完全代表长期动态但对于预算分配这种快速反馈的场景已经足够。做完这一步原始日志就变成了一条条形如(state, action, reward, next_state, done)的五元组序列。我处理完后的数据集大概有 20 万条这样的记录训练验证按 8:2 划分。3. 没有在线环境怎么构建一个“模拟环境”3.1 基于历史数据的 KNN 转移模型这是整个项目里最关键的一步。强化学习的标准流程要求智能体与环境交互一个动作执行下去环境返回下一个状态和新奖励。但真实的历史数据只是被动记录你不会有机会真的去试探“如果我给这个用户投放三档预算他的状态会怎么变”。一个常用的做法是从历史数据中用近邻匹配的方式来近似环境转移。思路很简单把历史数据中的所有(state, action)组合存下来同时记录每组对应的next_state和reward。当智能体在模拟环境中执行(s, a)时从历史记录里查找与(s, a)最相似的 K 条记录。从这 K 条记录的next_state和reward中按权重随机采样一个作为模拟环境的返回。听起来是不是有点像 KNN 回归没错本质就是一个带随机性的 KNN 回归。它的直觉是相似的用户在相似的投放策略下会产生相似的反应。这个假设在预算分配场景下是合理的因为用户行为虽然有随机性但大方向上是受特征驱动的。技术实现上相似度用的是归一化后的欧氏距离。注意一个细节state特征的权重和action特征的权重不能等量齐观。在我的项目里action的权重我调成了状态特征的 2 倍。这是故意为之的——因为预算档位是当前决策的直接干预变量它对用户反应的敏感性远高于用户历史特征。如果你让状态特征主导了距离计算那么环境看起来就跟动作无关了学习会非常缓慢。3.2 数据覆盖率检查你的历史数据够不够“努力”过构建模拟环境之前必须做一件事检查历史策略在状态-动作空间上的覆盖度。这个点几乎决定了离线强化学习项目能不能成。我把状态空间经过离散化后统计了每个状态区间里 4 个动作的出现频次。结果发现一个典型问题历史策略非常保守高价值用户几乎只投放 2 档和 3 档低价值用户基本都在 0 档和 1 档。也就是说很多(状态, 动作)组合在数据里根本没有出现或者样本极少。这会带来两个问题。第一KNN 转移模型在这些区域找不到足够的近邻预测质量无法保证。第二更重要的是强化学习算法会试图去学习“如果在这里执行那个动作会怎样”而数据里没有这个信息学出来的 Q 值就是瞎猜。这个问题在离线强化学习里有个专门名词叫分布偏移。我在后面算法部分会再展开。这里要先说解决方案对数据覆盖度过低的组合我在模拟环境里直接返回一个惩罚性的负奖励并强制设置doneTrue相当于告诉智能体“这个动作在这个状态下没有任何先例风险极高别碰”。这一招虽然没有增加信息量但至少阻止了模型信心十足地做出坏决策。3.3 模拟环境接口的设计细节我把模拟环境封装成了一个标准的 Gym 风格接口这样算法部分可以无缝切换class BudgetAllocEnv: def __init__(self, knn_model, topk8): self.knn knn_model self.topk topk self.state None def reset(self): # 从历史状态分布中随机采样一个初始状态 self.state self.knn.sample_initial_state() return self.state def step(self, action): # 调用KNN转移模型返回(next_state, reward, done, info) next_state, reward, coverage_flag self.knn.transition(self.state, action) done coverage_flag # 覆盖度不足时强制终止 self.state next_state return next_state, reward, done, {coverage: coverage_flag}这里有一个实操建议模拟环境的随机性要控制好。KNN 近邻采样如果完全随机环境的方差会很大导致训练曲线剧烈抖动。我在采样时加了一个温度参数当temperature较低时更倾向于选距离最近的那条记录而不是随机从 K 条里抽。训练初期用较高的温度增加探索训练后期降低温度让评估更稳定。这个技巧能明显改善收敛表现。4. 直接上 DQN 会翻车离线场景下的算法选择4.1 分布偏移和 Q 值高估为什么标准 DQN 在离线数据上不可靠很多读者会想数据都有了环境也模拟出来了那直接上个 DQN 不就完事了吗我当时也是这么想的结果被现实教育了一课。标准的 DQN 在训练过程中会通过贪心策略不断探索新的动作当前 Q 网络认为 3 档预算最好就会倾向于多选 3 档然后观察真实反馈来修正 Q 值。这在在线场景里没问题甚至可以说是必要的探索。但在离线场景里有一个致命矛盾智能体想探索的动作在历史数据里根本没有对应样本。比如在某些状态下Q 网络预估 3 档预算能有 50 的收益于是它开始频繁选择 3 档动作。但在模拟环境里3 档动作在这个状态下的近邻样本极少KNN 只能随机返回一些噪声很大的结果。这些噪声结果进一步污染 Q 值让网络以为“3 档在这个状态下确实不错”于是更加坚定地选 3 档。这就是典型的价值高估循环。更麻烦的是真实数据本身是从某个行为策略通常是人的经验规则生成的这个策略和我们要学的目标策略之间天然存在偏差。如果目标策略恰好选择了一个数据中很少出现的动作那么对这个动作的 Q 值估计就是没有任何数据支撑的外推专业说法叫外推误差。我拿标准 DQN 跑了几百轮观察到几乎所有的状态下Q 网络最终都把某个高预算动作的 Q 值推到了离奇的高度。这不是网络不够好而是算法和离线数据的本质结构不匹配。4.2 BCQ 的核心思想只在你见过的世界里优化既然问题出在“动作外推”那解决方案也就呼之欲出把动作的选择范围限制在历史数据中真实出现过的动作分布内。这就是 BCQBatch-Constrained Q-learning的核心思路。它比普通 DQN 多做了三件事训练一个条件生成模型我用的是条件 VAE专门学习“在给定状态 s 下历史数据中出现过的动作 a 的条件分布”。换句话说它能告诉你这个状态下过去的人通常做出哪些选择。Q 网络在决策时先从生成模型里采样一批候选动作比如 100 个再从这 100 个里选出 Q 值最高的那个作为最终动作。这样就保证了被评估的动作一定是“见过”的动作。加了一个扰动网络允许在生成动作附近做微小偏移给策略提供一点超越历史策略的改进空间但又不会偏移得太远。这个设计非常巧妙。你可以这样理解BCQ 就像一个谨慎的操盘手他从不主动尝试没见过的操作只是在历史成功经验的附近小步试探。每一步都在老路上走但偶尔向旁边挪一点点。4.3 BCQ 的核心代码骨架我用 PyTorch 把 BCQ 的核心模块实现了一遍。下面是最关键的三个组件的骨架每个我都加了注释说明为何这样设计import torch import torch.nn as nn import torch.optim as optim # 1. 条件VAE学习 p(a|s) class ConditionalVAE(nn.Module): def __init__(self, state_dim, action_dim, latent_dim4): super().__init__() self.encoder nn.Sequential( nn.Linear(state_dim action_dim, 64), nn.ReLU(), nn.Linear(64, latent_dim * 2) # 输出均值和方差 ) self.decoder nn.Sequential( nn.Linear(state_dim latent_dim, 64), nn.ReLU(), nn.Linear(64, action_dim), nn.Softmax(dim-1) # 动作是离散档位用Softmax输出分布 ) def forward(self, state, actionNone): # 训练阶段用真实动作做重构 if action is not None: mu, logvar self.encode(state, action) z self.reparameterize(mu, logvar) recon_action self.decode(state, z) else: # 采样阶段从先验分布采z生成候选动作 z torch.randn(state.shape[0], 4) recon_action self.decode(state, z) return recon_action这个 VAE 训练的目标是让生成的动作分布贴近历史数据的动作分布。训练好后它就变成了“历史的边界”。# 2. 扰动网络在生成动作附近加微量噪声提供微小探索 class PerturbationNet(nn.Module): def __init__(self, state_dim, action_dim): super().__init__() self.net nn.Sequential( nn.Linear(state_dim action_dim, 32), nn.ReLU(), nn.Linear(32, action_dim), nn.Tanh() # 输出限制在 [-1, 1]用来控制扰动幅度 )扰动网络的输出会乘以一个很小的系数我设置了 0.05作用是在动作的 one-hot 编码上稍微模糊一下边界让策略有机会学到“也许 1 档比纯 0 档好那么一点点”而不是只能机械复制历史动作。# 3. 多个Q网络取最小值抑制价值高估 class EnsembleQNet(nn.Module): def __init__(self, state_dim, action_dim, n_ensemble2): super().__init__() self.q_nets nn.ModuleList([ nn.Sequential( nn.Linear(state_dim action_dim, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, 1) ) for _ in range(n_ensemble) ]) def forward(self, state, action): # 返回所有Q网络的输出以及它们的最小值 q_values [net(torch.cat([state, action], dim-1)) for net in self.q_nets] q_min torch.min(torch.cat(q_values, dim-1), dim-1)[0] return q_values, q_min多个 Q 网络取最小值这个做法是受到 TD3 算法的启发两个 Q 网络同时估计同一个状态动作对的价值取较小的那个作为目标可以有效降低高估风险。在离线场景里高估风险比在线场景更大这个机制可以说是刚需。训练循环的伪代码如下for step in range(total_steps): # 1. 从历史数据buffer中采样 s, a, r, s_next, done sample_batch() # 2. 用VAE生成目标状态下的候选动作 with torch.no_grad(): candidate_actions vae.decode(s_next, ztorch.randn(n_samples, 4)) # 加上扰动网络的小幅度偏移 candidate_actions candidate_actions perturb_net(s_next, candidate_actions) * 0.05 # 裁剪到合法动作空间 candidate_actions candidate_actions.clamp(0, 3) # 3. 用Q网络的最小值筛选最优候选动作计算TD目标 _, q_next q_net(s_next, candidate_actions) target_q r gamma * (1 - done) * (lambda_coef * q_next.min(1, keepdimTrue)[0] (1 - lambda_coef) * q_next.max(1, keepdimTrue)[0]) # 4. 更新Q网络 loss mse_loss(q_net(s, a)[1], target_q) q_optimizer.zero_grad(); loss.backward(); q_optimizer.step() # 5. 更新VAE和扰动网络略细节这里lambda_coef我设置的是 0.75意思是以多个 Q 的最小值为主同时保留一部分最大值信息不至于让目标过于保守。这个值调大调小会直接影响策略的激进程度实践里值得多试几组。5. 离线评估不连线上系统也要知道策略大概能赚多少钱5.1 三个评估方法各有利弊模型的最终效果不能靠“感觉”来评判。但问题是离线强化学习的评估本来就难因为你没有真实环境去 rollout。我用了三种方式互相印证。第一种模拟环境内的 rollout 评估。直接在构建好的 KNN 模拟环境里跑训练好的策略积累 30 个时间步统计累计奖励。这种方法最大问题是模拟环境本身就是从历史数据构出来的用同一个数据构建的环境去评估同一个数据上学出来的策略天然就有自洽性偏差结果会偏乐观。但它能快速反馈训练趋势用于调参是够的。第二种只用未被用于训练构建模拟环境的数据做评估。我把原始数据按时间分成两段前 70% 用于构建 KNN 转移模型和训练策略后 30% 完全冻结不动。然后在后 30% 的数据上用行为日志里的真实状态序列来评估对每个状态取出模型最优策略选择的动作然后与日志中真实发生的动作、真实产生的奖励进行对比。这种评估方法叫离线策略评估的一种朴素版本它不能完整模拟策略的长期影响但至少能给出一个“如果在那时候遇到这个状态政策的决策会比历史决策好还是差”的答案。第三种重要性采样估计。这个方法需要一点统计学功底。它的核心是我们在评估目标策略 π 时日志数据其实是旧策略 β 生成的直接平均奖励是有偏的需要按照两条策略的动作概率比来加权。也就是w(s,a) π(a|s) / β(a|s)然后用加权后的平均累计奖励作为评估值。这个公式简单但问题也明显方差很大。因为如果目标策略选择了一个旧策略很少选择的动作这个权重会非常大极不稳定。我在实操中给它加了权重裁剪最大设 10并且配合分层采样来降方差。三种方法各有利弊我的最后决策是长期效果参考第一种短期决策质量看重第二种稳健性检查参考第三种。三者结果一致时我对模型才有信心。5.2 调参经验哪些旋钮值得拧我在这个项目上花了不少时间调参最后形成了一套自己的参数调节顺序。第一个值得关注的是奖励惩罚系数。前面提到 1.5 倍成本惩罚。我把它在 {1.0, 1.5, 2.0, 3.0} 之间做了对比实验。结果很有意思系数为 1.0 时策略明显倾向于高价值用户几乎每轮都给高预算整体收益看起来不错但成本高企系数调到 3.0 后策略走向另一个极端大量选择不投放。1.5 到 2.0 之间有一个收益和成本的平衡点。这个参数直接影响业务的利润结构需要和业务方讨论后确定而不是纯技术拍脑袋。第二个是 VAE 潜变量维度。我试过 2、4、8。维度太低生成的动作多样性不够很多有价值的状态-动作组合无法被表达维度太高生成的动作分布会过于分散失去了“限制在历史边界”的意义。最终 4 维表现最好。第三个是扰动幅度系数。前面提过默认 0.05。我试过 0 和 0.2。0 的时候策略几乎退化成行为克隆只是在复刻历史策略0.2 的时候扰动过大出现了和标准 DQN 类似的外推问题。0.05 是安全和进步的平衡点。这组对比实验也让我更深刻地理解了 BCQ 的设计哲学扰动是必要的但必须非常克制。第四是模拟环境的 K 值。KNN 的近邻数 K 我设为 8。K 太小转移模型的随机性太大训练曲线像心电图K 太大状态转移过于平滑丢失了真实数据中的异质性策略会变得钝感。8 到时 12 之间效果都不错我最后定为 8。这些参数之间其实存在耦合关系。比如提高扰动系数后需要同步增大 K 值来增加环境反馈的稳定性。直接告诉你一组“最优参数”是没有意义的更重要的是告诉你这些旋钮各自控制着什么让你在自己的数据上能更快地找到方向。6. 复盘真实数据 RL 项目里比算法更重要的那些事6.1 数据质量问题永远排在算法前面跑完整个流程我最大的感触是离线强化学习的性能上限是由历史数据的行为策略决定的。如果历史策略本身就很保守很多有效的动作组合从没出现过那任何算法都只能在一个受限的空间里寻找最优解。这个空间的天花板不是算法打破的而是数据定义的。所以如果你的项目拿到一份真实数据先不要急着写模型。花 30% 的时间做一件事画出状态-动作覆盖度热力图看哪些格子是空的。如果政府格子太多有两个选择一是换个场景找一个历史策略覆盖更充分的数据二是接受这个限制把优化目标从“找到全局最优”调整为“在数据支持的范围内做到最好”。此外还有一个容易被忽略的细节真实数据的生成过程往往不是随机的。历史策略是某些运营规则它可能专门针对特定用户群体做过定向投放这意味着数据里天然存在选择偏差。你看到的“低成本用户没转化”可能不是因为投放没效果而是因为那些用户从来就没有被投放过。这种偏差如果不识别模型学出来的东西会误导业务。6.2 下一步在线验证和策略迭代这篇文章写的是一套完整的离线流程但离“上线产生价值”还有距离。在实际业务里离线评估靠谱不代表线上一定能跑出预期。因为线上的真实反馈、用户行为的变化、以及其他渠道的干扰都会让模拟环境失效。下一步的常规做法是渐进式在线验证先给策略一个很低的预算额度在小流量上做 A/B 测试把线上采集到的真实反馈继续流入数据集再下一次训练迭代时更新转移模型和策略。这个过程听上去简单实际上很考验工程能力你要搭建数据回流管道、设计合理的分流机制、定义清楚线上的成本收益核算口径。这个系列如果继续写下去我打算在下一篇专门讲一讲从“离线训练好的策略”到“线上小流量验证”这段路怎么走包括数据回流、策略版本管理、安全护栏这些偏工程的环节。如果你正在做类似的项目或者正准备用一个真实业务场景上手强化学习希望这篇文章能帮你少走几步弯路。就我自己而言做完这个项目最大的收获不是掌握了 BCQ 这个算法而是学会了不断提醒自己一句话真实数据不会迁就你的模型所有看似聪明的设计最终都要回到数据的基本盘上来验证。