2019年深圳杯数学建模
D题 电视广告分户推送与营运规划
原题再现:
电视广告是地方电视台商业营运的主要业务之一。 假设广告视频由产品销售或制造商(下统称买方)负责制作并向电视台(下称卖方)竞买播放时段。假设广告播放时段分周期组织竞卖,当前周期组织完成下一个周期的竞价交易。你的任务是:
1、通过选择/提取视频广告和电视频道用户的分类特征建立二者分类匹配推送静态模型;
2、设计卖方分时段竞卖合理底价估算模型;
3、基于已知电视频道用户收视历史情况和在播视频广告的产品销售情况,建立视频广告与频道用户的分类匹配推送更新模型;
4、基于更新的分户推送方式建立竞价交易模型,在极大化卖方收益的同时提升收视率和买方产品销售量;
5、设计并实现你建立的模型的求解算法,通过收集或编撰数据给出算例。
整体求解过程概述(摘要)
针对问题一,为了建立广告视频与电视频道用户之间具有可解释性且能够推广到未见用户的静态分类匹配模型,首先依据题目"选择/提取分类特征"的要求构造用户人口属性、类别偏好、时段偏好以及广告目标年龄、目标收入、创意质量等特征。鉴于题目没有给出实际收视与销售明细,而问题五明确允许"收集或编撰数据给出算例",本文编撰1200名用户、40条广告和18000组用户---广告---时段样本,并对年龄、收入、偏好向量进行范围约束、对数变换、归一化和群体聚类。随后引入类别偏好匹配度、年龄适配度、收入适配度、时段适配度等9项交互特征,比较Logistic、随机森林和梯度提升三类模型。结果表明Logistic模型测试AUC为0.759、准确率为0.721,5折交叉验证AUC均值为0.754,且概率校准误差最小,故将其作为后续动态更新的静态先验。
针对问题二,为了使卖方在不同收视时段获得合理且可解释的竞卖底价,考虑到时段受众规模、广告主竞争强度和出价分布共同决定底价的机会成本,本文将底价问题转化为带保留价的第二价格密封拍卖收益最大化问题。对每个时段的历史竞价样本采用经验分布法估计最高价与次高价的联合响应,并在底价网格上计算成交概率和期望收入。由此得到六个时段的最优底价,平均为61.61 CPM,其中黄金档最优底价为93.66 CPM,显著高于深夜时段的34.89 CPM;底价---收入敏感性曲线显示最优点附近存在较平缓的平台区,说明模型具有一定定价鲁棒性。
针对问题三,为了使广告---用户匹配能够随收视行为和产品销售反馈持续更新,在问题一静态概率的基础上引入带遗忘因子的Beta-Bernoulli在线贝叶斯模型,并将广告观看反馈和购买转化反馈分别建模。考虑到电视内容偏好具有时变性,同时需要兼顾探索新广告与利用已知优势广告,进一步构造后验上置信界指标选择分户推送广告。12个交易周期的受控仿真表明,动态策略累计销售转化由静态策略的2896提升到3493,增幅约20.6%;虽然动态探索使平均观看率略低于完全静态贪心策略,但后验方差随周期收敛,能够有效捕捉需求漂移并显著提升商业转化。
针对问题四,在问题二保留价与问题三更新后匹配概率的基础上,构建以"卖方收入---预期观看量---买方销售转化"为三个目标的0-1混合整数规划竞价交易模型。通过对三类目标进行无量纲化处理,将收入权重设为0.58、观看权重设为0.24、销售权重设为0.18,并施加每个用户群每时段唯一分配、广告曝光容量、广告主预算等约束。结果显示,多目标方案预期卖方收入为61.38,相比纯收入优先方案仅下降1.92%,但预期观看量提高13.90%、预期销售量提高9.26%,说明适度牺牲短期卖方收入可以显著改善平台长期生态与买方转化。
针对问题五,本文给出从数据接入、静态模型训练、底价搜索、在线更新到混合整数规划求解的完整算法,并使用编撰数据完成端到端算例。算法将用户压缩为20个稳定群组,在保证行为异质性的同时显著降低交易优化维度;静态模型训练复杂度主要由样本规模决定,底价估计可按时段并行,动态更新复杂度为线性级,竞价分配则由MILP求解器处理。代码同时输出中间CSV、25幅可视化图和全部核心指标,便于接入真实电视台日志后复现与校准。
综合而言,本文形成"静态认识---价格发现---动态学习---联合交易---闭环验证"的递进式电视广告分户推送框架。模型既保持概率解释和经济机制解释,又能利用机器学习与运筹优化处理复杂异质性。需要强调的是,题目未提供真实用户级数据,本文数值结果属于严格标注的受控编撰算例,其作用是验证模型可行性和算法链路;在实际部署时应使用脱敏后的真实收视、销售和竞价数据重新训练并进行A/B测试。
模型假设:
1. 在同一竞卖周期内,用户基础人口属性与广告基础标签视为稳定,周期之间允许用户偏好和广告效果缓慢漂移。
2. 电视台能够在合法合规和用户授权范围内获得脱敏后的收视行为、广告曝光与商品销售归因数据;本文只使用群组级统计量进行最终竞价优化。
3. 广告主的投标金额能够近似反映其对该时段曝光价值的私人估值,历史竞价样本在相邻周期内具有局部平稳性。
4. 问题二采用带保留价的第二价格密封拍卖作为底价估计基准。实际若采用第一价格拍卖,可在保持需求估计模块不变的情况下替换支付规则。
5. 每个用户群在一个广告时段只展示一条广告;同一广告可同时分配给多个群组,但存在曝光容量和预算上限。
6. 购买转化可通过统计归因得到,且在给定用户群与广告后近似服从Bernoulli过程;观看行为亦采用Bernoulli近似。
7. 编撰算例中的随机数据不代表任何真实个人,全部参数仅用于方法学验证;模型结论中的绝对数值不得直接解释为真实电视台经营指标。
问题分析:
问题一分析
针对电视广告静态分户匹配建模需求,本题属于有监督二分类匹配建模问题,核心任务基于用户画像、广告标签、时段属性构造适配特征,搭建可解释的匹配概率模型,实现不同用户群组差异化广告推送。本题难点在于用户年龄、收入、偏好存在高维异质性,特征量纲差异大,且模型输出概率需要作为后续动态更新的贝叶斯先验,不仅要追求分类精度,更要保证概率校准效果。建模思路先完成数据清洗与特征变换,构建年龄、收入、类别、时段多维度适配交互特征,对比逻辑回归、随机森林、梯度提升三类模型,通过 5 折交叉验证、AUC、对数损失、布里尔分数综合筛选最优模型,再利用聚类压缩用户维度,生成群组级静态匹配概率矩阵,为后续定价、动态优化提供基础输入。
问题二分析
针对电视台分时段广告拍卖底价测算问题,本题属于保留价拍卖收益优化问题,核心依托历史竞价数据求解各时段最优底价,平衡成交概率与平台期望拍卖收入。本题难点是不同时段受众规模、广告主竞争强度差异显著,出价分布呈长尾离散特征,简单均值定价会造成黄金档收益流失、低时段流拍风险上升。建模思路以第二价格密封拍卖为理论基础,构建底价与期望收入的映射函数,采用网格遍历法对各时段候选底价批量测算成交收益,绘制底价敏感性曲线,依据收益峰值确定各时段最优 CPM 底价,同时分析受众活跃度与底价的相关规律,验证定价方案的鲁棒性。
问题三分析
针对广告匹配效果动态迭代更新问题,本题属于带遗忘因子的在线贝叶斯强化学习建模问题,在问题一静态概率先验的基础上,结合每期收视、购买反馈持续修正用户广告匹配概率。本题难点是用户兴趣、广告吸引力存在时变漂移,单纯静态模型无法适配长期业务,同时需要兼顾 "利用高转化广告" 与 "探索潜力广告" 的平衡。建模思路采用 Beta-Bernoulli 共轭分布搭建双反馈更新模型,引入遗忘因子衰减历史陈旧数据,设计 UCB 上置信界指标平衡探索与利用,通过多周期仿真对比静态固定策略与动态更新策略的观看量、累计销售转化,量化动态模型带来的商业收益提升,并验证后验方差随周期收敛的稳定性。
问题四分析
基于动态匹配概率与分时段底价,本题属于多目标 0-1 混合整数规划竞价分配问题,同步最大化平台广告收入、用户有效观看量、广告主产品销量三类存在天然冲突的经营目标。本题难点是三类指标量纲完全不同,无法直接加权,且投放存在单用户单时段唯一广告、广告预算、单时段广告覆盖上限多重线性约束。建模思路先对三类目标做无量纲归一化处理,设置兼顾平台、用户、广告主的综合加权目标函数,构建完整整数规划约束体系求解分户投放方案;对比收入优先、静态匹配、多目标折中三种投放策略,绘制 Pareto 权衡曲线分析目标间取舍关系,同时开展底价、特征噪声、广告集中度多维度敏感性检验,评估分配方案的稳定性与公平性。
问题五分析
针对整套分户广告营运模型完整算法设计与算例验证问题,本题属于一体化流水线算法构建问题,需要整合前四问全部建模流程,实现数据预处理、静态建模、底价求解、在线更新、多目标分配全链路自动化运算。本题难点是多模块存在前后依赖关系,用户个体维度计算量过大,且无官方真实业务数据,需要遵循题意规范生成可复现受控仿真数据。建模思路梳理 "数据生成 --- 特征工程 --- 模型训练 --- 拍卖定价 --- 贝叶斯迭代 --- 整数优化" 标准化算法流程,分模块推导时间与空间复杂度;基于自编仿真数据集完成端到端完整算例,输出全部指标图表,同时区分仿真算例与真实业务的边界,给出真实日志替换、线上 A/B 测试、隐私合规落地的完整改造方案,论证整套模型在视频广告行业的泛用拓展能力。
模型的建立与求解整体论文缩略图

