☰
动态背包与广告序列投放:从建模到落地
2026/10/15 17:14:50 网站建设 项目流程

广告序列投放,一句话说清楚就是:把一组广告按先后顺序,在有限的预算和时间窗口内,排到不同的流量场景里去,让整体效果最大化。听起来像普通的资源调度,可一旦牵扯到序列、预算共享、场景差异,事情就从简单的排序题变成了组合爆炸题。我今天想聊的是我自己在某个广告系统里落地“基于动态背包的广告序列投放算法”的全过程,包括建模思路、状态设计、工程取舍,以及我在测试里真正踩过的坑。

这个场景里,广告不是单条随便扔出去的,而是成组出现。比如某用户打开App首页,刷信息流时先看到品牌广告,滑动两屏后又看到效果广告,下单页附近再插入一个促销广告,这是完整的触达序列。每个场景(信息流、详情页、搜索位)可投放的位置数量和时点不同,预算又是全局共享的,还要避免同一条广告反复打扰用户。这类问题用贪心按点击率排序能解决一部分,但经常会在预算不多、位置紧缺的情况下把多数预算浪费在高价低效的广告上。把问题形式化成背包模型,用动态规划去解,往往能得到稳健得多的投放排布。下面我就按照从建模到落地再到调优的顺序展开,尽量把我在实际项目里验证过的细节都写出来。

1. 广告序列投放的核心矛盾:不是凑齐一条广告,而是排好一组广告

1.1 序列投放与一次性投放的本质区别

一次性投放的核心选择是“选什么广告”和“用什么价格”。这个问题的经典解法是用点击率预估排序,或者用市场竞价模型。序列投放多了一个时间维度和频次维度,不再是独立的单次决策,而是相互影响的组合决策。

举个例子,信息流第一屏如果给了品牌广告,用户在第二屏再看到同一个品牌的效果广告,点击率会怎样?有些数据会显示不降反升,因为认知在第一屏建立,第二屏形成转化,这是正向的序列协同。反过来,如果第一屏和第二屏放了两个竞争品牌的同类产品,用户的点击意愿通常会大幅下降,这是序列的负向干扰。单独把每个位置做成独立的点击率最大化,这些协同和干扰完全看不到,投放效果自然容易出问题。

序列投放的前一个广告是否被看到、是否发生点击,直接影响后续广告的曝光和转化概率,所以一条投放计划的评价指标应该是“序列整体收益”,不是单位置收益。这也是为什么我在建模时,优先考虑能承载时间顺序的优化模型。

1.2 用背包语言重新描述广告资源分配

把广告投放系统对号入座到背包问题,其实非常自然。背包里的“容量”是两个维度:预算总额和曝光额度(某个场景可用的广告位置总数)。背包里的“物品”是所有候选的“广告位投放单元”,每一个单元包含场景、时间点、对应广告创意、预估点击率、预估转化率、出价、曝光成本等等。

传统背包只回答“选哪些物品放进背包”,序列投放则多了一层“按什么顺序放”。这一层顺序信息可以编码在物品的价值函数里,让价值不只是独立收益,而是依赖前序投放结果的边际收益。这样一来,问题就变成了:在预算和曝光约束下,选择一组有序的广告位单元,使得序列整体边际收益之和最大。

这个描述一旦建立起来,后面的状态设计就有了落点。网上很多文章讲背包问题喜欢一上来就摆状态转移方程,我反而觉得先讲清楚“物品是什么、容量是什么、收益怎么算”,工程上才真的能落地。

1.3 为什么贪心策略在预算收紧时必然失衡

回到贪心这个老对手。贪心按当前收益排名的做法在单位置场景下简单高效,但它只考虑局部最优。序列投放里,一个候选单元的边际收益受到前序投放影响,而这个影响往往是动态更新的。假设某候选单独看的点击率是0.05,预算充裕时确实是好选择;但当预算只剩10%,继续投放它会挤掉后面三个能产生协同增益的单元,而这三个单元加起来的边际收益超过它两倍。贪心算法看不见这种跨单元的连带价值,于是预算越紧,贪心和全局优化的差距越大。

