预算控制(Budget Pacing)的作用就是平稳花掉广告主的预算,并帮助广告主优化转化效果。所以我们预算控制要完成如下目标:
广告匀速播放:通过广告日预算、当前消耗以及日曝光曲线来控制广告投放速度
提升广告主ROI:帮助广告主以更低的价钱拿到更多优质曝光量:通过PCTR分层,对优质客户优先展示广告。
本文是基于Probabilistic throttling(概率节流PTR),通常做法是通过某种方式丢弃一定量流量,丢弃的流量不参加竞价,进而控制预算花费速率;
为了这个目标我们参考了论文《Smart Pacing for Effective Online Ad Campaign
Optimization》,LinkedIn 在 2014 年发表的一篇论文,提出的预算控制策略并不复杂,并且具有很强的实践性和工程性。
接下来介绍下我们的这个算法,其主要思想跟LinkedIn算法比较类似。我们的主要思路就是将推广计划的消耗趋势与大盘曝光趋势保持一致,以天为时间单位,推广计划为预算控制单位。
首先根据历史数据,预测出当天大盘总曝光数。然后基于其曝光情况,在当前时间片,假如已消耗 / 当天预的比例大于大盘已曝光 / 大盘总曝光的比例,则说明预算已经消耗过快,需要减小消耗的速度,反之则要加快消耗的速度。