全部论文请见下方" 只会建模 QQ名片" 点击QQ名片即可
程序代码:(代码和文档not free)
python
from __future__ import annotations
import os, json, math, warnings
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import pandas as pd
os.environ.setdefault("MPLCONFIGDIR", "/tmp/mplconfig_ad_model")
import matplotlib.pyplot as plt
from matplotlib import font_manager
from scipy.special import expit
from scipy.optimize import milp, Bounds, LinearConstraint
from scipy.sparse import csr_matrix
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_validate
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.metrics import (roc_auc_score, accuracy_score, log_loss, brier_score_loss,
roc_curve, confusion_matrix)
from sklearn.calibration import calibration_curve
from sklearn.cluster import KMeans
warnings.filterwarnings("ignore")
RNG = np.random.default_rng(2026)
# 中文字体
font_candidates = [
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJKSC-Regular.otf",
]
for f in font_candidates:
if os.path.exists(f):
font_manager.fontManager.addfont(f)
plt.rcParams["font.family"] = font_manager.FontProperties(fname=f).get_name()
break
plt.rcParams["axes.unicode_minus"] = False
plt.rcParams["figure.dpi"] = 130
OUT = Path("/mnt/data/ad_model_outputs")
FIG = OUT / "figures"
OUT.mkdir(exist_ok=True)
FIG.mkdir(exist_ok=True)
CONFIG = {
"n_users": 1200,
"n_ads": 40,
"n_categories": 8,
"n_slots": 6,
"n_groups": 20,
"n_pair_samples": 18000,
"n_auction_hist": 180,
"n_cycles": 12,
"exposures_per_group_cycle": 120,
"forgetting": 0.92,
"auction_weights": {"revenue": 0.58, "view": 0.24, "sales": 0.18},
}
CATS = ["食品饮料","汽车出行","数码家电","美妆个护","金融保险","教育培训","家居生活","文旅娱乐"]
SLOTS = ["早间","午间","傍晚","黄金档","晚间","深夜"]
def savefig(name):
plt.tight_layout()
plt.savefig(FIG / name, bbox_inches="tight")
plt.close()
def generate_users(n):
ages = np.clip(RNG.normal(40, 13, n), 18, 72).round().astype(int)
gender = RNG.integers(0, 2, n)
income = np.exp(RNG.normal(np.log(8500), 0.55, n))
city_tier = RNG.choice([1,2,3], n, p=[0.45,0.38,0.17])
household = np.clip(RNG.poisson(2.3, n)+1, 1, 6)
education = RNG.choice([1,2,3,4], n, p=[0.18,0.32,0.37,0.13])
baseline_watch = np.clip(RNG.beta(4,2,n), 0.1, 0.98)
# 年龄影响类别先验,形成可解释的异质性
cat_pref = RNG.dirichlet(np.ones(CONFIG["n_categories"])*1.6, size=n)
cat_pref[:,1] += np.where((ages>=25)&(ages<=49),0.16,0)
cat_pref[:,2] += np.where(ages<=35,0.18,0)
cat_pref[:,4] += np.where(ages>=40,0.13,0)
cat_pref[:,5] += np.where(ages<=30,0.12,0)
cat_pref[:,6] += np.where(ages>=35,0.10,0)
cat_pref = cat_pref / cat_pref.sum(axis=1, keepdims=True)
slot_pref = RNG.dirichlet(np.array([1.0,1.0,1.7,3.0,2.3,0.7]), size=n)
df = pd.DataFrame({"user_id":np.arange(n),"age":ages,"gender":gender,"income":income,
"city_tier":city_tier,"household":household,"education":education,
"baseline_watch":baseline_watch})
for k,c in enumerate(CATS): df[f"pref_{c}"] = cat_pref[:,k]
for k,s in enumerate(SLOTS): df[f"slot_{s}"] = slot_pref[:,k]
return df
def generate_ads(m):
cat = RNG.integers(0, CONFIG["n_categories"], m)
age_center = RNG.uniform(23, 58, m)
age_width = RNG.uniform(9, 20, m)
income_center = np.exp(RNG.uniform(np.log(4500), np.log(18000), m))
creative = RNG.uniform(0.45,0.98,m)
duration = RNG.choice([10,15,20,30], m, p=[0.15,0.38,0.17,0.30])
margin = RNG.uniform(20,260,m)
bid_cpm = RNG.uniform(15,85,m) * (0.7 + 0.7*creative)
budget = RNG.uniform(5000,18000,m)
df=pd.DataFrame({"ad_id":np.arange(m),"category":cat,"category_name":[CATS[i] for i in cat],
"age_center":age_center,"age_width":age_width,"income_center":income_center,
"creative":creative,"duration":duration,"margin":margin,"bid_cpm":bid_cpm,
"budget":budget})
return df
def pair_features(users, ads, n_samples):
ui=RNG.integers(0,len(users),n_samples); ai=RNG.integers(0,len(ads),n_samples); si=RNG.integers(0,CONFIG["n_slots"],n_samples)
u=users.iloc[ui].reset_index(drop=True); a=ads.iloc[ai].reset_index(drop=True)
catpref=np.array([u.loc[j,f"pref_{CATS[int(a.loc[j,'category'])]}"] for j in range(n_samples)])
agefit=np.exp(-0.5*((u.age.values-a.age_center.values)/a.age_width.values)**2)
incfit=np.exp(-0.5*(np.log(u.income.values/a.income_center.values)/0.72)**2)
slotpref=np.array([u.loc[j,f"slot_{SLOTS[si[j]]}"] for j in range(n_samples)])
base=u.baseline_watch.values; creative=a.creative.values
hhfit=np.exp(-0.16*np.abs(u.household.values-(2.0+0.8*(a.category.values==6))))
z=-5.5+5.5*catpref+2.5*agefit+1.8*incfit+2.2*slotpref+1.5*creative+1.0*base+0.6*hhfit
z += RNG.normal(0,0.18,n_samples)
p=expit(z)
y=RNG.binomial(1,p)
X=pd.DataFrame({"category_pref":catpref,"age_fit":agefit,"income_fit":incfit,"slot_pref":slotpref,
"creative":creative,"baseline_watch":base,"household_fit":hhfit,
"duration":a.duration.values/30.0,"city_tier":u.city_tier.values/3.0})
meta=pd.DataFrame({"user_id":ui,"ad_id":ai,"slot":si,"true_p":p,"y":y})
return X, meta
def fit_static_models(X,y):
Xtr,Xte,ytr,yte=train_test_split(X,y,test_size=0.25,random_state=42,stratify=y)
models={
"Logistic":Pipeline([("scaler",StandardScaler()),("clf",LogisticRegression(max_iter=1500,C=1.0))]),
"RandomForest":RandomForestClassifier(n_estimators=220,max_depth=9,min_samples_leaf=12,random_state=42,n_jobs=-1),
"GradientBoosting":GradientBoostingClassifier(n_estimators=150,max_depth=3,learning_rate=0.045,random_state=42)
}
rows=[]; fitted={}; preds={}
cv=StratifiedKFold(5,shuffle=True,random_state=42)
for name,m in models.items():
cvres=cross_validate(m,Xtr,ytr,cv=cv,scoring={"auc":"roc_auc","acc":"accuracy"})
m.fit(Xtr,ytr); pr=m.predict_proba(Xte)[:,1]; yh=(pr>=0.5).astype(int)
rows.append({"model":name,"cv_auc_mean":cvres["test_auc"].mean(),"cv_auc_std":cvres["test_auc"].std(),
"test_auc":roc_auc_score(yte,pr),"accuracy":accuracy_score(yte,yh),
"logloss":log_loss(yte,pr),"brier":brier_score_loss(yte,pr)})
fitted[name]=m; preds[name]=(pr,yte)
res=pd.DataFrame(rows).sort_values("test_auc",ascending=False)
best=res.iloc[0].model
return res,fitted,best,(Xte,yte),preds
def build_groups(users):
feats=["age","income","baseline_watch"]+[f"pref_{c}" for c in CATS]+[f"slot_{s}" for s in SLOTS]
Z=users[feats].copy(); Z["income"]=np.log1p(Z["income"])
Z=StandardScaler().fit_transform(Z)
lab=KMeans(CONFIG["n_groups"],random_state=42,n_init=20).fit_predict(Z)
users=users.copy(); users["group"]=lab
agg={"age":"mean","income":"mean","baseline_watch":"mean","household":"mean","city_tier":"mean","user_id":"count"}
for c in CATS: agg[f"pref_{c}"]="mean"
for s in SLOTS: agg[f"slot_{s}"]="mean"
g=users.groupby("group").agg(agg).rename(columns={"user_id":"size"}).reset_index()
return users,g
def predict_group_ad(model, groups, ads, slot=3):
rows=[]
for _,g in groups.iterrows():
for _,a in ads.iterrows():
catpref=g[f"pref_{CATS[int(a.category)]}"]
agefit=np.exp(-0.5*((g.age-a.age_center)/a.age_width)**2)
incfit=np.exp(-0.5*(np.log(g.income/a.income_center)/0.72)**2)
slotpref=g[f"slot_{SLOTS[slot]}"]
hhfit=np.exp(-0.16*abs(g.household-(2.0+0.8*(a.category==6))))
rows.append({"group":int(g.group),"ad_id":int(a.ad_id),"category_pref":catpref,"age_fit":agefit,
"income_fit":incfit,"slot_pref":slotpref,"creative":a.creative,
"baseline_watch":g.baseline_watch,"household_fit":hhfit,"duration":a.duration/30.0,
"city_tier":g.city_tier/3.0})
D=pd.DataFrame(rows); X=D.drop(columns=["group","ad_id"])
D["p_static"]=model.predict_proba(X)[:,1]
return D
def auction_history(ads):
slot_q=np.array([0.76,0.84,1.08,1.28,1.02,0.68])
rows=[]
for t,q in enumerate(slot_q):
for h in range(CONFIG["n_auction_hist"]):
n_b=int(RNG.poisson(7+4*q)+2)
idx=RNG.choice(len(ads),size=min(n_b,len(ads)),replace=False)
vals=ads.loc[idx,"bid_cpm"].to_numpy()*q*np.exp(RNG.normal(0,0.24,len(idx)))
for aid,b in zip(idx,vals): rows.append({"slot":t,"auction":h,"ad_id":int(aid),"bid":b})
return pd.DataFrame(rows),slot_q
def optimize_reserves(hist):
out=[]; curves=[]
for t in range(CONFIG["n_slots"]):
hs=hist[hist.slot==t]
auctions=[g.bid.to_numpy() for _,g in hs.groupby("auction")]
maxbid=np.quantile(hs.bid,0.97)
grid=np.linspace(0,maxbid,90)
rev=[]; sale=[]
for r in grid:
rs=[]; sold=[]
for b in auctions:
b=np.sort(b)[::-1]
if b[0] < r: rs.append(0.0); sold.append(0)
else:
pay=max(r,b[1] if len(b)>1 else r)
rs.append(pay); sold.append(1)
rev.append(np.mean(rs)); sale.append(np.mean(sold))
curves.append({"slot":t,"reserve":r,"mean_revenue":np.mean(rs),"sale_rate":np.mean(sold)})
k=int(np.argmax(rev)); out.append({"slot":t,"slot_name":SLOTS[t],"optimal_reserve":grid[k],
"expected_revenue":rev[k],"sale_rate":sale[k]})
return pd.DataFrame(out),pd.DataFrame(curves)
def simulate_dynamic(groups,ads,static_matrix):
G=len(groups); A=len(ads); T=CONFIG["n_cycles"]
p0=static_matrix.pivot(index="group",columns="ad_id",values="p_static").to_numpy()
# 转化先验与真实参数
conv0=np.clip(0.025+0.12*ads.creative.to_numpy()[None,:]+0.02*RNG.normal(size=(G,A)),0.008,0.22)
alpha=1+8*p0; beta=1+8*(1-p0)
ca=1+25*conv0; cb=1+25*(1-conv0)
traj=[]; post_var=[]
static_choice=np.argmax(p0,axis=1)
cum_dyn_sales=cum_static_sales=0; cum_dyn_clicks=cum_static_clicks=0; cum_imp=0
for k in range(T):
drift=0.045*np.sin(2*np.pi*k/T + np.linspace(0,1.5,G)[:,None])
fatigue=np.exp(-0.025*k*np.arange(A)[None,:]/A)
ptrue=np.clip(p0*fatigue + drift + RNG.normal(0,0.012,(G,A)),0.01,0.92)
ctrue=np.clip(conv0*(0.95+0.08*np.cos(2*np.pi*k/T))+RNG.normal(0,0.004,(G,A)),0.003,0.30)
post=alpha/(alpha+beta); cpost=ca/(ca+cb)
ucb=post+0.75*np.sqrt(post*(1-post)/(alpha+beta+1))
dyn_choice=np.argmax(ucb*(0.65+0.35*cpost/cpost.max()),axis=1)
cycle_dyn_click=cycle_dyn_sales=cycle_sta_click=cycle_sta_sales=0
for g in range(G):
n=CONFIG["exposures_per_group_cycle"]
a=dyn_choice[g]
clicks=RNG.binomial(n,ptrue[g,a]); sales=RNG.binomial(clicks,ctrue[g,a]) if clicks>0 else 0
alpha[g,a]=CONFIG["forgetting"]*alpha[g,a]+clicks
beta[g,a]=CONFIG["forgetting"]*beta[g,a]+n-clicks
ca[g,a]=CONFIG["forgetting"]*ca[g,a]+sales
cb[g,a]=CONFIG["forgetting"]*cb[g,a]+max(clicks-sales,0)
cycle_dyn_click+=clicks; cycle_dyn_sales+=sales
a0=static_choice[g]
c0=RNG.binomial(n,ptrue[g,a0]); s0=RNG.binomial(c0,ctrue[g,a0]) if c0>0 else 0
cycle_sta_click+=c0; cycle_sta_sales+=s0
cum_imp += G*CONFIG["exposures_per_group_cycle"]
cum_dyn_clicks += cycle_dyn_click; cum_dyn_sales += cycle_dyn_sales
cum_static_clicks += cycle_sta_click; cum_static_sales += cycle_sta_sales
traj.append({"cycle":k+1,"dynamic_ctr":cycle_dyn_click/(G*CONFIG["exposures_per_group_cycle"]),
"static_ctr":cycle_sta_click/(G*CONFIG["exposures_per_group_cycle"]),
"dynamic_sales":cycle_dyn_sales,"static_sales":cycle_sta_sales,
"cum_dynamic_sales":cum_dyn_sales,"cum_static_sales":cum_static_sales})
post_var.append({"cycle":k+1,"mean_posterior_var":np.mean((alpha*beta)/((alpha+beta)**2*(alpha+beta+1)))})
final=pd.DataFrame([(g,a,alpha[g,a]/(alpha[g,a]+beta[g,a]),ca[g,a]/(ca[g,a]+cb[g,a]))
for g in range(G) for a in range(A)],columns=["group","ad_id","p_update","cvr_update"])
return pd.DataFrame(traj),pd.DataFrame(post_var),final
def build_allocation(groups,ads,static_mat,updated,reserve_df,weights):
G=len(groups); A=len(ads); T=CONFIG["n_slots"]
base=static_mat[["group","ad_id","p_static"]].merge(updated,on=["group","ad_id"])
P=base.pivot(index="group",columns="ad_id",values="p_update").to_numpy()
C=base.pivot(index="group",columns="ad_id",values="cvr_update").to_numpy()
slot_view=np.array([[groups.loc[g,f"slot_{s}"] for s in SLOTS] for g in range(G)])
group_size=groups["size"].to_numpy()
bid=ads.bid_cpm.to_numpy(); budget=ads.budget.to_numpy(); reserve=reserve_df.optimal_reserve.to_numpy()
# 变量索引 (t,g,a)
n=T*G*A
rev=np.zeros(n); views=np.zeros(n); sales=np.zeros(n); costs=np.zeros(n)
def idx(t,g,a): return (t*G+g)*A+a
for t in range(T):
for g in range(G):
imp=max(group_size[g]*slot_view[g,t]*0.52,2.0)
for a in range(A):
k=idx(t,g,a)
cpm=max(reserve[t],0.92*bid[a])
rev[k]=cpm*imp/1000
views[k]=imp*P[g,a]
sales[k]=imp*P[g,a]*C[g,a]
costs[k]=rev[k]
# min-max 归一化目标系数
def norm(v):
return (v-v.min())/(v.max()-v.min()+1e-12)
score=weights["revenue"]*norm(rev)+weights["view"]*norm(views)+weights["sales"]*norm(sales)
c=-score
rows=[]; lb=[]; ub=[]
# 每个(t,g)恰好一个广告
for t in range(T):
for g in range(G):
inds=[idx(t,g,a) for a in range(A)]
rows.append((inds,[1.0]*A)); lb.append(1.0); ub.append(1.0)
# 每广告每时段最多 6 个群组,避免过度集中
for t in range(T):
for a in range(A):
inds=[idx(t,g,a) for g in range(G)]
rows.append((inds,[1.0]*G)); lb.append(0.0); ub.append(6.0)
# 预算约束
for a in range(A):
inds=[]; vals=[]
for t in range(T):
for g in range(G):
k=idx(t,g,a); inds.append(k); vals.append(costs[k])
rows.append((inds,vals)); lb.append(0.0); ub.append(float(budget[a]))
rr=[];cc=[];dd=[]
for r,(inds,vals) in enumerate(rows):
rr.extend([r]*len(inds)); cc.extend(inds); dd.extend(vals)
M=csr_matrix((dd,(rr,cc)),shape=(len(rows),n))
cons=LinearConstraint(M,np.array(lb),np.array(ub))
sol=milp(c,integrality=np.ones(n),bounds=Bounds(np.zeros(n),np.ones(n)),constraints=cons,
options={"time_limit":40})
x=np.rint(sol.x).astype(int)
rec=[]
for t in range(T):
for g in range(G):
for a in range(A):
k=idx(t,g,a)
if x[k]>0:
rec.append({"slot":t,"slot_name":SLOTS[t],"group":g,"ad_id":a,
"revenue":rev[k],"expected_views":views[k],"expected_sales":sales[k],
"p_update":P[g,a],"cvr_update":C[g,a]})
df=pd.DataFrame(rec)
metrics={"revenue":df.revenue.sum(),"expected_views":df.expected_views.sum(),"expected_sales":df.expected_sales.sum(),
"weighted_ctr":df.expected_views.sum()/sum(groups["size"])*1.0}
return df,metrics
def make_figures(users,ads,X,model_res,fitted,best,test_info,preds,groups,static_mat,hist,slot_q,reserves,curves,traj,pvar,updated,alloc,scheme_metrics,frontier):
# 1 age
plt.figure(figsize=(7.2,4.2)); plt.hist(users.age,bins=18,edgecolor="white"); plt.xlabel("年龄"); plt.ylabel("用户数"); plt.title("图1 用户年龄结构分布"); savefig("fig01_age_distribution.png")
#2 income
plt.figure(figsize=(7.2,4.2)); plt.hist(users.income/1000,bins=20,edgecolor="white"); plt.xlabel("月收入(千元)"); plt.ylabel("用户数"); plt.title("图2 用户收入分布"); savefig("fig02_income_distribution.png")
#3 pref by age
agebin=pd.cut(users.age,[17,25,35,45,55,80],labels=["18-25","26-35","36-45","46-55","56+"])
mat=users.groupby(agebin,observed=False)[[f"pref_{c}" for c in CATS]].mean().to_numpy()
plt.figure(figsize=(9,4.8)); plt.imshow(mat,aspect="auto"); plt.xticks(range(len(CATS)),CATS,rotation=25,ha="right"); plt.yticks(range(5),["18-25","26-35","36-45","46-55","56+"]); plt.colorbar(label="平均偏好权重"); plt.title("图3 不同年龄层的广告类别偏好热力图"); savefig("fig03_age_category_heatmap.png")
#4 ad cat count
cnt=ads.category_name.value_counts().reindex(CATS,fill_value=0)
plt.figure(figsize=(8,4.3)); plt.bar(cnt.index,cnt.values); plt.xticks(rotation=25,ha="right"); plt.ylabel("广告数量"); plt.title("图4 广告供给的类别结构"); savefig("fig04_ad_category_counts.png")
#5 corr
corr=X.corr().to_numpy(); plt.figure(figsize=(7.5,6.3)); plt.imshow(corr,vmin=-1,vmax=1,cmap="coolwarm"); plt.xticks(range(len(X.columns)),X.columns,rotation=55,ha="right",fontsize=8); plt.yticks(range(len(X.columns)),X.columns,fontsize=8); plt.colorbar(label="相关系数"); plt.title("图5 静态匹配特征相关性热力图"); savefig("fig05_feature_corr.png")
#6 model comparison
rr=model_res.set_index("model")
plt.figure(figsize=(7.5,4.5)); x=np.arange(len(rr)); w=.32; plt.bar(x-w/2,rr.test_auc,w,label="测试AUC"); plt.bar(x+w/2,rr.accuracy,w,label="准确率"); plt.xticks(x,rr.index); plt.ylim(.5,1.0); plt.legend(); plt.title("图6 静态匹配候选模型性能比较"); savefig("fig06_model_compare.png")
#7 ROC
plt.figure(figsize=(6.2,5.2))
for name,(p,y) in preds.items():
fpr,tpr,_=roc_curve(y,p); plt.plot(fpr,tpr,label=f"{name} AUC={roc_auc_score(y,p):.3f}")
plt.plot([0,1],[0,1],"--",linewidth=1); plt.xlabel("假阳性率"); plt.ylabel("真阳性率"); plt.legend(); plt.title("图7 静态匹配模型ROC曲线"); savefig("fig07_roc.png")
#8 calibration best
p,y=preds[best]; fp,mp=calibration_curve(y,p,n_bins=10,strategy="quantile")
plt.figure(figsize=(6,5)); plt.plot(mp,fp,"o-",label=best); plt.plot([0,1],[0,1],"--"); plt.xlabel("预测概率"); plt.ylabel("实际命中率"); plt.legend(); plt.title("图8 最优静态模型概率校准曲线"); savefig("fig08_calibration.png")
#9 static matrix
mat=static_mat.pivot(index="group",columns="ad_id",values="p_static").to_numpy(); plt.figure(figsize=(10,5.5)); plt.imshow(mat,aspect="auto",cmap="viridis"); plt.xlabel("广告编号"); plt.ylabel("用户群组"); plt.colorbar(label="静态匹配概率"); plt.title("图9 用户群组---广告静态匹配概率热力图"); savefig("fig09_static_match_heatmap.png")
#10 slot quality
slot_aud=np.array([users[f"slot_{s}"].mean() for s in SLOTS]); plt.figure(figsize=(7.2,4.2)); plt.bar(SLOTS,slot_aud); plt.ylabel("平均收视偏好"); plt.title("图10 各时段受众活跃度"); savefig("fig10_slot_audience.png")
#11 bids boxplot
data=[hist[hist.slot==t].bid.values for t in range(6)]; plt.figure(figsize=(8,4.5)); plt.boxplot(data,tick_labels=SLOTS,showfliers=False); plt.ylabel("竞价(CPM)"); plt.title("图11 各时段历史竞价分布"); savefig("fig11_bid_boxplot.png")
#12 reserve curves
plt.figure(figsize=(8.6,5.2))
for t in range(6):
d=curves[curves.slot==t]; plt.plot(d.reserve,d.mean_revenue,label=SLOTS[t])
plt.xlabel("底价(CPM)"); plt.ylabel("期望成交收入(单拍卖)"); plt.legend(ncol=2); plt.title("图12 底价---期望收入响应曲线"); savefig("fig12_reserve_revenue_curves.png")
#13 optimal reserve vs q
plt.figure(figsize=(6.8,4.5)); plt.scatter(slot_q,reserves.optimal_reserve,s=60)
for i,s in enumerate(SLOTS): plt.annotate(s,(slot_q[i],reserves.optimal_reserve.iloc[i]),xytext=(4,4),textcoords="offset points")
plt.xlabel("时段质量系数"); plt.ylabel("最优底价CPM"); plt.title("图13 时段质量与最优底价关系"); savefig("fig13_quality_reserve.png")
#14 dynamic ctr
plt.figure(figsize=(7.4,4.5)); plt.plot(traj.cycle,traj.dynamic_ctr,"o-",label="动态更新"); plt.plot(traj.cycle,traj.static_ctr,"s--",label="静态固定"); plt.xlabel("交易周期"); plt.ylabel("命中/观看率"); plt.legend(); plt.title("图14 动态更新与静态推送的周期命中率"); savefig("fig14_dynamic_ctr.png")
#15 sales cumulative
plt.figure(figsize=(7.4,4.5)); plt.plot(traj.cycle,traj.cum_dynamic_sales,"o-",label="动态更新"); plt.plot(traj.cycle,traj.cum_static_sales,"s--",label="静态固定"); plt.xlabel("交易周期"); plt.ylabel("累计销售转化数"); plt.legend(); plt.title("图15 动态更新带来的累计销售改善"); savefig("fig15_cumulative_sales.png")
#16 posterior variance
plt.figure(figsize=(7,4.2)); plt.plot(pvar.cycle,pvar.mean_posterior_var,"o-")