9.2 裂变模型与传播系数 K

把七个社交传播面收成一个可递推的传播系数 K:K = 每用户邀请数 × 接受率,用整数千分比递推多轮传播,算不同 K 值下累计用户是翘上去还是沉下去,再用 B 层基准扫描出 K = 1000‰ 这条「衰减 / 自增长」的分界线,并验证增长只由乘积决定、不依赖因子拆分。

站内那套 46 篇的小程序专题 讲的是小程序工程口径——怎么把一个功能正确地实现出来;本节讲的是小游戏社交传播面的可量化裂变模型:把 9.1 那张七个面的清单收成一个数,再用这个数推出一条增长曲线。上一节我们算的是「每个传播面各自带来多少新用户」;这一节要算的是它们加起来会把总盘子推向哪里——是越滚越大,还是越滚越小。

一个数:传播系数 K

9.1 的七个传播面,综合转化率是 17‰ 到 90‰。这些数字回答「每个面效率多高」,却回答不了「整体增长会怎样」——因为一个玩家可能同时走好几个面,新玩家自己也会再去传播,这是一个多轮过程。把它压缩,只需要两个量:

  • 每用户邀请数(invitesPerUserPermille):一个玩家平均发出多少条邀请。1.4 条就是 1400‰。
  • 接受率(acceptPermille):收到邀请的人里,多少转化成了新用户。0.3 就是 300‰。

两者相乘,就是传播系数 K:

K = 每用户邀请数 × 接受率 = 1400‰ × 300‰ // 1000 = 420‰

注意单位:这里两个因子都用千分比,乘完再 // 1000,得到的 K 还是千分比。所以 K = 1000‰ 就等价于 K = 1,也就是「一个用户平均带来一个新用户」。这不是一个普通的数值——它是一条分界线:K 小于 1000‰,每一轮传播都在缩小,增长最终归零;K 大于 1000‰,每一轮都在放大,增长会自我加速。下面这份配置把模型固化成可读参数。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "K 因子裂变模型。K = 每用户邀请数(‰) × 接受率(‰) // 1000,单位千分比;K = 1000‰ 为临界点(K < 1000 衰减 / K > 1000 自增长)。invitesPerUserPermille = 每用户邀请条数 × 1000;acceptPermille = 被邀请者转化为新用户的比例。全部为整数千分比假设参数,真实邀请数与接受率需真实业务环境,命令行不可复现(X)。",
  "seedUsers": 1000,
  "rounds": 12,
  "invitesPerUserPermille": 1400,
  "acceptPermille": 300,
  "kScenariosPermille": [500, 800, 1000, 1200, 1600]
}

配置里有三组东西:基线参数(invitesPerUserPermille 1400、acceptPermille 300,相乘得基线 K = 420‰)、递推参数(seedUsers 1000、rounds 12)、场景档位(kScenariosPermille 从 500‰ 到 1600‰)。

多轮传播的整数递推(S 层)

K 是一个「每轮乘一次」的系数,所以增长是几何递推:第 r 轮的新增,是第 r-1 轮新增乘 K。下面这段是 S 层仿真脚本,读 config/ch09_kfactor.json,对每一档 K 跑一遍整数递推,产出累计用户曲线(JSON + CSV + SVG)。

#!/usr/bin/env python3
# file: sim/ch09_kfactor.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch09_kfactor.py --config config/ --out out/
"""裂变模型与传播系数 K:读 config/ch09_kfactor.json。

K = 每用户邀请数(‰) × 接受率(‰) // 1000,单位千分比,K = 1000‰ 为临界点。
多轮传播用整数递推:第 r 轮新增 new[r] = new[r-1] × K // 1000,累计 cum[r] = cum[r-1] + new[r]。
对 config 里的一组 K 场景各跑一条曲线,产出 JSON + CSV + SVG(累计用户折线)。
全程整数地板除,无浮点。真实邀请数 / 接受率需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET

CRITICAL_PERMILLE = 1000


def load(cfg_dir: str) -> dict:
    with open(os.path.join(cfg_dir, "ch09_kfactor.json"), encoding="utf-8") as f:
        return json.load(f)


def k_of(invites_per_user_permille: int, accept_permille: int) -> int:
    """K = 邀请数 × 接受率 // 1000(单位‰)。"""
    return (invites_per_user_permille * accept_permille) // 1000


