13.1 海外渠道与投放口径

把国内买量经验直接搬到海外会翻车:同一笔花费在不同渠道用不同结算单位与归因窗口报数,直接比大小必然选错。本节把「结算单位 + 单价 + 漏斗 + 归因口径」归一化到同一可比口径,用 D 层清单与 P 层 TypeScript 内核跨语言对拍,看清名义最便宜与可比最便宜为何常常不是同一个渠道。

卷三「买量渠道与投放机制」 与「素材设计与创意迭代」 已经把买量拆透了:前者做的是渠道机制与素材设计框架(国内口径)——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(分)可比排名
海外网盟 CPICPI2506005008334
海外达人 CPCCPC35212009503081
TikTok 信息流oCPM3578007505953
Meta 信息流CPM42811508804232
Apple 搜索广告CPI900100090010015

这张表最该记住的是前两行:网盟 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 素材本地化与合规 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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