卷三的目录 讲过渠道机制与增长预算分配,站内的行业与团队观察 则从组织角度看运营节奏。本节聚焦一个它们都没展开的机制——广告组的学习期成本曲线。上一节讲了「钱怎么分给各组」,但每个刚开跑的组都会先经历一段成本虚高期;不懂这条曲线,你会在第 2 天误杀一个本来能跑好的组,或者在第 10 天还养着一个早已跑废的组。
几乎所有投放后台都会告诉你「广告组处于学习期」,却不会给你一条可以算的曲线。学习期的本质是平台在缺乏样本时对人群的估计不准:样本越少,估计越偏,成本越高;随着转化样本累积,估计收敛,成本回落到稳态。所以它有两个要素——成本虚高的幅度,和收敛所需的样本量。两者都能量化。
学习期到底是什么
把学习期拆成两个独立的门槛,概念就清楚了:
- 成本收敛:早期平台把广告投给更宽泛的人群试探,单位成本偏高;随着它锁定高转化人群,成本逐步回落到稳态 CPA。
- 样本达标:平台需要一个最小的转化样本量,才认为这个组「学会了」。样本不够,模型不敢下结论,会继续试探。
只有两个门槛同时满足,才算真正退出学习期。这意味着:预算太小的组可能永远攒不够样本,卡在学习期里持续烧钱——这正是上一节「预算门槛」和本节「学习期」的接口。
成本曲线公式
用一个整数模型描述这条曲线:成本倍数从「首日溢价」按固定整数比逐日衰减,直到接近 1;转化数由当日 CPA 反推。全部整数、地板除,无浮点:
成本倍数(d) = 1000 + 首日溢价(‰) × decayNum^d // decayDen^d
第 d 日 CPA = 稳态CPA × 成本倍数(d) // 1000
第 d 日转化 = 日预算 // 第 d 日 CPA
学习期完成 = 首个「累计转化 ≥ 样本门槛 且 成本倍数 ≤ 收敛阈值」的 d
学习期多花 = Σ_{d≤完成日} (第 d 日 CPA − 稳态CPA) × 第 d 日转化
用整数比而不是浮点衰减,是为了让 Python 仿真、TS 内核、浏览器原型能逐位一致——这与全书的「一份 config 三种消费」是同一套纪律。
S 层仿真:把成本曲线算出来
下面这段是 S 层仿真脚本 sim/o01_coldstart.py,是完整可运行文件。它读 config/o01_coldstart.json,逐日算成本倍数、CPA、转化数与累计转化,找出学习期完成日,并统计学习期多花的钱,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o01_coldstart.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o01_coldstart.py --config config/ --out out/
"""冷启动期与学习期成本曲线仿真。
对 config/o01_coldstart.json:
mult(d) = 1000 + premiumPermille * decayNum^d // decayDen^d # 成本倍数(千分比)
dailyCpa(d) = steadyCpaFen * mult(d) // 1000 # 第 d 日 CPA(分)
conv(d) = dailyBudgetFen // dailyCpa(d) # 第 d 日转化数
learningDay = 首个「累计转化 >= minConversionsToLearn 且 mult <= converged」的 d
extraCostFen = Σ_{d<=learningDay} (dailyCpa(d) - steadyCpaFen) * conv(d) # 学习期多花的钱
幂运算用 Python 原生大整数,除法一律整数地板除,无浮点。
产出 out/o01_coldstart.json + .csv + .svg。
学习期成本虚高的幅度、衰减速度与样本量门槛是「公开可观察机制」口径的建模假设;
真实平台的学习期行为需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def multiplier(d: int, cfg: dict) -> int:
"""第 d 日成本倍数(千分比)。"""
return 1000 + cfg["premiumPermille"] * (cfg["decayNum"] ** d) // (cfg["decayDen"] ** d)
def simulate(cfg: dict):
steady = cfg["steadyCpaFen"]
days = []
cum_conv = 0
learning_day = -1
extra = 0
for d in range(0, cfg["horizonDays"] + 1):
m = multiplier(d, cfg)
cpa = steady * m // 1000
conv = cfg["dailyBudgetFen"] // cpa
cum_conv += conv
converged = m <= cfg["convergedMultiplierPermille"]
days.append({"day": d, "multPermille": m, "cpaFen": cpa, "conversions": conv,
"cumConversions": cum_conv, "converged": converged})
if learning_day < 0 and cum_conv >= cfg["minConversionsToLearn"] and converged:
learning_day = d
if learning_day >= 0:
for row in days[: learning_day + 1]:
extra += (row["cpaFen"] - steady) * row["conversions"]
return days, learning_day, cum_conv, extra
def digest(days: list[dict], learning_day: int, extra: int) -> str:
head = f'{days[0]["cpaFen"]}:{days[0]["multPermille"]}'
return f'{head}:{learning_day}:{extra}'
def draw_svg(path: str, days: list[dict], steady: int, learning_day: int) -> int:
"""纯 Python SVG:每日 CPA 折线 + 稳态虚线 + 学习期完成竖线。"""
W, H, PAD = 760, 300, 56
x_max = max(d["day"] for d in days) or 1
y_max = max(d["cpaFen"] for d in days) or 1
sx = lambda x: PAD + (W - 2 * PAD) * x // x_max
sy = lambda y: H - PAD - (H - 2 * PAD) * y // y_max
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 = (
"《合成小岛》冷启动每日 CPA(分)· 虚线=稳态 · 竖线=学习期完成")
ET.SubElement(root, "line", {"x1": str(PAD), "y1": str(H - PAD), "x2": str(W - PAD),
"y2": str(H - PAD), "stroke": "#9ca3af"})
ET.SubElement(root, "line", {"x1": str(PAD), "y1": str(PAD), "x2": str(PAD),
"y2": str(H - PAD), "stroke": "#9ca3af"})
ys = sy(steady)
ET.SubElement(root, "line", {"x1": str(PAD), "y1": str(ys), "x2": str(W - PAD),
"y2": str(ys), "stroke": "#ef4444", "stroke-dasharray": "4 4"})
ET.SubElement(root, "text", {"x": str(PAD + 4), "y": str(ys - 4), "font-size": "11",
"fill": "#ef4444"}).text = f"稳态 {steady} 分"
pts = " ".join(f"{sx(d['day'])},{sy(d['cpaFen'])}" for d in days)
ET.SubElement(root, "polyline", {"points": pts, "fill": "none", "stroke": "#2563eb",
"stroke-width": "2"})
if learning_day >= 0:
xl = sx(learning_day)
ET.SubElement(root, "line", {"x1": str(xl), "y1": str(PAD), "x2": str(xl),
"y2": str(H - PAD), "stroke": "#16a34a",
"stroke-dasharray": "3 3"})
ET.SubElement(root, "text", {"x": str(xl + 4), "y": str(PAD + 12), "font-size": "11",
"fill": "#16a34a"}).text = f"学习期完成 D{learning_day}"
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, "o01_coldstart.json"), encoding="utf-8") as f:
cfg = json.load(f)
days, learning_day, cum_conv, extra = simulate(cfg)
steady = cfg["steadyCpaFen"]
steady_daily_conv = cfg["dailyBudgetFen"] // steady
cum_at_learn = days[learning_day]["cumConversions"] if learning_day >= 0 else 0
dg = digest(days, learning_day, extra)
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "o01_coldstart.svg"), days, steady, learning_day)
report = {
"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"dailyBudgetFen": cfg["dailyBudgetFen"], "steadyCpaFen": steady,
"premiumPermille": cfg["premiumPermille"], "horizonDays": cfg["horizonDays"],
"minConversionsToLearn": cfg["minConversionsToLearn"],
"convergedMultiplierPermille": cfg["convergedMultiplierPermille"],
"firstDayCpaFen": days[0]["cpaFen"], "firstDayMultPermille": days[0]["multPermille"],
"learningDay": learning_day, "cumConversions": cum_conv,
"cumConversionsAtLearn": cum_at_learn,
"steadyDailyConversions": steady_daily_conv, "extraCostFen": extra,
"days": days, "digest": dg,
"svg": os.path.join(args.out, "o01_coldstart.svg"), "svgBytes": svg_bytes,
}
with open(os.path.join(args.out, "o01_coldstart.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "o01_coldstart.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(("day", "multPermille", "cpaFen", "conversions", "cumConversions", "converged"))
for r in days:
w.writerow([r[k] for k in ("day", "multPermille", "cpaFen", "conversions",
"cumConversions", "converged")])
print(json.dumps({
"firstDayCpaFen": days[0]["cpaFen"], "firstDayMultPermille": days[0]["multPermille"],
"learningDay": learning_day, "cumConversionsAtLearn": cum_at_learn,
"steadyDailyConversions": steady_daily_conv, "extraCostFen": extra,
"cpaByDay": [r["cpaFen"] for r in days], "digest": dg,
}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
参数放在 config/o01_coldstart.json——注意是「公开可观察机制」口径的建模假设,不是任何后台实测;真实学习期行为属于 X 层,需真实业务环境,命令行不可复现。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"dailyBudgetFen": 20000,
"steadyCpaFen": 40,
"premiumPermille": 600,
"decayNum": 70,
"decayDen": 100,
"convergedMultiplierPermille": 1050,
"minConversionsToLearn": 2000,
"horizonDays": 14,
"bandPermille": 200,
"seed": 20261013,
"note": "冷启动与学习期成本曲线参数,口径是「公开可观察机制」的建模假设,不是任何投放后台实测。steadyCpaFen=跑稳后的稳态 CPA(分);premiumPermille=首日成本溢价(千分比,600=+60%);decayNum/decayDen=每日溢价衰减整数比;convergedMultiplierPermille=判定「已收敛」的成本倍数阈值;minConversionsToLearn=退出学习期所需累计转化数;bandPermille=B 基准扰动的溢价带宽(千分比)。真实学习期行为、成本虚高幅度与样本量门槛由平台定义,需真实业务环境,命令行不可复现(X)。全整数,无浮点。"
}
在本机跑 /opt/homebrew/bin/python3 sim/o01_coldstart.py --config config/ --out out/,成本曲线(同机相对量级,绝对数值不可移植):
| 日 d | 成本倍数(‰) | 当日 CPA(分) | 当日转化 | 累计转化 | 收敛 |
|---|---|---|---|---|---|
| 0 | 1600 | 64 | 312 | 312 | 否 |
| 1 | 1420 | 56 | 357 | 669 | 否 |
| 5 | 1100 | 44 | 454 | 2375 | 否 |
| 7 | 1049 | 41 | 487 | 3338 | 是 |
| 14 | 1000 | 40 | 500 | 6825 | 是 |
首日 CPA 64 分,是稳态 40 分的 1.6 倍;到第 7 天,成本倍数降到 1049‰(在 1050 收敛阈值内),累计转化 3338 也越过了 2000 的样本门槛,学习期完成于 D7。学习期这 8 天比稳态多花的钱合计 26315 分——这才是「冷启动成本虚高」的真实账:不是每单都贵 60%,而是前几天的贵被后面逐日拉平,最后多花的绝对值落在这个数上。
B 基准:溢价假设稳不稳
上面的结论依赖一个关键假设:首日溢价 600‰。这个数要是估错了呢?B 层基准把溢价在 ±200‰ 区间取 5 档重跑,看学习期完成日晃不晃。下面这段是 B 层脚本 sim/o01_coldstart_bench.py,是完整可运行文件,它导入 S 层的 simulate() 复用同一套整数逻辑。
#!/usr/bin/env python3
# file: sim/o01_coldstart_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/o01_coldstart_bench.py --config config/ --out out/
"""冷启动学习期的 B 基准:扰动首日成本溢价,看「学习期完成日」稳不稳。
复算同一份 config/o01_coldstart.json,但把 premiumPermille 在 ±bandPermille 区间内
取 5 个整数档(-band, -band/2, 0, +band/2, +band),各自重跑 simulate(),记录
learningDay 与 extraCostFen。软告警:若 learningDay 极差 > 2 天,则模型对溢价假设
不稳健(stable=false)。产出 out/o01_coldstart_bench.json。
参数扰动是「公开可观察机制」口径的建模假设;真实平台学习期行为不可复现(X)。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from o01_coldstart import simulate # noqa: E402
def scenario(cfg: dict, premium: int) -> dict:
c = dict(cfg)
c["premiumPermille"] = premium
days, learning_day, cum_conv, extra = simulate(c)
return {"premiumPermille": premium, "learningDay": learning_day,
"cumConversions": cum_conv, "extraCostFen": extra,
"firstDayCpaFen": days[0]["cpaFen"]}
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, "o01_coldstart.json"), encoding="utf-8") as f:
cfg = json.load(f)
base = cfg["premiumPermille"]
band = cfg["bandPermille"]
premiums = [base - band, base - band // 2, base, base + band // 2, base + band]
rows = [scenario(cfg, p) for p in premiums]
learning_days = [r["learningDay"] for r in rows if r["learningDay"] >= 0]
spread = (max(learning_days) - min(learning_days)) if learning_days else 0
stable = spread <= 2
os.makedirs(args.out, exist_ok=True)
report = {
"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"basePremiumPermille": base, "bandPermille": band,
"scenarios": rows, "learningDaySpread": spread, "stable": stable,
"warn": None if stable else f"学习期完成日极差 {spread} 天 > 2,模型对溢价假设不稳健",
}
with open(os.path.join(args.out, "o01_coldstart_bench.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({
"scenarios": [{"premiumPermille": r["premiumPermille"], "learningDay": r["learningDay"]}
for r in rows],
"learningDaySpread": spread, "stable": stable, "warn": report["warn"],
}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑 /opt/homebrew/bin/python3 sim/o01_coldstart_bench.py --config config/ --out out/,5 档溢价的完成日:
| 首日溢价(‰) | 400 | 500 | 600 | 700 | 800 |
|---|---|---|---|---|---|
| 学习期完成日 | D6 | D7 | D7 | D8 | D8 |
溢价从 400 拉到 800(整整翻一倍),完成日只在 D6~D8 之间晃,极差 2 天,stable=true,软告警不触发。结论是:「大约一周完成学习期」这个判断对溢价假设不敏感——你可以放心用 D7 作为「该不该忍」的经验分界。同时本节也做了 P 层对拍:core/src/o01/coldstart.ts 用 BigInt 复算同一份 config,node --test 'core/test/o01_coldstart.test.ts' 断言它与 Python 摘要 64:1600:7:26315 逐字符一致,4 个用例全过。
X 层:真实学习期不可复现
必须把边界划清。上面算的是模型侧:给定溢价与样本门槛,成本曲线与完成日是多少。而下面这些属于 X 层,需真实业务环境,命令行不可复现:真实的成本虚高幅度与衰减速度;真实的样本量门槛(平台算法定义,且各平台不同、常变);平台是否真的「学会」了(有的组反复进出学习期);改定向/出价/素材是否会重置学习期(多数平台会,但规则不透明)。
工程上正确的做法是:把「成本曲线模型」与「平台学习期规则」分开——模型给你一条可算的基准曲线,真实数据用来反标定溢价与衰减参数。第三节要讲的是:出价方式与学习期是同一枚硬币的两面,oCPM 的「学习」其实就是平台在帮你做人群估计。
自检表
| 检查项 | 合格标准 |
|---|---|
| 两个门槛分开 | 成本收敛与样本达标各自有判据,都满足才算完成 |
| 曲线可复算 | 逐日倍数、CPA、转化都能从 config 复现 |
| 多花成本有数 | 学习期相对稳态多花的绝对值算得出来 |
| 假设可扰动 | 溢价/衰减可被 B 基准扫描,给出稳健性结论 |
| 跨语言一致 | TS 内核与 Python 仿真摘要逐字符相同 |
| 边界清晰 | 真实学习期规则标 X,不写成「实测」 |
学习期告诉你「该忍多久」,出价方式告诉你「该出多少钱」,账户结构告诉你「钱挂在哪」。这三件事合起来,就是投放后台的静态骨架。下一章我们让它们动起来:oCPM、CPA、ROI 到底怎么选,预算怎么爬坡,什么时候该关停。
阅读导航:上一节:1.2 出价方式与预算分配 · 下一节:2.1 oCPM、CPA 与 ROI 出价的选择 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。