6.2 eCPM 分层与流量分组

eCPM 分层与流量分组解决的是同一个广告位该配几个底价:把请求按预测价值分层、把每层挂到不同网络组合,用「填充率 × eCPM」的整数期望模型为每一层选一个最优底价;配可跑的 Python 分层仿真与 TypeScript 内核跨语言逐位对拍。

站内《上线变现与长线运营》 讲的是广告形态怎么选、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穿山甲87378570
T2 中价值210000中价值组600优量汇633132930
T3 低价值300000低价值组400AdMob425127500

规律很干净:越值钱的层,底价越高。高价值层敢把底价抬到 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 填充率与展示率诊断 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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