8.1 分语言指标口径

同一批新增事件,按语言分组后算出来的留存和付费指标常常互相矛盾。本节用可跑的 Python 仿真把分语言口径讲清楚:按什么分组、池化与简单平均为什么给出不同的数、小样本为什么会让最差的语言看起来最好,并给出可信度门槛与可复现的归集内核,附本机实跑的 JSON 摘要与产物。

站内那篇 1478 行的《独立游戏多语言本地化指南》 把「要不要做本地化、找谁翻、法务怎么走」讲透了,本节不重写它——它管的是译文从哪来。前七章我们已经把多语言资源管线(第 5 章)、文化审查(第 6 章)与本地化质量(第 7 章)打通;但把语言做出来,不等于看得懂每个语言的表现。这一章进入多语言运营的第一道工程题:同一批事件,按语言分组后,指标该怎么算才算数。上一节7.3 本地化成本与排期 算的是投入,这一节开始算「按语言拆开的产出」到底能不能信。

为什么指标要按语言拆

出海游戏的后台只有一个大盘:D1 留存 30%、付费率 4%。这个数字对任何决策都没用——它既回答不了「日语用户值不值得加投」,也解释不了「为什么德语区的付费率突然掉」。要回答这类问题,必须把同一批新增事件按语言切开,分别算留存与付费。

但「按语言切开」不是一句 SQL 分组就完事。它至少有三道口径坑,每一道都能让结论反转:按什么分组、怎么汇总、小样本算不算数。这一节用一个可跑的归集内核把三道坑都摆到台面上。

第一道坑:按什么分组

「这个用户是什么语言」至少有三种口径:系统语言(设备设置)、应用内选择语言(玩家自己改的)、首次进入时的判定语言(冷启动读一次)。三者经常不一致——一个中国玩家手机系统是中文,却把游戏切成英文,三种口径把他分到两个组里。

口径不统一,分组就会漂移:同一批人今天算英语、明天算中文,留存曲线自然对不上。工程纪律是:选定一个口径,写进埋点规范,全链路只认这一个字段。本节的仿真假设分组口径已经固定(即「首次判定语言」),把注意力集中在后两道坑上。

第二道坑:池化还是简单平均

拿到每个语言的留存数后,怎么给出「整体 D1 留存」?有两种算法,结果不一样:

  • 池化(blended):把所有语言的分子分母分别相加再相除——Σ D1活跃 // Σ 新增。这是按新增量加权的口径,也是唯一能回答「大盘 D1 是多少」的算法。
  • 简单平均(simple average):把每个语言的 D1 千分比直接求平均——Σ permille // 语言数。这给了每个语言相同的权重。

两个口径在语言体量接近时几乎一样,一旦某个语言体量极小,就会分叉:简单平均会把小语种的指标放大到和英语同权。这就是著名的「口径陷阱」——你想看大盘,却算成了「把每个语种当一个大盘」。

S 层:分语言指标归集内核

下面这段是 S 层仿真脚本 sim/g08_lang_metric.py。它读 config/g08_lang_metric.json,把同一批新增事件按语言分组,算 D1/D7/D30 留存、付费率、ARPU 与 ARPPU,同时给出池化与简单平均两套汇总,并按最小样本门槛标出不可信的语言,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/g08_lang_metric.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g08_lang_metric.py --config config/ --out out/
"""分语言指标口径仿真:把同一批新增事件按语言分组后,算留存与付费口径。

对 config/g08_lang_metric.json 的每个语言 L:

    d1Permille(L)  = d1Active  * 1000 // newUsers
    d7Permille(L)  = d7Active  * 1000 // newUsers
    d30Permille(L) = d30Active * 1000 // newUsers
    payRatePermille(L) = payers * 1000 // newUsers
    arpuFen(L)  = revenueFen // newUsers          # 每新增用户净收入
    arppuFen(L) = revenueFen // payers            # 每付费用户净收入

两种汇总口径必须分开:

    blended(池化)  = Σ分子 // Σ分母               # 按新增量加权,是唯一正确的总量口径
    simpleAvg(简单平均)= Σ permille(L) // len(langs)  # 各语言等权,小语种被放大

小样本门槛:newUsers < minSampleNewUsers 的语言,其指标标为「不可信」,只作观察不作决策。

全程整数、地板除,无浮点。产出 out/g08_lang_metric.json + .csv + .svg。
平台参数是「公开可观察机制」口径的建模假设;真实留存 / 付费 / 收入需真实业务环境,
命令行不可复现(X)。
"""

