站内那篇 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 分 |
|---|---|---|---|---|---|---|
| 英语(美国) | 42000 | 300 | 120 | 60 | 40 | 50 |
| 简体中文 | 15000 | 320 | 130 | 70 | 50 | 75 |
| 日语 | 9000 | 300 | 120 | 60 | 50 | 80 |
| 德语 | 6000 | 280 | 100 | 50 | 35 | 42 |
| 葡语(巴西) | 3000 | 270 | 90 | 40 | 30 | 30 |
| 泰语 | 1200 | 330 | 130 | 80 | 60 | 108 |
逐语言的表格一出来,大盘数字立刻被拆成了六条各不相同的曲线:英语的体量最大但 D1 只排第三,中文的留存与付费都靠前,德语/葡语明显偏弱。这正是按语言拆分的价值——它把「大盘 30%」还原成六个可以分别行动的战场。
第三道坑:小样本会让最差的语言看起来最好
把目光移到最后一列之外的那一行:泰语。它的 D1 留存 330‰、付费率 60‰、ARPU 108 分——三个指标全部排第一。如果只看这张表,结论会是「泰语用户质量最高,应该重点投泰语」。
但这个结论是假的。泰语只有 1200 个新增,是英语的四十二分之一。小样本的指标方差极大:多留住几十个人,D1 就能跳好几个百分点。脚本按 minSampleNewUsers = 2000 把泰语标成 sampleOk = false(JSON 里 untrustedLanguages: ["th-TH"]),意思是「这组指标只能观察,不能据此决策」。
这就是第三道坑的核心:样本最小的语言,指标往往最漂亮——因为它不是「质量高」,只是「噪音大」。正确的做法是给每个语言设一个最小样本门槛,门槛下的语言先攒样本,不进入排名。
池化与简单平均:两套汇总的差距
把六个语言汇总成「大盘」时,两种口径给出不同的数:
| 口径 | D1‰ | D7‰ | D30‰ | 付费率‰ |
|---|---|---|---|---|
| 池化(按新增加权) | 301 | 119 | 60 | 42 |
| 简单平均(等权) | 300 | 115 | 60 | 44 |
差距最大的两项恰好能说明问题:付费率简单平均 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 分语言活动与素材 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。