卷三「买量渠道与投放机制」 与「素材设计与创意迭代」 已经把买量拆透了:前者做的是渠道机制与素材设计框架(国内口径)——CPM/oCPM/CPC/CPI 四种模型怎么折算到同一条漏斗;后者讲素材怎么批量产出、怎么迭代。本章不重做那套素材设计方法论,只做三件出海特有、卷三没覆盖的事:海外渠道的投放口径差异(本节)、素材本地化的合规约束(13.2)、海外 ROI 与止损的可跑模型(13.3)。
口径不是数字,是「可比性」
国内买量你只需要一个数:CPI(单安装成本)。因为渠道、人群、结算口径大体一致,横向比 CPI 就够了。到了海外,同一个 CPI 背后可能是完全不同的东西:有人按千次曝光结算、有人按点击、有人按安装、还有人按「有效转化」;有人用 7 天点击窗口归因、有人用 1 天展示窗口;有人按美元报、有人按本币报。这些差异不是精度问题,是口径问题——把它们拉到同一把尺子上,才谈得上比较。
口径归一化要做三件事:把结算单位折算到同一个分母(每安装)、把归因口径折算到同一个窗口与去重规则、把货币与时区折算到同一个基准。本节聚焦前两件(货币口径见「汇率、手续费与到账周期」 )。
三种口径差异:结算、归因、去重
| 口径维度 | 差异来源 | 不归一化的后果 |
|---|---|---|
| 结算单位 | CPM / oCPM / CPC / CPI / CPA 五种模型 | 拿 CPM 渠道的「千次曝光价」直接比 CPI 渠道的「单安装价」 |
| 归因窗口 | 点击窗口 / 展示窗口长度不同 | 窗口窄的渠道自报安装偏少、显得贵;窗口宽的显得便宜 |
| 去重比例 | 多渠道抢同一个安装,各归各的 | 同一批安装被两个渠道重复计入,成本被摊薄 |
一句话:结算单位决定「名义成本」,归因口径决定「这个成本算给谁」。两者不拉平,选出来的「最便宜渠道」往往是把安装重复计入最多的那一个。
D 层:口径清单与 lint
下面这份是 D 层配置 config/g13_ua_caliber.json,把五个渠道的结算单位、漏斗、归因缩放与去重比例写成可机读整数表:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"spendFen": 1000000,
"baseline": { "clickDays": 7, "viewDays": 1 },
"note": "海外渠道投放口径归一化配置。口径是「公开可观察机制」的建模假设(观察时点 2026-10),不是任一投放后台实测。settleModel=结算单位(CPM/oCPM 按千次曝光、CPC 按点击、CPI 按安装、CPA 按转化);unitPriceFen=结算单价(分);ctrPermille=曝光→点击千分比;ipmPermille=点击→安装千分比;activationPermille=安装→转化千分比;windowScalePermille=把该渠道自报安装折算到统一归因窗口基准的缩放(>1000 表示该渠道窗口比基准窄、自报偏少需上调);dedupPermille=归因去重后剩余比例。spendFen=每渠道对齐用预算。真实出价与真实归因结果需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"settleModels": [
{ "id": "CPM", "label": "千次曝光" },
{ "id": "oCPM", "label": "千次曝光(按转化优化)" },
{ "id": "CPC", "label": "单次点击" },
{ "id": "CPI", "label": "单次安装" },
{ "id": "CPA", "label": "单次转化" }
],
"channels": [
{ "id": "meta_feed", "name": "Meta 信息流", "settleModel": "CPM", "unitPriceFen": 1800, "ctrPermille": 28, "ipmPermille": 150, "activationPermille": 700, "windowScalePermille": 1150, "dedupPermille": 880 },
{ "id": "tiktok_feed", "name": "TikTok 信息流", "settleModel": "oCPM", "unitPriceFen": 1500, "ctrPermille": 30, "ipmPermille": 140, "activationPermille": 680, "windowScalePermille": 800, "dedupPermille": 750 },
{ "id": "apple_search", "name": "Apple 搜索广告", "settleModel": "CPI", "unitPriceFen": 900, "ctrPermille": 1000, "ipmPermille": 1000, "activationPermille": 650, "windowScalePermille": 1000, "dedupPermille": 900 },
{ "id": "adnet_cpi", "name": "海外网盟 CPI", "settleModel": "CPI", "unitPriceFen": 250, "ctrPermille": 1000, "ipmPermille": 1000, "activationPermille": 500, "windowScalePermille": 600, "dedupPermille": 500 },
{ "id": "kol_cpc", "name": "海外达人 CPC", "settleModel": "CPC", "unitPriceFen": 60, "ctrPermille": 1000, "ipmPermille": 170, "activationPermille": 720, "windowScalePermille": 1200, "dedupPermille": 950 }
]
}
下面这段是 D 层清单解析脚本 sim/g13_ua_caliber.py。它读上面那份配置,把每个渠道归一化到「每归因安装成本」,并做 schema lint:
#!/usr/bin/env python3
# file: sim/g13_ua_caliber.py
# tier: D
# verify: /opt/homebrew/bin/python3 sim/g13_ua_caliber.py --config config/ --out out/
"""海外渠道投放口径归一化清单与 lint(D 层:清单结构 / lint)。
把「结算单位 + 单价 + 漏斗 + 归因口径」折算到同一可比口径(每归因安装成本):
nominalCpiFen: CPM/oCPM = unit*1000 // (ctr‰*ipm‰); CPC = unit*1000 // ipm‰;
CPI = unit; CPA = unit*1000 // activation‰
nominalInstalls = spendFen // nominalCpiFen
comparableInstalls = nominalInstalls * windowScale‰ // 1000
attributedInstalls = comparableInstalls * dedup‰ // 1000
comparableCpiFen = spendFen // attributedInstalls
lint:结算模型合法、千分比在 (0,1000]、缩放与单价为正。产出 out/g13_ua_caliber.json + .csv。
全整数、无浮点、无随机。口径是「公开可观察机制」的建模假设(观察时点 2026-10);真实出价
与真实归因结果需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
MODELS = ("CPM", "oCPM", "CPC", "CPI", "CPA")
def nominal_cpi(ch: dict) -> int:
"""把任意结算单位折算到「每安装名义成本」(分)。"""
m = ch["settleModel"]
if m in ("CPM", "oCPM"):
return ch["unitPriceFen"] * 1000 // (ch["ctrPermille"] * ch["ipmPermille"])
if m == "CPC":
return ch["unitPriceFen"] * 1000 // ch["ipmPermille"]
if m == "CPI":
return ch["unitPriceFen"]
return ch["unitPriceFen"] * 1000 // ch["activationPermille"] # CPA
def normalize(ch: dict, spend_fen: int) -> dict:
"""口径归一化:名义 → 统一窗口 → 去重 → 每归因安装成本。"""
nominal = nominal_cpi(ch)
nominal_installs = spend_fen // nominal
comparable_installs = nominal_installs * ch["windowScalePermille"] // 1000
attributed_installs = comparable_installs * ch["dedupPermille"] // 1000
comparable = spend_fen // attributed_installs if attributed_installs else 0
return {"nominalCpiFen": nominal, "nominalInstalls": nominal_installs,
"comparableInstalls": comparable_installs, "attributedInstalls": attributed_installs,
"comparableCpiFen": comparable}
def lint(cfg: dict) -> int:
"""schema lint:结算模型合法、千分比在 (0,1000]、缩放因子与单价为正。"""
ok = 1
for ch in cfg["channels"]:
if ch["settleModel"] not in MODELS:
ok = 0
for k in ("ctrPermille", "ipmPermille", "activationPermille", "dedupPermille"):
if not 0 < ch[k] <= 1000:
ok = 0
if ch["windowScalePermille"] <= 0 or ch["unitPriceFen"] <= 0:
ok = 0
return ok
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
with open(os.path.join(a.config, "g13_ua_caliber.json"), encoding="utf-8") as f:
cfg = json.load(f)
spend = cfg["spendFen"]
rows = [dict(ch, **normalize(ch, spend)) for ch in cfg["channels"]]
by_nominal = sorted(rows, key=lambda r: (r["nominalCpiFen"], r["id"]))
by_comparable = sorted(rows, key=lambda r: (r["comparableCpiFen"], r["id"]))
rank = {r["id"]: i + 1 for i, r in enumerate(by_comparable)}
for r in rows:
r["comparableRank"] = rank[r["id"]]
ok = lint(cfg)
parts = ";".join(f'{r["id"]}:{r["nominalCpiFen"]}:{r["comparableCpiFen"]}:{r["comparableRank"]}'
for r in rows)
digest = (f'schemaOk={ok},channels={len(rows)},nominalCheapest={by_nominal[0]["id"]},'
f'comparableCheapest={by_comparable[0]["id"]};{parts}')
os.makedirs(a.out, exist_ok=True)
cols = ("id", "name", "settleModel", "unitPriceFen", "nominalCpiFen", "nominalInstalls",
"windowScalePermille", "dedupPermille", "attributedInstalls", "comparableCpiFen",
"comparableRank")
with open(os.path.join(a.out, "g13_ua_caliber.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(cols)
for r in rows:
w.writerow([r[c] for c in cols])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"spendFen": spend, "schemaOk": ok, "nominalOrder": [r["id"] for r in by_nominal],
"comparableOrder": [r["id"] for r in by_comparable], "channels": rows,
"digest": digest, "csv": os.path.join(a.out, "g13_ua_caliber.csv")}
with open(os.path.join(a.out, "g13_ua_caliber.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"digest": digest, "schemaOk": ok, "nominalOrder": rep["nominalOrder"],
"comparableOrder": rep["comparableOrder"]}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑 /opt/homebrew/bin/python3 sim/g13_ua_caliber.py --config config/ --out out/,五个渠道的归一化结果(同机相对量级,绝对数值不可移植):
| 渠道 | 结算单位 | 名义 CPI(分) | 归因缩放‰ | 去重‰ | 可比 CPI(分) | 可比排名 |
|---|---|---|---|---|---|---|
| 海外网盟 CPI | CPI | 250 | 600 | 500 | 833 | 4 |
| 海外达人 CPC | CPC | 352 | 1200 | 950 | 308 | 1 |
| TikTok 信息流 | oCPM | 357 | 800 | 750 | 595 | 3 |
| Meta 信息流 | CPM | 428 | 1150 | 880 | 423 | 2 |
| Apple 搜索广告 | CPI | 900 | 1000 | 900 | 1001 | 5 |
这张表最该记住的是前两行:网盟 CPI 名义上最便宜(250 分,只有达人的七成),归一化后却掉到第四(833 分)——因为它自报的安装窗口窄(缩放 600‰)且去重后只剩一半(500‰),名义上的「便宜」是少报安装换来的。达人 CPC 名义上排第三(352 分),归一化后反而第一(308 分)。摘要行:
digest = schemaOk=1,channels=5,nominalCheapest=adnet_cpi,comparableCheapest=kol_cpc;meta_feed:428:423:2;tiktok_feed:357:595:3;apple_search:900:1001:5;adnet_cpi:250:833:4;kol_cpc:352:308:1
P 层内核:跨语言对拍
光看一张表,你没法确认是模型对还是算错了碰巧看着对。下面这段是 P 层 TypeScript 内核 core/src/g13/ua_caliber.ts,复算同一套归一化:同样的整数地板除、同样的缩放顺序、同样的排序。它读同一份 config/g13_ua_caliber.json,产出和 Python 逐字符一致的摘要。
// file: core/src/g13/ua_caliber.ts
// tier: P
// verify: node --test 'core/test/g13_*.test.ts'
//
// 海外渠道投放口径归一化内核:与 sim/g13_ua_caliber.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/g13_ua_caliber.json;全整数、地板除、无浮点、无随机。
export interface Channel {
readonly id: string; readonly name: string; readonly settleModel: string;
readonly unitPriceFen: number; readonly ctrPermille: number; readonly ipmPermille: number;
readonly activationPermille: number; readonly windowScalePermille: number; readonly dedupPermille: number;
}
export interface CaliberConfig {
readonly version: number; readonly game: string; readonly observedAt: string;
readonly spendFen: number; readonly channels: readonly Channel[];
}
export interface Normalized {
readonly nominalCpiFen: number; readonly nominalInstalls: number;
readonly comparableInstalls: number; readonly attributedInstalls: number; readonly comparableCpiFen: number;
}
export interface ChannelRow extends Channel, Normalized { readonly comparableRank: number }
export const MODELS = ["CPM", "oCPM", "CPC", "CPI", "CPA"];
/** 把任意结算单位折算到「每安装名义成本」(分,整数地板除)。 */
export function nominalCpi(ch: Channel): number {
const m = ch.settleModel;
if (m === "CPM" || m === "oCPM") return Math.floor((ch.unitPriceFen * 1000) / (ch.ctrPermille * ch.ipmPermille));
if (m === "CPC") return Math.floor((ch.unitPriceFen * 1000) / ch.ipmPermille);
if (m === "CPI") return ch.unitPriceFen;
return Math.floor((ch.unitPriceFen * 1000) / ch.activationPermille); // CPA
}
/** 口径归一化:名义 → 统一窗口 → 去重 → 每归因安装成本。 */
export function normalize(ch: Channel, spendFen: number): Normalized {
const nominal = nominalCpi(ch);
const nominalInstalls = Math.floor(spendFen / nominal);
const comparableInstalls = Math.floor((nominalInstalls * ch.windowScalePermille) / 1000);
const attributedInstalls = Math.floor((comparableInstalls * ch.dedupPermille) / 1000);
const comparable = attributedInstalls ? Math.floor(spendFen / attributedInstalls) : 0;
return { nominalCpiFen: nominal, nominalInstalls, comparableInstalls, attributedInstalls,
comparableCpiFen: comparable };
}
/** schema lint:结算模型合法、千分比在 (0,1000]、缩放因子与单价为正。 */
export function lint(cfg: CaliberConfig): number {
let ok = 1;
for (const ch of cfg.channels) {
if (!MODELS.includes(ch.settleModel)) ok = 0;
for (const k of [ch.ctrPermille, ch.ipmPermille, ch.activationPermille, ch.dedupPermille]) {
if (!(k > 0 && k <= 1000)) ok = 0;
}
if (ch.windowScalePermille <= 0 || ch.unitPriceFen <= 0) ok = 0;
}
return ok;
}
export function rows(cfg: CaliberConfig): ChannelRow[] {
const base = cfg.channels.map((ch) => ({ ...ch, ...normalize(ch, cfg.spendFen), comparableRank: 0 }));
const order = [...base].sort((a, b) => (a.comparableCpiFen - b.comparableCpiFen) || (a.id < b.id ? -1 : 1));
order.forEach((r, i) => { r.comparableRank = i + 1; });
return base;
}
/** 跨语言对拍摘要:与 sim/g13_ua_caliber.py 的 digest 逐字符一致。 */
export function digest(cfg: CaliberConfig): string {
const rs = rows(cfg);
const byNominal = [...rs].sort((a, b) => (a.nominalCpiFen - b.nominalCpiFen) || (a.id < b.id ? -1 : 1));
const byComparable = [...rs].sort((a, b) => (a.comparableCpiFen - b.comparableCpiFen) || (a.id < b.id ? -1 : 1));
const parts = rs.map((r) => `${r.id}:${r.nominalCpiFen}:${r.comparableCpiFen}:${r.comparableRank}`).join(";");
return `schemaOk=${lint(cfg)},channels=${rs.length},nominalCheapest=${byNominal[0].id},`
+ `comparableCheapest=${byComparable[0].id};${parts}`;
}
配套测试 core/test/g13_ua_caliber.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 复算结果逐字符相等,并验结算折算、口径翻转、归因方向与 lint。在本机跑 node --test 'core/test/g13_ua_caliber.test.ts',本节 6 个用例全过:
✔ 跨语言一致:TS 口径摘要 == Python 金标准
✔ 结算单位折算:CPM 除以漏斗、CPC 除以安装率、CPI 直取单价
✔ 口径翻转:名义最便宜与可比最便宜不是同一个渠道
✔ 归因口径:窄窗口(>1000)抬可比安装、低去重(‰低)压低可比安装
✔ schema lint:结算模型与千分比区间全部合法
✔ 可比口径排序:kol_cpc 第一、apple_search 末位
ℹ tests 6
ℹ pass 6
ℹ fail 0
两套独立实现给出同一个摘要(截取首段):schemaOk=1,channels=5,nominalCheapest=adnet_cpi,comparableCheapest=kol_cpc;…。只要有一边的整数语义、缩放顺序或排序写错,摘要立刻分叉。这与卷二「一份逻辑、两种宿主」
是同一个信念:同一个数,在 Python 仿真、TS 内核、浏览器原型里必须长得一模一样。
X 层:真实后台与归因结果不可复现
必须把边界划清。上面算的是模型侧:给定结算单位与归因口径,可比成本是多少。而下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的出价与竞价胜出价(同一素材、同一人群,不同时段能差很多);
- 真实的归因结果与去重比例(由第三方归因服务商定义,平台侧不透明);
- 各平台后台的真实口径定义(窗口长度、去重规则、货币结算方式会随版本调整,本节按观察时点 2026-10 冻结)。
工程上正确的做法:把可复现的归一化层与不可复现的后台层分开——归一化层固定「结算折算 + 归因缩放 + 去重」这套算法;后台层在拿到真实报表后反标定(把实测的去重比例与窗口缩放填回 config,排序立刻反映现实)。这套「同一份口径、跨语言一致」的做法,与卷四「ATT、IDFA 与 SKAdNetwork」 里把链路决策做成可对拍清单是同一个信念。
自检表
| 检查项 | 合格标准 |
|---|---|
| 结算折算 | 五种结算单位都折算到同一个分母(每安装) |
| 归因口径 | 窗口缩放与去重比例显式入表,不靠默认值 |
| 可比排序 | 同时给出名义与可比两套排名,暴露翻转 |
| 跨语言一致 | Python 与 TS 摘要逐字符相同 |
| 全整数 | 成本用分、比例用千分比,地板除无浮点 |
| 边界清晰 | 真实出价与归因结果标 X,不写成「实测」 |
口径这件事最容易骗人的地方是:它不改变任何一个数字,只改变你拿谁跟谁比。下一节处理买量素材本身——同一套素材投到不同地区,哪些标签会被拒、哪些需要改。
阅读导航:上一节:目录 · 下一节:13.2 素材本地化与合规 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。