4.2 ATT、IDFA 与 SKAdNetwork 链路

隐私新政把移动归因切成两条路:已授权用户走 IDFA 精确归因,未授权用户只能靠 SKAdNetwork 的聚合回传。本节用整数公式与可跑的 Python 仿真,算出真实安装里有多少还能被观测到、6 位转化值把收入量化后损失多少,配 TypeScript 内核跨语言对拍,并标明真实同意率与回传阈值属不可复现的 X 层。

上一节我们定义了归因窗口这条「坐标系」。本节处理坐标系里最硬的一块地基:隐私新政之后,你到底还能看见多少。卷三《上线变现与长线运营》 已经写完了收入模型与回本判断,但它默认你能拿到完整的转化数据;而站内 H5 产业综述 里提到的隐私清单,正好点出了这个前提的裂缝。本节不重复它们,只做一件事:把 ATT / IDFA / SKAdNetwork 这条链路拆成可算的几段,量出观测盲区有多大。同样先声明:这里的 归因 是移动广告归因,与站内那些讲 AI 特征归因(SHAP)的内容无关。

一次授权,切出两群人

ATT(App Tracking Transparency)把用户分成两群:同意授权的,系统给一个稳定的 IDFA,你可以做用户级精确归因;未授权的,你拿不到 IDFA,只能退回到平台提供的聚合回传——苹果生态里就是 SKAdNetwork。这个分叉是整个链路的起点:它不是「精度高一点还是低一点」,而是从个体级观测直接掉到聚合级观测。

群体可用标识归因粒度典型数据形态
已授权IDFA用户级逐个安装的点击 / 展示归因
未授权无(仅聚合)群体级SKAdNetwork 回传的转化值分布

关键在于:同意率不可控,也不该被诱导。你能做的是把链路两端都接好——授权侧尽力匹配,未授权侧把聚合回传的转化值定义好。本节把同意率当成一个可调参数,而不是去猜「真实同意率是多少」。

链路三段:匹配、覆盖、量化

把从「真实安装」到「你看到的数」这条路拆开,会经过三段折损,每一段都能单独建模:

段落输入折损来源参数
授权匹配已授权安装匹配失败(无 IDFA / 跨设备)idfaMatchPermille
聚合覆盖未授权安装SKAdNetwork 隐私门槛 / 回传延迟skanCoveragePermille
收入量化覆盖到的安装6 位转化值档位太少conversionBits / revenueUnitDeciFen

第一段和第二段决定能看到多少安装,第三段决定看到的收入有多粗。三者叠起来,才是最终「收入可见度」——它通常比「安装可见度」更低,因为量化又砍了一刀。

6 位转化值:64 档装不下真实收入分布

SKAdNetwork 回传的不是每笔收入,而是一个整数转化值。经典版本是 6 位,也就是 0 到 63 共 64 档。你得先定义「每档代表多少收入」(本节用 revenueUnitDeciFen),再把真实收入除以它、取整、封顶到 63。问题在于:真实收入的分布是长尾的,少数高价值用户会瞬间撞上 63 的天花板,而大量零收入用户全挤在第 0 档。这一封顶,就是量化损失的主要来源。

S 层仿真:算清可见度

