2.1 oCPM、CPA 与 ROI 出价的选择

三种出价方式不是玄学:oCPM 按曝光竞价、CPA 按安装结算、ROI 按目标回报反推成本。本节把它们折算到同一条「成本 → 回收」链,用整数公式算出每种方式的 CPI、D30 ROAS 与同预算下的绝对利润,配可跑的 Python 仿真与 TypeScript 内核跨语言对拍,回答这笔预算该用哪种出价。

站内《小游戏开发实战》卷三目录 已经把渠道机制框架讲完了——曝光成本、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 按曝光优化31211979615593125
CPA 按安装结算3241030925991580
ROI 目标出价36211538287458993

结论和直觉相反: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 放量节奏与预算爬坡 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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