1.2 出价方式与预算分配

出价方式看起来五花八门——oCPM、CPA、ROI 各有一套话术——但把每组折算到「边际 CPA」这一把尺子上,账户预算的最优分配就退化成一道按 CPA 升序贪心填充的题。本节用可跑的 Python 仿真把 30 万分预算分给四组,再配 TypeScript 内核跨语言对拍,看钱该流向谁。

站内有一篇小游戏广告变现设计 ,那是广告位怎么摆的清单式短文;卷三的目录 则讲了渠道机制与增长预算分配。本节和它们的分工很清楚:那两处回答「钱从哪来、往哪投」,本节回答一个更细的问题——同一个账户里,几组广告各自用什么出价方式,账户日预算到底该怎么分。出价是挂在广告组层、改动代价中等、却最直接影响成本的一层,值得单独算一遍。

新手常被出价方式的名字绕晕:oCPM、CPA、ROI、CPC、CPM……每个都像一门独立的学问。但只要问一个问题就能把它们看穿——这一组每买到一个「我要的结果」,边际成本是多少? 一旦有了这个数,出价方式的差异就只是「用哪种方式去逼近这个数」,而预算分配本身变成了一道纯粹的最优化题。

出价方式到底是什么

出价的本质是你对一次结果的报价。区别只在「结果」的定义:CPM 的结果是曝光,CPC 是点击,oCPM 是「以转化为目标的曝光优化」,CPA 是转化,ROI 是回收。方式越靠近最终价值,平台承担的优化责任越大,但你也越依赖平台「帮你找人」的能力。

出价方式你在为什么付费优化目标谁承担转化风险典型适用
CPM千次曝光曝光量广告主品牌曝光、素材冷启
CPC单次点击点击广告主搜索、达人、联盟
oCPM千次曝光,按转化优化转化广告主,平台帮找人群信息流主流
CPA单次转化转化数广告主,按转化结算追求稳定转化成本
ROI目标回收回收额广告主,按回收优化有回传、要保本

四种出价折叠到同一把尺子

关键洞察是:不管用什么方式出价,每个广告组最终都对应一个「边际 CPA」——多花一块钱能多买到几个转化。把每组折算到边际 CPA,分配问题就和出价方式无关了。

对一组广告组,若它的出价是每千次曝光 bidFen 分、曝光到转化的转化率是 cvrPermille 千分比,那么:

边际 CPA(0.001分) = bidFen × 1000 // cvrPermille
该组预算 B 的转化数 = B × cvrPermille // bidFen

oCPM 与 CPA 在模型里没有区别——区别只在人群质量(也就是真实 cvrPermille),而那是 X 层的事。一旦你把每组都折算成边际 CPA,账户预算的最优分配就退化成一道经典题:按边际 CPA 从低到高填,填到日预算上限为止。这不需要复杂算法,一个贪心循环就够。

S 层仿真:把 30 万分预算分给四组

