15.2 买量与留存的典型失败

第二节把镜头转向买量侧与留存侧:素材夸大承诺、低质网盟人群、放量过快、归因虚增、新手漏斗前段漏、D7 断崖。每个失败都拆成一个 patch,喂进第 5 章的买量 ROI 漏斗与第 7 章的留存锚点插值,跑出 CPI、ROAS、回本日与 D30 盈亏,并给出「只看 D1」「只看报表 ROAS」两种最常见的误判诊断。附这套内核的 TypeScript 版。

15.1 的失败发生在游戏内部——玩法、数值、变现。15.2 的失败发生在游戏与市场的接口上:一边是买量,一边是留存。这两条链最阴险的地方在于它们会互相放大——素材把卖点吹满,点击率冲高、CPI 变得好看,但点进来的人发现货不对板,首日留存就崩;反过来,留存本来就差的产品,买量只是把「留不住的人」成批搬进来,放量越快亏得越狠。本节要做的,是把这两种失败都变成可跑的坏数字。

延续 15.1 的分工:本站那篇独立开发者的手游广告失败复盘 是叙述型——「我以为靠广告就能赚钱」;本节是可复现型——把「以为」拆成 ctrPermille、retainD1Permille、organicInflationPermille 这些能改的数,看它们各自把回本日推到哪一天。一篇让你共情,一篇让你提前排雷。

两条会互相放大的失败链

买量链是:曝光 → 点击(CTR)→ 安装(IPM)→ CPI,再往上叠 LTV / CPI = ROAS。留存链是:D1 / D7 / D30 锚点,插值出整条留存曲线,累加成「累计活跃日」。两条链在 LTV 处汇合:LTV = 有效 ARPDAU × 累计活跃 ÷ 1000,而 ROAS30 = LTV30 × 100 ÷ CPI。于是任何一侧的失败都会通过这条乘积放大到 ROAS 上——CPI 便宜一倍或累计活跃掉一半,ROAS 就掉一半。这就是为什么「素材数据好看」和「产品数据好看」必须一起看,缺一个就会得出相反的结论。

S 层:把失败参数喂进买量-留存漏斗(S 层)

下面这段是 S 层仿真脚本,读 config/l15_ua_retention.json。它对基线(健康投放)与六条失败各算一遍,产出 out/l15_ua_retention.{json,csv,svg}。六条失败覆盖买量侧(素材夸大承诺、低质网盟人群、放量过快、归因虚增)与留存侧(新手漏斗前段漏、D7 断崖)。脚本额外给出两个诊断字段:roasD1Permille(只看首日的乐观值)与 reportedRoas30Permille(把自然量算进买量后的虚增报表值)。

#!/usr/bin/env python3
# file: sim/l15_ua_retention.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l15_ua_retention.py --config config/ --out out/
"""买量与留存的失败模式对照:读 config/l15_ua_retention.json。

把第 5 章(买量 ROI 漏斗)与第 7 章(留存锚点插值)合成一条链,对 baseline 与每条 failure
(一个 patch)各算:
    cpiFen       = cpmFen * 1000 // (ctrPermille * ipmPermille)
    ret(d)       = D1/D7/D30 锚点整数线性插值(安装日恒 1000)
    ltv(D)       = arpdauDeciFen * cumActive(D) // 1000            # 0.1 分
    roas(D)‰     = ltv(D) * 100 // cpiFen                          # 1000‰ = 回本
    paybackDay   = 首个 ltv(D) >= cpiFen*10 的 D(找不到记 -1)
    reportedRoas = roas30 * (1000 + organicInflationPermille) // 1000

额外给出两个诊断:`roasD1`(只看首日的乐观值)与 `reportedRoas30`(归因虚增后的报表值),
用来钉死两种最常见误判——「只看 D1 就放量」与「把自然量算成买量成效」。渠道参数、留存锚点
与归因口径均为公开可观察机制的建模假设;真实出价、真实归因与真实亏损需真实业务环境,命令行
不可复现(X)。全整数、地板除,无浮点。产出 out/l15_ua_retention.{json,csv,svg}。
"""
from __future__ import annotations
import argparse, csv, json, os, sys, xml.etree.ElementTree as ET

COLOR = {"买量": "#2563eb", "留存": "#f59e0b"}


def ret(p: dict, d: int) -> int:
    if d <= 0:
        return 1000
    r1, r7, r30 = p["retainD1Permille"], p["retainD7Permille"], p["retainD30Permille"]
    if d <= 7:
        return r1 + (r7 - r1) * (d - 1) // 6
    return r7 + (r30 - r7) * (d - 7) // 23 if d <= 30 else r30


