站内有一篇广告变现设计清单 ,那是清单式短文:把该配的广告位、该做的频控列出来。本节做的是它没做的事——把瀑布流与竞价放在同一批请求上对照,用可跑的模型算出收入差。另有卷三目录 ,那 51 篇把 eCPM 收益模型、LTV 回本讲透了;本节的定位是聚合层内部的中介策略:在已经知道 eCPM 怎么来的前提下,决定「一次请求到底怎么撮合」。5.1 讲了瀑布流怎么按顺序找填充,5.2 讲了三家网络的接入差异,现在到了一个绕不开的配置问题:瀑布的顺序和底价该怎么定?要不要从瀑布切到实时竞价? 这看起来是运营手感,其实是可以算的——只要把两种撮合方式放在同一批请求上跑一遍,收入差就是答案。
瀑布 vs 竞价:两种撮合方式
| 维度 | 瀑布流 waterfall | 实时竞价 in-app bidding |
|---|---|---|
| 撮合方式 | 按顺序逐层问,命中即停 | 所有买家同时出价,最高者胜 |
| 决策时机 | 请求前排好顺序(静态) | 每次请求现算(动态) |
| 参与门槛 | 靠瀑布位置,位置有限 | 只要能出价就能参与 |
| 延迟 | 逐层问询,层数越多越慢 | 一次并发问询,层数与延迟弱相关 |
| 主要风险 | 顺序定错 → 高价被低层挡住 | 参与率低 → 高价买家没出价 |
| 配置对象 | 顺序 + 各层底价 | 参与率 + 底价 |
关键差别在参与门槛:瀑布流里,一家网络能不能吃到某次请求,取决于它在瀑布里排第几、以及前面几层是否落空;竞价里,只要它愿意出价就能参与,高位买家的参与不再受瀑布位置限制。这就是竞价通常能抬升 eCPM 的机制来源。两种模式用的是同一批随机数序列(同一个 seed),所以收入差只来自撮合规则本身,不来自随机波动——这是「对照实验」在模型层的最小实现。
两种模式的整数公式
瀑布模式:逐层抽 r = randPermille();r < fillPermille[i] → 第 i 层填充,贡献 ecpmFen[i](命中即停)
竞价模式:每层抽 r;r < bidPermille[i] → 参与出价;最高 ecpmFen 者胜,无人出价则未填充
平均 eCPM(分) = Σ 命中/胜出的 ecpmFen / filled
当日收入(分) = Σ 命中/胜出的 ecpmFen / 1000
收入差(‰) = (竞价收入 - 瀑布收入) × 1000 / 瀑布收入
S 层:同一批请求,两种撮合各走一遍
下面这段是 S 层仿真脚本 sim/o05_waterfall.py。它读 config/o05_waterfall.json,用可播种整数 LCG 把 requestsPerDay 次请求分别走一遍瀑布与竞价,输出两种模式的填充率、平均 eCPM、当日收入与收入差,并产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o05_waterfall.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o05_waterfall.py --config config/ --out out/
"""瀑布流 vs 实时竞价:同一批请求,两种撮合方式各走一遍,算收入差。
对 config/o05_waterfall.json 的请求流(可播种整数 LCG,seed 来自 config):
瀑布模式:自上而下请求,逐层抽 r,r < fillPermille 即该层填充并贡献其 ecpmFen。
竞价模式:每个参与者抽一次 r,r < bidPermille 即出价;所有出价者里取
ecpmFen 最高者胜出;无人出价则本次未填充。
avgEcpmFen(mode) = ecpmSum // filled
revenueFen(mode) = ecpmSum // 1000
upliftPermille = (bidRev - wfRev) * 1000 // wfRev # 竞价相对瀑布的收入差
随机数与 core/src/o05/waterfall.ts 逐位一致,两套实现给出同一个 digest。
全程整数、地板除,无浮点。产出 out/o05_waterfall.json + .csv + .svg。
撮合参数是「公开可观察机制」口径的建模假设;真实胜出价与真实 eCPM 需真实业务
环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
MOD = 2147483648 # 2^31
MUL = 1664525
INC = 1013904223
def lcg_next(state: int) -> int:
return (MUL * state + INC) % MOD
def rand_permille(state: int) -> int:
return (lcg_next(state) * 1000) // MOD
def run_waterfall(nets: list[dict], requests: int, seed: int) -> dict:
state = seed
ecpm_sum = 0
filled = 0
for _ in range(requests):
for n in nets:
r = rand_permille(state)
state = lcg_next(state)
if r < n["fillPermille"]:
ecpm_sum += n["ecpmFen"]
filled += 1
break
return {"mode": "waterfall", "filledRequests": filled, "unfilledRequests": requests - filled,
"fillPermille": filled * 1000 // requests,
"avgEcpmFen": ecpm_sum // filled if filled else 0,
"revenueFen": ecpm_sum // 1000, "ecpmSumFen": ecpm_sum}
def run_bidding(nets: list[dict], requests: int, seed: int) -> dict:
state = seed
ecpm_sum = 0
filled = 0
for _ in range(requests):
best = 0
for n in nets:
r = rand_permille(state)
state = lcg_next(state)
if r < n["bidPermille"] and n["ecpmFen"] > best:
best = n["ecpmFen"]
if best > 0:
ecpm_sum += best
filled += 1
return {"mode": "bidding", "filledRequests": filled, "unfilledRequests": requests - filled,
"fillPermille": filled * 1000 // requests,
"avgEcpmFen": ecpm_sum // filled if filled else 0,
"revenueFen": ecpm_sum // 1000, "ecpmSumFen": ecpm_sum}
def draw_svg(path: str, wf: dict, bid: dict) -> int:
"""纯 Python SVG:两种模式的收入横条对照。"""
W, H, PAD, ROW = 760, 150, 150, 46
root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(root, "text", {"x": "16", "y": "28", "font-size": "15", "fill": "#111827"}).text = (
"《合成小岛》瀑布流 vs 实时竞价:当日广告收入对照(分)")
rows = [("瀑布 waterfall", wf, "#93c5fd"), ("竞价 bidding", bid, "#2563eb")]
span = W - PAD - 24
hi = max(wf["revenueFen"], bid["revenueFen"], 1)
for i, (label, res, color) in enumerate(rows):
y = 60 + i * ROW
ET.SubElement(root, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
"fill": "#374151"}).text = label
w = span * res["revenueFen"] // hi
ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(w),
"height": "22", "rx": "3", "fill": color})
ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 20), "font-size": "11",
"fill": "#6b7280"}).text = (
f'{res["revenueFen"]}分 · 填充 {res["fillPermille"]}‰ · 平均 eCPM {res["avgEcpmFen"]}分')
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(root))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
with open(os.path.join(args.config, "o05_waterfall.json"), encoding="utf-8") as f:
cfg = json.load(f)
nets, requests, seed = cfg["networks"], cfg["requestsPerDay"], cfg["seed"]
wf = run_waterfall(nets, requests, seed)
bid = run_bidding(nets, requests, seed)
uplift = (bid["revenueFen"] - wf["revenueFen"]) * 1000 // wf["revenueFen"] if wf["revenueFen"] else 0
digest = (f'wf:{wf["filledRequests"]}:{wf["fillPermille"]}:{wf["avgEcpmFen"]}:{wf["revenueFen"]}'
f'|bid:{bid["filledRequests"]}:{bid["fillPermille"]}:{bid["avgEcpmFen"]}:{bid["revenueFen"]}')
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "o05_waterfall.svg"), wf, bid)
report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"seed": seed, "requests": requests, "waterfall": wf, "bidding": bid,
"upliftPermille": uplift, "digest": digest,
"svg": os.path.join(args.out, "o05_waterfall.svg"), "svgBytes": svg_bytes}
with open(os.path.join(args.out, "o05_waterfall.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "o05_waterfall.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
cols = ("mode", "filledRequests", "unfilledRequests", "fillPermille", "avgEcpmFen",
"revenueFen", "ecpmSumFen")
w.writerow(cols)
for r in (wf, bid):
w.writerow([r[k] for k in cols])
print(json.dumps({"digest": digest, "waterfall": wf, "bidding": bid,
"upliftPermille": uplift}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
撮合参数放在 config/o05_waterfall.json。数字仍是「公开可观察机制」口径的建模假设,不是任何聚合平台后台的实测;真实填充率、真实胜出价属于 X 层。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"seed": 771013,
"requestsPerDay": 20000,
"note": "瀑布流 vs 实时竞价对照参数。口径是「公开可观察机制」的建模假设,不是任何聚合平台后台实测。ecpmFen=该网络出价(分/千次);fillPermille=瀑布模式下该层被填充的千分比;bidPermille=竞价模式下该网络参与竞价的千分比。瀑布按数组顺序自上而下请求,命中即停;竞价模式下所有参与者同时出价,取最高 eCPM 者胜出,无人出价则未填充。真实填充率、真实竞价胜出价、真实 eCPM 需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"networks": [
{ "id": "pangle", "name": "穿山甲", "ecpmFen": 6800, "fillPermille": 700, "bidPermille": 880 },
{ "id": "ylh", "name": "优量汇", "ecpmFen": 5200, "fillPermille": 800, "bidPermille": 900 },
{ "id": "admob", "name": "AdMob", "ecpmFen": 4300, "fillPermille": 880, "bidPermille": 920 },
{ "id": "house", "name": "自有兜底", "ecpmFen": 800, "fillPermille": 1000, "bidPermille": 1000 }
]
}
在本机跑 /opt/homebrew/bin/python3 sim/o05_waterfall.py --config config/ --out out/,跑出来的结果(同机相对量级,绝对数值不可移植):
| 模式 | 填充率‰ | 平均 eCPM(分) | 当日收入(分) |
|---|---|---|---|
| 瀑布 waterfall | 1000 | 6236 | 124733 |
| 竞价 bidding | 1000 | 6592 | 131844 |
竞价模式的当日收入 131844 分,比瀑布的 124733 分高出 57‰(约 5.7%)。差额来自哪里?看平均 eCPM:竞价 6592 分 vs 瀑布 6236 分——竞价把高位买家的参与从「受瀑布位置限制」松绑成了「愿意出价就能上」,于是更多请求被高价买走。但注意:竞价不是无条件的更优——它的前提是 bidPermille(参与率)够高。如果参与率低于瀑布的填充率,结论会翻号(这正是下一节 B 基准要盯的事)。
B 层:扰动参数,看结论稳不稳
模型给了一个「竞价更优」的结论,但这个结论依赖一批可扰动的参数:eCPM、填充率、参与率。B 基准脚本 sim/o05_waterfall_bench.py 把这三类参数按整数网格扰动(eCPM ±20% / ±10% / 0,填充率与参与率 ±10% / 0),每个组合重跑两种撮合,记录收入差。在本机跑 /opt/homebrew/bin/python3 sim/o05_waterfall_bench.py --config config/ --out out/,输出 { "combos": 15, "minUpliftPermille": 54, "maxUpliftPermille": 59, "flipCount": 0, "verdict": "stable" }。15 个扰动组合里,收入差始终落在 54‰ ~ 59‰ 区间,没有任何一个组合翻号(flipCount 0)。这意味着「在这套参数附近,竞价略优于瀑布」是一个稳健结论,不是某一个巧合参数凑出来的。B 层的纪律是软告警不硬失败:一旦出现翻号,正文就必须如实写明前提,而不是硬说「竞价一定更好」。
P 层:跨语言对拍
S 层与 B 层都是 Python。本套用一份 P 层 TypeScript 内核 core/src/o05/waterfall.ts 复算同一条链:同样的整数 LCG、同样的瀑布命中即停、同样的竞价取最高。它读同一份 config/o05_waterfall.json,产出和 Python 逐字符一致的摘要。
// file: core/src/o05/waterfall.ts
// tier: P
// verify: node --test 'core/test/o05_waterfall.test.ts'
//
// 瀑布流 vs 实时竞价内核:与 sim/o05_waterfall.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o05_waterfall.json;整数 LCG、地板除、无浮点;只用可擦除语法。
export interface WaterfallNetwork {
readonly id: string; readonly name: string; readonly ecpmFen: number;
readonly fillPermille: number; readonly bidPermille: number;
}
export interface WaterfallConfig {
readonly version: number; readonly game: string; readonly seed: number;
readonly requestsPerDay: number; readonly networks: readonly WaterfallNetwork[];
}
export interface ModeResult {
readonly mode: string; readonly filledRequests: number; readonly unfilledRequests: number;
readonly fillPermille: number; readonly avgEcpmFen: number; readonly revenueFen: number;
readonly ecpmSumFen: number;
}
const MOD = 2147483648; // 2^31
const MUL = 1664525;
const INC = 1013904223;
export function lcgNext(state: number): number {
return (MUL * state + INC) % MOD;
}
export function randPermille(state: number): number {
return Math.floor((lcgNext(state) * 1000) / MOD);
}
function pack(mode: string, requests: number, filled: number, ecpmSum: number): ModeResult {
return { mode, filledRequests: filled, unfilledRequests: requests - filled,
fillPermille: Math.floor((filled * 1000) / requests),
avgEcpmFen: filled ? Math.floor(ecpmSum / filled) : 0,
revenueFen: Math.floor(ecpmSum / 1000), ecpmSumFen: ecpmSum };
}
export function runWaterfall(nets: readonly WaterfallNetwork[], requests: number, seed: number): ModeResult {
let state = seed; let ecpmSum = 0; let filled = 0;
for (let i = 0; i < requests; i += 1) {
for (const n of nets) {
const r = randPermille(state);
state = lcgNext(state);
if (r < n.fillPermille) { ecpmSum += n.ecpmFen; filled += 1; break; }
}
}
return pack("waterfall", requests, filled, ecpmSum);
}
export function runBidding(nets: readonly WaterfallNetwork[], requests: number, seed: number): ModeResult {
let state = seed; let ecpmSum = 0; let filled = 0;
for (let i = 0; i < requests; i += 1) {
let best = 0;
for (const n of nets) {
const r = randPermille(state);
state = lcgNext(state);
if (r < n.bidPermille && n.ecpmFen > best) best = n.ecpmFen;
}
if (best > 0) { ecpmSum += best; filled += 1; }
}
return pack("bidding", requests, filled, ecpmSum);
}
/** 竞价相对瀑布的收入差(千分比)。 */
export function upliftPermille(wf: ModeResult, bid: ModeResult): number {
return wf.revenueFen ? Math.floor(((bid.revenueFen - wf.revenueFen) * 1000) / wf.revenueFen) : 0;
}
/** 跨语言对拍摘要:与 sim/o05_waterfall.py 的 digest 逐字符一致。 */
export function digest(wf: ModeResult, bid: ModeResult): string {
return `wf:${wf.filledRequests}:${wf.fillPermille}:${wf.avgEcpmFen}:${wf.revenueFen}`
+ `|bid:${bid.filledRequests}:${bid.fillPermille}:${bid.avgEcpmFen}:${bid.revenueFen}`;
}
配套测试 core/test/o05_waterfall.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并验竞价收入高于瀑布、收入口径可复算。在本机跑 node --test 'core/test/o05_waterfall.test.ts',5 个用例全过;两套独立实现给出同一个摘要 digest = wf:20000:1000:6236:124733|bid:20000:1000:6592:131844。只要有一边的整数语义、竞价取最大的比较、或 LCG 常数写错,摘要立刻分叉。这和卷二「固定步长与确定性」
是同一个信念:同一批输入、同一套规则,必须给出同一个数——广告撮合也是确定性的工程对象。
X 层:真实竞价环境不可复现
- 真实的
bidPermille(各网络对某次请求是否出价,由网络侧算法决定,聚合层看不到); - 真实的胜出价与真实 eCPM(同一广告位不同时段、不同人群差异极大);
- 真实的竞价与瀑布混用策略(很多聚合平台支持「瀑布 + 竞价」混合,具体混合规则由平台定义)。
这些需真实业务环境,命令行不可复现。工程上的做法仍是分离两层:用模型层回答「参与率到多少、竞价才开始赢过瀑布」,用真实投放数据反标定参与率与 eCPM。
自检表
| 检查项 | 合格标准 |
|---|---|
| 对照公平 | 两种模式用同一批随机数序列,差异只来自撮合规则 |
| 公式完整 | 瀑布 / 竞价两条链都有整数公式,收入可复算 |
| 结论有界 | 给 B 基准,明确结论成立的参数区间 |
| 跨语言一致 | Python / TS 摘要逐字符相同 |
| 反标定可行 | 参与率、eCPM 能直接吃线上实测值 |
| 边界清晰 | 真实竞价环境标 X,不写成「实测」 |
瀑布与竞价的取舍,本质是**「用配置换确定性」还是「用竞价换上限」**:瀑布可控、可解释、延迟稳定;竞价上限更高、但依赖参与率。到这一节,聚合层的架构(5.1)、接入差异(5.2)、撮合策略(5.3)就闭环了。但模型终究只是模型——下一章我们从模型走到真实配置:广告位 ID 体系怎么设计、配置怎么下发到客户端、eCPM 怎么分层、填充率和展示率出问题时怎么诊断。
阅读导航:上一节:5.2 穿山甲、优量汇与 AdMob 的接入差异 · 下一节:6.1 广告位 ID 体系与配置下发 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。