前两节把广告(IAA)和内购(IAP)两半都摆上了桌,但都默认了一个前提:广告频次是给定的。这一节正面回答那个被反复推迟的问题——频次到底该定多少。边界同样要说清:LTV 回本周期、ARPDAU 与收入情景预测是卷三第 3 章的主题,本节给的是一个可跑的权衡模型:把「频次 → 日收益」和「频次 → 留存」两条曲线乘起来,找净 LTV 最大的落点,并用 B 基准验证这个落点稳不稳。
这个问题的棘手之处在于,它没有直觉解。频次加一档,收益一定涨;但留存一定掉。到底是赚是赔,取决于两条曲线的斜率之比——而斜率是没法靠感觉估的,只能算。
变现强度是一个旋钮,不是开关
先把「变现强度」这个词落地。它不是一个「要变现 / 不变现」的开关,而是几个可以连续调的旋钮:日均广告次数、插屏占比、激励视频的奖励力度、内购点的价格带。本节选最简单也最关键的一个旋钮——日均广告频次——来做权衡,因为它同时踩住收益和留存两端。
为什么频次会伤留存?因为它对应的是打断。上一节讲过,激励视频是自愿的,打断成本低;但当我们把「日均广告频次」当成一个整体旋钮往上拧时,多出来的部分必然会落到插屏、落地到「本来可以不看广告的时刻」。每多一次强制打断,玩家的耐受度就掉一点,最直接的体现就是 D1 留存下降——而 D1 又通过生命周期,反过来决定这个玩家一生能贡献多少广告收入。于是收益和留存之间形成了一条此消彼长的曲线。
D 层:把权衡写成一个可调参的模型
下面这份是 D 层配置。它把权衡拆成两组参数:一组是基准留存(不带广告惩罚的曲线形状),一组是惩罚与收益(每加一次频次,留存乘数降多少、每次展示值多少分)。全部整数,没有浮点。
{
"version": 1,
"game": "merge-island",
"note": "变现强度 vs 留存权衡模型(S 层)。baseD1Permille / penaltyPermillePerFreq / impressionRevenueDeciFen 均为示例假设参数;真实留存与广告收益需真实业务环境,命令行不可复现(X)。",
"baseD1Permille": 450,
"earlyDecayPermille": 850,
"lateDecayPermille": 960,
"earlyEndDay": 7,
"horizonDays": 60,
"penaltyPermillePerFreq": 100,
"minRetentionFactorPermille": 100,
"impressionRevenueDeciFen": 20,
"freqMax": 8,
"benchPerturbPct": 20,
"warnPeakShift": 1
}
核心是两个参数。penaltyPermillePerFreq = 100 表示「日均频次每加 1 次,整体留存乘数下降 100‰」——也就是频次从 0 加到 5,留存乘数从 1000‰ 掉到 500‰(半衰)。impressionRevenueDeciFen = 20 表示「每次展示值 2 分」(等价于 eCPM 20 元的示例量级)。这两个数都是假设:留存惩罚有多狠、单次展示值多少,必须靠真实环境标定,属于「需真实业务环境,命令行不可复现」(X)。本节算的是曲线形状与最优点位置。
S 层:频次 → 收益 × 留存 = 净 LTV
下面这段是 S 层仿真脚本。它扫频次 0 到 8,对每个频次算「整体留存乘数 × 基准留存曲线」得到实际留存,再乘上日收益、对生命周期逐日积分,得到净 LTV,最后找出峰值。全程整数地板除,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/ch10_balance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch10_balance.py --config config/ --out out/
"""变现强度 vs 留存权衡模型:扫「日均广告频次」0..freqMax。频次越高日收益越高,
但整体留存乘数越低、生命周期越短。把两者乘起来得到净 LTV,找净 LTV 最大的频次。
产出 JSON + CSV + SVG。全程整数,收益用「0.1 分」的 deciFen 记账。
模型(整数地板除):
baseCurve[d] = day0=1000, day1=baseD1, 其后按早/晚两段衰减
factor(f) = max(minRetentionFactor, 1000 - f * penaltyPermillePerFreq) # 整体留存乘数(‰)
curve(f)[d] = baseCurve[d] * factor(f) // 1000
dailyRev(f) = f * impressionRevenueDeciFen # 0.1 分
ltv(f) = sum_d curve(f)[d] * dailyRev(f) // 1000 # 0.1 分
注意:baseD1Permille / penaltyPermillePerFreq / impressionRevenueDeciFen 均为示例
假设参数,真实留存与广告收益需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(cfg_dir):
with open(os.path.join(cfg_dir, "ch10_balance.json"), encoding="utf-8") as f:
return json.load(f)
def base_curve(cfg):
curve = [1000, cfg["baseD1Permille"]]
for d in range(2, cfg["horizonDays"] + 1):
decay = cfg["earlyDecayPermille"] if d <= cfg["earlyEndDay"] else cfg["lateDecayPermille"]
curve.append(curve[-1] * decay // 1000)
return curve
def factor_of(cfg, freq):
return max(cfg["minRetentionFactorPermille"], 1000 - freq * cfg["penaltyPermillePerFreq"])
def sweep(cfg):
base = base_curve(cfg)
rows = []
for f in range(0, cfg["freqMax"] + 1):
fac = factor_of(cfg, f)
daily = f * cfg["impressionRevenueDeciFen"]
ltv = sum(v * fac // 1000 * daily // 1000 for v in base)
rows.append({"freqPerDay": f, "retentionFactorPermille": fac,
"d1Permille": base[1] * fac // 1000, "dailyRevDeciFen": daily, "netLtvDeciFen": ltv})
return rows
def draw_svg(path, rows, peak_freq):
W, H, pl, pr, pt, pb = 720, 420, 64, 64, 56, 56
iw, ih = W - pl - pr, H - pt - pb
mf = max(r["freqPerDay"] for r in rows) or 1
ml = max(r["netLtvDeciFen"] for r in rows) or 1
md = max(r["d1Permille"] for r in rows) or 1
sx = lambda f: pl + iw * f // mf
sl = lambda v: pt + ih - ih * v // ml
sd = lambda v: pt + ih - ih * v // md
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": "26", "font-size": "15", "fill": "#111827"}).text = (
"《合成小岛》变现频次 vs 净 LTV 与 D1 留存(示例参数)"
)
for i in range(5):
y = pt + ih * i // 4
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(y), "x2": str(W - pr), "y2": str(y), "stroke": "#e5e7eb"})
ET.SubElement(svg, "text", {"x": str(pl - 8), "y": str(y + 4), "font-size": "11",
"text-anchor": "end", "fill": "#2563eb"}).text = str(ml * (4 - i) // 4)
ET.SubElement(svg, "text", {"x": str(W - pr + 8), "y": str(y + 4), "font-size": "11",
"fill": "#dc2626"}).text = str(md * (4 - i) // 4)
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt + ih), "x2": str(W - pr), "y2": str(pt + ih), "stroke": "#9ca3af"})
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt), "x2": str(pl), "y2": str(pt + ih), "stroke": "#9ca3af"})
for f in range(0, mf + 1, 2):
ET.SubElement(svg, "text", {"x": str(sx(f)), "y": str(pt + ih + 18), "font-size": "11",
"text-anchor": "middle", "fill": "#374151"}).text = str(f)
ET.SubElement(svg, "text", {"x": str(pl + iw // 2), "y": str(H - 16), "font-size": "12",
"text-anchor": "middle", "fill": "#374151"}).text = "日均广告频次(次/日)"
ET.SubElement(svg, "polyline", {"points": " ".join(f"{sx(r['freqPerDay'])},{sl(r['netLtvDeciFen'])}" for r in rows),
"fill": "none", "stroke": "#2563eb", "stroke-width": "2.5"})
ET.SubElement(svg, "polyline", {"points": " ".join(f"{sx(r['freqPerDay'])},{sd(r['d1Permille'])}" for r in rows),
"fill": "none", "stroke": "#dc2626", "stroke-width": "2", "stroke-dasharray": "5 4"})
pk = next(r for r in rows if r["freqPerDay"] == peak_freq)
ET.SubElement(svg, "line", {"x1": str(sx(peak_freq)), "y1": str(pt), "x2": str(sx(peak_freq)), "y2": str(pt + ih),
"stroke": "#059669", "stroke-dasharray": "4 4"})
ET.SubElement(svg, "circle", {"cx": str(sx(peak_freq)), "cy": str(sl(pk["netLtvDeciFen"])), "r": "5", "fill": "#059669"})
ET.SubElement(svg, "text", {"x": str(sx(peak_freq) + 8), "y": str(sl(pk["netLtvDeciFen"]) - 8), "font-size": "12",
"fill": "#059669"}).text = f"净 LTV 峰值 {peak_freq} 次/日 = {pk['netLtvDeciFen'] / 10:.1f}分"
ET.SubElement(svg, "text", {"x": "16", "y": "46", "font-size": "11", "fill": "#2563eb"}).text = "实线 = 净 LTV(0.1 分,左轴)"
ET.SubElement(svg, "text", {"x": "240", "y": "46", "font-size": "11", "fill": "#dc2626"}).text = "虚线 = D1 留存(‰,右轴)"
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/")
args = ap.parse_args()
cfg = load(args.config)
rows = sweep(cfg)
peak_freq = max(rows, key=lambda r: r["netLtvDeciFen"])["freqPerDay"]
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "ch10_balance.svg"), rows, peak_freq)
report = {
"version": cfg["version"], "game": cfg["game"], "freqMax": cfg["freqMax"],
"peakFreqPerDay": peak_freq, "peakNetLtvDeciFen": max(r["netLtvDeciFen"] for r in rows),
"rows": rows, "svg": os.path.join(args.out, "ch10_balance.svg"), "svgBytes": svg_bytes,
}
with open(os.path.join(args.out, "ch10_balance.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch10_balance.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["freqPerDay", "retentionFactorPermille", "d1Permille", "dailyRevDeciFen", "netLtvDeciFen"])
for r in rows:
w.writerow([r["freqPerDay"], r["retentionFactorPermille"], r["d1Permille"],
r["dailyRevDeciFen"], r["netLtvDeciFen"]])
print(json.dumps({"peakFreqPerDay": peak_freq, "peakNetLtvDeciFen": report["peakNetLtvDeciFen"],
"curve": [[r["freqPerDay"], r["netLtvDeciFen"]] for r in rows]}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):
{"peakFreqPerDay": 5, "peakNetLtvDeciFen": 283, "curve": [[0, 0], [1, 84], [2, 170], [3, 228], [4, 269], [5, 283], [6, 266], [7, 226], [8, 167]]}
读曲线:峰值落在 5 次/日
把 out/ch10_balance.csv 摊开看,整条权衡曲线一目了然:
| 日均频次 | 留存乘数‰ | D1 留存‰ | 日收益(0.1分) | 净 LTV(0.1分) |
|---|---|---|---|---|
| 0 | 1000 | 450 | 0 | 0 |
| 1 | 900 | 405 | 20 | 84 |
| 2 | 800 | 360 | 40 | 170 |
| 3 | 700 | 315 | 60 | 228 |
| 4 | 600 | 270 | 80 | 269 |
| 5 | 500 | 225 | 100 | 283 |
| 6 | 400 | 180 | 120 | 266 |
| 7 | 300 | 135 | 140 | 226 |
| 8 | 200 | 90 | 160 | 167 |
曲线讲了一个很清楚的故事。频次 0 时净 LTV 为 0——不放广告就没有广告收入,这是最朴素的起点。往后每加一次频次,日收益线性上涨(20、40、60……),但留存乘数同步线性下降(900、800、700……),D1 从 450 一路掉到 90。净 LTV 是这两个数的乘积,先升后降,在 5 次/日 处见顶(283 deciFen),过了 5 就掉头向下:频次 8 时净 LTV 只剩 167,比峰值低了四成还多。
out/ch10_balance.svg 把这条曲线画了出来:蓝实线是净 LTV(左轴),红虚线是 D1 留存(右轴)。两条线一个拱起、一个单调下滑,交点附近正是净 LTV 的峰——多赚的那点广告钱,被掉下去的留存吃掉了。这就是「变现与体验平衡」的量化版本:平衡点不是一个感觉,而是净 LTV 曲线上的一个极值点。
要点:峰值 5 是这一组假设参数下的结论,不是普适常数。但方法可复用——同一份配置、同一个脚本,换参数就能重算你的峰值。真正的问题是:参数本身估不准,峰值会不会跟着飘?这就是 B 层要回答的。
B 层:权衡参数的敏感性
净 LTV 峰值定在 5 次/日,但这个结论依赖三个假设参数:每次展示值多少(impressionRevenueDeciFen)、留存惩罚多狠(penaltyPermillePerFreq)、基准留存多高(baseD1Permille)。它们都会估偏。下面这段是 B 层基准脚本,把每个参数单独上下扰动 ±20%,重算最优频次,看峰值会不会移动。B 层是软告警层:漂移超阈值只打 WARN,不判失败。
#!/usr/bin/env python3
# file: sim/ch10_balance_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch10_balance_bench.py --config config/ --out out/
"""B 基准:权衡参数的敏感性。
分别把 impressionRevenueDeciFen / penaltyPermillePerFreq / baseD1Permille 单独上下
扰动 ±benchPerturbPct%,重算净 LTV 峰值频次,看「最优频次」会不会移动。
软告警:峰值频次漂移超过 warnPeakShift 只打 WARN,不返回非 0。
产出:out/ch10_balance_bench.json
"""
from __future__ import annotations
import argparse
import json
import os
from ch10_balance import load, sweep
def peak_freq(cfg):
rows = sweep(cfg)
return max(rows, key=lambda r: r["netLtvDeciFen"])["freqPerDay"]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config)
base_peak = peak_freq(cfg)
pct = cfg["benchPerturbPct"]
knobs = ["impressionRevenueDeciFen", "penaltyPermillePerFreq", "baseD1Permille"]
cases = []
for key in knobs:
for sign, tag in ((+1, "up"), (-1, "down")):
trial = dict(cfg)
trial[key] = cfg[key] * (100 + sign * pct) // 100
pf = peak_freq(trial)
cases.append({"knob": key, "dir": tag, "value": trial[key],
"peakFreqPerDay": pf, "shift": pf - base_peak})
max_shift = max(abs(c["shift"]) for c in cases)
os.makedirs(args.out, exist_ok=True)
report = {
"version": cfg["version"],
"basePeakFreqPerDay": base_peak,
"perturbPct": pct,
"cases": cases,
"maxAbsShift": max_shift,
"warnPeakShift": cfg["warnPeakShift"],
"withinTolerance": max_shift <= cfg["warnPeakShift"],
}
with open(os.path.join(args.out, "ch10_balance_bench.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({"basePeakFreqPerDay": base_peak, "maxAbsShift": max_shift,
"withinTolerance": report["withinTolerance"]}, ensure_ascii=False))
if not report["withinTolerance"]:
print(f"WARN 参数扰动 {pct}% 后最优频次漂移 {max_shift} > {cfg['warnPeakShift']}")
return 0 # B 层软告警:恒返回 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):
{"basePeakFreqPerDay": 5, "maxAbsShift": 1, "withinTolerance": true}
逐个参数看最优频次怎么动(out/ch10_balance_bench.json 里的 cases):
| 扰动参数 | 方向 | 新值 | 最优频次 | 漂移 |
|---|---|---|---|---|
| impressionRevenueDeciFen | +20% | 24 | 5 | 0 |
| impressionRevenueDeciFen | -20% | 16 | 5 | 0 |
| penaltyPermillePerFreq | +20% | 120 | 4 | -1 |
| penaltyPermillePerFreq | -20% | 80 | 6 | +1 |
| baseD1Permille | +20% | 540 | 5 | 0 |
| baseD1Permille | -20% | 360 | 5 | 0 |
结果非常干净,而且揭示了一个关键结构。单次展示值(impressionRevenueDeciFen)和基准留存(baseD1Permille)怎么扰动,最优频次都稳稳停在 5——因为这两个参数对每个频次是等比例作用的:展示值翻倍,所有频次的日收益一起翻倍;基准留存抬高,所有频次的留存一起抬高。等比例缩放不改变曲线的形状,自然不移动峰值。换句话说,「每次展示到底值 2 分还是 2.4 分」不影响你该放几次广告。
真正会移动峰值的是留存惩罚系数(penaltyPermillePerFreq):它 +20% 到 120,最优频次掉到 4;-20% 到 80,最优频次升到 6。因为它改变的是曲线的斜率——惩罚越狠,多放一次广告的代价越大,最优频次自然越低。
这个结论有很强的实践指向:别把精力花在「精算 eCPM 到底多少」上,要花在「量清楚广告到底多伤留存」上。前者不影响最优频次的决策,后者直接决定它。而后者恰恰是最难估、最需要真实 A/B 实验去标定的(X)。B 层在这里的作用,不是给你一个确定的峰值,而是告诉你峰值对哪类误差敏感、对哪类不敏感——把有限的实测预算,投到真正会改变决策的那个参数上。
落到设计上的三条规则
- 把频次当连续旋钮,找净 LTV 的峰,而不是「越多越好」。 收益随频次单调上升,净 LTV 却是单峰的。不加约束地拉满频次,等于主动滑过峰值、一路亏到曲线的另一侧。
- 用净 LTV 而不是当日收益做决策。 只看当天广告收入,频次永远是「再加一次」;把留存衰减折进生命周期,才知道哪一次是赔本赚吆喝。这是变现与体验平衡的全部要义。
- 敏感度决定实测优先级。 峰值对收益水平不敏感、对留存惩罚敏感——所以实测预算该压在「广告频次对 D1/D7 留存的真实影响」上,而不是纠结单次展示的精确单价。
本章到此收束:10.1 把广告放进场景,10.2 把内购拼成混合结构,10.3 用净 LTV 把变现和体验拉到一个可算的平衡点上。这三节合起来,就是「变现设计前置」——在写代码之前,先把钱怎么来、从谁身上来、来多少会伤到体验,全部想清楚。下一章进入产品文档、预算与立项决策:把本章和前面九章的结论,装进一页纸,算清预算,划出止损线。
阅读导航:上一节:IAP 内购与混合变现结构 · 下一节:一页纸设计与立项文档 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。