站内有一篇清单式的广告变现设计短文
,它从变现侧列了「放什么广告、放在哪、怎么控频次」;而卷三《上线变现与长线运营》
已经写完了渠道机制、素材设计与收入回本模型。本节和它们都不重复:它处理的是买量之后、变现之前那段最容易被含糊过去的路——一笔转化到底算谁的。先澄清一个词:站内 归因 几乎都指 AI 特征归因(SHAP 那类「模型为什么这么判」),本节讲的是移动广告归因,两者毫无关系。
前三章建好了账户结构、出价放量与素材工业化,但它们都建立在一个前提上:平台回传的「转化」是可信的。而现实里,同一个安装会被多个渠道声称是自己带来的。谁说了算,取决于你给每个渠道设了多长的回看窗口、算不算展示。窗口没定义清楚,后面所有出价优化都是在错误的靶子上调参。
归因窗口:给每次转化划一条时间线
归因窗口(attribution window)是一条回看期:从转化发生的时刻往回推,只有落在窗口内的触点才有资格「认领」这次转化。窗口之外的行为一律作废——哪怕它明显是转化的源头。这条规则同时解决了三件事:把无限的因果链截断成一个有限集合、给去重一个可计算的边界、让不同渠道的声称可以放在同一把尺子上比较。
| 概念 | 含义 | 谁在定义 |
|---|---|---|
| 点击窗口 | 点击发生后多久内的转化算这次点击 | 归因服务商 / 平台 |
| 展示窗口 | 只看到(未点击)后多久内的转化算这次展示 | 归因服务商 / 平台 |
| 去重规则 | 多触点命中时谁优先、按什么顺序取 | 归因服务商 |
| 归因模型 | 单触点独占还是多触点分摊 | 你在 MMP 后台配置 |
关键在于:窗口长度不是物理常量,是一份合同。它由归因服务商与平台各自定义,你只能在可用选项里选。所以本节不去猜「真实窗口是几天」,而是把它当成一个可调参数,问「不同窗口下,渠道之间的转化分配会怎么变」。
点击归因与展示归因
点击归因(click-through)要求用户真的点过广告;展示归因(view-through)只要广告在他的屏幕上出现过就算。两者的证据强度差得很远:点击是明确的意向信号,展示只是「被看见过」。所以业内惯例是——展示窗口远短于点击窗口(常见量级是小时级对天级),因为展示归因越松,越容易把自然量错记到某个只是恰好曝光过的渠道头上。
这一节的可跑模型里,我们把两套窗口都设为可调参数:点击窗口 clickWindowMinutes、展示窗口 viewWindowMinutes。一个触点是否合格,就看它的类型与距转化的分钟数是否落在对应窗口内。合格与否,直接决定这次转化会不会被算到某个渠道账上。
四种归因模型
同一批合格触点,按不同的「分配规则」会得出完全不同的渠道贡献。本节实现四种最常见的规则:
| 模型 | 规则 | 适用直觉 |
|---|---|---|
| last-click | 取窗口内最近一次点击所在渠道 | 保守、偏向转化前的临门一脚 |
| first-click | 取窗口内最早一次点击所在渠道 | 偏向「谁把人带进来」 |
| last-touch | 取窗口内最近一个触点(点击或展示都算) | 会额外吸收展示归因的量 |
| linear | 窗口内全部合格触点均分(各 1000‰ 之一) | 平摊,不赌谁最关键 |
last-touch 与 last-click 的差值,正好就是纯展示贡献的那部分转化——这也是后面表里最能说明问题的一列。linear 用整数千分比均分,1000 // qualified 会舍掉余数,属于可接受的近似。
S 层仿真:一条流算到底
下面这段是 S 层仿真脚本 sim/o04_window.py。它读 config/o04_window.json,用 seed + 自实现整数 LCG 生成一批确定性触点流,对每个事件套用四种模型,输出各渠道的归因转化数与摘要,并产出 JSON + CSV + SVG。随机数完全可播种,同一份 config 在任何机器上得到同一串数字。
#!/usr/bin/env python3
# file: sim/o04_window.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o04_window.py --config config/ --out out/
"""归因窗口仿真:在点击 / 展示两套窗口下把确定性触点流分到渠道,比较 last-click /
last-touch / first-click / linear 四种模型。读 config/o04_window.json:seed + 整数 LCG
生成 events 个事件、每事件 1..maxTouches 个触点(渠道 / 类型 / 距转化分钟数);点击
合格 = isClick and lag <= clickWindowMinutes,展示合格 = not isClick and lag <=
viewWindowMinutes;last-touch = 全部合格里 lag 最小者,linear = 全部合格均分 1000‰。
LCG: state = (state * 1103515245 + 12345) mod 2^31。产出 out/o04_window.json + .csv +
.svg。窗口规则属「公开可观察机制」建模假设;真实归因结果需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse, csv, json, os
import xml.etree.ElementTree as ET
MASK = 0x7FFFFFFF
def lcg(s: int) -> int:
return (s * 1103515245 + 12345) & MASK
def generate(cfg: dict) -> list:
"""确定性触点流:每事件一串 (channelIndex, isClick, lagMinutes)。"""
n_ch, s, events = len(cfg["channels"]), cfg["seed"], []
for _ in range(cfg["events"]):
s = lcg(s)
touches = []
for _t in range(1 + s % cfg["maxTouches"]):
s = lcg(s); ci = s % n_ch
s = lcg(s); is_click = (s % 100) < cfg["clickSharePct"]
s = lcg(s); touches.append((ci, is_click, s % cfg["maxLagMinutes"]))
events.append(touches)
return events
def attribute(cfg: dict, events: list) -> dict:
n_ch, cw, vw = len(cfg["channels"]), cfg["clickWindowMinutes"], cfg["viewWindowMinutes"]
lc, fc, lt, lin = [0] * n_ch, [0] * n_ch, [0] * n_ch, [0] * n_ch
with_click = view_only = 0
for touches in events:
has_click = has_view = False
blc = bfc = blt = None # best last/first click、best last touch
qualified = 0
for ci, is_click, lag in touches:
q_click, q_view = is_click and lag <= cw, (not is_click) and lag <= vw
if q_click:
has_click = True
if blc is None or lag < blc[0]: blc = (lag, ci)
if bfc is None or lag > bfc[0]: bfc = (lag, ci)
if q_view: has_view = True
if q_click or q_view:
qualified += 1
if blt is None or lag < blt[0]: blt = (lag, ci)
if blc is not None: lc[blc[1]] += 1
if bfc is not None: fc[bfc[1]] += 1
if blt is not None:
lt[blt[1]] += 1
share = 1000 // qualified
for ci, is_click, lag in touches:
if (is_click and lag <= cw) or ((not is_click) and lag <= vw): lin[ci] += share
if has_click: with_click += 1
elif has_view: view_only += 1
return dict(lastClick=lc, firstClick=fc, lastTouch=lt, linearPermille=lin,
eventsWithClick=with_click, viewOnlyEvents=view_only)
def draw_svg(path: str, rows: list) -> int:
"""纯 Python SVG:每渠道两根横条——last-click 与 last-touch 归因转化数。"""
W, PAD, ROW = 760, 170, 64
H = 70 + 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 = "《合成小岛》各渠道归因转化:last-click vs last-touch"
span, hi = W - PAD - 24, max([max(r["lastClick"], r["lastTouch"]) for r in rows] + [1])
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, lab) in enumerate([("lastClick", "#93c5fd", "LC"), ("lastTouch", "#2563eb", "LT")]):
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'{lab} {r[key]} · first-click {r["firstClick"]}'
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, "o04_window.json"), encoding="utf-8") as f:
cfg = json.load(f)
agg = attribute(cfg, generate(cfg))
rows = [dict(cfg["channels"][i], lastClick=agg["lastClick"][i], firstClick=agg["firstClick"][i],
lastTouch=agg["lastTouch"][i], linearPermille=agg["linearPermille"][i])
for i in range(len(cfg["channels"]))]
view_through = agg["viewOnlyEvents"] * 1000 // cfg["events"]
# 跨语言对拍摘要:core/test/o04_window.test.ts 复算同一串数字,逐位比对。
digest = ",".join(f'{r["id"]}:{r["lastClick"]}:{r["lastTouch"]}:{r["firstClick"]}' for r in rows)
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "o04_window.svg"), rows)
report = dict(cfg, eventsWithClick=agg["eventsWithClick"], viewOnlyEvents=agg["viewOnlyEvents"],
viewThroughSharePermille=view_through, channels=rows, digest=digest,
svg=os.path.join(args.out, "o04_window.svg"), svgBytes=svg_bytes)
with open(os.path.join(args.out, "o04_window.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "o04_window.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
cols = ("id", "name", "lastClick", "firstClick", "lastTouch", "linearPermille")
w.writerow(cols)
for r in rows: w.writerow([r[k] for k in cols])
print(json.dumps({"events": cfg["events"], "eventsWithClick": agg["eventsWithClick"],
"viewOnlyEvents": agg["viewOnlyEvents"], "viewThroughSharePermille": view_through,
"channels": [{"id": r["id"], "lastClick": r["lastClick"], "lastTouch": r["lastTouch"],
"firstClick": r["firstClick"], "linearPermille": r["linearPermille"]}
for r in rows], "digest": digest}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
触点流参数放在 config/o04_window.json(D 层配置)。这些数字是「公开可观察机制」口径的建模假设,不是任何渠道后台或归因服务商的实测。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"seed": 20261013,
"events": 4000,
"maxTouches": 3,
"clickSharePct": 55,
"maxLagMinutes": 14400,
"clickWindowMinutes": 10080,
"viewWindowMinutes": 1440,
"channels": [
{ "id": "feed", "name": "信息流" },
{ "id": "rewarded", "name": "激励视频" },
{ "id": "kol", "name": "达人合作" },
{ "id": "union", "name": "联盟网盟" }
],
"note": "归因窗口建模假设,不是任何投放后台或归因服务商实测。seed + 整数 LCG 生成确定性触点流;点击窗口 10080 分钟(7 天)、展示窗口 1440 分钟(1 天)。真实窗口长度与去重规则需真实业务环境,命令行不可复现(X)。全整数,无浮点。"
}
在本机跑 /opt/homebrew/bin/python3 sim/o04_window.py --config config/ --out out/,这一条流跑出来的结果(同机相对量级,绝对数值不可移植):
| 渠道 | last-click | last-touch | first-click | linear‰ |
|---|---|---|---|---|
| 信息流 | 620 | 666 | 625 | 670975 |
| 激励视频 | 600 | 638 | 622 | 650475 |
| 达人合作 | 584 | 642 | 549 | 621641 |
| 联盟网盟 | 577 | 630 | 585 | 632810 |
4000 个事件里 2381 个有合格点击、195 个只有合格展示,纯展示贡献占 48‰。这张表最值得看的是 last-touch 减 last-click 的差:四个渠道分别多出 46 / 38 / 58 / 53,合计正好 195——这就是展示归因「白捡」的量。换句话说,只要把展示窗口从 0 调到 1 天,就有约 4.8% 的转化会从「无归属」变成「某个渠道的功劳」。这也是为什么展示窗口必须显式约定,而不是留给平台默认值。
P 层内核:跨语言对拍,证明公式没写错
光看一张表,你没法确认「是模型对」还是「算错了碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/o04/window.ts 复算同一条流:同样的 LCG、同样的窗口判定、同样的地板除。它读同一份 config/o04_window.json,产出和 Python 逐字符一致的摘要。注意 LCG 在 TS 侧必须用 BigInt——state × 1103515245 会超过 2^53,用普通 number 会静默丢精度、导致两边摘要分叉。
// file: core/src/o04/window.ts
// tier: P
// verify: node --test 'core/test/o04_*.test.ts'
//
// 归因窗口内核:与 sim/o04_window.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o04_window.json;LCG 用 BigInt 保证与 Python 大整数一致;
// 全整数、地板除、无浮点;只用可擦除语法(禁 enum / namespace)。
export interface WindowConfig {
readonly version: number; readonly game: string; readonly seed: number; readonly events: number;
readonly maxTouches: number; readonly clickSharePct: number; readonly maxLagMinutes: number;
readonly clickWindowMinutes: number; readonly viewWindowMinutes: number;
readonly channels: readonly { readonly id: string; readonly name: string }[];
}
export interface WindowResult {
readonly lastClick: number[]; readonly firstClick: number[]; readonly lastTouch: number[];
readonly linearPermille: number[]; readonly eventsWithClick: number; readonly viewOnlyEvents: number;
}
const MASK = 0x7fffffffn;
type Touch = readonly [number, boolean, number];
/** 整数线性同余,BigInt 版:与 Python 大整数逐位等价。 */
export function lcg(state: bigint): bigint {
return (state * 1103515245n + 12345n) & MASK;
}
/** 确定性触点流:每事件一串 (channelIndex, isClick, lagMinutes)。 */
export function generate(cfg: WindowConfig): Touch[][] {
const nCh = cfg.channels.length;
let state = BigInt(cfg.seed);
const events: Touch[][] = [];
for (let e = 0; e < cfg.events; e += 1) {
state = lcg(state);
const nTouch = 1 + Number(state % BigInt(cfg.maxTouches));
const touches: Touch[] = [];
for (let t = 0; t < nTouch; t += 1) {
state = lcg(state);
const ci = Number(state % BigInt(nCh));
state = lcg(state);
const isClick = Number(state % 100n) < cfg.clickSharePct;
state = lcg(state);
touches.push([ci, isClick, Number(state % BigInt(cfg.maxLagMinutes))]);
}
events.push(touches);
}
return events;
}
export function attribute(cfg: WindowConfig, events: readonly Touch[][]): WindowResult {
const nCh = cfg.channels.length;
const cw = cfg.clickWindowMinutes, vw = cfg.viewWindowMinutes;
const lastClick = new Array<number>(nCh).fill(0), firstClick = new Array<number>(nCh).fill(0);
const lastTouch = new Array<number>(nCh).fill(0), linear = new Array<number>(nCh).fill(0);
let eventsWithClick = 0, viewOnly = 0;
for (const touches of events) {
let hasClick = false, hasView = false, qualified = 0;
let blc: Touch | null = null, bfc: Touch | null = null, blt: Touch | null = null;
for (const [ci, isClick, lag] of touches) {
const qClick = isClick && lag <= cw, qView = !isClick && lag <= vw;
if (qClick) {
hasClick = true;
if (blc === null || lag < blc[2]) blc = [ci, isClick, lag];
if (bfc === null || lag > bfc[2]) bfc = [ci, isClick, lag];
}
if (qView) hasView = true;
if (qClick || qView) {
qualified += 1;
if (blt === null || lag < blt[2]) blt = [ci, isClick, lag];
}
}
if (blc !== null) lastClick[blc[0]] += 1;
if (bfc !== null) firstClick[bfc[0]] += 1;
if (blt !== null) {
lastTouch[blt[0]] += 1;
const share = Math.floor(1000 / qualified);
for (const [ci, isClick, lag] of touches) {
if ((isClick && lag <= cw) || (!isClick && lag <= vw)) linear[ci] += share;
}
}
if (hasClick) eventsWithClick += 1;
else if (hasView) viewOnly += 1;
}
return { lastClick, firstClick, lastTouch, linearPermille: linear, eventsWithClick, viewOnlyEvents: viewOnly };
}
/** 跨语言对拍摘要:与 sim/o04_window.py 的 digest 逐字符一致。 */
export function digest(cfg: WindowConfig, r: WindowResult): string {
return cfg.channels.map((c, i) => `${c.id}:${r.lastClick[i]}:${r.lastTouch[i]}:${r.firstClick[i]}`).join(",");
}
配套测试 core/test/o04_window.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等。在本机跑 node --test 'core/test/o04_*.test.ts',5 个用例全过,两套独立实现给出同一个摘要 feed:620:666:625,rewarded:600:638:622,kol:584:642:549,union:577:630:585。只要有一边的窗口判定、min/max 取法、LCG 语义写错,摘要立刻分叉。这和卷二「一份逻辑、两种宿主」
是同一个信念:同一个数,在 Python 仿真、TS 内核、浏览器原型里必须长得一模一样。
X 层:真实归因结果不可复现
必须把边界划清。上面算的是模型侧:给定窗口与触点流,四种模型各分到多少。而下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的窗口长度与去重优先级(由归因服务商与平台各自定义,且会随版本调整);
- 真实的归因结果(同一批安装在不同 MMP 口径下数字不同,平台侧回传还有延迟与丢失),以及自然量与付费量真实的混入比例(决定展示归因到底「白捡」了多少)。
工程上正确的做法是:把可复现的模型层与不可复现的真实数据层分开——模型层用来做决策前的「如果窗口调长会怎样」,真实数据层用来在投放后反标定参数(把实测触点分布填回 config,模型立刻反映现实)。
自检表
| 检查项 | 合格标准 |
|---|---|
| 窗口口径 | 点击窗口与展示窗口各自写死,不用平台默认 |
| 展示归因 | 显式决定算不算、算多久,并记录在案 |
| 可播种 | 触点流来自 seed,无 Math.random() |
| 跨语言一致 | Python 与 TS 摘要逐字符相同 |
| 边界清晰 | 真实窗口与真实回传标 X,不写成「实测」 |
归因窗口是整条投放链的坐标系:坐标系定歪了,后面每一张报表都是错的。下一节我们把坐标系本身拆开——ATT、IDFA 与 SKAdNetwork 如何在隐私新政下把「可见」切成几块。
阅读导航:上一节:3.3 素材衰减与疲劳替换 · 下一节:4.2 ATT、IDFA 与 SKAdNetwork 链路 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。