4.1 归因窗口、点击归因与展示归因

移动归因的第一步是定义窗口:点击归因与展示归因各给多长回看期,决定同一批转化在渠道之间怎么分。本节用整数公式与可跑的 Python 仿真,把确定性触点流在 last-click、last-touch、first-click、linear 四种模型下逐一归因,配 TypeScript 内核跨语言对拍,并划清与站内 AI 特征归因(SHAP)无关的边界。

站内有一篇清单式的广告变现设计短文 ,它从变现侧列了「放什么广告、放在哪、怎么控频次」;而卷三《上线变现与长线运营》 已经写完了渠道机制、素材设计与收入回本模型。本节和它们都不重复:它处理的是买量之后、变现之前那段最容易被含糊过去的路——一笔转化到底算谁的。先澄清一个词:站内 归因 几乎都指 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-clicklast-touchfirst-clicklinear‰
信息流620666625670975
激励视频600638622650475
达人合作584642549621641
联盟网盟577630585632810

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 链路 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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