def propagate(seed_users: int, k_permille: int, rounds: int) -> list[dict]:
    """整数递推多轮传播,返回每轮的 新增 / 累计 序列。"""
    rows = []
    new = seed_users
    cum = 0
    for r in range(rounds):
        if r > 0:
            new = new * k_permille // 1000
        cum += new
        rows.append({"round": r, "newUsers": new, "cumUsers": cum})
    return rows


def draw_svg(path: str, series: list[dict], seed_users: int) -> int:
    """纯 Python SVG:每档 K 一条累计用户折线。"""
    W, H, pl, pt, pb = 720, 360, 74, 34, 46
    rounds = len(series[0]["curve"])
    hi = max(p["cumUsers"] for s in series for p in s["curve"]) or 1
    iw, ih = W - pl - 150, H - pt - pb
    colors = ["#94a3b8", "#60a5fa", "#22c55e", "#f59e0b", "#dc2626"]
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                             "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": str(pl), "y": "22", "font-size": "14",
                                "fill": "#111827"}).text = "不同 K 值下的累计传播用户(起始 1000 人)"
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt + ih), "x2": str(pl + iw), "y2": str(pt + ih),
                                "stroke": "#9ca3af"})
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt), "x2": str(pl), "y2": str(pt + ih),
                                "stroke": "#9ca3af"})
    for i, s in enumerate(series):
        pts = [f'{pl + iw * p["round"] // (rounds - 1)},{pt + ih - ih * p["cumUsers"] // hi}'
               for p in s["curve"]]
        col = colors[i % len(colors)]
        ET.SubElement(svg, "polyline", {"points": " ".join(pts), "fill": "none", "stroke": col,
                                        "stroke-width": "2"})
        ly = pt + 14 + i * 20
        ET.SubElement(svg, "rect", {"x": str(W - 138), "y": str(ly - 9), "width": "10",
                                    "height": "10", "fill": col})
        ET.SubElement(svg, "text", {"x": str(W - 122), "y": str(ly), "font-size": "11",
                                    "fill": "#374151"}).text = (
            f'K={s["kPermille"]}‰ 末轮{s["curve"][-1]["cumUsers"]}'
        )
    ET.ElementTree(svg).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/")
    args = ap.parse_args()

    cfg = load(args.config)
    seed = cfg["seedUsers"]
    rounds = cfg["rounds"]
    base_k = k_of(cfg["invitesPerUserPermille"], cfg["acceptPermille"])

    series = []
    for k in cfg["kScenariosPermille"]:
        curve = propagate(seed, k, rounds)
        series.append({"kPermille": k, "curve": curve,
                       "finalNew": curve[-1]["newUsers"], "finalCum": curve[-1]["cumUsers"],
                       "growOrDecay": "grow" if k > CRITICAL_PERMILLE else
                                      ("flat" if k == CRITICAL_PERMILLE else "decay")})

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "ch09_kfactor.svg"), series, seed)
    with open(os.path.join(args.out, "ch09_kfactor.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["kPermille", "round", "newUsers", "cumUsers"])
        for s in series:
            for p in s["curve"]:
                w.writerow([s["kPermille"], p["round"], p["newUsers"], p["cumUsers"]])

    base_curve = propagate(seed, base_k, rounds)
    report = {
        "version": cfg["version"],
        "game": cfg["game"],
        "seed": cfg["seed"],
        "seedUsers": seed,
        "rounds": rounds,
        "invitesPerUserPermille": cfg["invitesPerUserPermille"],
        "acceptPermille": cfg["acceptPermille"],
        "baseKPermille": base_k,
        "criticalPermille": CRITICAL_PERMILLE,
        "baseFinalCum": base_curve[-1]["cumUsers"],
        "baseFinalNew": base_curve[-1]["newUsers"],
        "scenarios": [{"kPermille": s["kPermille"], "finalNew": s["finalNew"],
                       "finalCum": s["finalCum"], "growOrDecay": s["growOrDecay"]} for s in series],
        "csv": os.path.join(args.out, "ch09_kfactor.csv"),
        "svg": os.path.join(args.out, "ch09_kfactor.svg"),
        "svgBytes": svg_bytes,
    }
    with open(os.path.join(args.out, "ch09_kfactor.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    print(json.dumps({
        "baseKPermille": base_k,
        "baseFinalCum": report["baseFinalCum"],
        "scenarios": [(s["kPermille"], s["finalCum"], s["growOrDecay"]) for s in series],
    }, ensure_ascii=False))
    return 0


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

在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):

