5.1 买量渠道与投放机制

买量渠道机制是《合成小岛》上线后的第一道成本闸门:把曝光成本、点击率、安装率、留存与 ARPDAU 串成一条整数漏斗,算出每个渠道的 CPI 与 D0/D7/D30 ROAS,判断谁在 D21 回本、谁在持续失血;配可跑的 Python 仿真与 TypeScript 内核跨语言对拍。

站内有一篇小游戏买量与广告变现的失败复盘 ,那是事后视角的单篇教训:把「乱投一通、素材糊弄、留存崩盘」的坑按现象列出来。本节的定位不同——它讲的是买量这件事本身的机制:钱从哪一步花出去、在哪一步折损、最后能不能从玩家身上收回来。那篇告诉你「别踩什么坑」,这一节给你一台能算账的机器:改一个参数,立刻看到这个渠道是在给你赚钱还是在持续失血。两者的分工很清楚,不重复。

前四章我们把「怎么从玩家身上收钱」拆完了:广告变现、内购混合、收入建模与回本、定价促销。但一个残酷的前提是——得先有玩家。自然量能撑起的盘子有限,想放大就得花钱买量。而买量是整条商业链里唯一一个「先付钱、后回收」的环节:你先按曝光或按安装把现金付给渠道,能不能赚回来取决于之后几天的留存与付费。所以买量的本质不是「投放操作」,是一笔现金流的时间价值判断。

买量到底在买什么

外行以为买量是「买用户」,其实你买的是一次曝光机会。渠道不保证用户会点、会装、会留下来。你付的钱,换来的是把素材塞进某个人的信息流或激励视频里。从这一刻起,后面每一步都可能在折损:曝光 → 点击 → 安装 → 首局完成 → 次日留存 → 付费 → 累计 LTV。

这条链上,每一步都是一个乘法因子。渠道报价(CPM)决定分子的成本,点击率(CTR)和点击到安装率(IPM)决定分母的效率。CTR 掉一半,你的单安装成本就翻倍——但注意,这里的「翻倍」只在同机同参的相对意义上成立,绝对数值随渠道、素材、人群而剧烈漂移,任何一方都不可移植。

渠道机制:四种模型,一套漏斗

模型结算单位谁承担转化风险适用场景
CPM千次曝光广告主(你)信息流竞价,优化空间最大
oCPM千次曝光,按转化目标优化广告主,但系统帮你找人群信息流主流,需要转化回传
CPC单次点击广告主承担点击后风险达人 / 搜索 / 部分联盟
CPI单次安装渠道承担点击与安装风险网盟 / 激励量,单价高但省心

四种模型最终都能折算到同一个分母:单安装成本 CPI。这正是本节要做的事——把模型差异折叠掉,只留一条「成本 → 收入」的漏斗。oCPM 看似复杂,但一旦你有了稳定的 CTR 与 IPM,它和一个固定 CPM 在算术上没有区别,区别只在人群质量(也就是留存)。

漏斗公式:从 CPM 到 ROAS

把上面那条链写成整数公式,一切都清楚了(成本单位统一用「分」,避免浮点):

CPI(分)      = CPM(分) × 1000 / (CTR‰ × IPM‰)
累计活跃日(‰) = Σ_{d=0..N} 留存(d)‰
LTV(0.1分)   = ARPDAU(0.1分) × 累计活跃日(‰) / 1000
ROAS(‰)      = LTV(0.1分) × 100 / CPI(分)      # 1000‰ = 回本
回本日        = 首个 LTV(d) ≥ CPI 的 d

ROAS 是衡量「这笔钱花得值不值」的核心指标:D0 ROAS 看当天能收回多少,D7 看一周,D30 看长线。1000‰ 是回本线——低于它,这个渠道在烧钱;高于它,才值得放量。而回本日告诉你这笔钱要压多久才能收回来,这直接决定你的现金流能不能撑住放量的节奏。

S 层仿真:一条链算到底

