站内《上线变现与长线运营》 讲的是广告形态怎么选、eCPM 与频次怎么建模——那是「该不该有激励视频、插屏多久弹一次」的问题;站内还有一篇清单式的小游戏广告变现设计 ,把「要做什么」列成清单。本节与两者分工不同:它只处理同一个广告位,怎么把流量按价值分层、给每层配一个底价——这是配置下发系统里最值钱的那个参数。上一节讲了配置怎么下发,这一节讲配置里底价该填多少。
一个广告位只配一个底价,是把钱丢掉
最省事的做法是给每个广告位配一个底价,全平台所有流量都用它。但同一批请求的价值差异极大:一线城市晚高峰的激励视频,广告主愿意出高价;凌晨的 Banner,没人抢。用一个底价,两头都亏——底价定高了,低价值流量填不满,展示量为零;底价定低了,高价值流量被贱卖,白白少收钱。
eCPM 分层与流量分组就是把这一件事拆成两个正交维度:
| 维度 | 做什么 | 决定什么 |
|---|---|---|
| 分层(tier) | 按预测价值把请求分进几个桶 | 谁跟谁共用一个底价 |
| 分组(group) | 把每个桶挂到一组广告网络 | 这一层能碰到哪些广告主 |
公式:期望 eCPM = 填充率 × eCPM,全程整数
一个广告位在某底价下的期望收益,是填充率与成交 eCPM的乘积。两者都随底价反向变化:底价抬升,eCPM 上去但填充率下来,存在一个内部最优。写成整数公式:
expEcpm(group, f) = max_{n in group}( fill[n][f] × ecpm[n][f] // 1000 ) # 分/千次请求
tierRevenue(tier, f) = requests[tier] × expEcpm(group[tier], f) // 1000 # 分
tierBest(tier) = argmax_f tierRevenue(tier, f) # 并列取底价表靠前者
flatBest = max_f Σ_tier requests × expEcpm(group[tier], f) // 1000
tierBest 就是「这一层该配哪个底价」。flatBest 是没有分层能力时的最好结果:所有层被迫共用一个底价,只能挑一个让总收益最大的档位。两者之差,就是「分层」这件事本身值多少钱。
S 层:为每一层选底价,并与统一底价对照
下面这段是 S 层仿真脚本 sim/o06_ecpm_tier.py。它读 config/o06_ecpm.json 的三层流量与四个网络,为每层选最优底价,再算出「统一底价」的最好结果作对照,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o06_ecpm_tier.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o06_ecpm_tier.py --config config/ --out out/
"""eCPM 分层与流量分组仿真:读 config/o06_ecpm.json,把「按价值分层的流量」挂到
「不同网络组合的流量组」上,为每一层选一个底价,最大化期望收益。
expEcpm(group, f) = max_{n in group}( fill[n][f] * ecpm[n][f] // 1000 ) # 分/千次请求
tierRevenue(tier, f) = requests[tier] * expEcpm(group[tier], f) // 1000 # 分
tierBest(tier) = argmax_f tierRevenue(tier, f) # 并列取靠前者
对照口径(关键工程点):没有分层能力时只能给所有流量设同一底价,
flatBest = max_f Σ_tier requests * expEcpm(group[tier], f) // 1000
有分层能力后每层各选底价:tieredTotal = Σ_tier tierBest(tier).revenue
两者之差就是「分层」本身带来的收益(同机相对量级,绝对数值不可移植)。
真实 eCPM 与填充率由竞价环境决定,需真实业务环境,命令行不可复现(X)。
产出 out/o06_ecpm_tier.json + .csv + .svg。全整数,无浮点。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(d: str) -> dict:
with open(os.path.join(d, "o06_ecpm.json"), encoding="utf-8") as f:
return json.load(f)
def exp_ecpm(net: dict, f_idx: int) -> int:
"""某网络在底价候选 f_idx 下的期望 eCPM(分/千次请求)。"""
return net["fillPermilleByFloor"][f_idx] * net["ecpmCentByFloor"][f_idx] // 1000
def group_ecpm(by_net: dict, group: dict, f_idx: int) -> tuple:
"""流量组在某底价下的最优 (网络, 期望 eCPM)。并列取网络表靠前者。"""
best_id, best_val = "", -1
for nid in group["networks"]:
v = exp_ecpm(by_net[nid], f_idx)
if v > best_val:
best_id, best_val = nid, v
return best_id, best_val
def best_floor_for_tier(by_net: dict, by_group: dict, tier: dict, floors: list) -> dict:
g = by_group[tier["groupId"]]
best = None
for f_idx, f in enumerate(floors):
nid, val = group_ecpm(by_net, g, f_idx)
rev = tier["requests"] * val // 1000
cand = {"floorIdx": f_idx, "floorPriceCent": f, "network": nid, "expEcpmCent": val, "revenueCent": rev}
if best is None or rev > best["revenueCent"]:
best = cand
return best
def flat_revenue(by_net: dict, by_group: dict, tiers: list, floors: list, f_idx: int) -> int:
total = 0
for t in tiers:
_, val = group_ecpm(by_net, by_group[t["groupId"]], f_idx)
total += t["requests"] * val // 1000
return total
def draw_svg(path: str, rows: list, flat_total: int, tiered_total: int) -> int:
"""每个口径一根横条:各层自选底价 vs 统一底价,同机相对量级。"""
W, PAD, ROW = 720, 210, 46
items = [(r["name"], r["revenueCent"], f'底价 {r["floorPriceCent"]}分 · {r["network"]} · eCPM {r["expEcpmCent"]}', "#2563eb") for r in rows]
items += [("统一底价合计", flat_total, "所有层共用一个底价", "#94a3b8"),
("分层合计", tiered_total, "每层各选底价", "#16a34a")]
H, peak = 90 + ROW * len(items), max(v for _, v, _, _ in items)
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H),
"viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "28", "font-size": "15",
"fill": "#111827"}).text = "分层 vs 统一底价:期望收益(分,同机相对量级)"
for i, (name, val, sub, color) in enumerate(items):
y, w = 54 + i * ROW, (W - PAD - 40) * val // peak
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12", "fill": "#374151"}).text = name
ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "22",
"rx": "4", "fill": color})
ET.SubElement(svg, "text", {"x": str(PAD + w + 8), "y": str(y + 22), "font-size": "11",
"fill": "#6b7280"}).text = f"{val} 分 · {sub}"
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(svg))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
cfg = load(a.config)
floors = cfg["floorCandidatesCent"]
by_net = {n["id"]: n for n in cfg["networks"]}
by_group = {g["id"]: g for g in cfg["groups"]}
tiers = cfg["tiers"]
rows = []
for t in tiers:
best = best_floor_for_tier(by_net, by_group, t, floors)
rows.append({**t, **best})
tiered_total = sum(r["revenueCent"] for r in rows)
flat_by_floor = [{"floorPriceCent": f, "revenueCent": flat_revenue(by_net, by_group, tiers, floors, i)}
for i, f in enumerate(floors)]
flat_best = max(flat_by_floor, key=lambda r: r["revenueCent"])
digest = ",".join(f'{r["id"]}:{r["floorPriceCent"]}:{r["network"]}:{r["expEcpmCent"]}:{r["revenueCent"]}'
for r in rows) + f',total={tiered_total},base={flat_best["revenueCent"]}'
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "o06_ecpm_tier.svg"), rows, flat_best["revenueCent"], tiered_total)
with open(os.path.join(a.out, "o06_ecpm_tier.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["tierId", "name", "groupId", "requests", "floorPriceCent", "network", "expEcpmCent", "revenueCent"])
for r in rows:
w.writerow([r["id"], r["name"], r["groupId"], r["requests"], r["floorPriceCent"],
r["network"], r["expEcpmCent"], r["revenueCent"]])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"tierCount": len(rows), "tiers": rows, "tieredTotalCent": tiered_total,
"flatByFloor": flat_by_floor, "flatBestFloorCent": flat_best["floorPriceCent"],
"flatBestCent": flat_best["revenueCent"],
"liftPermille": tiered_total * 1000 // flat_best["revenueCent"] if flat_best["revenueCent"] else 0,
"deltaCent": tiered_total - flat_best["revenueCent"], "digest": digest,
"csv": os.path.join(a.out, "o06_ecpm_tier.csv"),
"svg": os.path.join(a.out, "o06_ecpm_tier.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "o06_ecpm_tier.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"digest": digest, "tieredTotalCent": tiered_total,
"flatBestFloorCent": flat_best["floorPriceCent"], "flatBestCent": flat_best["revenueCent"],
"liftPermille": rep["liftPermille"], "deltaCent": rep["deltaCent"],
"tiers": [(r["id"], r["floorPriceCent"], r["network"], r["expEcpmCent"], r["revenueCent"]) for r in rows]},
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
参数放在 config/o06_ecpm.json。填充率与 eCPM 都写成「每个候选底价一个值」的整数数组,没有任何浮点:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "eCPM 分层与流量分组参数。口径是「公开可观察机制」的建模假设,不是任何聚合后台实测。floorCandidatesCent=可选底价(分);fillPermilleByFloor=该网络在某底价下的填充率千分比;ecpmCentByFloor=该网络在某底价下的 eCPM(分/千次展示);group 把流量按价值分层挂到不同网络组合上。真实 eCPM 与填充率由竞价环境决定,需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"floorCandidatesCent": [0, 200, 400, 600, 800, 1000],
"groups": [
{ "id": "g_high", "name": "高价值组", "networks": ["csj"] },
{ "id": "g_mid", "name": "中价值组", "networks": ["ylh"] },
{ "id": "g_low", "name": "低价值组", "networks": ["admob", "house"] }
],
"tiers": [
{ "id": "t1", "name": "T1 高价值", "groupId": "g_high", "requests": 90000 },
{ "id": "t2", "name": "T2 中价值", "groupId": "g_mid", "requests": 210000 },
{ "id": "t3", "name": "T3 低价值", "groupId": "g_low", "requests": 300000 }
],
"networks": [
{ "id": "csj", "name": "穿山甲", "fillPermilleByFloor": [900, 880, 860, 840, 780, 300], "ecpmCentByFloor": [420, 560, 720, 900, 1120, 1360] },
{ "id": "ylh", "name": "优量汇", "fillPermilleByFloor": [880, 850, 800, 720, 560, 320], "ecpmCentByFloor": [400, 540, 700, 880, 1100, 1340] },
{ "id": "admob", "name": "AdMob", "fillPermilleByFloor": [950, 930, 760, 500, 300, 150], "ecpmCentByFloor": [300, 420, 560, 700, 860, 1020] },
{ "id": "house", "name": "自有兜底", "fillPermilleByFloor": [1000, 0, 0, 0, 0, 0], "ecpmCentByFloor": [60, 0, 0, 0, 0, 0] }
]
}
在本机跑 for f in sim/o06_*.py; do /opt/homebrew/bin/python3 "$f" --config config/ --out out/; done,每层选出的最优底价是(同机相对量级,绝对数值不可移植):
| 层 | 请求 | 流量组 | 最优底价(分) | 最优网络 | 期望 eCPM(分) | 期望收益(分) |
|---|---|---|---|---|---|---|
| T1 高价值 | 90000 | 高价值组 | 800 | 穿山甲 | 873 | 78570 |
| T2 中价值 | 210000 | 中价值组 | 600 | 优量汇 | 633 | 132930 |
| T3 低价值 | 300000 | 低价值组 | 400 | AdMob | 425 | 127500 |
规律很干净:越值钱的层,底价越高。高价值层敢把底价抬到 800 分,因为它的网络在高底价下填充率仍有 780‰;低价值层只能压到 400 分,再高填充就崩了。这就是「分层」的全部意义——同一批请求,价值不同,底价就不该一样。
P 层内核:跨语言对拍,证明选价逻辑没写错
一张表看不出「是模型对」还是「碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/o06/ecpm.ts 复算同一套选价逻辑:同样的填充率 × eCPM 地板除、同样的 argmax、同样的并列取法。它读同一份 config/o06_ecpm.json,产出与 Python 逐字符一致的摘要。
// file: core/src/o06/ecpm.ts
// tier: P
// verify: node --test 'core/test/o06_*.test.ts'
//
// eCPM 分层与流量分组内核:与 sim/o06_ecpm_tier.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o06_ecpm.json;全整数、地板除、无浮点、无 Math.random。
export interface AdNetwork { readonly id: string; readonly name: string;
readonly fillPermilleByFloor: readonly number[]; readonly ecpmCentByFloor: readonly number[]; }
export interface TrafficGroup { readonly id: string; readonly name: string; readonly networks: readonly string[]; }
export interface EcpmTier { readonly id: string; readonly name: string; readonly groupId: string; readonly requests: number; }
export interface EcpmConfig { readonly version: number; readonly game: string; readonly floorCandidatesCent: readonly number[];
readonly groups: readonly TrafficGroup[]; readonly tiers: readonly EcpmTier[]; readonly networks: readonly AdNetwork[]; }
export interface TierResult { readonly id: string; readonly name: string; readonly groupId: string; readonly requests: number;
readonly floorPriceCent: number; readonly network: string; readonly expEcpmCent: number; readonly revenueCent: number; }
/** 某网络在底价候选 fIdx 下的期望 eCPM(分/千次请求)。 */
export function expEcpm(net: AdNetwork, fIdx: number): number {
return Math.floor((net.fillPermilleByFloor[fIdx] * net.ecpmCentByFloor[fIdx]) / 1000);
}
/** 流量组在某底价下的最优 (网络, 期望 eCPM);并列取网络表靠前者。 */
export function groupEcpm(byNet: Record<string, AdNetwork>, group: TrafficGroup, fIdx: number): { id: string; val: number } {
let bestId = "";
let bestVal = -1;
for (const nid of group.networks) {
const v = expEcpm(byNet[nid], fIdx);
if (v > bestVal) { bestId = nid; bestVal = v; }
}
return { id: bestId, val: bestVal };
}
/** 某一层在候选底价里选收益最大的那个;并列取底价表靠前者。 */
export function bestFloorForTier(byNet: Record<string, AdNetwork>, byGroup: Record<string, TrafficGroup>,
tier: EcpmTier, floors: readonly number[]): Omit<TierResult, "id" | "name" | "groupId" | "requests"> {
const g = byGroup[tier.groupId];
let best = { floorPriceCent: 0, network: "", expEcpmCent: 0, revenueCent: -1 };
for (let fIdx = 0; fIdx < floors.length; fIdx += 1) {
const { id, val } = groupEcpm(byNet, g, fIdx);
const rev = Math.floor((tier.requests * val) / 1000);
if (rev > best.revenueCent) best = { floorPriceCent: floors[fIdx], network: id, expEcpmCent: val, revenueCent: rev };
}
return best;
}
/** 统一底价(不分层)在某底价下的总收益。 */
export function flatRevenue(byNet: Record<string, AdNetwork>, byGroup: Record<string, TrafficGroup>,
tiers: readonly EcpmTier[], fIdx: number): number {
let total = 0;
for (const t of tiers) {
const { val } = groupEcpm(byNet, byGroup[t.groupId], fIdx);
total += Math.floor((t.requests * val) / 1000);
}
return total;
}
export function evaluateAll(cfg: EcpmConfig): TierResult[] {
const byNet = Object.fromEntries(cfg.networks.map((n) => [n.id, n]));
const byGroup = Object.fromEntries(cfg.groups.map((g) => [g.id, g]));
return cfg.tiers.map((t) => ({ id: t.id, name: t.name, groupId: t.groupId, requests: t.requests,
...bestFloorForTier(byNet, byGroup, t, cfg.floorCandidatesCent) }));
}
/** 跨语言对拍摘要:与 sim/o06_ecpm_tier.py 的 digest 逐字符一致。 */
export function digest(rows: readonly TierResult[], baseCent: number): string {
const total = rows.reduce((s, r) => s + r.revenueCent, 0);
return rows.map((r) => `${r.id}:${r.floorPriceCent}:${r.network}:${r.expEcpmCent}:${r.revenueCent}`)
.join(",") + `,total=${total},base=${baseCent}`;
}
配套测试 core/test/o06_ecpm.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等。在本机跑 node --test 'core/test/o06_*.test.ts',12 个用例全过;两套独立实现给出同一个摘要:
digest = t1:800:csj:873:78570,t2:600:ylh:633:132930,t3:400:admob:425:127500,total=339000,base=305970
分层 vs 不分层:收益差多少
把 flatBest 也跑出来,就能回答「分层到底值不值」。脚本落盘的结果是:
| 口径 | 底价 | 期望收益(分) |
|---|---|---|
| 统一底价 | 600(所有层共用) | 305970 |
| 分层 | 每层各选 | 339000 |
分层比统一底价多 33030 分,同机相对量级是 339000 × 1000 // 305970 = 1107‰。注意两点:一是这个差值不是「提升 11 倍」,而是同机同参下的相对量级,换成别的填充率曲线会变;二是统一底价的最优档位落在 600 分——它被中价值层(占比最大)拖住了,高价值层被贱卖、低价值层被勉强照顾,这正是没有分层能力时必然的妥协。
X 层:真实 eCPM 与填充率不可复现
边界必须划清。上面算的是模型侧:给定填充率与 eCPM 曲线,最优底价是什么。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的 eCPM 与填充率曲线(由竞价环境、时段、人群、广告主预算共同决定,同一底价在不同天能差很多);
- 真实的流量分层信号(用什么特征预测「这层用户值多少钱」——是设备、地域还是历史变现,平台侧不透明)、真实的底价生效与冷却(改了底价要多久生效、会不会触发网络的低质标记)。
工程上正确的做法是:把可复现的选价模型与不可复现的真实曲线分开——模型用来回答「如果填充率是这条曲线,底价该填多少」,真实曲线靠投放后反标定(把实测填充率与 eCPM 填回 config,模型立刻反映现实)。
自检表
| 检查项 | 合格标准 |
|---|---|
| 分层有依据 | 每一层的划分标准写清楚,不是拍脑袋分组 |
| 底价可复算 | 给定填充率与 eCPM 曲线,最优底价逐位可复算 |
| 对照有基线 | 与「统一底价」对照,量化分层收益 |
| 全程整数 | 期望 eCPM 与收益全为整数地板除,无浮点 |
| 跨语言一致 | Python 与 TS 两套实现的摘要逐字符相等 |
| 边界清晰 | 真实 eCPM 与填充率曲线标 X,不写成「实测」 |
eCPM 分层最危险的错觉,是把「模型算出的最优底价」当成「后台该填的数」。模型只回答「在假设成立时填多少最优」,假设是否成立要靠投放后的真实曲线去证伪。下一节我们把视角从「配置该填什么」转到「填完之后为什么没效果」——填充率与展示率的诊断。
阅读导航:上一节:6.1 广告位 ID 体系与配置下发 · 下一节:6.3 填充率与展示率诊断 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。