def cum(p: dict, d: int) -> int:
    return sum(ret(p, i) for i in range(d + 1))


def evaluate(cfg: dict, p: dict) -> dict:
    cpi = p["cpmFen"] * 1000 // (p["ctrPermille"] * p["ipmPermille"])
    ltv = {n: p["arpdauDeciFen"] * cum(p, n) // 1000 for n in (1, 7, 30)}
    roas = {n: ltv[n] * 100 // cpi for n in (1, 7, 30)}
    payback = next((d for d in range(cfg["horizonDays"] + 1)
                    if p["arpdauDeciFen"] * cum(p, d) // 1000 >= cpi * 10), -1)
    installs = cfg["budgetFen"] // cpi
    reported = roas[30] * (1000 + p["organicInflationPermille"]) // 1000
    return {"id": p["id"], "name": p["name"], "cpmFen": p["cpmFen"], "ctrPermille": p["ctrPermille"],
            "ipmPermille": p["ipmPermille"], "arpdauDeciFen": p["arpdauDeciFen"],
            "retainD1Permille": p["retainD1Permille"], "retainD7Permille": p["retainD7Permille"],
            "retainD30Permille": p["retainD30Permille"], "organicInflationPermille": p["organicInflationPermille"],
            "cpiFen": cpi, "cumActive30Permille": cum(p, 30), "ltvD30DeciFen": ltv[30],
            "roasD1Permille": roas[1], "roasD7Permille": roas[7], "roasD30Permille": roas[30],
            "paybackDay": payback, "installs": installs, "d30RevenueFen": installs * ltv[30] // 10,
            "d30ProfitFen": installs * ltv[30] // 10 - cfg["budgetFen"],
            "reportedRoas30Permille": reported, "attributionGapPermille": reported - roas[30],
            "d1Optimistic": roas[1] >= 1000, "d30Payback": roas[30] >= 1000}


def svg(path: str, rows: list[dict], base: dict) -> int:
    W, PAD, ROW = 780, 190, 42
    H = 78 + ROW * len(rows)
    r = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                           "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(r, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(r, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = \
        "《合成小岛》买量 / 留存失败对照:D30 ROAS(‰,1000=回本)"
    ET.SubElement(r, "text", {"x": "16", "y": "46", "font-size": "12", "fill": "#6b7280"}).text = \
        f'基线 D30 ROAS {base["roasD30Permille"]}‰ · 回本 D{base["paybackDay"]} · CPI {base["cpiFen"]}分'
    span = W - PAD - 130
    hi = max(max(x["roasD30Permille"] for x in rows), base["roasD30Permille"], 1200) or 1
    x1000 = PAD + span * 1000 // hi
    ET.SubElement(r, "line", {"x1": str(x1000), "y1": "56", "x2": str(x1000), "y2": str(H - 16),
                              "stroke": "#ef4444", "stroke-dasharray": "4 4"})
    for i, row in enumerate(rows):
        y, w = 74 + i * ROW, span * row["roasD30Permille"] // hi
        ET.SubElement(r, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
                                  "fill": "#374151"}).text = row["name"]
        ET.SubElement(r, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "22",
                                  "rx": "4", "fill": COLOR.get(row.get("aspect"), "#6b7280")})
        ET.SubElement(r, "text", {"x": str(PAD + w + 8), "y": str(y + 22), "font-size": "12",
                                  "fill": "#6b7280"}).text = f'{row["roasD30Permille"]}‰'
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(r).write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg = json.load(open(os.path.join(a.config, "l15_ua_retention.json"), encoding="utf-8"))
    base = evaluate(cfg, cfg["baseline"])
    rows = []
    for fa in cfg["failures"]:
        r = evaluate(cfg, dict(cfg["baseline"], **fa["patch"]))
        r.update(id=fa["id"], name=fa["name"], aspect=fa["aspect"], mechanism=fa["mechanism"],
                 patch=fa["patch"], deltaRoasPermille=r["roasD30Permille"] - base["roasD30Permille"])
        rows.append(r)
    worst = min(rows, key=lambda x: x["roasD30Permille"])
    misjudged = [x["id"] for x in [base] + rows if x["d1Optimistic"] != x["d30Payback"]]
    inflated = [x["id"] for x in rows if x["attributionGapPermille"] > 0]
    os.makedirs(a.out, exist_ok=True)
    sb = svg(os.path.join(a.out, "l15_ua_retention.svg"), rows, base)
    json.dump({"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
               "note": cfg["note"], "horizonDays": cfg["horizonDays"], "budgetFen": cfg["budgetFen"],
               "baseline": base, "failures": rows, "worstId": worst["id"],
               "misjudgedByD1": misjudged, "attributionInflated": inflated, "svgBytes": sb},
              open(os.path.join(a.out, "l15_ua_retention.json"), "w", encoding="utf-8"),
              ensure_ascii=False, indent=2)
    cols = ("id", "name", "aspect", "cpmFen", "ctrPermille", "ipmPermille", "cpiFen", "arpdauDeciFen",
            "retainD1Permille", "retainD7Permille", "retainD30Permille", "ltvD30DeciFen",
            "roasD1Permille", "roasD30Permille", "reportedRoas30Permille", "attributionGapPermille",
            "paybackDay", "d30ProfitFen", "deltaRoasPermille")
    with open(os.path.join(a.out, "l15_ua_retention.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(cols)
        for row in rows:
            w.writerow([row[k] for k in cols])
    print(f'基线 CPI {base["cpiFen"]}分  ROAS D1 {base["roasD1Permille"]}‰ / D30 '
          f'{base["roasD30Permille"]}‰  回本 D{base["paybackDay"]}')
    for row in rows:
        print(f'{row["aspect"]}  {row["name"]:<12} CPI {row["cpiFen"]:>4}分  ROAS30 '
              f'{row["roasD30Permille"]:>5}‰ ({row["deltaRoasPermille"]:+d})  回本 '
              f'{("D" + str(row["paybackDay"])) if row["paybackDay"] >= 0 else "未回本"}  '
              f'D30盈亏 {row["d30ProfitFen"]:>8}分')
    print(f'最差: {worst["id"]}  只看 D1 会误判: {misjudged}  归因虚增: {inflated}')
    return 0


if __name__ == "__main__":
    sys.exit(main())

真跑输出(同机):

基线 CPI 291分  ROAS D1 314‰ / D30 1412‰  回本 D16
买量  素材夸大承诺       CPI  212分  ROAS30   729‰ (-683)  回本 未回本  D30盈亏  -812410分
买量  低质网盟人群       CPI  291分  ROAS30   496‰ (-916)  回本 未回本  D30盈亏 -1510350分
买量  放量过快         CPI  458分  ROAS30   787‰ (-625)  回本 未回本  D30盈亏  -636760分
买量  归因虚增         CPI  291分  ROAS30  1412‰ (+0)  回本 D16  D30盈亏  1236999分
留存  新手漏斗前段漏      CPI  291分  ROAS30  1090‰ (-322)  回本 D26  D30盈亏   270014分
留存  D7 断崖        CPI  291分  ROAS30   940‰ (-472)  回本 未回本  D30盈亏  -178427分
最差: low-quality-cohort  只看 D1 会误判: ['healthy', 'attribution-organic', 'funnel-front-leak']  归因虚增: ['attribution-organic']

三个诊断,专治三种最常见的自欺

诊断一:只看 D1。 脚本的 misjudgedByD1 字段专门标出「首日乐观但长期回不了本」的配置。输出显示,连基线都中招——基线 D1 ROAS 只有 314‰,远没到 1000‰,但 D30 是 1412‰、第 16 天回本。也就是说,任何买量决策都不该看 D1:D1 天然低估,真正的判据是 D7/D30 与回本日。归因虚增(attribution-organic)和新手漏斗前段漏(funnel-front-leak)也被标进「只看 D1 会误判」,因为它们的首日表现与长期表现背离。

诊断二:只看报表 ROAS。 归因虚增 这一条的真实 D30 ROAS 与基线一模一样(1412‰),但报表值是 2118‰——因为它把自然量、平台推荐量一并算进了买量成效(organicInflationPermille=500)。它的 D30 盈亏写着 +1236999 分,看起来是唯一赚钱的,实际却和基线分文不差。这就是买量报表最经典的幻觉:把不属于你的功劳记在自己头上。

诊断三:CPI 幻觉。 素材夸大承诺(overclaim-creative)的 CPI 只有 212 分——比基线的 291 分便宜三成,单看 CPI 会以为捡了便宜。但它把留存从 D1 430 拖到 260、D30 110 拖到 40,最终 D30 ROAS 只有 729‰、亏损 -812410 分。便宜的流量从来不是免费的,它只是把成本从「一次付清的 CPI」转成了「每天都在流血的低留存」。

P 层:这套失败对照内核的 TypeScript 版(P 层)

15.1 的浏览器失败开关台与本节的分析共用同一套留存-变现内核。下面这段是它的 P 层 TypeScript 实现,与 sim/l15_value_failure.py 同一份 config、同一套整数地板除,因此逐位一致——这是「一份 config 三种消费」在失败对照上的落地。配套的 P 层测试覆盖留存锚点插值、广告频次惩罚边界(factor(4,1)=620、factor(12,8)=200)、基线回本日与每条失败低于基线,node --test 'core/test/l15_*.test.ts' 全绿。

// file: core/src/l15/failure.ts
// tier: P
// verify: node --test 'core/test/l15_*.test.ts'
// 《合成小岛》失败模式对照内核:与 sim/l15_value_failure.py 同一份 config、同一套整数地板除,
// 因此逐位一致。把第 1 章(广告频次惩罚)与第 3 章(LTV / 回本)合成一条最小流水线,
// 供浏览器失败开关台复用。全整数、无 Math.random(),只用可擦除语法(禁 enum / namespace /
// 参数属性 / 装饰器)。

export interface ModelConfig {
  readonly horizonDays: number;
  readonly cpaFen: number;
  readonly minFactorPermille: number;
  readonly voluntaryPenaltyPermillePerImp: number;
  readonly forcedPenaltyPermillePerImp: number;
}
export interface FailureParams {
  readonly retainD1Permille: number;
  readonly retainD7Permille: number;
  readonly retainD30Permille: number;
  readonly voluntary: number;
  readonly forced: number;
  readonly arpdauDeciFen: number;
}
export interface FailureResult {
  readonly factorPermille: number;
  readonly arpdauEffDeciFen: number;
  readonly cumActive30Permille: number;
  readonly ltv30DeciFen: number;
  readonly roas30Permille: number;
  readonly paybackDay: number;
}

const fdiv = (a: number, b: number): number => Math.floor(a / b);

/** 第 d 日留存千分比(d<=0 安装日恒 1000);D1/D7/D30 锚点间整数线性插值。 */
export function dailyRetention(p: FailureParams, d: number): number {
  if (d <= 0) return 1000;
  const r1 = p.retainD1Permille, r7 = p.retainD7Permille, r30 = p.retainD30Permille;
  if (d <= 7) return r1 + fdiv((r7 - r1) * (d - 1), 6);
  if (d <= 30) return r7 + fdiv((r30 - r7) * (d - 7), 23);
  return r30;
}

/** 第 0..d 日累计活跃,单位千分比日(1000 = 一整日)。 */
export function cumulativeActive(p: FailureParams, d: number): number {
  let s = 0;
  for (let i = 0; i <= d; i += 1) s += dailyRetention(p, i);
  return s;
}

/** 广告频次惩罚:自愿与强制两类各按每展示惩罚扣减,下限 minFactor。 */
export function factorPermille(cfg: ModelConfig, v: number, f: number): number {
  return Math.max(cfg.minFactorPermille,
    1000 - v * cfg.voluntaryPenaltyPermillePerImp - f * cfg.forcedPenaltyPermillePerImp);
}

/** 对一组参数算:广告惩罚因子、有效 ARPDAU、LTV30、ROAS30、回本日。 */
export function evaluate(cfg: ModelConfig, p: FailureParams): FailureResult {
  const fac = factorPermille(cfg, p.voluntary, p.forced);
  const arpdauEff = fdiv(p.arpdauDeciFen * fac, 1000);
  const cum30 = cumulativeActive(p, cfg.horizonDays);
  const ltv30 = fdiv(arpdauEff * cum30, 1000);
  const roas30 = fdiv(ltv30 * 100, cfg.cpaFen);
  let payback = -1;
  for (let d = 0; d <= cfg.horizonDays; d += 1) {
    if (fdiv(arpdauEff * cumulativeActive(p, d), 1000) >= cfg.cpaFen * 10) { payback = d; break; }
  }
  return { factorPermille: fac, arpdauEffDeciFen: arpdauEff, cumActive30Permille: cum30,
    ltv30DeciFen: ltv30, roas30Permille: roas30, paybackDay: payback };
}

小结

失败侧表面数据真实 D30 ROAS回本日陷阱
素材夸大承诺买量CPI 便宜三成729‰未回本便宜流量的留存代价
低质网盟人群买量单价最低496‰未回本一日游用户
放量过快买量量最大787‰未回本出价被抬高、人群被稀释
归因虚增买量报表 2118‰1412‰D16把自然量算成买量成效
新手漏斗前段漏留存—1090‰D26加载与引导摩擦
D7 断崖留存—940‰未回本中期没有可追求目标

一句话:买量的失败是「花钱买错人」,留存的失败是「来了也留不住」。前者可以在投放后台里改,后者只能回产品里改。而 15.3 要讲的运营与合规失败,是最不像技术问题、却最容易让一款已经跑起来的游戏一夜归零的那一类。

阅读导航:上一节:15.1 玩法、数值与变现的典型失败 · 下一节:15.3 运营与合规的典型失败 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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