6.3 填充率与展示率诊断

填充率与展示率诊断回答的是「配置都对了、收入还是上不去」:把请求到完成的五级计数换成四级通过率,按上游优先的诊断树定位唯一瓶颈,并用 B 基准逐条阈值 ±20% 扰动检验诊断对健康线的敏感度;并配可跑的 Python 诊断脚本与可落 JSON 的稳健性基准。

站内《上线变现与长线运营》 把 eCPM 与填充率建过模,站内Defold 的广告接入那篇 讲清了 SDK 怎么接、瀑布流怎么聚。本节与两者分工不同:它不重做收益模型,也不重写 SDK 接入,只做一件运维最常撞上的事——**广告位接好了、配置也下发了,为什么收益还是上不去?**答案往往就藏在漏斗的某一级里。

上一节把「底价该填多少」算清楚了。但真实线上你会遇到更棘手的情况:按模型填了底价,收入反而掉了。这时候要的不是再算一遍模型,而是诊断——先定位钱丢在哪一级,再决定动哪里。

广告漏斗:五级计数,四级通过率

一个广告位从被调用到完成,经过五个计数、四级通过率:

级计数通过率健康线(千分比)
请求→填充requests → fillsfillRate = fills × 1000 // requestsfillMin = 800
填充→展示fills → showsshowOfFill = shows × 1000 // fillsshowOfFillMin = 700
展示→点击shows → clicksclickOfShow = clicks × 1000 // showsclickOfShowMin = 15
点击→完成clicks → completescompleteRate = completes × 1000 // clickscompleteOfClickMin = 800

四个通过率回答四个不同的问题:填充低是广告网络没货(底价太高、网络覆盖不够);展示低是有货没播出去(预加载没到位、生命周期太短、频次被限);点击低是播了没人理(形态不匹配、位置不显眼);完成低是点了没看完(激励视频被中途关掉、奖励吸引力不够)。

诊断树:上游优先短路

四级通过率不能平铺着看——它们有因果顺序。填充率崩了,展示率再漂亮也没有意义(根本没有广告可展示)。所以诊断必须上游优先短路:从填充率开始逐级往下看,第一个跌破健康线的级别就是当前唯一该动手的瓶颈,它之后的级别一律标 downstream,读数不可解释。

bottleneck = 第一个 rate < threshold 的级别;全达标则 ok
gap        = threshold - rate(千分比,负值取 0)

这条规则看着简单,却是运维最常犯错的点:看到「点击率低」就去改素材,其实填充率早就崩了,改素材纯属浪费。

S 层:诊断每个广告位的瓶颈

下面这段是 S 层仿真脚本 sim/o06_funnel_diag.py。它读 config/o06_funnel.json 的五个广告位计数,算出四级通过率、按诊断树定位瓶颈,并统计「哪个级别被卡得最多」,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/o06_funnel_diag.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o06_funnel_diag.py --config config/ --out out/
"""广告漏斗诊断树:读 config/o06_funnel.json,把每个广告位的「请求 → 填充 → 展示 →
点击 → 完成」五级计数换算成四级通过率,再按诊断树定位瓶颈。

    fillRate     = fills   * 1000 // requests     (请求 → 填充)
    showOfFill   = shows   * 1000 // fills        (填充 → 展示)
    clickOfShow  = clicks  * 1000 // shows        (展示 → 点击)
    completeRate = completes * 1000 // clicks     (点击 → 完成)

诊断树按**上游优先**短路:先看填充率——填充崩了,下游展示率再高也没有意义;
填充达标才看展示率,依此类推。第一个跌破健康线的级别就是当前唯一该动手的瓶颈,
它之后的级别一律标 downstream(上游没修好,下游读数不可解释)。
    bottleneck = 第一个 rate < threshold 的级别;全达标则 ok;gap = threshold - rate
口径:真实填充率 / 展示率由聚合后台给出,需真实业务环境,命令行不可复现(X)。
产出 out/o06_funnel_diag.json + .csv + .svg。全整数,无浮点。
"""
from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET

STAGES = [
    ("fill", "请求→填充", "fills", "requests", "fillMin"),
    ("show", "填充→展示", "shows", "fills", "showOfFillMin"),
    ("click", "展示→点击", "clicks", "shows", "clickOfShowMin"),
    ("complete", "点击→完成", "completes", "clicks", "completeOfClickMin"),
]