我在仿真里多次复现过这个现象。预算覆盖率达到90%的时候,贪心和动态背包几乎没差;覆盖率降到50%左右时,贪心的总收益会落后15%以上。这个规律非常稳定,所以如果你的广告系统经常遇到预算紧张,模型上换掉贪心是值得的。

2. 动态背包状态设计:从“选不选”变成“怎么排”

2.1 状态变量与决策变量的具体定义

我用的是基于动态规划的背包模型,但状态不再是经典的 i 表示前 i 个物品、j 表示剩余容量。因为序列投放要求决策之间有先后依赖,所以我把状态定义为三个维度的组合:当前处理到第几个广告位单元,剩余可用预算,以及“最近一次投放的上下文摘要”。

这里“上下文摘要”是我自定义的向量,用来记录最近一次广告投放所属的场景类型、行业类目、投放形式。例如,最近一次投了信息流里的快消行业品牌广告,下一次再投同类广告时,边际收益可能要乘以一个衰减系数;如果上一次是投搜索场景的精准广告,下一次再在信息流里投放同类广告,协同增益可能为正。为了让状态可枚举,我会对上下文摘要做离散化处理,只保留最近一次的类别编号和间隔时长。

决策变量则是“下一组广告位单元”中选择哪一个。因为这是一个有向无环的决策链,我按时间轴顺序或者场景拓扑顺序把广告位排成一个候选数组,每一个候选单元对应一个可能的下一步投放,决策做完之后,背包容量减去对应成本,上下文摘要更新,继续往前决策。

2.2 收益函数的三层拆解:即时收益、序列协同、机会成本

广告位单元的基础收益,我建议分成三层来看。第一层是即时收益,表示该单元单独投放产生的预期转化价值,通常用预估点击率乘以预估点击价值再乘上一个转化系数得到。第二层是序列协同,基于上下文摘要计算这次投放对后续投放的影响。如果上下文显示上一个广告和当前广告是同一个行业但不同品类,协同系数通常大于1;如果是直接竞争品,协同系数可能小于0.8,需要把这个系数乘到即时收益上。

第三层是机会成本,指的是把这一预算用在其他场景其他位置上可能获得的最大收益。在背包动态规划里,机会成本本身不是显式参数,而是通过状态转移的比较自动体现的。不过为了让实时决策更稳定,我会在收益函数里加入一个小的正则项,用来惩罚高成本单元的边际占用,防止动态规划把大量预算集中押在某一两个高溢价场景。

收益层计算方式作用
即时收益预估点击率 × 点击价值 × 转化系数衡量单点投放的直接产出
序列协同即时收益 × 协同系数(依赖上下文)体现前后广告的正负联动
机会成本状态转移中的比较项 + 成本惩罚项抑制预算过度集中的风险

这个三层结构在工程上的意义是:可解释性很强。投放团队问“为什么这次把预算挪给了信息流”,我可以直接拿出即时收益和协同系数来对账,而不是丢给他们一个黑盒优化结果。

2.3 核心状态转移与候选单元枚举逻辑

我用自然语言描述一下核心转移:从最后一个候选开始,反向计算到初始状态,每一步都尝试“不投当前候选”和“投当前候选”两种选择,然后挑收益更高的一条路径。其中的状态转移核心是:当前状态的最优值等于“不投放当前候选时的最优值”和“投完当前候选后的边际收益 + 上一个状态的最优值”两者取最大值。

我摘一段实际用过的原型代码简化版本:

def max_sequence_value(candidates, total_budget, context_map): dp = dict() n = len(candidates) for idx in range(n): for budget in range(total_budget + 1): for ctx in context_map: # 不投放当前候选 dp[(idx, budget, ctx)] = dp.get((idx - 1, budget, ctx), 0) # 投放当前候选(如果预算足够) if budget >= candidates[idx].cost: prev_ctx = ctx new_ctx = next_context(prev_ctx, candidates[idx]) coop = context_map.get((prev_ctx, candidates[idx].category), 1.0) marginal_value = candidates[idx].base_value * coop marginal_value -= LAMBDA * candidates[idx].cost before = dp.get((idx - 1, budget - candidates[idx].cost, prev_ctx), 0) dp[(idx, budget, ctx)] = max( dp[(idx, budget, ctx)], before + marginal_value ) return dp[(n - 1, total_budget, initial_ctx)]

这里有三个地方容易出错。一是预算维度的单位换算,如果不同场景计费单位不统一,状态转移里会出现“预算够但实际扣款对不上”的问题;二是上下文离散化后的映射键,必须包含场景类型和行业类目,否则协同系数无法区分;三是收益函数里的惩罚系数 lambda,过大会让系统趋于保守,好的序列被拆散,过小又可能让预算过度集中,需要单独做参数扫描。

2.4 标准背包、序列背包与整型规划的边界

很多人问我,为什么不用整型规划直接求解,或者干脆交给商业求解器?整型规划理论上可以建模同样的约束,甚至更灵活,但在线上系统里,它有一个现实问题:当候选集超过几百个、约束超过几十条时,求解时间不可控,时延抖动会直接影响投放响应。动态背包的状态设计虽然要花心思,但计算路径是确定的,复杂度可以提前估算,这对线上稳定性很有价值。

标准0/1背包其实也可以跑得很快,但它没有办法建模序列依赖。标准背包里物品价值固定,放进背包只是占空间;序列背包里物品价值随上下文变化。这多出来的“状态依赖”正是动态背包和标准背包的分水岭。实务里如果上下游系统比较多,可以借鉴上下文摘要来解耦:只用最近一次的类别信息进入状态,更长时间跨度的序列依赖则通过收益函数里的时间衰减系数隐式表达,状态量不至于爆炸。

3. 多场景联合投放:预算在渠道间转移的隐性成本

3.1 场景互斥与预算共享:一个反直觉的实测案例

多场景比单场景复杂在约束的耦合。我实测过两个场景:信息流场景和详情页场景。分开各自做背包优化时,两个场景的独立收益看起来都很不错;放在一起共享预算时,反而整体的转化指标下降明显。

原因后来查清楚:两个场景的优化模型都倾向于在晚上8点到10点这个高峰时段消耗预算,因为那个时段的预估点击率最高。结果全局预算在这个时段集中消耗,非高峰时段的广告覆盖变成零,序列触达断档,用户只在高峰时段被连续轰炸,前后链路没有可持续的曝光跟进。分开优化时,两个场景各有独立的预算池,高峰时段叠加消耗的绝对值看似高,但总预算约束被分散到两个池子里,全局结构反而更均衡,这其实是一种伪均衡。

这个案例告诉我,多场景联合调度一定要在模型层面显式处理预算共享和时间轴重叠,不能简单把人分成渠道池子各算各的。使用动态背包后,我可以在状态里加一层场景维度,把预算作为公共容量,让动态规划自动在高峰和非高峰之间做权衡。

3.2 场景维度压缩:分组、合并和候选裁剪

直接加场景维度,状态数量会膨胀得非常厉害。三个场景、每个场景20个候选、预算细分100档,状态数就是三万起步,再做上下文摘要的枚举,得再多乘几十。所以在工程上我会做两层裁剪。

第一层,候选集裁剪。每个场景内,先把明显低收益的候选单元去掉。比如信息流里点击率低于某个百分位数的候选,如果历史数据显示它从来没有带来协同增益,就直接不进入背包的候选集。这样每个场景的有效候选控制在10到20个,整体计算量立刻降下来。