下面这段是 S 层仿真脚本 sim/o04_att.py。它读 config/o04_att.json,按同意率把真实安装切成两群,算出各自的覆盖,再把收入分布量化到 6 位转化值、还原后与真值比较,输出安装可见度、量化损失与收入可见度,并产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/o04_att.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o04_att.py --config config/ --out out/
"""ATT / IDFA / SKAdNetwork 链路仿真:算「真实安装」有多少能被观测到,
以及 SKAdNetwork 6 位转化值把收入量化后损失多少。

读 config/o04_att.json:

    optedIn     = trueInstalls * optInPermille // 1000        # 授权同意用户
    optedOut    = trueInstalls - optedIn                      # 未授权用户
    idfaMatched = optedIn * idfaMatchPermille // 1000         # 能按 IDFA 精确归因
    skanCovered = optedOut * skanCoveragePermille // 1000     # SKAN 聚合回传覆盖
    coverage‰   = (idfaMatched + skanCovered) * 1000 // trueInstalls

收入侧:每安装收入分布按 revenueHistogram 折算,SKAN 把每档收入量化到 6 位转化值:

    cv_b        = min(2^conversionBits - 1, rev_b // revenueUnitDeciFen)
    reported_b  = cv_b * revenueUnitDeciFen                   # 还原值(下界)
    quantLoss‰  = (revPerInstall - reportedPerInstall) * 1000 // revPerInstall
    revShare‰   = (idfaMatched*revPerInstall + skanCovered*reportedPerInstall)
                  * 1000 // (trueInstalls*revPerInstall)

全程整数、地板除,无浮点。产出 out/o04_att.json + .csv + .svg。
同意率 / 回传阈值 / 隐私门槛属「公开可观察机制」的建模假设;真实链路需真实
业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse, csv, json, os
import xml.etree.ElementTree as ET

def evaluate(cfg: dict) -> dict:
    ti = cfg["trueInstalls"]
    opted_in = ti * cfg["optInPermille"] // 1000
    opted_out = ti - opted_in
    idfa = opted_in * cfg["idfaMatchPermille"] // 1000
    skan = opted_out * cfg["skanCoveragePermille"] // 1000
    coverage = (idfa + skan) * 1000 // ti
    max_cv = (1 << cfg["conversionBits"]) - 1
    unit = cfg["revenueUnitDeciFen"]
    hist, rev_pi, rep_pi = [], 0, 0
    for b in cfg["revenueHistogram"]:
        cv = min(max_cv, b["revenueDeciFen"] // unit)
        rev_pi += b["revenueDeciFen"] * b["countPermille"] // 1000
        rep_pi += cv * unit * b["countPermille"] // 1000
        hist.append({"revenueDeciFen": b["revenueDeciFen"], "countPermille": b["countPermille"],
                     "cv": cv, "reportedDeciFen": cv * unit})
    quant_loss = (rev_pi - rep_pi) * 1000 // rev_pi
    rev_share = (idfa * rev_pi + skan * rep_pi) * 1000 // (ti * rev_pi)
    return dict(optedIn=opted_in, optedOut=opted_out, idfaMatched=idfa, skanCovered=skan,
                measuredInstalls=idfa + skan, coveragePermille=coverage, conversionBits=cfg["conversionBits"],
                maxConversionValue=max_cv, revPerInstallDeciFen=rev_pi, reportedPerInstallDeciFen=rep_pi,
                quantLossPermille=quant_loss, measuredRevSharePermille=rev_share, histogram=hist)

def draw_svg(path: str, rep: dict) -> int:
    """纯 Python SVG:安装覆盖 / 收入覆盖 / 量化损失三根横条(千分比)。"""
    W, H, PAD = 760, 240, 170
    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 = "《合成小岛》ATT 后:可观测覆盖(千分比,1000=全部可见)"
    span = W - PAD - 24
    bars = [("安装覆盖", rep["coveragePermille"], "#2563eb"),
            ("收入覆盖", rep["measuredRevSharePermille"], "#60a5fa"),
            ("收入量化损失", rep["quantLossPermille"], "#ef4444")]
    for i, (label, val, color) in enumerate(bars):
        y = 60 + i * 50
        w = span * val // 1000
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
                                     "fill": "#374151"}).text = label
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w),
                                     "height": "18", "rx": "3", "fill": color})
        ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 20), "font-size": "11",
                                     "fill": "#6b7280"}).text = f"{val}‰"
    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_att.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    rep = evaluate(cfg)
    # 跨语言对拍摘要:core/test/o04_att.test.ts 复算同一串数字,逐位比对。
    digest = (f'{rep["coveragePermille"]}:{rep["quantLossPermille"]}:'
              f'{rep["measuredRevSharePermille"]}:'
              + ",".join(str(h["cv"]) for h in rep["histogram"]))
    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "o04_att.svg"), rep)
    report = dict(cfg, **rep)
    report.update({"svg": os.path.join(args.out, "o04_att.svg"), "svgBytes": svg_bytes, "digest": digest})
    with open(os.path.join(args.out, "o04_att.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "o04_att.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(("revenueDeciFen", "countPermille", "cv", "reportedDeciFen"))
        for h in rep["histogram"]:
            w.writerow([h[k] for k in ("revenueDeciFen", "countPermille", "cv", "reportedDeciFen")])
    print(json.dumps({"coveragePermille": rep["coveragePermille"], "quantLossPermille": rep["quantLossPermille"],
                      "measuredRevSharePermille": rep["measuredRevSharePermille"], "optedIn": rep["optedIn"],
                      "optedOut": rep["optedOut"], "idfaMatched": rep["idfaMatched"],
                      "skanCovered": rep["skanCovered"], "revPerInstallDeciFen": rep["revPerInstallDeciFen"],
                      "reportedPerInstallDeciFen": rep["reportedPerInstallDeciFen"],
                      "histogram": rep["histogram"], "digest": digest}, ensure_ascii=False, indent=2))
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

参数放在 config/o04_att.json(D 层配置)。同意率、匹配率、覆盖门槛都是「公开可观察机制」口径的建模假设,不是任何平台的实测。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "trueInstalls": 100000,
  "optInPermille": 250,
  "idfaMatchPermille": 900,
  "skanCoveragePermille": 700,
  "conversionBits": 6,
  "revenueUnitDeciFen": 50,
  "revenueHistogram": [
    { "revenueDeciFen": 0, "countPermille": 400 },
    { "revenueDeciFen": 120, "countPermille": 250 },
    { "revenueDeciFen": 500, "countPermille": 200 },
    { "revenueDeciFen": 1500, "countPermille": 120 },
    { "revenueDeciFen": 6000, "countPermille": 30 }
  ],
  "note": "ATT / IDFA / SKAdNetwork 链路建模假设,不是任何平台或归因服务商实测。optInPermille=同意率千分比;idfaMatchPermille=已授权里能匹配上的比例;skanCoveragePermille=未授权里聚合回传覆盖的比例;conversionBits=转化值位数;revenueUnitDeciFen=每档收入步长(0.1 分)。真实同意率与回传门槛需真实业务环境,命令行不可复现(X)。全整数,无浮点。"
}

在本机跑 /opt/homebrew/bin/python3 sim/o04_att.py --config config/ --out out/,结果如下(同机相对量级,绝对数值不可移植):

指标数值含义
授权 / 未授权25000 / 75000同意率 250‰
IDFA 匹配 / SKAN 覆盖22500 / 52500两群各自被观测到的安装
安装可见度750‰10 万里能看到 7.5 万
每安装收入(真值 / 还原)490 / 399(0.1 分)量化后低估 185‰
收入可见度652‰安装 750‰ 再被量化砍到 652‰

这张表最该记住的是两个数字不一样:安装可见度 750‰,但收入可见度只有 652‰。差额来自转化值封顶——6000 // 50 = 120 档被截到 63,高价值用户贡献的收入被系统性低估。也就是说,光把安装数对上还不够,收入口径的偏差是另一笔账。

P 层内核:跨语言对拍,证明公式没写错

下面这段是 P 层 TypeScript 内核 core/src/o04/att.ts,复算同一条链路:同样的分组、同样的封顶、同样的地板除。它读同一份 config/o04_att.json,产出和 Python 逐字符一致的摘要。

// file: core/src/o04/att.ts
// tier: P
// verify: node --test 'core/test/o04_*.test.ts'
//
// ATT / IDFA / SKAdNetwork 链路内核:与 sim/o04_att.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o04_att.json;全整数、地板除、无浮点;只用可擦除语法(禁 enum / namespace)。

export interface AttConfig {
  readonly version: number; readonly game: string; readonly trueInstalls: number;
  readonly optInPermille: number; readonly idfaMatchPermille: number; readonly skanCoveragePermille: number;
  readonly conversionBits: number; readonly revenueUnitDeciFen: number;
  readonly revenueHistogram: readonly { readonly revenueDeciFen: number; readonly countPermille: number }[];
}
export interface AttResult {
  readonly optedIn: number; readonly optedOut: number; readonly idfaMatched: number;
  readonly skanCovered: number; readonly measuredInstalls: number; readonly coveragePermille: number;
  readonly maxConversionValue: number; readonly revPerInstallDeciFen: number;
  readonly reportedPerInstallDeciFen: number; readonly quantLossPermille: number;
  readonly measuredRevSharePermille: number; readonly cvByBucket: number[];
}

export function evaluate(cfg: AttConfig): AttResult {
  const ti = cfg.trueInstalls;
  const optedIn = Math.floor((ti * cfg.optInPermille) / 1000);
  const optedOut = ti - optedIn;
  const idfa = Math.floor((optedIn * cfg.idfaMatchPermille) / 1000);
  const skan = Math.floor((optedOut * cfg.skanCoveragePermille) / 1000);
  const coverage = Math.floor(((idfa + skan) * 1000) / ti);
  const maxCv = (1 << cfg.conversionBits) - 1, unit = cfg.revenueUnitDeciFen;
  let revPerInstall = 0, reportedPerInstall = 0;
  const cvByBucket: number[] = [];
  for (const b of cfg.revenueHistogram) {
    const cv = Math.min(maxCv, Math.floor(b.revenueDeciFen / unit));
    cvByBucket.push(cv);
    revPerInstall += Math.floor((b.revenueDeciFen * b.countPermille) / 1000);
    reportedPerInstall += Math.floor((cv * unit * b.countPermille) / 1000);
  }
  const quantLoss = Math.floor(((revPerInstall - reportedPerInstall) * 1000) / revPerInstall);
  const revShare = Math.floor(((idfa * revPerInstall + skan * reportedPerInstall) * 1000) / (ti * revPerInstall));
  return { optedIn, optedOut, idfaMatched: idfa, skanCovered: skan, measuredInstalls: idfa + skan,
    coveragePermille: coverage, maxConversionValue: maxCv, revPerInstallDeciFen: revPerInstall,
    reportedPerInstallDeciFen: reportedPerInstall, quantLossPermille: quantLoss,
    measuredRevSharePermille: revShare, cvByBucket };
}

/** 跨语言对拍摘要:与 sim/o04_att.py 的 digest 逐字符一致。 */
export function digest(r: AttResult): string {
  return `${r.coveragePermille}:${r.quantLossPermille}:${r.measuredRevSharePermille}:` + r.cvByBucket.join(",");
}

配套测试 core/test/o04_att.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 复算结果逐字符相等。在本机跑 node --test 'core/test/o04_*.test.ts',5 个用例全过,两套实现给出同一个摘要 750:185:652:0,2,10,30,63。摘要末尾那串 0,2,10,30,63 就是五个收入档位被量化后的转化值——最后一档 63 正是封顶的证据。

X 层:真实同意率与回传阈值不可复现

上面算的是模型侧:给定同意率与门槛,可见度是多少。而下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的 ATT 同意率(随地区、品类、弹窗时机剧烈变化,且平台不允许诱导);
  • 真实的 SKAdNetwork 回传门槛与延迟(隐私阈值、回传窗口、转化值更新规则由平台定义);
  • 真实的收入分布(决定量化损失到底多严重,且长尾越重损失越大)。

工程上的正确做法依旧是把模型层与真实数据层分开:模型层回答「同意率从 250‰ 掉到 150‰ 会怎样」,真实数据层用来在投放后反标定参数。这也是为什么归因参数必须与埋点口径一一对应——口径对不上,反标定就无从谈起。

自检表

检查项合格标准
两群都接好授权侧走 IDFA、未授权侧走聚合回传,不遗漏任一端
转化值定义每档收入步长写死,并评估长尾封顶的损失
可见度双算安装可见度与收入可见度分别算,不只报一个
可播种参数来自 config,core/ 无 Math.random()
跨语言一致Python 与 TS 摘要逐字符相同
边界清晰真实同意率与回传门槛标 X,不写成「实测」

把可见度算清楚,你才不会拿一个「打了七五折的安装数」当成全量去优化。下一节处理最后一环:当 MMP、投放平台、自有后端三个口径都对不上时,怎么对账、怎么定位是谁在骗你。

阅读导航:上一节:4.1 归因窗口、点击归因与展示归因 · 下一节:4.3 三方归因与渠道数据对账 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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