站内《小游戏开发实战》卷三目录 里已经讲过增长预算怎么在渠道之间分配(那 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%/日 | 4657999 | 14445 | 322 | 1178 | 831101 | 0 |
| 稳健 +25%/日 | 10868614 | 24785 | 438 | 866 | -1450314 | 14 |
| 激进 +40%/日 | 38541948 | 72629 | 530 | 716 | -10942928 | 14 |
结果非常干脆:只有保守节奏赚钱。+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 止损线与关停规则 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。