第二层,场景维度解耦。我把场景按“时间冲突强度”和“用户重叠度”分组。信息流、详情页这两个场景如果面对着几乎是同一批用户,我就合并成一个虚拟场景来算预算分配;搜索场景的用户意图更明确,和其他场景重叠小,就单独建模。合并之后再进背包,状态里依然保留场景类型标识,但不需要每个场景拆成独立维度,方程变得更简洁。

3.3 为什么预分配阶段用背包、在线阶段用轻量决策

还有一个经验是:不要一上来就做全局背包。全局背包看似最优,但候选集一多,动态规划的耗时和内存会指数增长,而且收益预估噪声一大,全局最优解在实时环境里反而容易震荡。

我采用的做法是两层结构:离线预分配加在线微调。离线阶段,用动态背包算出每个场景的预算占用和投放节奏基线;在线阶段,每15分钟根据实时反馈调整场景内的投放顺序,但不改变场景间的预算分配比例。这样既避免了全局背包的实时计算压力,又能在场景内部保留序列优化的优势。万一某个场景的实时效果严重低于预期,再触发挥发性的一次全局重算,频率控制在每小时一次以内。

预算重算触发条件的判断是:某个场景过去15分钟的投放完成率低于目标80%,或者预估点击率的实时分布和历史基线偏差超过两个标准差。这两个信号只要命中一个,就拉起重算线程。其余时间保持轻量决策,系统整体负载平稳。

4. 仿真对比实验:动态背包在什么条件下更值得上

4.1 实验设置:数据构造与基线算法

没有真实广告流量数据的情况下,仿真数据可以比较干净地检验算法行为。我构造了三类场景,分别是信息流、详情页、搜索结果页。每个场景设置12个候选广告位单元,模拟用户每天8个时段的流量分布,预算总额设置为能让70%的候选单元被投放的水平。

基线算法我选了三个:贪心按预估点击率降序投放;标准0/1背包不考虑序列协同;基于整型规划的离线求解器(当作理论上限参考)。动态背包算法用的就是我前面描述的三维DP版本,但候选集裁剪后规模可接受。所有算法共享同样的收益预估输入,这样对比的是算法本身的结构性差异。

4.2 结果解读:动态背包的收益优势来自哪里

从仿真结果看,动态背包在三类场景混合投放的场景里,总收益比贪心高约18%,比标准0/1背包高约9%。这个差距的来源主要是序列协同项。标准背包模型忽略了两个广告之间的承接关系,容易把两个高点击率但同质化严重的广告排在一起;动态背包则会主动把这些广告拆开,让中间插入一个互补的广告,整体收益反而更好。

优势最大的情况出现在“预算偏紧+候选同质化严重”的组合。预算紧张时,贪心会把所有预算投给点击率最高的少数几个候选,长的序列根本建不起来;动态背包则会保留一个较长的优质序列,哪怕每个位置的收益稍低,整体链条价值更高。这个现象在搜索结果页和详情页这种转化链路上尤其明显。

我把三个算法的主要指标列在下面:

算法总收益(相对值)序列平均长度高峰时段预算占比单轮耗时
贪心100%2.162%极低
标准0/1背包109%3.454%低
动态背包118%4.648%中等

这个表格直观地说明,动态背包不需要牺牲太多计算效率,就能换回明显的收益提升。耗时的“中等”指的是百毫秒级别,在实际投放系统的实时链路里可以接受。

4.3 参数敏感性:序列长度、预算波动、投放密度

我还做了三组敏感性测试。第一组,序列长度从3变到7,动态背包收益持续上升,但在序列长度超过5以后提升趋缓,说明过长序列带来的循环疲劳抵消了协同效应。第二组,预算波动:预算总额在基线基础上上下浮动30%,动态背包的收益稳定性明显好于贪心,因为它能在更多候选之间做平衡。第三组,投放密度:单位时间段内候选数量从8个增加到16个,动态背包的耗时增长接近线性,不会出现组合爆炸。这几项结果后续会直接指导生产环境里的窗口长度设置和候选裁剪阈值。

