1.3 冷启动期与学习期

新广告组上线头几天成本总是虚高,跑稳又需要足够的转化样本——这就是学习期。本节把「首日溢价按整数比衰减 + 累计转化达标」写成可跑的 Python 成本曲线仿真,再用 B 基准扰动溢价假设,看学习期完成日稳不稳,判断该忍多久、何时判定跑废。

卷三的目录 讲过渠道机制与增长预算分配,站内的行业与团队观察 则从组织角度看运营节奏。本节聚焦一个它们都没展开的机制——广告组的学习期成本曲线。上一节讲了「钱怎么分给各组」,但每个刚开跑的组都会先经历一段成本虚高期;不懂这条曲线,你会在第 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(分)当日转化累计转化收敛
0160064312312否
1142056357669否
51100444542375否
71049414873338是
141000405006825是

首日 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 档溢价的完成日:

首日溢价(‰)400500600700800
学习期完成日D6D7D7D8D8

溢价从 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 出价的选择 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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