def load(d: str) -> dict:
    with open(os.path.join(d, "o06_funnel.json"), encoding="utf-8") as f:
        return json.load(f)

def diagnose(slot: dict, th: dict) -> dict:
    """返回该广告位的四级通过率 + 瓶颈级别(上游优先短路)。"""
    rates = {}
    for key, _cn, num, den, _tk in STAGES:
        rates[key] = slot[num] * 1000 // slot[den] if slot[den] else 0
    bottleneck, gap = "ok", 0
    for key, _cn, _num, _den, tk in STAGES:
        if rates[key] < th[tk]:
            bottleneck = key
            gap = th[tk] - rates[key]
            break
    downstream = False
    stage_rows = []
    for key, cn, _num, _den, tk in STAGES:
        status = "ok"
        if key == bottleneck:
            status = "bottleneck"
            downstream = True
        elif downstream:
            status = "downstream"
        stage_rows.append({"stage": key, "label": cn, "ratePermille": rates[key],
                           "thresholdPermille": th[tk], "status": status})
    return {"rates": rates, "bottleneck": bottleneck, "gapPermille": gap, "stages": stage_rows,
            "passThroughPermille": slot["completes"] * 1000 // slot["requests"]}

def draw_svg(path: str, rows: list) -> int:
    """每个广告位一组 4 条:各级通过率 / 健康线(1000 = 刚好达标),跌破线标红。"""
    W, H, PAD, ROW = 760, 80 + 74 * len(rows), 210, 74
    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 = "广告漏斗诊断:各级通过率 / 健康线(1000=达标,红=跌破)"
    span = W - PAD - 150
    for i, r in enumerate(rows):
        y = 54 + i * ROW
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 16), "font-size": "12", "fill": "#374151"}).text = r["name"]
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 32), "font-size": "11", "fill": "#ef4444"}).text = f'瓶颈 {r["bottleneck"]} · 缺口 {r["gapPermille"]}‰'
        for j, st in enumerate(r["stages"]):
            ratio = st["ratePermille"] * 1000 // st["thresholdPermille"] if st["thresholdPermille"] else 0
            w = span * min(ratio, 1400) // 1400
            color = "#ef4444" if st["status"] == "bottleneck" else ("#cbd5e1" if st["status"] == "downstream" else "#16a34a")
            ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + j * 15 + 2), "width": str(w), "height": "12", "rx": "3", "fill": color})
            ET.SubElement(svg, "text", {"x": str(PAD + w + 6), "y": str(y + j * 15 + 12), "font-size": "10", "fill": "#6b7280"}).text = f'{st["label"]} {st["ratePermille"]}‰ (线 {st["thresholdPermille"]}‰)'
    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)
    th = cfg["thresholdsPermille"]
    rows = [{**s, **diagnose(s, th)} for s in cfg["slots"]]

    hist = {k: 0 for k, _cn, _n, _d, _t in STAGES}
    hist["ok"] = 0
    for r in rows:
        hist[r["bottleneck"]] += 1
    digest = ",".join(f'{r["id"]}:{r["bottleneck"]}' for r in rows) + ";" + \
        ",".join(f"{k}={hist[k]}" for k in ("fill", "show", "click", "complete", "ok"))

    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "o06_funnel_diag.svg"), rows)
    with open(os.path.join(a.out, "o06_funnel_diag.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "platform", "requests", "fills", "shows", "clicks", "completes",
                    "fillPermille", "showOfFillPermille", "clickOfShowPermille", "completeOfClickPermille",
                    "passThroughPermille", "bottleneck", "gapPermille"])
        for r in rows:
            rt = r["rates"]
            w.writerow([r["id"], r["name"], r["platform"], r["requests"], r["fills"], r["shows"],
                        r["clicks"], r["completes"], rt["fill"], rt["show"], rt["click"], rt["complete"],
                        r["passThroughPermille"], r["bottleneck"], r["gapPermille"]])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "thresholdsPermille": th, "slotCount": len(rows), "slots": rows, "histogram": hist,
           "digest": digest, "csv": os.path.join(a.out, "o06_funnel_diag.csv"),
           "svg": os.path.join(a.out, "o06_funnel_diag.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "o06_funnel_diag.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"digest": digest, "histogram": hist,
                      "slots": [(r["id"], r["bottleneck"], r["gapPermille"], r["passThroughPermille"]) for r in rows]},
                     ensure_ascii=False, indent=2))
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