下面这段是 S 层仿真脚本 sim/o01_bid.py,是完整可运行文件。它读 config/o01_bid.json 的四组广告,先算每组的边际 CPA,再按 CPA 升序贪心把账户日预算填下去,输出每组分到的预算、转化数与混合 CPA,并产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/o01_bid.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o01_bid.py --config config/ --out out/
"""出价方式与预算分配仿真:把账户日预算按边际 CPA 从低到高贪心分配到各广告组。

对 config/o01_bid.json 的每个广告组:

    cpaMilliFen  = bidFen * 1000 // cvrPermille        # 每转化成本(0.001 分)
    conversions  = allocFen * cvrPermille // bidFen     # 该预算能买到的转化数

把不同出价方式(oCPM / CPA / ROI)折叠成同一个「边际 CPA」视角后,最优分配
就是按 CPA 升序、逐组填到日预算上限为止。全程整数、地板除,无浮点。
产出 out/o01_bid.json + .csv + .svg。

出价与转化率是「公开可观察机制」口径的建模假设;真实出价、真实转化率与平台
分配算法需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

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


def evaluate(cfg: dict):
    groups = cfg["groups"]
    rows = []
    for g in groups:
        rows.append({
            "id": g["id"], "name": g["name"], "bidType": g["bidType"],
            "bidFen": g["bidFen"], "cvrPermille": g["cvrPermille"],
            "dailyCapFen": g["dailyCapFen"],
            "cpaMilliFen": g["bidFen"] * 1000 // g["cvrPermille"],
        })
    # 贪心:按 (cpaMilliFen, id) 升序填预算,直到账户日预算耗尽。
    order = sorted(rows, key=lambda r: (r["cpaMilliFen"], r["id"]))
    remaining = cfg["accountDailyBudgetFen"]
    for r in order:
        alloc = min(remaining, r["dailyCapFen"])
        r["allocFen"] = alloc
        r["cappedOut"] = alloc == r["dailyCapFen"]
        remaining -= alloc
    total_alloc = sum(r["allocFen"] for r in rows)
    for r in rows:
        r["conversions"] = r["allocFen"] * r["cvrPermille"] // r["bidFen"]
    total_conv = sum(r["conversions"] for r in rows)
    blended = total_alloc * 1000 // total_conv if total_conv else 0
    return rows, order, total_alloc, total_conv, blended


def digest(rows: list[dict]) -> str:
    return ";".join(f'{r["id"]}:{r["cpaMilliFen"]}:{r["allocFen"]}:{r["conversions"]}'
                    for r in rows)


def draw_svg(path: str, rows: list[dict]) -> int:
    """纯 Python SVG:每组一根横条(分配预算),标注 CPA 与转化数。"""
    W, PAD, ROW = 760, 230, 44
    H = 64 + ROW * len(rows)
    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["allocFen"] for r in rows] + [1])
    for i, r in enumerate(rows):
        y = 56 + i * ROW
        w = span * r["allocFen"] // hi
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 16), "font-size": "11",
                                     "fill": "#374151"}).text = f'{r["id"]} {r["bidType"]}'
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(w),
                                     "height": "20", "rx": "3",
                                     "fill": "#2563eb" if r["cappedOut"] else "#93c5fd"})
        ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 18), "font-size": "11",
                                     "fill": "#6b7280"}).text = (
            f'{r["allocFen"]} 分 · CPA {r["cpaMilliFen"] // 1000}.{r["cpaMilliFen"] % 1000:03d} 分'
            f' · {r["conversions"]} 转化')
    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, "o01_bid.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    rows, order, total_alloc, total_conv, blended = evaluate(cfg)
    dg = digest(rows)

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "o01_bid.svg"), order)
    report = {
        "version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
        "accountDailyBudgetFen": cfg["accountDailyBudgetFen"],
        "groupCount": len(rows), "totalAllocFen": total_alloc, "totalConversions": total_conv,
        "blendedCpaMilliFen": blended,
        "allocationOrder": [r["id"] for r in order],
        "groups": rows, "digest": dg,
        "svg": os.path.join(args.out, "o01_bid.svg"), "svgBytes": svg_bytes,
    }
    with open(os.path.join(args.out, "o01_bid.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "o01_bid.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(("id", "name", "bidType", "bidFen", "cvrPermille", "dailyCapFen",
                    "cpaMilliFen", "allocFen", "cappedOut", "conversions"))
        for r in rows:
            w.writerow([r[k] for k in ("id", "name", "bidType", "bidFen", "cvrPermille",
                                       "dailyCapFen", "cpaMilliFen", "allocFen", "cappedOut",
                                       "conversions")])

    print(json.dumps({
        "allocationOrder": [r["id"] for r in order],
        "allocFen": {r["id"]: r["allocFen"] for r in rows},
        "conversions": {r["id"]: r["conversions"] for r in rows},
        "totalConversions": total_conv, "blendedCpaMilliFen": blended, "digest": dg,
    }, ensure_ascii=False, indent=2))
    return 0


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

出价参数放在 config/o01_bid.json——这些数字是「公开可观察机制」口径的建模假设,不是任何后台实测。真实出价、真实转化率与平台分配算法属于 X 层,需真实业务环境,命令行不可复现。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "accountDailyBudgetFen": 300000,
  "note": "出价方式与预算分配参数,口径是「公开可观察机制」的建模假设,不是任何投放后台实测。bidFen=每组出价(分/千次曝光);cvrPermille=曝光→转化千分比;dailyCapFen=该组日预算上限(分)。不同出价方式(oCPM/CPA/ROI)在模型里折叠成同一个「边际 CPA」视角:cpaMilliFen=bidFen*1000//cvrPermille(0.001 分/转化)。真实出价、真实转化率与平台分配算法需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "groups": [
    { "id": "g1", "name": "oCPM-冷启动", "bidType": "oCPM", "bidFen": 2000, "cvrPermille": 50, "dailyCapFen": 120000 },
    { "id": "g2", "name": "oCPM-放量", "bidType": "oCPM", "bidFen": 2200, "cvrPermille": 44, "dailyCapFen": 100000 },
    { "id": "g3", "name": "CPA-目标转化", "bidType": "CPA", "bidFen": 3600, "cvrPermille": 60, "dailyCapFen": 80000 },
    { "id": "g4", "name": "ROI-目标回收", "bidType": "ROI", "bidFen": 1600, "cvrPermille": 32, "dailyCapFen": 60000 }
  ]
}

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

顺序广告组出价方式边际 CPA(分)分到预算(分)转化数是否填满上限
1g1 冷启动oCPM40.0001200003000是
2g2 放量oCPM50.0001000002000是
3g4 目标回收ROI50.000600001200是
4g3 目标转化CPA60.00020000333否

这张表最有意思的地方在最后一行:CPA 组(g3)明明转化率最高(60‰),却只分到 20000 分。原因是它的出价也最高(3600 分/千次),折算下来边际 CPA 是四组里最贵的 60 分。预算有限时,最优分配不看你「转化率高不高」,只看你「每转化贵不贵」——g3 排到最后,只捡到前面几组没吃完的剩余预算。总转化数 6533,混合 CPA 45.920 分。

P 层内核:跨语言对拍,证明贪心口径没写错

同样用一份 P 层 TypeScript 内核 core/src/o01/bid.ts 复算:同样的边际 CPA、同样的 (cpa, id) 排序、同样的贪心填充。它读同一份 config/o01_bid.json,产出和 Python 逐字符一致的摘要。

// file: core/src/o01/bid.ts
// tier: P
// verify: node --test 'core/test/o01_bid.test.ts'
//
// 出价方式与预算分配内核:与 sim/o01_bid.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o01_bid.json;全整数、地板除、无浮点;只用可擦除语法(禁 enum / namespace)。

export interface BidGroup {
  readonly id: string; readonly name: string; readonly bidType: string;
  readonly bidFen: number; readonly cvrPermille: number; readonly dailyCapFen: number;
}
export interface BidConfig {
  readonly version: number; readonly game: string;
  readonly accountDailyBudgetFen: number; readonly groups: readonly BidGroup[];
}
export interface BidResult {
  readonly id: string; readonly name: string; readonly bidType: string;
  readonly bidFen: number; readonly cvrPermille: number; readonly dailyCapFen: number;
  readonly cpaMilliFen: number; readonly allocFen: number; readonly cappedOut: boolean;
  readonly conversions: number;
}
export interface BidAllocation {
  readonly rows: BidResult[]; readonly order: BidResult[];
  readonly totalAllocFen: number; readonly totalConversions: number; readonly blendedCpaMilliFen: number;
}

export function evaluate(cfg: BidConfig): BidAllocation {
  const rows: BidResult[] = cfg.groups.map((g) => ({
    id: g.id, name: g.name, bidType: g.bidType, bidFen: g.bidFen, cvrPermille: g.cvrPermille,
    dailyCapFen: g.dailyCapFen, cpaMilliFen: Math.floor((g.bidFen * 1000) / g.cvrPermille),
    allocFen: 0, cappedOut: false, conversions: 0,
  }));
  // 贪心:按 (cpaMilliFen, id) 升序填预算,直到账户日预算耗尽。
  const order = [...rows].sort((a, b) => (a.cpaMilliFen - b.cpaMilliFen) || (a.id < b.id ? -1 : 1));
  let remaining = cfg.accountDailyBudgetFen;
  for (const r of order) {
    const alloc = Math.min(remaining, r.dailyCapFen);
    r.allocFen = alloc;
    r.cappedOut = alloc === r.dailyCapFen;
    remaining -= alloc;
  }
  const totalAllocFen = rows.reduce((s, r) => s + r.allocFen, 0);
  for (const r of rows) r.conversions = Math.floor((r.allocFen * r.cvrPermille) / r.bidFen);
  const totalConversions = rows.reduce((s, r) => s + r.conversions, 0);
  const blendedCpaMilliFen = totalConversions ? Math.floor((totalAllocFen * 1000) / totalConversions) : 0;
  return { rows, order, totalAllocFen, totalConversions, blendedCpaMilliFen };
}

/** 跨语言对拍摘要:与 sim/o01_bid.py 的 digest 逐字符一致。 */
export function digest(rows: readonly BidResult[]): string {
  return rows.map((r) => `${r.id}:${r.cpaMilliFen}:${r.allocFen}:${r.conversions}`).join(";");
}

配套测试 core/test/o01_bid.test.ts 把 Python 摘要写成金标准,断言 TS 复算结果逐字符相等,并验边际 CPA、排序与「预算耗尽」边界。在本机跑 node --test 'core/test/o01_bid.test.ts',5 个用例全过;两套实现给出同一个摘要:

digest = g1:40000:120000:3000;g2:50000:100000:2000;g3:60000:20000:333;g4:50000:60000:1200

注意 g2 与 g4 的边际 CPA 都是 50000——同分时按 id 升序(g2 先于 g4)保证可确定性回放,否则同一份配置可能跑出两种分配。这正是「一份逻辑、两种宿主」 强调的:确定性不是巧合,是显式约定。

X 层:真实出价与分配不可复现

必须把边界划清。上面算的是模型侧:给定出价与转化率,钱该怎么分。而下面这些属于 X 层,需真实业务环境,命令行不可复现:真实的出价与竞价胜出价;真实的曝光→转化率(受素材、人群、平台算法共同影响,平台侧不透明);真实的日预算上限与消耗节奏;平台自己的「预算分配/放量」算法(是否真按边际成本分,各家不同且不公开)。

工程上正确的做法是:用模型层做决策前的「如果…会怎样」,用真实数据层反标定参数——把线上实测的出价与转化率填回 config,模型立刻反映现实。这也是为什么这套 config 的字段要和投放后台的字段一一对应。

自检表

检查项合格标准
出价口径统一所有出价方式都折算到「边际 CPA」同一把尺子
分配可复算给定出价与转化率,分配结果可逐位复现
排序确定同 CPA 时有确定的 tie-break,不依赖随机
上限与残差每组的日预算上限与「未填满」状态都报出来
混合 CPA 可算总预算 ÷ 总转化,能对账
边界清晰真实出价与平台算法标 X,不写成「实测」

出价与预算分配回答了「钱分给谁」,但还有一半问题没答:一个刚上线的广告组,头几天成本为什么总是虚高? 这是学习期的成本曲线,也是下一节的主题——它决定了你该忍多久、什么时候该判定「这组跑废了」。

阅读导航:上一节:1.1 账户、计划、广告组与创意层级 · 下一节:1.3 冷启动期与学习期 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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