如果你正在评估是否要引入这套算法,我建议你先看两个指标:一是系统里是否存在明显的序列协同场景(同一用户连续多次曝光同一广告的场景),二是预算覆盖率是否经常低于80%。两个条件只要有一个成立,动态背包大概率比贪心更划算。

5. 落地时真正重要的几个工程细节

5.1 动态规划表爆炸:滚动数组和稀疏哈希

完整三维DP在生产环境基本跑不动,我实际用的是滚动数组加稀疏哈希。因为当前状态只依赖上一个候选单元或当前候选的前一状态,所以我只需要保留两个二维表,来回滚动。预算维度不能滚动,必须保留所有的预算档位;上下文摘要维度用哈希键,只保存实际出现过的摘要状态,不预生成全部组合。

这样一来,即使候选单元到数千个,预算细分到500档,实际保存的状态量也就几十万个哈希键,比完整三维数组小了一到两个数量级。实测单次离线重算在一台普通开发机上可以控制在秒级,能够满足小时级别的刷新。

如果连几十万个哈希键都嫌多,还有一个更粗暴的降维方法:把预算档位的粒度从1元改成5元。因为低价值单元的边际差异通常很小,预算粒度放粗之后,收益损失往往可以忽略。我在这个项目里就是把预算档位从1元调整为5元,状态量直接砍掉近一半,最终总收益只下滑了1%左右。

5.2 收益预估误差的贝叶斯平滑处理

序列协同系数 alpha 如果直接拿历史点击率算,数据量少的时候非常不稳定。我这边会给 alpha 套一层贝叶斯平滑:当某个上下文组合的样本量不足时,alpha 会朝全局均值收缩,避免模型因为一两条样本就高估协同。同理,即时收益里的点击率估值也会用 Dirichlet 先验做平滑。

这个处理的收益在冷启动场景很直观:新广告上线前几个小时,实时点击数据极少,原始收益预估会剧烈跳动,导致背包选出来的序列反复横跳。加了平滑之后,序列结构稳定了很多,后续观察到的整体点击率反而比不平滑时更优。要注意收缩强度不能过大,否则 alpha 会失去区分度,序列协同带来的收益增量会被平滑掉。

5.3 冷启动场景下的候选集裁剪与探索策略

冷启动阶段,很多候选单元没有足够的历史数据来算收益。如果按统一百分位裁剪,新广告很容易被全部砍掉。我的做法是设置双阈值:基础裁剪保留所有历史样本超过50次的候选;对于样本不足50次的候选,用类别平均收益作为初始化估值,只参加低预算档位的竞争。这样可以兼顾探索和稳定,也让动态背包更容易在有限数据下找到可用的序列。

在线投放时还会叠加一个随机探索策略:每一轮投放中,候选集里有5%的概率替换成探索池里的新广告,替换后仍然参与同样的背包优化。这样既不会破坏背包整体的稳定性,又能持续给序列带进新的变化。

5.4 上下文摘要的离散化粒度:一个让我排查了很久的线上问题

最后分享一个真实经历。有一段时间线上收益出现小幅抖动,排查了很久,最后发现是上下文摘要的离散化太粗。我把广告形式只划分成了四类:纯文字、大图、视频、落地页,结果两类差异很大的信息流广告因为都是视频形式被归进了同一个类别,导致协同系数在它们之间错误共享。系统以为两个视频广告之间存在正向协同,实际上其中一类视频广告和另一类视频广告的受众重合度高,会互相稀释。

把摘要的类别从4种增加到8种,同时把场景类型和行业类目也纳入摘要维度之后,问题立刻消失。这个教训说明,模型精细化程度不是越高越好,但关键维度的区分度必须保得住,否则协同系数就是不靠谱的中间变量。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询