下面这段是 S 层仿真脚本 sim/l05_roi_funnel.py。它读 config/l05_roi.json 的四个渠道,用整数公式把整条漏斗算到底,输出每个渠道的 CPI、D0/D7/D30 ROAS、回本日,以及按预算折算的安装数与 D30 盈亏,并产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/l05_roi_funnel.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l05_roi_funnel.py --config config/ --out out/
"""买量投放 ROI 漏斗仿真:把「曝光成本 → 点击 → 安装 → 留存 → 收入」整条链算成整数。

对 config/l05_roi.json 的每个渠道:

    cpiFen        = cpmFen * 1000 // (ctrPermille * ipmPermille)   # 单安装成本(分)
    activeDays(d) = (Σ_{t=0..d} retentionPermille(t)) // 1000      # 每安装累计活跃日(千分比累加)
    ltvDeciFen(d) = arpdauDeciFen * activePermille(d) // 1000      # 0.1 分
    roasPermille  = ltvDeciFen(d) * 100 // cpiFen                   # ROAS 千分比(1000 = 回本)
    paybackDay    = 首个 ltvDeciFen(d) >= cpiFen*10 的天(否则 -1)

每日留存按锚点(D1 / D7 / D30)做整数线性插值,D30 之后持平。投放预算按 cpiFen
折算安装数,再按 D30 累计 LTV 折算回收额,得到该渠道的 D30 盈亏。

全程整数、地板除,无浮点。产出 out/l05_roi_funnel.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(ch: dict, d: int) -> int:
    """第 d 日留存千分比(d=0 安装日恒 1000);锚点之间整数线性插值。"""
    if d <= 0:
        return 1000
    r1, r7, r30 = ch["retainD1Permille"], ch["retainD7Permille"], ch["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(ch: dict, n: int) -> int:
    """每安装的累计活跃日,单位千分比日(1000 = 一整日)。"""
    return sum(daily_retention(ch, d) for d in range(0, n + 1))


def evaluate(ch: dict, budget_fen: int) -> dict:
    cpi = ch["cpmFen"] * 1000 // (ch["ctrPermille"] * ch["ipmPermille"])
    ltv = {n: ch["arpdauDeciFen"] * cumulative_active_permille(ch, n) // 1000
           for n in (0, 7, 30)}
    roas = {n: ltv[n] * 100 // cpi for n in (0, 7, 30)}
    payback = -1
    for d in range(0, HORIZON + 1):
        if ch["arpdauDeciFen"] * cumulative_active_permille(ch, d) // 1000 >= cpi * 10:
            payback = d
            break
    installs = budget_fen // cpi
    d30_revenue_fen = installs * ltv[30] // 10
    return dict(ch, cpiFen=cpi, ltvD0DeciFen=ltv[0], ltvD7DeciFen=ltv[7], ltvD30DeciFen=ltv[30],
                roasD0Permille=roas[0], roasD7Permille=roas[7], roasD30Permille=roas[30],
                paybackDay=payback, budgetFen=budget_fen, installs=installs,
                d30RevenueFen=d30_revenue_fen, d30ProfitFen=d30_revenue_fen - budget_fen)


def draw_svg(path: str, rows: list[dict]) -> int:
    """纯 Python SVG:每个渠道两根横条——D0 ROAS 与 D30 ROAS(回本线 1000)。"""
    W, H, PAD, ROW = 760, 70 + 64 * len(rows), 170, 64
    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 = "《合成小岛》各渠道 D0 / D30 ROAS(千分比,1000=回本)"
    span = W - PAD - 24
    hi = max([r["roasD30Permille"] for r in rows] + [1200]) or 1
    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"]
        for j, (key, color, label) in enumerate([("roasD0Permille", "#93c5fd", "D0"),
                                                 ("roasD30Permille", "#2563eb", "D30")]):
            w = span * r[key] // hi
            ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 6 + j * 20), "width": str(w),
                                         "height": "16", "rx": "3", "fill": color})
            ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 18 + j * 20),
                                         "font-size": "11", "fill": "#6b7280"}).text = (
                f'{label} {r[key]}‰ · CPI {r["cpiFen"]}分 · 回本 D{r["paybackDay"]}')
    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, "l05_roi.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    rows = [evaluate(ch, cfg["budgetFen"]) for ch in cfg["channels"]]

    # 跨语言对拍摘要:core/test/l05_roi.test.ts 复算同一串数字,逐位比对。
    digest = ",".join(
        f'{r["id"]}:{r["cpiFen"]}:{r["roasD0Permille"]}:{r["roasD7Permille"]}'
        f':{r["roasD30Permille"]}:{r["paybackDay"]}' for r in rows)

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "l05_roi_funnel.svg"), rows)
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "budgetFen": cfg["budgetFen"], "horizonDays": HORIZON, "channelCount": len(rows),
              "channels": rows, "digest": digest,
              "bestByD30": max(rows, key=lambda r: r["roasD30Permille"])["id"],
              "svg": os.path.join(args.out, "l05_roi_funnel.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(args.out, "l05_roi_funnel.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "l05_roi_funnel.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        cols = ("id", "name", "model", "cpmFen", "ctrPermille", "ipmPermille", "cpiFen",
                "ltvD0DeciFen", "ltvD7DeciFen", "ltvD30DeciFen", "roasD0Permille",
                "roasD7Permille", "roasD30Permille", "paybackDay", "installs",
                "d30RevenueFen", "d30ProfitFen")
        w.writerow(cols)
        for r in rows:
            w.writerow([r[k] for k in cols])

    print(json.dumps({"channels": [{"id": r["id"], "cpiFen": r["cpiFen"],
                                     "roasD0Permille": r["roasD0Permille"],
                                     "roasD7Permille": r["roasD7Permille"],
                                     "roasD30Permille": r["roasD30Permille"],
                                     "paybackDay": r["paybackDay"]} for r in rows],
                      "digest": digest, "bestByD30": report["bestByD30"]},
                     ensure_ascii=False, indent=2))
    return 0


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

渠道参数放在 config/l05_roi.json。注意这里的数字是「公开可观察机制」口径的建模假设,不是任何渠道后台的实测:真实出价、真实归因结果属于 X 层——需真实业务环境,命令行不可复现。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "budgetFen": 3000000,
  "note": "买量渠道机制参数,口径是「公开可观察机制」的建模假设,不是任何渠道后台实测。cpmFen=千次曝光成本(分);ctrPermille=曝光→点击千分比;ipmPermille=点击→安装千分比;arpdauDeciFen=0.1 分/DAU/日(含 IAA+IAP);retainD1/D7/D30Permille=第 1/7/30 日留存千分比(安装日恒 1000)。真实出价、真实归因结果需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "channels": [
    { "id": "feed", "name": "信息流(oCPM)", "model": "oCPM", "cpmFen": 1600, "ctrPermille": 30, "ipmPermille": 140, "arpdauDeciFen": 520, "retainD1Permille": 430, "retainD7Permille": 180, "retainD30Permille": 110 },
    { "id": "rewarded", "name": "激励视频(CPI)", "model": "CPI", "cpmFen": 2400, "ctrPermille": 55, "ipmPermille": 190, "arpdauDeciFen": 380, "retainD1Permille": 360, "retainD7Permille": 150, "retainD30Permille": 90 },
    { "id": "kol", "name": "达人合作(CPC)", "model": "CPC", "cpmFen": 4200, "ctrPermille": 45, "ipmPermille": 170, "arpdauDeciFen": 900, "retainD1Permille": 470, "retainD7Permille": 230, "retainD30Permille": 140 },
    { "id": "union", "name": "联盟/网盟(CPI)", "model": "CPI", "cpmFen": 900, "ctrPermille": 18, "ipmPermille": 110, "arpdauDeciFen": 240, "retainD1Permille": 240, "retainD7Permille": 95, "retainD30Permille": 50 }
  ]
}

在本机跑 for f in sim/l05_*.py; do /opt/homebrew/bin/python3 "$f" --config config/ --out out/; done,这一条链跑出来的结果(同机相对量级,绝对数值不可移植):

渠道CPI(分)D0 ROAS‰D7 ROAS‰D30 ROAS‰回本日
信息流 oCPM380136428878未回本
激励视频 CPI229165462913未回本
达人合作 CPC5491635651253D21
联盟网盟 CPI45452114200未回本

这张表就是买量决策的全部依据:只有达人渠道在 D21 回本(D30 ROAS 1253‰ > 1000),其余三个渠道到 D30 都没回本。注意联盟渠道——它 CPI 不算最高(454 分),但 D30 ROAS 只有 200‰,是典型的「便宜但垃圾」:便宜是买来的低质安装,留存撑不起回收。

P 层内核:跨语言对拍,证明公式没写错

光看一张表,你没法确认「是模型对」还是「算错了碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/l05/roi.ts 复算同一条链:同样的整数地板除、同样的留存插值、同样的回本判定。它读同一份 config/l05_roi.json,产出和 Python 逐字符一致的摘要。

// file: core/src/l05/roi.ts
// tier: P
// verify: node --test 'core/test/l05_*.test.ts'
//
// 买量 ROI 漏斗内核:与 sim/l05_roi_funnel.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/l05_roi.json;全整数、地板除、无浮点;只用可擦除语法(禁 enum / namespace)。

export interface RoiChannel {
  readonly id: string; readonly name: string; readonly model: string;
  readonly cpmFen: number; readonly ctrPermille: number; readonly ipmPermille: number;
  readonly arpdauDeciFen: number;
  readonly retainD1Permille: number; readonly retainD7Permille: number; readonly retainD30Permille: number;
}
export interface RoiConfig {
  readonly version: number; readonly game: string; readonly budgetFen: number;
  readonly channels: readonly RoiChannel[];
}
export interface RoiResult {
  readonly id: string; readonly cpiFen: number;
  readonly ltvD0DeciFen: number; readonly ltvD7DeciFen: number; readonly ltvD30DeciFen: number;
  readonly roasD0Permille: number; readonly roasD7Permille: number; readonly roasD30Permille: number;
  readonly paybackDay: number; readonly installs: number; readonly d30ProfitFen: number;
}

export const HORIZON = 30;

/** 第 d 日留存千分比(d=0 安装日恒 1000);D1/D7/D30 锚点之间整数线性插值。 */
export function dailyRetention(ch: RoiChannel, d: number): number {
  if (d <= 0) return 1000;
  const { retainD1Permille: r1, retainD7Permille: r7, retainD30Permille: r30 } = ch;
  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(ch: RoiChannel, n: number): number {
  let sum = 0;
  for (let d = 0; d <= n; d += 1) sum += dailyRetention(ch, d);
  return sum;
}

export function evaluate(ch: RoiChannel, budgetFen: number): RoiResult {
  const cpiFen = Math.floor((ch.cpmFen * 1000) / (ch.ctrPermille * ch.ipmPermille));
  const ltv = (n: number) => Math.floor((ch.arpdauDeciFen * cumulativeActivePermille(ch, n)) / 1000);
  const roas = (n: number) => Math.floor((ltv(n) * 100) / cpiFen);
  let paybackDay = -1;
  for (let d = 0; d <= HORIZON; d += 1) {
    if (ltv(d) >= cpiFen * 10) { paybackDay = d; break; }
  }
  const installs = Math.floor(budgetFen / cpiFen);
  const d30RevenueFen = Math.floor((installs * ltv(30)) / 10);
  return { id: ch.id, cpiFen, ltvD0DeciFen: ltv(0), ltvD7DeciFen: ltv(7), ltvD30DeciFen: ltv(30),
    roasD0Permille: roas(0), roasD7Permille: roas(7), roasD30Permille: roas(30), paybackDay,
    installs, d30ProfitFen: d30RevenueFen - budgetFen };
}

export function evaluateAll(cfg: RoiConfig): RoiResult[] {
  return cfg.channels.map((ch) => evaluate(ch, cfg.budgetFen));
}

/** 跨语言对拍摘要:与 sim/l05_roi_funnel.py 的 digest 逐字符一致。 */
export function digest(rows: readonly RoiResult[]): string {
  return rows.map((r) => `${r.id}:${r.cpiFen}:${r.roasD0Permille}:${r.roasD7Permille}:`
    + `${r.roasD30Permille}:${r.paybackDay}`).join(",");
}

配套测试 core/test/l05_roi.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并顺手验回本判定与留存插值。在本机跑 node --test 'core/test/l05_*.test.ts',5 个用例全过;两套独立实现给出同一个摘要:

digest = feed:380:136:428:878:-1,rewarded:229:165:462:913:-1,kol:549:163:565:1253:21,union:454:52:114:200:-1

只要有一边的整数语义、插值方向、回本阈值写错,摘要立刻分叉。这和卷二「一份逻辑、两种宿主」 是同一个信念:同一个数,在 Python 仿真、TS 内核、浏览器原型里必须长得一模一样。

X 层:真实渠道后台不可复现

必须把边界划清。上面算的是模型侧:给定 CTR、IPM、留存,成本与回收是多少。而下面这些东西属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的 CPM 出价与竞价胜出价(同一素材、同一人群,不同时段能差很多);
  • 真实的 CTR / IPM(受素材、人群、渠道算法共同影响,且平台侧不透明);
  • 真实的归因结果与平台侧「学习期」行为(归因窗口、去重规则由第三方服务商定义;oCPM 冷启动阶段成本虚高,跑稳需要样本量)。

这些都不是「跑个脚本就能知道」的。工程上正确的做法是:把可复现的模型层与不可复现的真实数据层分开——模型层用来做决策前的「如果…会怎样」,真实数据层用来在投放后反标定模型参数(把实测 CTR / 留存填回 config,模型立刻反映现实)。这也是第 11 章埋点口径要跟模型字段一一对应的原因。

自检表

检查项合格标准
漏斗完整曝光 / 点击 / 安装 / 留存 / 付费每一环都有数,没有断点
成本口径统一到「分」的整数,CPI 可复算
回本判定每个渠道都算 D0/D7/D30 ROAS 与回本日
模型可反标定config 字段能直接吃线上实测值
归因明确点击还是曝光、窗口多长、由谁归因,写在案上
边界清晰真实后台数据标 X,不写成「实测」

买量最危险的错觉,是把「模型算出来的数」当成「平台会给我的数」。模型只回答「在假设成立时结果如何」,假设是否成立要靠投放后的真实数据去证伪。下一节我们处理这条链上唯一由你完全掌控、也最影响 CTR 的变量——素材:怎么批量产出、怎么小额测试、怎么迭代淘汰、怎么放量。

阅读导航:上一节:目录 · 下一节:素材设计与创意迭代 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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