1.2 的滑杆已经透露了本节的结论:把激励视频上限从 8 拉到 12,当日收益涨了,净收益代理却掉了。这说明广告频次有一个最优值——太少,白丢收入;太多,把玩家赶走,长期 LTV 反而更低。本节要把「最优值在哪」算出来。这正是独立开发者手游广告失败复盘 里反复出现的坑:为了短期收入堆广告,结果 D1 留存崩了,买量成本再也收不回来。本节把这个坑变成一张可扫描、可扰动的量化模型。
两类广告:自愿的与强制的,惩罚不一样
1.1 已经按「打断性」把广告分成自愿与强制两类。它们对留存的影响不对称:激励视频是玩家主动点的,用「看广告」换「省时间」,单次收益高(26 个 0.1 分)、对留存的惩罚低;插屏是打断式的,单次收益低(12 个 0.1 分)、对留存的惩罚高。于是「一天放几次广告」不是一维问题,而是「自愿放几次 v × 强制放几次 f」的二维问题——有限的「体验预算」该花在哪一类上?下面这张 D 层配置给出留存曲线与两类惩罚系数的示例假设值,S 层脚本在 v × f 的网格上扫出净 LTV 最大的一格。
把留存曲线与惩罚系数写成一张可被程序消费的表(D 层)
下面这段是 D 层静态配置:一条 60 天的示例留存曲线(D1 留存 450‰,前 7 天衰减快、之后慢),两类广告各自的单次惩罚、单次收益与每日上限。要点:曲线与系数都是示例假设值,真实留存需真实业务环境,命令行不可复现(X);净 LTV 用 0.1 分记账,全程整数无浮点。
{
"version": 1,
"series": "《小游戏开发实战》",
"volume": "卷三《上线变现与长线运营》",
"game": "merge-island",
"observedAt": "2026-10",
"note": "广告频次与体验平衡(D 层)。留存曲线与两类广告的惩罚系数、单次收益均为示例假设参数;真实留存需真实业务环境,命令行不可复现(X)。收益用 0.1 分(deciFen)记账。",
"units": {
"voluntary": "自愿广告(激励视频)日均展示次数",
"forced": "强制广告(插屏)日均展示次数",
"factorPermille": "整体留存乘数,千分比",
"ltvDeciFen": "60 天净 LTV,单位 0.1 分"
},
"baseD1Permille": 450,
"earlyDecayPermille": 850,
"lateDecayPermille": 960,
"earlyEndDay": 7,
"horizonDays": 60,
"voluntaryPenaltyPermillePerImp": 60,
"forcedPenaltyPermillePerImp": 140,
"minFactorPermille": 200,
"voluntaryRevDeciFenPerImp": 26,
"forcedRevDeciFenPerImp": 12,
"voluntaryMaxPerDay": 12,
"forcedMaxPerDay": 8,
"benchPenaltyPerturbPct": 30,
"warnOptShift": 1
}
用一段 S 层脚本扫出净 LTV 最大的频次组合
下面这段是 S 层仿真脚本,读上面那份配置,在 v(0–12) × f(0–8) 共 117 个格子上各算一次「留存乘数 = max(下限, 1000 − v×自愿惩罚 − f×强制惩罚)」,再乘上每日广告收入与留存曲线,累加成 60 天净 LTV,找出最大的一格,产出 JSON + CSV + SVG。它回答的不是「能放多少」,而是「放多少最划算」。
#!/usr/bin/env python3
# file: sim/l01_frequency.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l01_frequency.py --config config/ --out out/
"""广告频次与体验平衡:读 config/l01_frequency.json,在「自愿广告 v × 强制广告 f」的
二维网格上扫 0..voluntaryMaxPerDay × 0..forcedMaxPerDay,每格算:
factor = max(minFactor, 1000 - v*volPen - f*forPen)
dailyRev = v*volRev + f*forRev # 0.1 分
LTV = sum_d baseCurve[d] * factor // 1000 * dailyRev // 1000
找出净 LTV 最大的频次组合。自愿广告是玩家主动点的,单次收益高、惩罚低;强制广告是
打断式的,单次收益低、惩罚高。模型回答:有限的「体验预算」该花在哪一类上。
留存曲线与惩罚系数、单次收益均为示例假设参数,真实留存需真实业务环境,命令行不可
复现(X)。全整数,无浮点。
"""
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, "l01_frequency.json"), encoding="utf-8") as f:
return json.load(f)
def base_curve(cfg: dict) -> list[int]:
c = [0] * cfg["horizonDays"]
c[0], c[1] = 1000, cfg["baseD1Permille"]
for d in range(2, cfg["horizonDays"]):
rate = cfg["earlyDecayPermille"] if d <= cfg["earlyEndDay"] else cfg["lateDecayPermille"]
c[d] = c[d - 1] * rate // 1000
return c
def cell(cfg: dict, curve: list[int], v: int, f: int) -> dict:
fac = max(cfg["minFactorPermille"],
1000 - v * cfg["voluntaryPenaltyPermillePerImp"] - f * cfg["forcedPenaltyPermillePerImp"])
daily = v * cfg["voluntaryRevDeciFenPerImp"] + f * cfg["forcedRevDeciFenPerImp"]
ltv = sum(curve[d] * fac // 1000 * daily // 1000 for d in range(len(curve)))
return {"voluntary": v, "forced": f, "total": v + f, "factorPermille": fac, "ltvDeciFen": ltv}
def color(ratio: int) -> str:
for cut, c in ((950, "#0369a1"), (800, "#0ea5e9"), (600, "#38bdf8"),
(400, "#7dd3fc"), (200, "#bae6fd")):
if ratio >= cut:
return c
return "#e0f2fe"
def svg(path: str, grid: list[dict], vmax: int, fmax: int, best: dict) -> int:
"""纯 Python SVG 热力图:行 = 自愿次数 0..vmax,列 = 强制次数 0..fmax。"""
cw, ch, ox, oy = 52, 26, 96, 76
W, H = ox + cw * (fmax + 1) + 40, oy + ch * (vmax + 1) + 30
r = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(r, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(r, "text", {"x": "16", "y": "26", "font-size": "15",
"fill": "#111827"}).text = "净 LTV 热力图:行=自愿次数 v,列=强制次数 f(越深越优)"
top = max((g["ltvDeciFen"] for g in grid), default=1) or 1
for j in range(fmax + 1):
ET.SubElement(r, "text", {"x": str(ox + j * cw + cw // 2), "y": str(oy - 8),
"font-size": "11", "text-anchor": "middle",
"fill": "#374151"}).text = f"f={j}"
for g in grid:
hit = g["voluntary"] == best["voluntary"] and g["forced"] == best["forced"]
ET.SubElement(r, "rect", {"x": str(ox + g["forced"] * cw), "y": str(oy + g["voluntary"] * ch),
"width": str(cw - 2), "height": str(ch - 2), "rx": "3",
"fill": color(g["ltvDeciFen"] * 1000 // top),
"stroke": "#111827" if hit else "none", "stroke-width": "2"})
for i in range(vmax + 1):
ET.SubElement(r, "text", {"x": str(ox - 8), "y": str(oy + i * ch + ch // 2 + 4),
"font-size": "11", "text-anchor": "end",
"fill": "#374151"}).text = f"v={i}"
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(r).write(path, encoding="utf-8", xml_declaration=True)
return os.path.getsize(path)
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)
curve = base_curve(cfg)
vmax, fmax = cfg["voluntaryMaxPerDay"], cfg["forcedMaxPerDay"]
grid = [cell(cfg, curve, v, f) for v in range(vmax + 1) for f in range(fmax + 1)]
best = max(grid, key=lambda g: (g["ltvDeciFen"], -g["total"], g["voluntary"]))
pure_v = next(g for g in grid if g["voluntary"] == vmax and g["forced"] == 0)
pure_f = next(g for g in grid if g["voluntary"] == 0 and g["forced"] == fmax)
os.makedirs(a.out, exist_ok=True)
sb = svg(os.path.join(a.out, "l01_frequency.svg"), grid, vmax, fmax, best)
with open(os.path.join(a.out, "l01_frequency.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["voluntary", "forced", "total", "factorPermille", "ltvDeciFen"])
for g in grid:
w.writerow([g["voluntary"], g["forced"], g["total"], g["factorPermille"], g["ltvDeciFen"]])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"horizonDays": cfg["horizonDays"], "cellCount": len(grid), "best": best,
"pureVoluntary": pure_v, "pureForced": pure_f,
"bestVsPureForcedPermille": best["ltvDeciFen"] * 1000 // pure_f["ltvDeciFen"]
if pure_f["ltvDeciFen"] else 0,
"svg": os.path.join(a.out, "l01_frequency.svg"), "svgBytes": sb}
with open(os.path.join(a.out, "l01_frequency.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"cellCount": len(grid), "best": best,
"pureVoluntary": {"v": pure_v["voluntary"], "f": pure_v["forced"],
"ltvDeciFen": pure_v["ltvDeciFen"]},
"pureForced": {"v": pure_f["voluntary"], "f": pure_f["forced"],
"ltvDeciFen": pure_f["ltvDeciFen"]},
"bestVsPureForcedPermille": rep["bestVsPureForcedPermille"],
"svgBytes": sb}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机跑出来的输出(同机相对量级,绝对数值不可移植):
{
"cellCount": 117,
"best": { "voluntary": 8, "forced": 0, "total": 8, "factorPermille": 520, "ltvDeciFen": 639 },
"pureVoluntary": { "v": 12, "f": 0, "ltvDeciFen": 507 },
"pureForced": { "v": 0, "f": 8, "ltvDeciFen": 90 },
"bestVsPureForcedPermille": 7100,
"svgBytes": 13762
}
最优组合是自愿 8 次、强制 0 次,净 LTV 639;把所有预算堆到自愿广告上限(v=12、f=0)反而只有 507,因为惩罚把留存乘数压到了 280;全部用强制广告(v=0、f=8)只剩 90——最优组合是纯强制的 7100‰(7.1 倍)。两个结论:第一,自愿广告的最优次数落在中间(8),不是上限;第二,在这个模型里强制广告的最优次数是 0,插屏只会净亏。注意这是同机相对量级,真实惩罚系数得自己用 A/B 测(X)。
用一段 B 层基准检验「最优组合稳不稳」
上面的 (8, 0) 依赖两个惩罚系数,而它们恰恰是最不确定的假设。下面这段是 B 层基准:把自愿惩罚与强制惩罚各自 ±30% 交叉扰动,共 4 场景 + 1 基线,重算每格的最优,看 v* 会不会漂移。B 层是软告警——无论漂移多少,退出码恒为 0,它的职责是暴露「结论有多依赖假设」,而不是判失败。
#!/usr/bin/env python3
# file: sim/l01_frequency_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/l01_frequency_bench.py --config config/ --out out/
"""广告频次「最优组合稳定性」基准(软告警,不硬失败)。
l01_frequency.py 的最优组合 (v*, f*) 依赖两个惩罚系数,而它们正是最不确定的假设。
本脚本把 voluntaryPenaltyPermillePerImp / forcedPenaltyPermillePerImp 各自 ±30% 交叉
扰动,共 4 场景 + 1 基线,重算网格最优,看 (v*, f*) 会不会漂移。
判据(软):任一扰动场景下 v* 与基线相差 >= warnOptShift 记为「最优漂移」告警;
无论多少告警,退出码恒为 0。全整数,无浮点。
"""
from __future__ import annotations
import argparse
import json
import os
def load(p: str) -> dict:
with open(p, encoding="utf-8") as f:
return json.load(f)
def base_curve(cfg: dict) -> list[int]:
c = [0] * cfg["horizonDays"]
c[0], c[1] = 1000, cfg["baseD1Permille"]
for d in range(2, cfg["horizonDays"]):
rate = cfg["earlyDecayPermille"] if d <= cfg["earlyEndDay"] else cfg["lateDecayPermille"]
c[d] = c[d - 1] * rate // 1000
return c
def optimum(cfg: dict, curve: list[int], vol_pen: int, for_pen: int) -> dict:
best_key, best = None, None
for v in range(cfg["voluntaryMaxPerDay"] + 1):
for f in range(cfg["forcedMaxPerDay"] + 1):
fac = max(cfg["minFactorPermille"], 1000 - v * vol_pen - f * for_pen)
daily = v * cfg["voluntaryRevDeciFenPerImp"] + f * cfg["forcedRevDeciFenPerImp"]
ltv = sum(curve[d] * fac // 1000 * daily // 1000 for d in range(len(curve)))
key = (ltv, -(v + f), v)
if best_key is None or key > best_key:
best_key, best = key, {"voluntary": v, "forced": f, "factorPermille": fac,
"ltvDeciFen": ltv}
return best
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
cfg = load(os.path.join(a.config, "l01_frequency.json"))
curve = base_curve(cfg)
vp0, fp0 = cfg["voluntaryPenaltyPermillePerImp"], cfg["forcedPenaltyPermillePerImp"]
pct = cfg["benchPenaltyPerturbPct"]
base = optimum(cfg, curve, vp0, fp0)
scen = [{"id": "base", "opt": base}]
for vp_pct in (100 + pct, 100 - pct):
for fp_pct in (100 + pct, 100 - pct):
vp, fp = vp0 * vp_pct // 100, fp0 * fp_pct // 100
scen.append({"id": f"vol{vp_pct}-for{fp_pct}", "volPen": vp, "forPen": fp,
"opt": optimum(cfg, curve, vp, fp)})
warns = []
for s in scen[1:]:
if abs(s["opt"]["voluntary"] - base["voluntary"]) >= cfg["warnOptShift"]:
warns.append(f"{s['id']}:最优自愿次数 {base['voluntary']}→{s['opt']['voluntary']} 漂移")
rep = {"version": cfg["version"], "source": "sim/l01_frequency_bench.py",
"observedAt": cfg["observedAt"], "perturbPct": pct, "scenarioCount": len(scen),
"baseline": base, "scenarios": scen,
"voluntaryShift": sorted({s["opt"]["voluntary"] for s in scen}),
"forcedShift": sorted({s["opt"]["forced"] for s in scen}), "warnings": warns,
"verdict": "PASS(soft)" if not warns else f"WARN(soft) {len(warns)} 条"}
os.makedirs(a.out, exist_ok=True)
with open(os.path.join(a.out, "l01_frequency_bench.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"scenarioCount": len(scen), "baseline": base,
"opts": [{"id": s["id"], "v": s["opt"]["voluntary"], "f": s["opt"]["forced"],
"ltvDeciFen": s["opt"]["ltvDeciFen"]} for s in scen],
"verdict": rep["verdict"], "warnings": warns}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机跑出来的输出(同机相对量级,绝对数值不可移植):
{
"scenarioCount": 5,
"baseline": { "voluntary": 8, "forced": 0, "factorPermille": 520, "ltvDeciFen": 639 },
"opts": [
{ "id": "base", "v": 8, "f": 0, "ltvDeciFen": 639 },
{ "id": "vol130-for130", "v": 7, "f": 0, "ltvDeciFen": 483 },
{ "id": "vol130-for70", "v": 7, "f": 0, "ltvDeciFen": 483 },
{ "id": "vol70-for130", "v": 11, "f": 0, "ltvDeciFen": 921 },
{ "id": "vol70-for70", "v": 11, "f": 0, "ltvDeciFen": 921 }
],
"verdict": "WARN(soft) 4 条",
"warnings": ["vol130-for130:最优自愿次数 8→7 漂移", "vol130-for70:最优自愿次数 8→7 漂移",
"vol70-for130:最优自愿次数 8→11 漂移", "vol70-for70:最优自愿次数 8→11 漂移"]
}
结果值得警惕:自愿次数的最优值在 7 到 11 之间漂移——惩罚系数降三成,最优就从中点 8 滑到上限 11。这说明「8」这个具体数字并不稳健,它完全取决于你对惩罚系数的估计。但另一个结论很稳健:强制次数在所有扰动场景里都是 0(forcedShift 只有 [0])。所以可落地的经验是——强制广告的频次要保守到「尽量不放」,自愿广告的频次给一个区间(如 7–11)再用 A/B 校准,而不是背一个固定值。这就是第 8 章 A/B 实验要接手的活。
阅读导航:上一节:1.2 eCPM、填充率与收益模型 · 下一节:2.1 内购点与定价分层 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。