from __future__ import annotations

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


def permille(num: int, den: int) -> int:
    return 0 if den == 0 else num * 1000 // den


def per_language(cfg: dict) -> list[dict]:
    rows = []
    for L in cfg["languages"]:
        n = L["newUsers"]
        rows.append({
            "id": L["id"], "name": L["name"], "newUsers": n,
            "d1Permille": permille(L["d1Active"], n),
            "d7Permille": permille(L["d7Active"], n),
            "d30Permille": permille(L["d30Active"], n),
            "payRatePermille": permille(L["payers"], n),
            "arpuFen": 0 if n == 0 else L["revenueFen"] // n,
            "arppuFen": 0 if L["payers"] == 0 else L["revenueFen"] // L["payers"],
            "sampleOk": n >= cfg["minSampleNewUsers"],
        })
    return rows


def summarize(cfg: dict, rows: list[dict]) -> dict:
    """池化口径(按新增量加权)与简单平均口径各算一份,暴露口径分歧。"""
    sn = sum(r["newUsers"] for r in rows)
    sd1 = sum(L["d1Active"] for L in cfg["languages"])
    sd7 = sum(L["d7Active"] for L in cfg["languages"])
    sd30 = sum(L["d30Active"] for L in cfg["languages"])
    spay = sum(L["payers"] for L in cfg["languages"])
    srev = sum(L["revenueFen"] for L in cfg["languages"])
    n = len(rows)
    blended = {
        "newUsers": sn,
        "d1Permille": permille(sd1, sn), "d7Permille": permille(sd7, sn),
        "d30Permille": permille(sd30, sn), "payRatePermille": permille(spay, sn),
        "arpuFen": 0 if sn == 0 else srev // sn,
    }
    avg = {
        "d1Permille": sum(r["d1Permille"] for r in rows) // n,
        "d7Permille": sum(r["d7Permille"] for r in rows) // n,
        "d30Permille": sum(r["d30Permille"] for r in rows) // n,
        "payRatePermille": sum(r["payRatePermille"] for r in rows) // n,
    }
    return {"blended": blended, "simpleAvg": avg,
            "trustedLanguages": [r["id"] for r in rows if r["sampleOk"]],
            "untrustedLanguages": [r["id"] for r in rows if not r["sampleOk"]]}


