站内《小游戏开发实战》卷三目录 已经把渠道机制框架讲完了——曝光成本、CTR、IPM、留存、ROAS 怎么串成一条漏斗,那 51 篇写得足够细。本节不重做那套框架,只回答一个更靠后的问题:后台里那个「出价方式」下拉框,到底该选哪个。第 1 章讲清了账户、计划、广告组、创意四层结构与预算怎么分;这一章接着往下,讲钱怎么花出去——出价方式怎么选、预算怎么爬坡、什么时候该关停。三节的分工边界很清楚,不重复。
买量后台给的选择通常就三种:oCPM(按曝光竞价、系统帮你找转化人群)、CPA(按安装结算)、ROI(按目标回报反推可承受成本)。新手容易把这件事当玄学——「哪个跑得好就用哪个」。其实三者只是同一笔成本换了一个结算口径,一旦折算到同一条「成本 → 回收」链上,选择就变成一个可以算的整数题。
三种出价方式到底差在哪
| 出价方式 | 你出什么价 | 谁承担转化风险 | 成本怎么定 |
|---|---|---|---|
| oCPM | 千次曝光出价 | 你(系统只优化人群) | 由素材漏斗 CTR×IPM 反推 |
| CPA | 单个安装目标价 | 平台(按安装结算) | 目标价 + 超投滑点 |
| ROI | 目标 ROAS | 平台(按回报交付) | 由 LTV 与目标回报反推 |
关键差别不在「贵不贵」,而在成本由谁决定、风险由谁背。oCPM 的成本完全由你的素材效率决定;CPA 把安装成本锁在目标价附近,但要接受实际超投;ROI 最保守——平台只给你预测回报达标的人群,量最小、单用户价值最高。
把三种出价拉平到一条公式
ocpm: cpiFen = bidFen * 1000 // (ctrPermille * ipmPermille) # 曝光竞价 → 单安装成本
cpa: cpiFen = targetCpaFen * (1000 + overrunPermille) // 1000 # 安装结算 + 超投滑点
roi: cpiFen = ltvD30DeciFen * 100 // targetRoasPermille # 回报目标反推可承受成本
roasD30Permille = ltvD30DeciFen * 100 // cpiFen # 1000 = 回本
d30ProfitFen = budgetFen // cpiFen * ltvD30DeciFen // 10 - budgetFen
三种方式的 CPI 算法不同,但收入侧完全一致:都是 arpdau × 累计活跃日。所以选择标准不是「谁的 ROAS 高」,而是同一笔预算下谁的绝对利润最大——这是本节最重要的判断口径。
S 层仿真:同预算下算绝对利润
下面这段是 S 层仿真脚本 sim/o02_bid_choice.py。它读 config/o02_bid_choice.json 的三种出价方式,按各自的成本口径算出 CPI、D30 ROAS 与同预算(300 万分 = 3 万元)下的绝对利润,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o02_bid_choice.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o02_bid_choice.py --config config/ --out out/
"""出价方式量化选择仿真:把 oCPM / CPA / ROI 三种出价折算到同一条「成本 → 回收」链。
ocpm: cpiFen = bidFen * 1000 // (ctrPermille * ipmPermille) # 按曝光竞价,成本由素材漏斗决定
cpa: cpiFen = targetCpaFen * (1000 + overrunPermille) // 1000 # 按安装结算,含超投滑点
roi: cpiFen = ltvD30DeciFen * 100 // targetRoasPermille # 按 ROI 目标反推可承受成本
ltvD30DeciFen = arpdauDeciFen * retainMultPermille // 1000 * cumulativeActive(30) // 1000
roasD30Permille = ltvD30DeciFen * 100 // cpiFen # 1000 = 回本
d30ProfitFen = budgetFen // cpiFen * ltvD30DeciFen // 10 - budgetFen
决策:在 installs >= minInstalls 的方式里取 d30ProfitFen 最大者——选的是「同预算下的最大
绝对利润」,不是「最高 ROAS」。全程整数、地板除,无浮点。产出 out/o02_bid_choice.json
+ .csv + .svg。出价参数是「公开可观察机制」口径的建模假设;真实竞价胜出价与真实归因结果
需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
HORIZON = 30
def daily_retention(m: dict, d: int) -> int:
"""第 d 日留存千分比(d=0 安装日恒 1000);锚点之间整数线性插值。"""
if d <= 0:
return 1000
r1, r7, r30 = m["retainD1Permille"], m["retainD7Permille"], m["retainD30Permille"]
if d <= 7:
return r1 + (r7 - r1) * (d - 1) // 6
if d <= 30:
return r7 + (r30 - r7) * (d - 7) // 23
return r30
def cumulative_active_permille(m: dict, n: int) -> int:
"""每安装的累计活跃日,单位千分比日(1000 = 一整日)。"""
return sum(daily_retention(m, d) for d in range(0, n + 1))
def effective_arpdau(m: dict) -> int:
return m["arpdauDeciFen"] * m["retainMultPermille"] // 1000
def cpi_fen(m: dict, ltv_d30: int) -> int:
kind = m["kind"]
if kind == "ocpm":
return m["bidFen"] * 1000 // (m["ctrPermille"] * m["ipmPermille"])
if kind == "cpa":
return m["targetCpaFen"] * (1000 + m["overrunPermille"]) // 1000
if kind == "roi":
return ltv_d30 * 100 // m["targetRoasPermille"]
raise ValueError(f"unknown kind: {kind}")
def evaluate(m: dict, budget_fen: int) -> dict:
ltv = effective_arpdau(m) * cumulative_active_permille(m, HORIZON) // 1000
cpi = cpi_fen(m, ltv)
installs = budget_fen // cpi
revenue = installs * ltv // 10
roas = ltv * 100 // cpi
return dict(id=m["id"], name=m["name"], kind=m["kind"], arpdauEffDeciFen=effective_arpdau(m),
ltvD30DeciFen=ltv, cpiFen=cpi, roasD30Permille=roas, installs=installs,
d30RevenueFen=revenue, d30ProfitFen=revenue - budget_fen, payback=roas >= 1000)
def draw_svg(path: str, rows: list[dict]) -> int:
"""纯 Python SVG:每种出价方式一根横条——D30 ROAS(回本线 1000)。"""
W, H, PAD, ROW = 760, 70 + 56 * len(rows), 150, 56
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 = "《合成小岛》三种出价方式 D30 ROAS(千分比,1000=回本)"
span = W - PAD - 24
hi = max([r["roasD30Permille"] for r in rows] + [1200])
x1000 = PAD + span * 1000 // hi
ET.SubElement(root, "line", {"x1": str(x1000), "y1": "44", "x2": str(x1000), "y2": str(H - 16),
"stroke": "#ef4444", "stroke-dasharray": "4 4"})
ET.SubElement(root, "text", {"x": str(x1000 + 4), "y": "58", "font-size": "11",
"fill": "#ef4444"}).text = "回本线 1000"
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["roasD30Permille"] // hi
ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "18",
"rx": "3", "fill": "#2563eb" if r["payback"] else "#93c5fd"})
ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 20), "font-size": "11",
"fill": "#6b7280"}).text = (
f'{r["roasD30Permille"]}‰ · CPI {r["cpiFen"]}分 · 利润 {r["d30ProfitFen"]}分')
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_bid_choice.json"), encoding="utf-8") as f:
cfg = json.load(f)
rows = [evaluate(m, cfg["budgetFen"]) for m in cfg["modes"]]
eligible = [r for r in rows if r["installs"] >= cfg["minInstalls"]] or rows
best = max(eligible, key=lambda r: r["d30ProfitFen"])
# 跨语言对拍摘要:core/test/o02_bid_choice.test.ts 复算同一串数字,逐位比对。
digest = ",".join(f'{r["id"]}:{r["cpiFen"]}:{r["roasD30Permille"]}:{r["installs"]}'
f':{r["d30ProfitFen"]}' for r in rows)
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "o02_bid_choice.svg"), rows)
report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"budgetFen": cfg["budgetFen"], "minInstalls": cfg["minInstalls"], "modeCount": len(rows),
"modes": rows, "digest": digest, "bestMode": best["id"],
"bestProfitFen": best["d30ProfitFen"], "svg": os.path.join(args.out, "o02_bid_choice.svg"),
"svgBytes": svg_bytes}
with open(os.path.join(args.out, "o02_bid_choice.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "o02_bid_choice.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
cols = ("id", "name", "kind", "arpdauEffDeciFen", "ltvD30DeciFen", "cpiFen",
"roasD30Permille", "installs", "d30RevenueFen", "d30ProfitFen")
w.writerow(cols)
for r in rows:
w.writerow([r[k] for k in cols])
print(json.dumps({"modes": [{"id": r["id"], "cpiFen": r["cpiFen"],
"roasD30Permille": r["roasD30Permille"], "installs": r["installs"],
"d30ProfitFen": r["d30ProfitFen"]} for r in rows],
"digest": digest, "bestMode": best["id"]}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
三种方式的参数放在 config/o02_bid_choice.json。注意这些数字是「公开可观察机制」口径的建模假设(观察时点 2026-10),不是任何投放后台的实测:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"budgetFen": 3000000,
"minInstalls": 2000,
"note": "三种出价方式的量化选择参数,口径是「公开可观察机制」的建模假设,不是任何投放后台实测。kind=ocpm 用 bidFen(千次曝光出价,分)+ ctrPermille/ipmPermille 反推 CPI;kind=cpa 用 targetCpaFen + overrunPermille(超投滑点千分比);kind=roi 用 targetRoasPermille(ROI 目标,千分比)反推可承受 CPI。arpdauDeciFen=0.1 分/DAU/日;retainMultPermille=该出价方式定向能力带来的单用户价值抬升(1000=无抬升);retainD1/D7/D30Permille=第 1/7/30 日留存千分比(安装日恒 1000)。真实竞价胜出价与真实归因结果需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"modes": [
{ "id": "ocpm", "name": "oCPM 按曝光优化", "kind": "ocpm", "bidFen": 1500, "ctrPermille": 32, "ipmPermille": 150, "arpdauDeciFen": 520, "retainMultPermille": 1120, "retainD1Permille": 430, "retainD7Permille": 180, "retainD30Permille": 110 },
{ "id": "cpa", "name": "CPA 按安装结算", "kind": "cpa", "targetCpaFen": 300, "overrunPermille": 80, "arpdauDeciFen": 520, "retainMultPermille": 1000, "retainD1Permille": 430, "retainD7Permille": 180, "retainD30Permille": 110 },
{ "id": "roi", "name": "ROI 目标出价", "kind": "roi", "targetRoasPermille": 1150, "arpdauDeciFen": 520, "retainMultPermille": 1250, "retainD1Permille": 430, "retainD7Permille": 180, "retainD30Permille": 110 }
]
}
在本机跑 /opt/homebrew/bin/python3 sim/o02_bid_choice.py --config config/ --out out/,结果(同机相对量级,绝对数值不可移植):
| 出价方式 | CPI(分) | D30 ROAS‰ | 安装数 | D30 利润(分) |
|---|---|---|---|---|
| oCPM 按曝光优化 | 312 | 1197 | 9615 | 593125 |
| CPA 按安装结算 | 324 | 1030 | 9259 | 91580 |
| ROI 目标出价 | 362 | 1153 | 8287 | 458993 |
结论和直觉相反:ROAS 最高的不是最赚的。ROI 出价把 ROAS 锁在 1153‰(贴着 1150 目标),但它的 CPI 最高(362 分),同预算只买到 8287 个安装;oCPM 的 ROAS 虽然「只有」1197‰,CPI 却最低(312 分),买到的安装最多,绝对利润反而最高(593125 分)。CPA 排在末位——8% 的超投滑点把 CPI 顶到 324,ROAS 只剩 1030‰,离回本线只差 30‰,最脆弱。
P 层内核:跨语言对拍
一张表证明不了模型对。下面这段 P 层 TypeScript 内核 core/src/o02/bid_choice.ts 复算同一条链:同样的整数地板除、同样的留存插值、同样的决策规则,读同一份 config,产出和 Python 逐字符一致的摘要。
// file: core/src/o02/bid_choice.ts
// tier: P
// verify: node --test 'core/test/o02_*.test.ts'
//
// 出价方式选择内核:与 sim/o02_bid_choice.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o02_bid_choice.json;全整数、地板除、无浮点;只用可擦除语法(禁 enum / namespace)。
export interface BidMode {
readonly id: string; readonly name: string; readonly kind: string;
readonly bidFen?: number; readonly ctrPermille?: number; readonly ipmPermille?: number;
readonly targetCpaFen?: number; readonly overrunPermille?: number; readonly targetRoasPermille?: number;
readonly arpdauDeciFen: number; readonly retainMultPermille: number;
readonly retainD1Permille: number; readonly retainD7Permille: number; readonly retainD30Permille: number;
}
export interface BidConfig {
readonly budgetFen: number; readonly minInstalls: number; readonly modes: readonly BidMode[];
}
export interface BidResult {
readonly id: string; readonly cpiFen: number; readonly ltvD30DeciFen: number;
readonly roasD30Permille: number; readonly installs: number; readonly d30ProfitFen: number;
}
export const HORIZON = 30;
/** 第 d 日留存千分比(d=0 安装日恒 1000);锚点之间整数线性插值。 */
export function dailyRetention(m: BidMode, d: number): number {
if (d <= 0) return 1000;
const r1 = m.retainD1Permille, r7 = m.retainD7Permille, r30 = m.retainD30Permille;
if (d <= 7) return r1 + Math.floor(((r7 - r1) * (d - 1)) / 6);
if (d <= 30) return r7 + Math.floor(((r30 - r7) * (d - 7)) / 23);
return r30;
}
/** 每安装的累计活跃日,单位千分比日(1000 = 一整日)。 */
export function cumulativeActivePermille(m: BidMode, n: number): number {
let sum = 0;
for (let d = 0; d <= n; d += 1) sum += dailyRetention(m, d);
return sum;
}
export function effectiveArpdau(m: BidMode): number {
return Math.floor((m.arpdauDeciFen * m.retainMultPermille) / 1000);
}
export function cpiFen(m: BidMode, ltvD30: number): number {
if (m.kind === "ocpm") return Math.floor((m.bidFen! * 1000) / (m.ctrPermille! * m.ipmPermille!));
if (m.kind === "cpa") return Math.floor((m.targetCpaFen! * (1000 + m.overrunPermille!)) / 1000);
if (m.kind === "roi") return Math.floor((ltvD30 * 100) / m.targetRoasPermille!);
throw new Error(`unknown kind: ${m.kind}`);
}
export function evaluate(m: BidMode, budgetFen: number): BidResult {
const ltv = Math.floor((effectiveArpdau(m) * cumulativeActivePermille(m, HORIZON)) / 1000);
const cpi = cpiFen(m, ltv);
const installs = Math.floor(budgetFen / cpi);
const revenue = Math.floor((installs * ltv) / 10);
return { id: m.id, cpiFen: cpi, ltvD30DeciFen: ltv, roasD30Permille: Math.floor((ltv * 100) / cpi),
installs, d30ProfitFen: revenue - budgetFen };
}
export function evaluateAll(cfg: BidConfig): BidResult[] {
return cfg.modes.map((m) => evaluate(m, cfg.budgetFen));
}
/** 跨语言对拍摘要:与 sim/o02_bid_choice.py 的 digest 逐字符一致。 */
export function digest(rows: readonly BidResult[]): string {
return rows.map((r) => `${r.id}:${r.cpiFen}:${r.roasD30Permille}:${r.installs}:${r.d30ProfitFen}`).join(",");
}
/** 决策:在 installs >= minInstalls 的方式里取最大 d30ProfitFen。 */
export function bestMode(cfg: BidConfig, rows: readonly BidResult[]): string {
const eligible = rows.filter((r) => r.installs >= cfg.minInstalls);
const pool = eligible.length > 0 ? eligible : rows;
return pool.reduce((a, b) => (b.d30ProfitFen > a.d30ProfitFen ? b : a)).id;
}
配套测试 core/test/o02_bid_choice.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 复算逐字符相等,并验证「ROAS 贴住目标」「oCPM 胜出」两条结论。跑 node --test 'core/test/o02_*.test.ts',8 个用例全过:
digest = ocpm:312:1197:9615:593125,cpa:324:1030:9259:91580,roi:362:1153:8287:458993
只要有一边的整数语义、成本口径、决策规则写错,摘要立刻分叉。这和卷二「一份逻辑、两种宿主」 是同一个信念:同一个数,在 Python 仿真、TS 内核、浏览器原型里必须长得一模一样。
X 层:真实竞价与归因不可复现
上面算的是模型侧。下面这些属于 X 层——需真实业务环境,命令行不可复现:
- 真实竞价胜出价(同一素材、同一人群,不同时段差很多,平台侧不透明);
- 真实 CTR / IPM(受素材、人群、平台算法共同影响);
- 真实的 oCPM 冷启动成本虚高与 CPA 超投幅度(跑稳需要样本量)。
正确做法是把可复现的模型层与不可复现的真实数据层分开:模型层做决策前的「如果…会怎样」,真实数据层用来反标定——把实测 CPI / 留存填回 config,模型立刻反映现实。
自检表
| 检查项 | 合格标准 |
|---|---|
| 成本口径统一 | 三种方式的 CPI 都能用同一单位(分)复算 |
| 收入侧一致 | 都用 arpdau × 累计活跃日,不因出价方式而变 |
| 决策口径正确 | 选同预算下的最大绝对利润,不是最高 ROAS |
| 量级下限 | 高 ROAS 但买不到量的方式不进候选 |
| 边界清晰 | 真实竞价与归因标 X,不写成「实测」 |
出价方式选对了,只是把单位成本压到了合理区间。真正决定盈亏的是预算怎么爬——爬得太快会触发学习期重置,把有效 CPA 抬到回本线以上。下一节处理放量节奏。
阅读导航:上一节:1.3 冷启动期与学习期 · 下一节:2.2 放量节奏与预算爬坡 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。