2.2 放量节奏与预算爬坡

出价方式选对了,预算还得一天一天加上去。本节把「日预算按多少比例爬坡」变成一个整数模型:超过安全步长就触发学习期重置,把有效 CPA 抬到回本线以上;用可跑的 Python 仿真对照保守、稳健、激进三种爬坡节奏,再用 TypeScript 内核跨语言对拍,算出到底该加多快。

站内《小游戏开发实战》卷三目录 里已经讲过增长预算怎么在渠道之间分配(那 51 篇的框架),本节不重做分配问题,只盯住一个更细的动作:同一个渠道内,日预算一天加多少。上一节我们把三种出价方式折算到同一条「成本 → 回收」链,选出了同预算下绝对利润最高的方式;这一节假设方式已经选定,处理预算怎么从 1000 元爬到 2 万元——爬坡的速度上限不是拍脑袋定的,而是由平台的学习期稳定性决定的。

小游戏投放后台里,「放量」通常就是手动调日预算。新手的第一反应是「翻倍加」——今天 1000,明天 2000,后天 4000。跑两天就发现成本飙升、ROAS 掉到回本线以下。原因不在素材,而在学习期:oCPM 这类系统需要一段时间的稳定投放来找到转化人群,日预算突变会打断它,把成本重新推高。

学习期:为什么爬坡不能太快

单日涨幅系统行为有效 CPA 影响
≤ 20%(安全步长内)学习期连续,人群模型不重置稳定在基准 CPA 附近
> 20%触发学习期重置,重新探索按超出幅度递增惩罚
远超(翻倍级)反复重置,几乎不收敛成本被抬到回本线以上

这张表的口径是「公开可观察机制」(观察时点 2026-10),不是任何后台的实测:各平台对「安全步长」的定义和惩罚强度并不公开,但**「小步快跑优于大步跳跃」是普遍共识**。本节把它写成一个可调参的整数模型,用来比较不同节奏的长期代价。

爬坡公式