def draw_svg(path: str, rows: list[dict], summary: dict) -> int:
    """纯 Python SVG:每语言三根留存条(D1/D7/D30),样本不足的语言加灰色标记。"""
    W, ROW, LEFT, SPAN = 800, 40, 150, 560
    H = 74 + 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": "26", "font-size": "15", "fill": "#111827"}).text = (
        f'《合成小岛》分语言留存口径:池化 D1 {summary["blended"]["d1Permille"]}‰ · '
        f'简单平均 D1 {summary["simpleAvg"]["d1Permille"]}‰')
    ET.SubElement(root, "text", {"x": "16", "y": "46", "font-size": "12", "fill": "#6b7280"}).text = (
        f'可信语言 {len(summary["trustedLanguages"])} 个 · '
        f'样本不足 {summary["untrustedLanguages"]}')
    series = (("d1Permille", "#2563eb"), ("d7Permille", "#0891b2"), ("d30Permille", "#f59e0b"))
    for i, r in enumerate(rows):
        y = 66 + i * ROW
        label = r["name"] + ("" if r["sampleOk"] else " ⚠")
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
                                     "fill": "#374151"}).text = label
        for j, (key, color) in enumerate(series):
            val = r[key]
            w = SPAN * val // 1000
            ET.SubElement(root, "rect", {"x": str(LEFT), "y": str(y + 4 + j * 11), "width": str(w),
                                         "height": "9", "rx": "2", "fill": color})
            ET.SubElement(root, "text", {"x": str(LEFT + w + 5), "y": str(y + 12 + j * 11),
                                         "font-size": "10", "fill": "#6b7280"}).text = str(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, "g08_lang_metric.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    rows = per_language(cfg)
    summary = summarize(cfg, rows)

    digest = (f'langs={len(rows)}|blended:d1={summary["blended"]["d1Permille"]},'
              f'd7={summary["blended"]["d7Permille"]},d30={summary["blended"]["d30Permille"]},'
              f'pay={summary["blended"]["payRatePermille"]}|avg:d1={summary["simpleAvg"]["d1Permille"]},'
              f'd7={summary["simpleAvg"]["d7Permille"]},d30={summary["simpleAvg"]["d30Permille"]},'
              f'pay={summary["simpleAvg"]["payRatePermille"]}|untrusted={len(summary["untrustedLanguages"])}')

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "g08_lang_metric.svg"), rows, summary)
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "minSampleNewUsers": cfg["minSampleNewUsers"], "languages": rows,
              "blended": summary["blended"], "simpleAvg": summary["simpleAvg"],
              "trustedLanguages": summary["trustedLanguages"],
              "untrustedLanguages": summary["untrustedLanguages"], "digest": digest,
              "svg": os.path.join(args.out, "g08_lang_metric.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(args.out, "g08_lang_metric.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "g08_lang_metric.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        cols = ("id", "name", "newUsers", "d1Permille", "d7Permille", "d30Permille",
                "payRatePermille", "arpuFen", "arppuFen", "sampleOk")
        w.writerow(cols)
        for r in rows:
            w.writerow([r[k] for k in cols])

    print(json.dumps({"digest": digest, "blended": summary["blended"],
                      "simpleAvg": summary["simpleAvg"],
                      "trustedLanguages": summary["trustedLanguages"],
                      "untrustedLanguages": summary["untrustedLanguages"],
                      "languages": [{k: r[k] for k in ("id", "newUsers", "d1Permille", "d7Permille",
                                                       "d30Permille", "payRatePermille", "arpuFen")}
                                    for r in rows]},
                     ensure_ascii=False, indent=2))
    return 0


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

参数放在 config/g08_lang_metric.json。这些数字是「公开可观察机制」口径的建模假设,不是任何平台后台的实测:真实留存、真实付费率属于 X 层——需真实业务环境,命令行不可复现。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "分语言指标口径参数。口径是「公开可观察机制」的建模假设,不是任何平台后台的实测。languages[].newUsers=该语言窗口内新增用户数;d1Active/d7Active/d30Active=该语言新增用户在次日/第 7 日/第 30 日的活跃数;payers=该语言付费人数;revenueFen=该语言窗口内净收入(分)。minSampleNewUsers=判定指标可信所需的最小新增样本。全整数,无浮点。",
  "minSampleNewUsers": 2000,
  "languages": [
    { "id": "en-US", "name": "英语(美国)", "newUsers": 42000, "d1Active": 12600, "d7Active": 5040, "d30Active": 2520, "payers": 1680, "revenueFen": 2100000 },
    { "id": "zh-CN", "name": "简体中文", "newUsers": 15000, "d1Active": 4800, "d7Active": 1950, "d30Active": 1050, "payers": 750, "revenueFen": 1125000 },
    { "id": "ja-JP", "name": "日语", "newUsers": 9000, "d1Active": 2700, "d7Active": 1080, "d30Active": 540, "payers": 450, "revenueFen": 720000 },
    { "id": "de-DE", "name": "德语", "newUsers": 6000, "d1Active": 1680, "d7Active": 600, "d30Active": 300, "payers": 210, "revenueFen": 252000 },
    { "id": "pt-BR", "name": "葡萄牙语(巴西)", "newUsers": 3000, "d1Active": 810, "d7Active": 270, "d30Active": 120, "payers": 90, "revenueFen": 90000 },
    { "id": "th-TH", "name": "泰语", "newUsers": 1200, "d1Active": 396, "d7Active": 156, "d30Active": 96, "payers": 72, "revenueFen": 129600 }
  ]
}

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

