站内有一篇小游戏广告变现设计 ,那是广告位怎么摆的清单式短文;卷三的目录 则讲了渠道机制与增长预算分配。本节和它们的分工很清楚:那两处回答「钱从哪来、往哪投」,本节回答一个更细的问题——同一个账户里,几组广告各自用什么出价方式,账户日预算到底该怎么分。出价是挂在广告组层、改动代价中等、却最直接影响成本的一层,值得单独算一遍。
新手常被出价方式的名字绕晕: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(分) | 分到预算(分) | 转化数 | 是否填满上限 |
|---|---|---|---|---|---|---|
| 1 | g1 冷启动 | oCPM | 40.000 | 120000 | 3000 | 是 |
| 2 | g2 放量 | oCPM | 50.000 | 100000 | 2000 | 是 |
| 3 | g4 目标回收 | ROI | 50.000 | 60000 | 1200 | 是 |
| 4 | g3 目标转化 | CPA | 60.000 | 20000 | 333 | 否 |
这张表最有意思的地方在最后一行: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 冷启动期与学习期 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。