参数放在 config/o06_funnel.json。健康线是判断值,计数是同一广告位同期的整数:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "广告漏斗诊断参数。口径是「公开可观察机制」的建模假设,不是任何聚合后台实测。requests/fills/shows/clicks/completes 是同一广告位同期的计数;thresholdsPermille 是每一级的健康线(千分比)。真实填充率/展示率由聚合后台给出,需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "thresholdsPermille": { "fillMin": 800, "showOfFillMin": 700, "clickOfShowMin": 15, "completeOfClickMin": 800 },
  "slots": [
    { "id": "rv_double_wx", "name": "激励视频·翻倍(微信)", "platform": "wechat", "requests": 120000, "fills": 114000, "shows": 108000, "clicks": 19000, "completes": 17000 },
    { "id": "rv_offline_wx", "name": "激励视频·离线收益(微信)", "platform": "wechat", "requests": 90000, "fills": 58000, "shows": 55000, "clicks": 9000, "completes": 8000 },
    { "id": "itl_level_end_dy", "name": "插屏·过关(抖音)", "platform": "douyin", "requests": 100000, "fills": 92000, "shows": 46000, "clicks": 7000, "completes": 6000 },
    { "id": "banner_home_web", "name": "Banner·主页(Web)", "platform": "web", "requests": 200000, "fills": 180000, "shows": 170000, "clicks": 1200, "completes": 1100 },
    { "id": "rv_double_dy", "name": "激励视频·翻倍(抖音)", "platform": "douyin", "requests": 80000, "fills": 76000, "shows": 72000, "clicks": 14000, "completes": 8000 }
  ]
}

在本机跑 for f in sim/o06_*.py; do /opt/homebrew/bin/python3 "$f" --config config/ --out out/; done,诊断结果是(同机相对量级,绝对数值不可移植):

广告位填充‰展示/填充‰点击/展示‰完成/点击‰端到端‰瓶颈缺口‰
激励视频·翻倍(微信)950947175894141ok0
激励视频·离线收益(微信)64494816388888fill156
插屏·过关(抖音)92050015285760show200
Banner·主页(Web)90094479165click8
激励视频·翻倍(抖音)950947194571100complete229

五个广告位恰好覆盖了五种情况——一个健康、四个各卡在不同级别。这正是诊断树的价值:每个广告位给出一个明确、唯一的动作方向。离线收益位填充只有 644‰,先别管它的展示率;插屏过关位填充 920‰ 达标,才轮到看它的展示率只有 500‰(有货没播出去)。级别分布统计是 fill=1, show=1, click=1, complete=1, ok=1。

B 基准:诊断对健康线有多敏感

诊断结论依赖健康线,而健康线是人的判断。所以必须问:「如果我把某条线上调 / 下调 20%,瓶颈判定会翻盘吗?」下面这段是 B 层基准脚本 sim/o06_funnel_bench.py,逐条阈值 ±20% 重跑诊断,比较每个广告位的瓶颈是否变化,软告警不硬失败。

#!/usr/bin/env python3
# file: sim/o06_funnel_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/o06_funnel_bench.py --config config/ --out out/
"""B 基准:漏斗诊断对「健康线设在哪儿」的稳健性。

诊断结论依赖 thresholdsPermille——而健康线是**人的判断**,不是客观常数。所以要问:
「如果我把某条健康线上调 / 下调 20%,瓶颈判定会翻盘吗?」
做法:逐条阈值 ±20% 重跑诊断,逐广告位比较瓶颈级别是否变化。
软告警:任一广告位的瓶颈级别发生翻转即打 WARN(说明该位的诊断对健康线敏感)。
退出码恒为 0——这是基准,不是门禁。全整数,无浮点。
"""
from __future__ import annotations

import argparse
import json
import os

from o06_funnel_diag import diagnose, load

PERTURB_PCT = 20
KEYS = ("fillMin", "showOfFillMin", "clickOfShowMin", "completeOfClickMin")

