5.3 瀑布流与竞价配置

瀑布流按顺序找填充、实时竞价让所有买家同时出价,两种撮合方式在同一批广告请求上算出来的收入并不相同。本节讲瀑布与竞价的机制差异与配置取舍,用可跑的 Python 双模式仿真、TypeScript 内核跨语言对拍与 B 层扰动基准,算清竞价相对瀑布的收入差从哪里来、又受哪些参数约束。

站内有一篇广告变现设计清单 ,那是清单式短文:把该配的广告位、该做的频控列出来。本节做的是它没做的事——把瀑布流与竞价放在同一批请求上对照,用可跑的模型算出收入差。另有卷三目录 ,那 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(分)当日收入(分)
瀑布 waterfall10006236124733
竞价 bidding10006592131844

竞价模式的当日收入 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 体系与配置下发 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制