{"baseKPermille": 420, "baseFinalCum": 1718, "scenarios": [[500, 1994, "decay"], [800, 4644, "decay"], [1000, 12000, "flat"], [1200, 39552, "grow"], [1600, 467356, "grow"]]}

基线 K = 420‰(1400‰ × 300‰ // 1000),12 轮后累计 1718 人——只比起始的 1000 人多了一点点就停了,这就是 K < 1000‰ 的宿命。五档场景把这条规律摊开:500‰ 累计 1994(衰减)、800‰ 累计 4644(衰减)、1000‰ 累计 12000(持平)、1200‰ 累计 39552(自增长)、1600‰ 累计 467356(自增长)。

K = 1000‰ 那行是 12000——起始 1000 人,12 轮每轮恰好补回 1000,累计正好是 1000 × 12,不多不少。这就是「临界」的样子:每一轮新增都等于上一轮,总量线性增长而非指数增长。往左一档(800‰)掉到 4644,往右一档(1200‰)跳到 39552——跨过临界点,量级差了一个数量级。另外注意 K = 500‰ 那行序列 1000, 500, 250, 125, 62, 31, 15, 7, 3, 1, 0, 0:整数地板除让它在第 10 轮真正归零(1 × 500‰ 被地板除成 0),这不是精度损失而是刻意的建模选择——现实中也没有「0.5 个新用户」。

K 参数敏感性:分界线在哪(B 层)

上面只看了五档 K。要回答「分界线到底在哪」,需要把 K 加密扫描。下面这段是 B 层基准脚本:把 K 从 400‰ 到 1800‰ 每 50‰ 一档扫描,找出「末轮新增 ≥ 起始人数」的第一个 K 档;再做归因检验,验证不同(邀请数,接受率)拆分只要乘积相同、曲线就相同。它是基准,无论多少告警退出码恒为 0。

#!/usr/bin/env python3
# file: sim/ch09_kbench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch09_kbench.py --config config/ --out out/
"""B 基准:K 参数敏感性——找出「衰减 / 自增长」的分界线。

两件事:
1) K 扫描:把 K 从 low 到 high 每 step 一档,跑整数递推,记录末轮新增是涨还是落,
   找出第一个「末轮新增 >= 起始人数」的 K 档——即自增长起点(理论上应在 1000‰ 附近)。
2) 归因检验:K 是「邀请数 × 接受率」的乘积,验证不同 (邀请数, 接受率) 组合只要 K 相同,
   曲线就相同——说明增长只由乘积决定,不被两个因子的具体拆分影响。

软告警:自增长起点偏离 1000‰ 超过容差,或「同 K 不同拆分」曲线不一致,只打 WARN,
退出码恒为 0——这是基准,不是门禁。全整数地板除,无浮点。
"""

from __future__ import annotations

import argparse
import json
import os

from ch09_kfactor import CRITICAL_PERMILLE, k_of, load, propagate

BOUNDARY_TOLERANCE = 100  # 自增长起点与 1000‰ 的允许偏差(‰)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load(args.config)
    seed, rounds = cfg["seedUsers"], cfg["rounds"]

    # 1) K 扫描:从 400‰ 到 1800‰,每 50‰ 一档
    sweep = []
    boundary = None
    for k in range(400, 1801, 50):
        curve = propagate(seed, k, rounds)
        final_new = curve[-1]["newUsers"]
        grows = final_new >= seed
        sweep.append({"kPermille": k, "finalNew": final_new, "finalCum": curve[-1]["cumUsers"],
                      "grows": grows})
        if grows and boundary is None:
            boundary = k

    # 2) 归因检验:同一 K,不同 (邀请数, 接受率) 拆分,曲线应逐位一致
    target_k = cfg["kScenariosPermille"][2] if len(cfg["kScenariosPermille"]) > 2 else 1000
    combos = [(invites, target_k * 1000 // invites)
              for invites in (1000, 2000, 4000) if invites * 1000 // invites]
    combos = [(i, a) for i, a in combos if k_of(i, a) == target_k]
    ref = None
    attrib = []
    for invites, accept in combos:
        curve = propagate(seed, target_k, rounds)
        cum = [p["cumUsers"] for p in curve]
        if ref is None:
            ref = cum
        attrib.append({"invitesPerUserPermille": invites, "acceptPermille": accept,
                       "kPermille": k_of(invites, accept), "finalCum": cum[-1],
                       "sameCurve": cum == ref})

    warnings = []
    if boundary is None or abs(boundary - CRITICAL_PERMILLE) > BOUNDARY_TOLERANCE:
        warnings.append(f"自增长起点 {boundary}‰ 偏离临界 {CRITICAL_PERMILLE}‰ 超过 {BOUNDARY_TOLERANCE}‰")
    if any(not a["sameCurve"] for a in attrib):
        warnings.append("同 K 不同拆分曲线不一致——增长不应依赖因子拆分")

    os.makedirs(args.out, exist_ok=True)
    report = {
        "version": cfg["version"],
        "source": "sim/ch09_kbench.py",
        "seedUsers": seed,
        "rounds": rounds,
        "criticalPermille": CRITICAL_PERMILLE,
        "boundaryTolerance": BOUNDARY_TOLERANCE,
        "growthBoundaryPermille": boundary,
        "sweep": sweep,
        "attributionKPermille": target_k,
        "attribution": attrib,
        "warnings": warnings,
        "verdict": "PASS(soft)" if not warnings else f"WARN(soft) {len(warnings)} 条",
    }
    with open(os.path.join(args.out, "ch09_kbench.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    print(json.dumps({
        "growthBoundaryPermille": boundary,
        "attributionKPermille": target_k,
        "attribution": [(a["invitesPerUserPermille"], a["acceptPermille"], a["sameCurve"])
                        for a in attrib],
        "verdict": report["verdict"],
        "warnings": warnings,
    }, ensure_ascii=False))
    return 0  # B 层软告警:恒返回 0


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

执行 /opt/homebrew/bin/python3 sim/ch09_kbench.py --config config/ --out out/,结果如下(退出码 0):

{"growthBoundaryPermille": 1000, "attributionKPermille": 1000, "attribution": [[1000, 1000, true], [2000, 500, true], [4000, 250, true]], "verdict": "PASS(soft)", "warnings": []}

growthBoundaryPermille = 1000——自增长起点精确落在临界点上,与理论一致。边界附近三档最能说明问题:950‰ 末轮新增 565(衰减)、1000‰ 末轮新增 1000(持平)、1050‰ 末轮新增 1704(自增长)。950 到 1050 只差 100‰,末轮新增却从 565 跳到 1704,近三倍——临界点附近就是这么陡,这正是为什么「把 K 从 0.9 抬到 1.1」比「把 K 从 0.4 抬到 0.6」重要得多。

归因检验:K 是乘积,不是两个独立旋钮

B 层还做了一件容易被忽略但很关键的事:归因检验。attribution 那三行是三种不同的参数拆分——(邀请数 1000‰, 接受率 1000‰)、(2000‰, 500‰)、(4000‰, 250‰),乘积都是 K = 1000‰,脚本跑出来曲线逐位一致(sameCurve 全是 true)。这不是巧合,而是模型定义决定的:增长只由乘积决定,不被两个因子的拆分方式影响。

这条结论有直接的运营含义:把接受率翻倍和把邀请数翻倍,对 K 的贡献是一样的。所以「加分享按钮把邀请数从 1.4 抬到 1.8」和「把奖励改成双方都能领、把接受率从 0.3 抬到 0.39」在 K 上等价——该选成本更低、更可持续的那条。通常后者更优:邀请数有天花板(一个人不会无限发),接受率没有(口碑越好越高)。

K 落在哪,决定要不要「烧钱」

把 K 放进业务判断,结论很直接:

  • K < 1000‰(比如基线 420‰):自然裂变不足以自我维持,增长必须靠买量或平台流量注入。这时 K 的作用不是「带来增长」,而是降低获客成本——每 1000 个新用户里有 420 个自然来,买量只需买剩下 580 个。
  • K ≈ 1000‰:自然裂变刚好持平,用户盘不涨不缩,是最脆弱的区间——任何一次留存下滑都会把 K 推回 1000 以下。
  • K > 1000‰:自增长。但要警惕——K > 1 的曲线是指数,现实中会立刻撞到「熟人网络饱和」的天花板(一个人的好友有限,发完就没了),所以真实 K 通常远低于模型外推。

这也是为什么本节的基线 K 只设了 420‰——它不是悲观,而是诚实:绝大多数小游戏的自然 K 都在 0.1 到 0.5 之间。下一节处理 K 的另一面:为了把 K 抬上去,团队最容易走的捷径是强制分享——不给分享就不让继续玩。它短期抬高邀请数,代价却写在留存曲线上,我们要给它建一个能跑的对照模型。

阅读导航:上一节:小游戏特有的社交传播面 · 下一节:社交压力与体验保护 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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