def perturbed(th: dict, key: str, pct: int) -> dict:
    out = dict(th)
    out[key] = th[key] * (100 + pct) // 100
    return out

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)
    th = cfg["thresholdsPermille"]
    base = {s["id"]: diagnose(s, th)["bottleneck"] for s in cfg["slots"]}

    cases, flips = [], []
    for key in KEYS:
        for pct in (-PERTURB_PCT, PERTURB_PCT):
            th2 = perturbed(th, key, pct)
            verdict = {s["id"]: diagnose(s, th2)["bottleneck"] for s in cfg["slots"]}
            changed = {sid: [base[sid], verdict[sid]] for sid in base if verdict[sid] != base[sid]}
            cases.append({"threshold": key, "pct": pct, "newValue": th2[key], "verdict": verdict,
                          "changed": changed})
            for sid, pair in changed.items():
                flips.append({"threshold": key, "pct": pct, "slot": sid, "from": pair[0], "to": pair[1]})

    os.makedirs(a.out, exist_ok=True)
    rep = {"version": cfg["version"], "source": "sim/o06_funnel_bench.py", "perturbPct": PERTURB_PCT,
           "baseThresholds": th, "baseVerdict": base, "cases": cases,
           "flipCount": len(flips), "flips": flips,
           "verdict": "PASS(soft)" if not flips else f"WARN(soft) {len(flips)} 条翻转"}
    with open(os.path.join(a.out, "o06_funnel_bench.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"baseVerdict": base, "flipCount": len(flips), "flips": flips,
                      "verdict": rep["verdict"]}, ensure_ascii=False, indent=2))
    return 0  # B 层软告警:恒返回 0

if __name__ == "__main__":
    raise SystemExit(main())

在本机跑它,结果是 6 条翻转(WARN(soft),退出码仍为 0):

扰动广告位瓶颈 从 → 到
fillMin −20%离线收益(微信)fill → ok
fillMin +20%翻倍(微信)ok → fill
fillMin +20%插屏(抖音)show → fill
fillMin +20%Banner(Web)click → fill
fillMin +20%翻倍(抖音)complete → fill
completeOfClickMin +20%翻倍(微信)ok → complete

读这张表要读出两件事。第一,填充健康线是最敏感的旋钮——它一上调,四个广告位的瓶颈全部「前移」到填充。这不是 bug,而是诊断树短路逻辑的必然:填充是上游,上游线一动,下游结论全部重排。第二,这也说明诊断结论必须和健康线一起交代——单说「这个位卡在展示」而不说「在填充线 800‰ 的前提下」,是把判断当成了事实。B 基准的用途正是把这层前提暴露出来。

X 层:真实后台计数不可复现

边界必须划清。上面算的是诊断方法:给定五级计数与健康线,瓶颈在哪一级。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的五级计数(由聚合后台 / 平台后台给出,口径与去重规则各家不同);
  • 真实的健康线(不同品类、不同平台、不同季节的合理填充率差异很大,没有普适常数);
  • 真实的原因归属(填充低可能是底价、可能是网络封禁、可能是地域无货——诊断树只指出「哪一级」,不指出「为什么」)。

工程上正确的做法是:把可复现的诊断树与不可复现的计数来源分开——诊断树保证「给定计数,结论确定」,计数靠埋点与后台导出。诊断树只回答「哪一级」,为什么要靠人去查后台、查底价、查网络覆盖。

自检表

检查项合格标准
五级齐全请求 / 填充 / 展示 / 点击 / 完成每一级都有计数
上游优先诊断从填充开始短路,不跳级下结论
唯一瓶颈每个广告位给出一个瓶颈级别,不含糊
健康线留痕结论与所用健康线一起给出,不把判断当事实
稳健性有据对健康线做 ±20% 扰动,量化结论翻转数
边界清晰真实计数与原因归属标 X,不写成「实测」

漏斗诊断最危险的错觉,是把「瓶颈级别」当成「根因」。诊断树只告诉你钱丢在哪一级,为什么丢永远要回到后台、底价与网络覆盖去查。第 6 章到这里把广告位的配置、选价与诊断串成了一条闭环:6.1 把配置送到端上,6.2 决定配置填什么,6.3 检查填完之后哪里漏。下一章我们换一条完全不同的变现通道——内购与支付。

阅读导航:上一节:6.2 eCPM 分层与流量分组 · 下一节:7.1 平台内购通道与虚拟支付 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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