budgetFen(d)    = min(budgetFen(d-1) * growthNum // growthDen, capBudgetFen)
stepPermille(d) = budgetFen(d) * 1000 // budgetFen(d-1) - 1000       # 当日涨幅(千分比)
penalty(d)      = resetPenaltyPermille + (step - safeStepPermille) * overshootSlopePermille // 1000
cpiFen(d)       = baseCpiFen * (1000 + penalty) // 1000   if step > safeStepPermille
                = baseCpiFen                              otherwise
effectiveCpiFen = cumSpendFen // cumInstalls                          # 累计有效 CPA

关键在 penalty:它由基础惩罚加超出安全步长的部分按斜率放大。所以越激进的涨幅,惩罚越重——这是模型里「学习期越乱、成本越高」的量化表达。增长率一律用整数比(growthNum/growthDen),不用浮点。

S 层仿真:三种爬坡节奏对照

下面这段是 S 层仿真脚本 sim/o02_ramp.py。它读 config/o02_ramp.json 的三种爬坡策略,逐日按上面的公式推进 14 天,算出每种策略的累计花费、累计安装、有效 CPA、ROAS 与利润,产出 JSON + CSV + SVG。日噪音由 config 里的 seed 驱动自实现整数 LCG,保证可复现。

#!/usr/bin/env python3
# file: sim/o02_ramp.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o02_ramp.py --config config/ --out out/
"""放量节奏与预算爬坡仿真:日预算按整数比增长,超过安全步长则触发学习期重置。

    budgetFen(d)    = min(budgetFen(d-1) * growthNum // growthDen, capBudgetFen)
    stepPermille(d) = budgetFen(d) * 1000 // budgetFen(d-1) - 1000      # 当日涨幅
    reset(d)        = stepPermille(d) > safeStepPermille
    penalty(d)      = resetPenaltyPermille + (step - safeStepPermille) * overshootSlopePermille // 1000
    cpiFen(d)       = (baseCpiFen * (1000 + penalty) // 1000) if reset(d) else baseCpiFen
    cpiFen(d)       = cpiFen(d) * (1000 + jitter(d)) // 1000            # jitter ∈ [-noise, +noise]
    effectiveCpiFen = cumSpendFen // cumInstalls                        # 累计有效 CPA
    roasPermille    = cumInstalls * ltvD30DeciFen // 10 * 1000 // cumSpendFen

jitter 由 config 里的 seed 驱动自实现整数 LCG(core/ 禁用 Math.random)。全程整数、地板除,
无浮点。产出 out/o02_ramp.json + .csv + .svg。爬坡与学习期参数是「公开可观察机制」口径的
建模假设;真实平台学习期行为需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

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


def lcg_next(s: int, mod: int, mul: int) -> int:
    return (s * mul) % mod


def simulate(cfg: dict, st: dict) -> dict:
    budget = cfg["startBudgetFen"]
    s = st["seed"]
    mod, mul, noise = cfg["lcgModulus"], cfg["lcgMultiplier"], cfg["noisePermille"]
    cum_spend = cum_installs = reset_days = 0
    days = []
    for d in range(1, cfg["days"] + 1):
        prev = budget
        budget = min(prev * st["growthNum"] // st["growthDen"], cfg["capBudgetFen"])
        step = budget * 1000 // prev - 1000
        reset = step > cfg["safeStepPermille"]
        cpi = cfg["baseCpiFen"]
        if reset:
            penalty = cfg["resetPenaltyPermille"] + (step - cfg["safeStepPermille"]) \
                * cfg["overshootSlopePermille"] // 1000
            cpi = cpi * (1000 + penalty) // 1000
        s = lcg_next(s, mod, mul)
        jitter = s % (2 * noise + 1) - noise
        cpi = cpi * (1000 + jitter) // 1000
        installs = budget // cpi
        cum_spend += budget
        cum_installs += installs
        reset_days += 1 if reset else 0
        days.append(dict(day=d, budgetFen=budget, stepPermille=step, reset=reset, cpiFen=cpi,
                         installs=installs))
    revenue = cum_installs * cfg["ltvD30DeciFen"] // 10
    return dict(id=st["id"], name=st["name"], growth=f'{st["growthNum"]}/{st["growthDen"]}',
                cumSpendFen=cum_spend, cumInstalls=cum_installs,
                effectiveCpiFen=cum_spend // cum_installs, roasPermille=revenue * 1000 // cum_spend,
                d30RevenueFen=revenue, profitFen=revenue - cum_spend, resetDays=reset_days,
                days=days)


def draw_svg(path: str, rows: list[dict]) -> int:
    """纯 Python SVG:每个策略一根横条——累计有效 CPA 与回本线(ltvD30/10 分)。"""
    W, H, PAD, ROW = 760, 70 + 56 * len(rows), 150, 56
    breakeven = rows[0]["d30RevenueFen"] // max(rows[0]["cumInstalls"], 1)
    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(分,越低越好)"
    span = W - PAD - 24
    hi = max([r["effectiveCpiFen"] for r in rows] + [breakeven * 3 // 2])
    xbe = PAD + span * breakeven // hi
    ET.SubElement(root, "line", {"x1": str(xbe), "y1": "44", "x2": str(xbe), "y2": str(H - 16),
                                 "stroke": "#ef4444", "stroke-dasharray": "4 4"})
    ET.SubElement(root, "text", {"x": str(xbe + 4), "y": "58", "font-size": "11",
                                 "fill": "#ef4444"}).text = f"回本线 {breakeven} 分"
    for i, r in enumerate(rows):
        y = 60 + i * ROW
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
                                     "fill": "#374151"}).text = r["name"]
        w = span * r["effectiveCpiFen"] // hi
        color = "#2563eb" if r["effectiveCpiFen"] <= breakeven else "#f59e0b"
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "18",
                                     "rx": "3", "fill": color})
        ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 20), "font-size": "11",
                                     "fill": "#6b7280"}).text = (
            f'{r["effectiveCpiFen"]}分 · 重置 {r["resetDays"]} 天 · 利润 {r["profitFen"]}分')
    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, "o02_ramp.json"), encoding="utf-8") as f:
        cfg = json.load(f)

    rows = [simulate(cfg, st) for st in cfg["strategies"]]
    best = max(rows, key=lambda r: r["profitFen"])
    digest = ",".join(f'{r["id"]}:{r["cumSpendFen"]}:{r["cumInstalls"]}:{r["effectiveCpiFen"]}'
                      f':{r["roasPermille"]}:{r["profitFen"]}' for r in rows)

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "o02_ramp.svg"), rows)
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "days": cfg["days"], "safeStepPermille": cfg["safeStepPermille"],
              "strategies": rows, "digest": digest, "bestStrategy": best["id"],
              "svg": os.path.join(args.out, "o02_ramp.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(args.out, "o02_ramp.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "o02_ramp.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        cols = ("id", "name", "growth", "cumSpendFen", "cumInstalls", "effectiveCpiFen",
                "roasPermille", "d30RevenueFen", "profitFen", "resetDays")
        w.writerow(cols)
        for r in rows:
            w.writerow([r[k] for k in cols])

    print(json.dumps({"strategies": [{"id": r["id"], "cumSpendFen": r["cumSpendFen"],
                                      "cumInstalls": r["cumInstalls"],
                                      "effectiveCpiFen": r["effectiveCpiFen"],
                                      "roasPermille": r["roasPermille"],
                                      "profitFen": r["profitFen"], "resetDays": r["resetDays"]}
                                     for r in rows],
                      "digest": digest, "bestStrategy": best["id"]}, ensure_ascii=False, indent=2))
    return 0


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

爬坡参数放在 config/o02_ramp.json。safeStepPermille 设为 200(即 20%),三种策略的增长率分别用整数比 115/100、125/100、140/100 表示,全整数无浮点:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "days": 14,
  "startBudgetFen": 100000,
  "capBudgetFen": 12000000,
  "safeStepPermille": 200,
  "baseCpiFen": 320,
  "resetPenaltyPermille": 250,
  "overshootSlopePermille": 2000,
  "ltvD30DeciFen": 3800,
  "noisePermille": 60,
  "lcgModulus": 2147483647,
  "lcgMultiplier": 48271,
  "note": "预算爬坡参数,口径是「公开可观察机制」的建模假设。startBudgetFen=起始日预算(分);capBudgetFen=日预算上限;safeStepPermille=单日涨幅安全阈值(超过即触发学习期重置);baseCpiFen=基准单安装成本;resetPenaltyPermille=重置的基础惩罚千分比;overshootSlopePermille=超出安全步长后每千分比追加的惩罚斜率;ltvD30DeciFen=单安装 D30 收入(0.1 分);noisePermille=日噪音幅度;seed 驱动自实现整数 LCG(core/ 禁用 Math.random)。真实平台学习期行为需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "strategies": [
    { "id": "conservative", "name": "保守 +15%/日", "growthNum": 115, "growthDen": 100, "seed": 1101 },
    { "id": "moderate", "name": "稳健 +25%/日", "growthNum": 125, "growthDen": 100, "seed": 2203 },
    { "id": "aggressive", "name": "激进 +40%/日", "growthNum": 140, "growthDen": 100, "seed": 3307 }
  ]
}

在本机跑 /opt/homebrew/bin/python3 sim/o02_ramp.py --config config/ --out out/,结果(同机相对量级,绝对数值不可移植):

爬坡策略累计花费(分)累计安装有效 CPA(分)ROAS‰利润(分)重置天数
保守 +15%/日46579991444532211788311010
稳健 +25%/日1086861424785438866-145031414
激进 +40%/日3854194872629530716-1094292814

结果非常干脆:只有保守节奏赚钱。+15%/日 始终在 20% 的安全步长内,14 天里一次学习期都没重置,有效 CPA 稳在 322 分(回本线 380 分之下),利润 831101 分。+25%/日 和 +40%/日 每天都在踩线,14 天重置 14 次——有效 CPA 被抬到 438 和 530 分,双双越过 380 分的回本线,越激进亏得越多:稳健亏 145 万分,激进亏 1094 万分。注意激进的累计花费是保守的 8 倍多,但全部打了水漂——放量的目的不是花更多钱,是买到更多「在回本线之内」的安装。

P 层内核:跨语言对拍

同样,一张表不足以证明模型对。下面这段 P 层 TypeScript 内核 core/src/o02/ramp.ts 复算同一条爬坡链:同样的整数比增长、同样的重置判定、同样的 LCG 噪音,读同一份 config,产出和 Python 逐字符一致的摘要。

// file: core/src/o02/ramp.ts
// tier: P
// verify: node --test 'core/test/o02_*.test.ts'
//
// 预算爬坡内核:与 sim/o02_ramp.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o02_ramp.json;全整数、地板除、无浮点;LCG 可播种,禁 Math.random。

export interface RampStrategy {
  readonly id: string; readonly name: string;
  readonly growthNum: number; readonly growthDen: number; readonly seed: number;
}
export interface RampConfig {
  readonly days: number; readonly startBudgetFen: number; readonly capBudgetFen: number;
  readonly safeStepPermille: number; readonly baseCpiFen: number; readonly resetPenaltyPermille: number;
  readonly overshootSlopePermille: number; readonly ltvD30DeciFen: number; readonly noisePermille: number;
  readonly lcgModulus: number; readonly lcgMultiplier: number; readonly strategies: readonly RampStrategy[];
}
export interface RampResult {
  readonly id: string; readonly cumSpendFen: number; readonly cumInstalls: number;
  readonly effectiveCpiFen: number; readonly roasPermille: number; readonly profitFen: number;
  readonly resetDays: number;
}

/** 自实现整数 LCG(core/ 禁用 Math.random)。 */
export function lcgNext(s: number, mod: number, mul: number): number {
  return (s * mul) % mod;
}

export function simulate(cfg: RampConfig, st: RampStrategy): RampResult {
  let budget = cfg.startBudgetFen;
  let s = st.seed;
  let cumSpend = 0;
  let cumInstalls = 0;
  let resetDays = 0;
  for (let d = 1; d <= cfg.days; d += 1) {
    const prev = budget;
    budget = Math.min(Math.floor((prev * st.growthNum) / st.growthDen), cfg.capBudgetFen);
    const step = Math.floor((budget * 1000) / prev) - 1000;
    const reset = step > cfg.safeStepPermille;
    let cpi = cfg.baseCpiFen;
    if (reset) {
      const penalty = cfg.resetPenaltyPermille
        + Math.floor(((step - cfg.safeStepPermille) * cfg.overshootSlopePermille) / 1000);
      cpi = Math.floor((cpi * (1000 + penalty)) / 1000);
    }
    s = lcgNext(s, cfg.lcgModulus, cfg.lcgMultiplier);
    const jitter = (s % (2 * cfg.noisePermille + 1)) - cfg.noisePermille;
    cpi = Math.floor((cpi * (1000 + jitter)) / 1000);
    cumSpend += budget;
    cumInstalls += Math.floor(budget / cpi);
    resetDays += reset ? 1 : 0;
  }
  const revenue = Math.floor((cumInstalls * cfg.ltvD30DeciFen) / 10);
  return { id: st.id, cumSpendFen: cumSpend, cumInstalls, effectiveCpiFen: Math.floor(cumSpend / cumInstalls),
    roasPermille: Math.floor((revenue * 1000) / cumSpend), profitFen: revenue - cumSpend, resetDays };
}

export function simulateAll(cfg: RampConfig): RampResult[] {
  return cfg.strategies.map((st) => simulate(cfg, st));
}

/** 跨语言对拍摘要:与 sim/o02_ramp.py 的 digest 逐字符一致。 */
export function digest(rows: readonly RampResult[]): string {
  return rows.map((r) => `${r.id}:${r.cumSpendFen}:${r.cumInstalls}:${r.effectiveCpiFen}:`
    + `${r.roasPermille}:${r.profitFen}`).join(",");
}

配套测试 core/test/o02_ramp.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 复算逐字符相等,并验证「保守策略零重置」「激进策略 CPA 越过回本线」「同一 seed 逐位可复现」。跑 node --test 'core/test/o02_*.test.ts',8 个用例全过:

digest = conservative:4657999:14445:322:1178:831101,moderate:10868614:24785:438:866:-1450314,aggressive:38541948:72629:530:716:-10942928

这和卷二「一份逻辑、两种宿主」 是同一个信念:同一个数,在 Python 仿真、TS 内核、浏览器原型里必须长得一模一样。爬坡模型的价值也在这里——它把「加预算」这件凭感觉的事,变成一个可以逐位复算、可以调参对照的工程决策。

X 层:真实学习期不可复现

上面的惩罚强度、安全步长都是建模假设。下面这些属于 X 层——需真实业务环境,命令行不可复现:

  • 平台对「安全步长」的真实定义与惩罚强度(各平台不同且不公开);
  • 真实学习期的收敛速度与所需样本量(受账户历史、素材、人群共同影响);
  • 真实竞价环境里「加预算」带来的成本变化(可能与模型的单调惩罚完全不同)。

正确的用法是:先用模型定出「不加超过安全步长」的纪律,再用投放后的真实数据反标定 baseCpiFen、resetPenaltyPermille 这几个参数。模型负责纪律,数据负责校准。

自检表

检查项合格标准
增长率整数化用 growthNum/growthDen,config 里无浮点
步长可算每日涨幅用千分比,安全阈值可调
惩罚随涨幅递增越激进惩罚越重,不是固定惩罚
噪音可复现由 seed 驱动 LCG,禁 Math.random()
口径统一有效 CPA 与回本线同单位(分)可比
边界清晰真实学习期标 X,不写成「实测」

爬坡模型回答的是「加多快」,但它默认了一件危险的事:这个渠道值得继续加。现实中很多渠道从第一天就不该继续投。下一节处理这条底线——止损线与关停规则:什么时候承认这个渠道不行,把剩下的钱收回来。

阅读导航:上一节:2.1 oCPM、CPA 与 ROI 出价的选择 · 下一节:2.3 止损线与关停规则 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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