(1) 首先我们会把每个广告计划的所有请求会被分成 L 层,则在第 t-1 个时间片内各层的参竞率记为:
各层消耗记为:
一天的总预算 B 会根据时间片划分成 K份小预算,即:
但是实际投放中不能保证每个时间片的消耗都刚好达到分配的预算值,因此如果前面的时间片中出现了少投或超投情况,就要把少投或超投那些部分均摊到后面的时间片中,所以每个时刻的预算需要根据前面的花费来调整,调整后的消耗记为:
上式中的 B m B_{m} Bm表示经过了 m 个时间片后剩余的实际预算,
表示经过了 m 个时间片后剩余的实际预算,分子 B m − ∑ i = 1 K B i B_{m}-\sum_{i=1}^{K}B^{i} Bm−∑i=1KBi表示当前预算花费是否超过了预期(<0)或者不满足预期(>0),并通过分母均摊到后面的 K−m 个时间片中。(这里你可以看做该时刻的预算与实际时刻预算的残差)
则调整各层参竞率的控制算法如下图所示,图中的, R = C ^ t − C t − 1 R=\hat C^{t}-C^{t-1} R=C^t−Ct−1表示如果按照前一时间片的参竞率投放时,当前时间片的预算能否花完。则当 R>0 时,需要提高当前时间片的参竞率,反之需要降低 这一时间片的参竞率。 C t − 1 C^{t-1} Ct−1表示上一时间段的实际消耗。
上面算法还有几点细节需要注意:
L层表示参竞率最大的层, [公式] 层表示参竞率为非0的最小的层
当需要提升参竞率最时,是从第 L层到 [公式] 层进行的;而需要降低参竞率时,是从第[公式]层到第 L层进行的,其目的都是优先提升ctr高的层的参竞率,优先降低ctr低的层的参竞率,从而达到最小化成本的目的
trial rate 的目的是让参竞率非 0 的最小层的下一层以一个很小的参竞率进行参竞(参竞率会随着层数增加而增加),本来这一层的参竞率应该是 0 的,但是这里给了一个很小的 trail rate,目的是为了方便后面加快预算花费做准备(代码实现上也不用特殊处理)
在实际处理中,我们要有很多情况需要考虑的,以下列举部分:
上文的核心代码部分如下所示,具体技术细节,暂时保密:
float pvPdfLast = Float.parseFloat(args[0]); //当前前一时刻累计曝光比例,例如0.5421
float pvPdfNow = Float.parseFloat(args[1]); //当前时刻累计曝光比例,例如0.5556
float costPdf = Float.parseFloat(args[2]); //当前时刻累计花费例如520
int layerLen = args[6];//读取层数,3
int curT = args[7];//当前时刻1440
float[] cost = new float[layerLen];
if (StringUtils.isNotEmpty(args[3])) {
cost = args[3].split( "#");//每层花费,0#10#30#50,已经按照PCTR从小到大
} else {
for (int i = 0; i < layerLen; i++) {
cost[i] = 0.0f;
}}
float[] r = new float[layerLen];
if (StringUtils.isNotEmpty(args[4])) {
r = args[4].split( "#");//上一时刻每层的消耗cost,按照#分割,已经按照PCTR从小到大
} else {
for (int i = 0; i < layerLen; i++) {
r[i] = 0.0f;
} }
float curBudget = 0.0f;
if (StringUtils.isNotEmpty(args[5])) {
curBudget = Float.parseFloat(args[5]);//预算
}
float bt = curBudget * (pvPdfNow - pvPdfLast); //本时刻单元预算
float ct = bt + ((curBudget - costPdf) - curBudget * (1 - pvPdfNow)) / (1320 - curT);
//调整后的消耗记为Ct
//B-costPdf:剩余花费 B * (1 - pvPdfNow):计划剩余花费
float lastC = 0;//上一时刻总花费
for (int i = 0; i < cost.length; i++) {
lastC += cost[i];
}
float restC = ct - lastC;
float[] rNext = r; //上一时刻每层的竞参率r 应该是一串数据,按照_$_分割,已经按照PCTR从小到大,r.length:分成多少层
float trialRate;
//目的是让参竞率非 0 的最小层的下一层以一个很小的参竞率进行参竞
if (restC < 0) { // 需要减速
for (int i = 0; i < r.length; i++) {//先从低质流量减速
if (r[i] > 0.0f)//等于0的情况下不用减速
{
float x = (r[i] + 0.01f) * (cost[i] + restC) / (cost[i] + 1);
//防止分母出现0,cost[i]+1
//r[i]防止启动失败,+0.01f
rNext[i] = x <= 0? 0.000000f: x;
}
}
for (int i = 1; i < r.length - 1; i++) {
trialRate = 0.01f * ct / (cost[i] + 1) * r[i];//防止cost[i]为0
trialRate = trialRate > 1? 1.00f: trialRate;
rNext[i] = rNext[i + 1] > trialRate && rNext[i] < trialRate? trialRate: rNext[i];
}
} else if (restC > 0) {// 提高当前时间片的参竞率
for (int i = r.length - 1; i >= 0; i--) {//先从高流量提速
if (r[i] < 1.0f) {//等于1的情况下不用提速
float x = (r[i]+0.01f) * (cost[i] + restC) / (cost[i] + 1);
rNext[i] = x >= 1? 1.000000f: x;
}
}
for (int i = 0; i < r.length - 1; i++) {
trialRate = 0.05f * ct / (cost[i] + 1) * r[i];//防止cost[i]为0
trialRate = trialRate > 1? 1.00f: trialRate;
rNext[i] = rNext[i + 1] > trialRate && rNext[i] < trialRate? trialRate: rNext[i];
}
}
rNext就是下次竞参率,scala简易版如下:
public class AdjustWithoutPerfGoal {
public static void main(String[] args){
float pv_pdf = 0.501f;
float pv_pdf_t = 0.534f;
float cost_pdf = 0.514f;
String cost = "3#5#7#9";
String r = "0.3#0.5#0.7#0.9";
float B = 50f;
float goal = 0.5f;
System.out.println(PTR(new String[]{
String.valueOf(pv_pdf),
String.valueOf(pv_pdf_t),
String.valueOf(cost_pdf),
cost,
r,
String.valueOf(B),
String.valueOf(goal)
}));
}
public static float[] PTR(String[] args){
/**
输入:累计曝光pv_pdf//cost_pdf当前时刻累计花费比例//上一时刻的消耗cost/
上一时刻竞参率r//B当天单元预算
*/
float pv_pdf = Float.parseFloat(args[0]); //当前时刻累计曝光比例
float pv_pdf_t = Float.parseFloat(args[1]); //下一时刻累计曝光比例
float cost_pdf = Float.parseFloat(args[2]); //当前时刻累计花费比例
float[] cost = strToFloatArrBySep(args[3], "#");
float[] r = strToFloatArrBySep(args[4],"#");//上一时刻每层的消耗cost,应该是一串数据,按照#分割,已经按照PCTR从小到大
float[] r_new = r; //上一时刻每层的竞参率r 应该是一串数据,按照_$_分割,已经按照PCTR从小到大,r.length:分成多少层
float B = Float.parseFloat(args[5]); //当天单元预算
float B_Next = B*(pv_pdf_t-pv_pdf); //下一时刻单元预算
float R = (pv_pdf-cost_pdf)*B_Next; //比例
if(R<0){ // 比例小了,需要减速
for (int i=0; i0){
float x =r[i]*(cost[i]+R)/cost[i];
if (x<=0){//更新0
r_new[i] = 0;
}else {
r_new[i] = x;
}
}
}
}else if(R>0){// 比例大了,需要提速
for (int i=r.length-1; i>=0; i--) {//先从高流量提速
if(r[i]>0){
float x =r[i]*(cost[i]+R)/cost[i];
if (x>=1){
r_new[i] = 1;
}else {
r_new[i] = x;
}
}
}
}
return r_new;
}
public static float[] strToFloatArrBySep(String val, String sep){
String[] elems = val.trim().split(sep);
float[] valFlt = new float[elems.length];
for (int idx=0; idx
有目标的优化如下:
public class AdjustWithPerfGoal {
public static void main(String[] args){
float pv_pdf = 0.501f;
float pv_pdf_t = 0.534f;
float cost_pdf = 0.514f;
String cost = "3_$_5_$_7_$_9";
String r = "0.3_$_0.5_$_0.7_$_0.9";
String ecpc = "0.2_$_0.4_$_0.2_$_0.4";
float B = 50f;
float goal = 0.5f;
System.out.println(PTR(new String[]{
String.valueOf(pv_pdf),
String.valueOf(pv_pdf_t),
String.valueOf(cost_pdf),
cost,
r,
ecpc,
String.valueOf(B),
String.valueOf(goal)
}));
}
public static float[] PTR(String[] args){
/**
输入:累计曝光pv_pdf//cost_pdf当前时刻累计花费比例//上一时刻的消耗cost/
上一时刻竞参率r//B当天单元预算
*/
float pv_pdf = Float.parseFloat(args[0]); //当前时刻累计曝光比例
float pv_pdf_t = Float.parseFloat(args[1]); //下一时刻累计曝光比例
float cost_pdf = Float.parseFloat(args[2]); //当前时刻累计花费比例
float[] cost = strToFloatArrBySep(args[3], "_\\$_");
float[] r = strToFloatArrBySep(args[4],"_\\$_");//上一时刻每层的消耗cost,应该是一串数据,按照_$_分割,已经按照PCTR从小到大
float[] r_new = r; //上一时刻每层的竞参率r 应该是一串数据,按照_$_分割,已经按照PCTR从小到大,r.length:分成多少层
float[] ecpc = strToFloatArrBySep(args[5],"_\\$_"); //上一时刻每层ecpc 应该是一串数据,按照_$_分割,已经按照PCTR从小到大
float B = Float.parseFloat(args[6]); //当天单元预算
float B_Next = B*(pv_pdf_t-pv_pdf); //下一时刻单元预算
float R = (pv_pdf-cost_pdf)*B_Next; //比例
float goal = Float.parseFloat(args[6]); //目标,在本程序里,为整体ecpc
if(R<0){ // 比例小了,需要减速
for (int i=0; i0){
float x =r[i]*(cost[i]+R)/cost[i];
if (x<=0){//更新0
r_new[i] = 0;
}else {
r_new[i] = x;
}
}
}
}else if(R>0){// 比例大了,需要提速
for (int i=r.length-1; i>=0; i--) {//先从高流量提速
if(r[i]>0){
float x =r[i]*(cost[i]+R)/cost[i];
if (x>=1){
r_new[i] = 1;
}else {
r_new[i] = x;
}
}
}
}
//判断整体ecpc是否高于目标
float joint_ecpc = ExpPerf(r,r_new,ecpc,cost,0);
if(joint_ecpc > goal){
for (int j=0; j goal){
r_new[j] = 0;
}else {
float[] x1 = fltSlice(cost,j+1,r.length);
float[] x4 = fltSlice(ecpc,j+1,r.length);
for (int i=0; i