语言新增D1‰D7‰D30‰付费率‰ARPU 分
英语(美国)42000300120604050
简体中文15000320130705075
日语9000300120605080
德语6000280100503542
葡语(巴西)300027090403030
泰语12003301308060108

逐语言的表格一出来,大盘数字立刻被拆成了六条各不相同的曲线:英语的体量最大但 D1 只排第三,中文的留存与付费都靠前,德语/葡语明显偏弱。这正是按语言拆分的价值——它把「大盘 30%」还原成六个可以分别行动的战场。

第三道坑:小样本会让最差的语言看起来最好

把目光移到最后一列之外的那一行:泰语。它的 D1 留存 330‰、付费率 60‰、ARPU 108 分——三个指标全部排第一。如果只看这张表,结论会是「泰语用户质量最高,应该重点投泰语」。

但这个结论是假的。泰语只有 1200 个新增,是英语的四十二分之一。小样本的指标方差极大:多留住几十个人,D1 就能跳好几个百分点。脚本按 minSampleNewUsers = 2000 把泰语标成 sampleOk = false(JSON 里 untrustedLanguages: ["th-TH"]),意思是「这组指标只能观察,不能据此决策」。

这就是第三道坑的核心:样本最小的语言,指标往往最漂亮——因为它不是「质量高」,只是「噪音大」。正确的做法是给每个语言设一个最小样本门槛,门槛下的语言先攒样本,不进入排名。

池化与简单平均:两套汇总的差距

把六个语言汇总成「大盘」时,两种口径给出不同的数:

口径D1‰D7‰D30‰付费率‰
池化(按新增加权)3011196042
简单平均(等权)3001156044

差距最大的两项恰好能说明问题:付费率简单平均 44‰ 高于池化 42‰——因为小体量语言(泰语 60‰)被抬到了和英语同权;D7 反过来,简单平均 115‰ 低于池化 119‰——大体量的英语(120‰)在池化里权重大,简单平均则被体量小、D7 低的葡语(90‰)拖下来。

结论:报「大盘」只能用池化,简单平均只在「想给每个语种同等话语权」时才用。把两者混着看,就会得出「大盘付费率是 44‰ 还是 42‰」这种各说各话的争论。摘要行把两套口径都钉了下来:

digest = langs=6|blended:d1=301,d7=119,d30=60,pay=42|avg:d1=300,d7=115,d30=60,pay=44|untrusted=1

X 层:真实留存与付费不可复现

上面算的是模型侧。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 各语言真实的留存曲线(受语言区用户结构、当地竞品、网络环境共同影响);
  • 各语言真实的付费率与 ARPU(受当地支付习惯、定价策略影响,Web 端尤其分散);
  • 真实的语言分组字段(系统语言 / 应用内选择 / 首次判定三者在真实数据里并不一致)。

工程上正确的做法是:把可复现的口径层与不可复现的真实数据层分开——口径层固定「怎么分组、怎么汇总、门槛多少」这三条纪律;真实数据层在接入后反标定(把后台实测的分语言留存填回 config,口径不变,数字立刻反映现实)。

自检表

检查项合格标准
分组唯一系统语言 / 应用内选择 / 首次判定只选一个,写进埋点规范
汇总分离池化与简单平均分开输出,报大盘只用池化
样本门槛低于 minSampleNewUsers 的语言标为不可信,不进入排名
全整数留存与付费率用千分比地板除,无浮点
边界清晰真实留存 / 付费 / 分组字段标 X,不写成「实测」

分语言口径把「同一批事件按语言切开」这件事钉成了可复现的算法。但看清了每个语言的表现之后,下一个问题立刻出现:每个语言的用户,你给他看什么——活动文案、素材、节奏都要分语言配。下一节我们把「活动与素材」也做成一张按语言叉乘的分配矩阵。

阅读导航:上一节:7.3 本地化成本与排期 · 下一节:8.2 分语言活动与素材 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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