同一款游戏,三个 ARPU
周会上,增长负责人说 ARPU 是 3.1 元,财务负责人说 ARPU 是 2.43 元,投放负责人说 ARPU 是 12.3 元。三个人说的都是《潮汐消消》,用的都是同一个自然月的数据,没有一个算错。
- 增长的 3.1 元:总流水 987 万除以 MAU 320 万。
- 财务的 2.43 元:到手收入 778.1 万除以 MAU 320 万。
- 投放的 12.3 元:总流水 987 万除以 DAU 80 万。
三个数字回答三个问题:一个月活用户值多少钱、实际到手多少钱、一个日活用户值多少钱。只要分母或分子换一个,结论就换一个。本节的任务不是选出「正确」的 ARPU,而是把每个口径的分子分母钉死,让不同部门在同一个句子里说的是同一件事。
四个分母决定四个故事
| 指标 | 分子 | 分母 | 回答的问题 | 常见错法 |
|---|---|---|---|---|
| 付费率 | 月付费用户数 | MAU | 多少活跃用户会付钱 | 用订单数当付费用户数 |
| ARPPU | 内购流水 | 付费用户数 | 一个付费用户一个月花多少 | 用含广告的总收入做分子 |
| ARPU | 总收入 | MAU | 一个活跃用户一个月贡献多少 | 分母换成 DAU 却不标注 |
| eCPM | 广告收入 × 1000 | 广告曝光次数 | 每一千次曝光的结算价 | 把填充率、完成率重复乘进去 |
这张表的关键不在公式,而在「分子分母必须来自同一个统计窗口」。付费率用月付费用户配月活,ARPPU 用月内购流水配月付费用户,ARPU 用月总收入配月活。任何一个指标跨窗口取数,比率就会失真。
付费率:分子是「月付费用户」,不是「订单」
消消月订单 42 万笔,月付费用户 8.96 万,MAU 320 万。付费率是 8.96 / 320 = 2.8%。如果拿订单数当分子,42 / 320 = 13.1%,等于把复购算成了新付费用户,虚高近五倍。
订单量远大于付费人数,说明复购是常态,订单口径不能用来衡量「有多少人愿意付钱」。月度付费率的分母只能用去重后的月活,分子只能用去重后的月付费用户。
付费率的分子必须去重到「人」,统计窗口必须与分母一致。月付费率配月活,日付费率配日活。把月付费用户除以日活,会得到一个看起来很高但没有解释力的数。
ARPPU 与 ARPU 差的是付费率,但只在纯内购产品成立
在纯内购产品里,内购 ARPU 等于付费率乘 ARPPU。消消是 2.8% × ¥68 = ¥1.90,与基线里的内购 ARPU 一致;内购占比越高的产品,这个式子越接近总 ARPU。
但总 ARPU 还要加上广告:
| 口径 | 消消 | 方舟 |
|---|---|---|
| 付费率 × ARPPU(内购 ARPU) | ≈¥1.90 | ≈¥27.3 |
| 广告 ARPU | ≈¥1.18 | ≈¥0.90 |
| 总 ARPU | ≈¥3.08 | ≈¥28.2 |
内购占比高的产品,广告项很小,加上后几乎闭合;消消的内购只占 61.7%,1.90 元与 3.08 元之间隔着 1.18 元广告收入。这就是混合变现产品最常见的口径错误:用 ARPPU 直接推总 ARPU,在消消身上会少算 38%。
恒等式的正确写法是:总 ARPU = 付费率 × ARPPU + 广告 ARPU。少写后一项,等于把广告收入从模型里删掉。
eCPM 是单价,不是收入
eCPM 的定义是「每一千次广告曝光的结算价格」,公式是广告收入除以曝光次数再乘 1000。它是一个价格,不是收入总额。把 eCPM 当收入写进月报,数值会差三个数量级。
用消消的激励视频算一遍。案例设定:人均激励 3.5 次/日,填充率 92%,激励完成率 88%,eCPM ¥45。每个 DAU 每天的激励视频价值是:
3.5 × 0.92 × 0.88 × 45 / 1000 ≈ ¥0.1275
80 万 DAU 乘 30 天,约 306 万元。报表里的广告收入是 378 万元,差额约 72 万元。这部分来自插屏(eCPM ¥18)与 Banner,事实表没有给出它们的曝光量,所以不能拿激励视频的公式去反推总广告收入。
这里有两个容易混淆的乘数。填充率是「广告请求中被填充的比例」,完成率是「填充后被看完的比例」,两者决定有效曝光次数;eCPM 已经按曝光结算,不能再被填充率或完成率调整一次。乘的顺序不影响数值,但影响解释:先算有效曝光,再乘单价,才能说清每一分钱来自哪里。
用同一个分母重算三个指标
把消消的几组数字放在同一张表里,可以看清分母变化带来的量级差:
| 指标 | 分子 | 分母 | 结果 |
|---|---|---|---|
| ARPU(MAU,毛) | 987 万元 | 320 万 | ≈¥3.08 |
| ARPU(DAU,毛) | 987 万元 | 80 万 | ≈¥12.34 |
| ARPU(MAU,净) | 778.1 万元 | 320 万 | ≈¥2.43 |
| ARPPU | 609 万元 | 8.96 万 | ≈¥67.97 |
同一款产品、同一个月,四个数从 2.43 到 67.97,相差近 28 倍。放在一张表里不是为了比较大小,而是说明:任何一个 ARPU 离开分母就无法解释。汇报时必须同时写清三件事:分子是否含广告、是否扣渠道、分母是 MAU 还是 DAU。缺一项,数字就可以被另一种口径重新解释。
让报表自己证明自己
下面这份实现把四个指标的定义写死,并提供一个对账函数:给定一组绝对额与三个「已发布」的比率,检查它们是否互相自洽。它的用途不是替代财务,而是在月报发出前发现口径漂移。
// file: src/biz/unit-economics.ts
// tier: L1
// verify: node --test test/biz/unit-economics.test.ts
export interface PeriodInput {
readonly mau: number;
readonly payingUsers: number;
readonly iapRevenue: number;
readonly adRevenue: number;
}
export interface UnitEconomics {
readonly totalRevenue: number;
readonly payRate: number;
readonly arppu: number;
readonly arpu: number;
readonly iapArpu: number;
readonly adArpu: number;
}
export interface ReportedPeriod extends PeriodInput {
readonly reportedPayRate: number;
readonly reportedArppu: number;
readonly reportedArpu: number;
}
export interface Reconciliation {
readonly ok: boolean;
readonly notes: readonly string[];
}
export interface IncentiveInput {
readonly impressionsPerDau: number;
readonly fillRate: number;
readonly completeRate: number;
readonly ecpm: number;
}
function assertCount(name: string, value: number): void {
if (!Number.isFinite(value) || value < 0) {
throw new RangeError(`${name} must be a finite non-negative number, got ${value}`);
}
}
function assertRatio(name: string, value: number): void {
if (!Number.isFinite(value) || value < 0 || value > 1) {
throw new RangeError(`${name} must be in [0, 1], got ${value}`);
}
}
export function unitEconomics(input: PeriodInput): UnitEconomics {
if (!Number.isInteger(input.mau) || input.mau <= 0) {
throw new RangeError(`mau must be a positive integer, got ${input.mau}`);
}
if (!Number.isInteger(input.payingUsers) || input.payingUsers < 0 || input.payingUsers > input.mau) {
throw new RangeError(`payingUsers out of range: ${input.payingUsers}`);
}
assertCount("iapRevenue", input.iapRevenue);
assertCount("adRevenue", input.adRevenue);
const totalRevenue = input.iapRevenue + input.adRevenue;
return {
totalRevenue,
payRate: input.payingUsers / input.mau,
arppu: input.payingUsers === 0 ? 0 : input.iapRevenue / input.payingUsers,
arpu: totalRevenue / input.mau,
iapArpu: input.iapRevenue / input.mau,
adArpu: input.adRevenue / input.mau,
};
}
export function relativeDrift(actual: number, reported: number): number {
if (!Number.isFinite(actual) || !Number.isFinite(reported)) {
throw new RangeError("relativeDrift needs two finite numbers");
}
if (actual === 0) {
return reported === 0 ? 0 : Number.POSITIVE_INFINITY;
}
return Math.abs(reported - actual) / Math.abs(actual);
}
export function reconcile(input: ReportedPeriod, tolerance = 0.01): Reconciliation {
assertRatio("tolerance", tolerance);
const measured = unitEconomics(input);
const notes: string[] = [];
if (relativeDrift(measured.payRate, input.reportedPayRate) > tolerance) {
notes.push("pay_rate_drift");
}
if (relativeDrift(measured.arppu, input.reportedArppu) > tolerance) {
notes.push("arppu_drift");
}
if (relativeDrift(measured.arpu, input.reportedArpu) > tolerance) {
notes.push("arpu_drift");
}
return { ok: notes.length === 0, notes };
}
export function ecpmRevenue(impressions: number, ecpm: number): number {
assertCount("impressions", impressions);
assertCount("ecpm", ecpm);
return (impressions / 1000) * ecpm;
}
export function effectiveEcpm(revenue: number, impressions: number): number {
assertCount("revenue", revenue);
if (!Number.isFinite(impressions) || impressions <= 0) {
throw new RangeError(`impressions must be positive, got ${impressions}`);
}
return (revenue / impressions) * 1000;
}
export function incentiveValuePerDau(input: IncentiveInput): number {
assertCount("impressionsPerDau", input.impressionsPerDau);
assertRatio("fillRate", input.fillRate);
assertRatio("completeRate", input.completeRate);
assertCount("ecpm", input.ecpm);
return input.impressionsPerDau * input.fillRate * input.completeRate * (input.ecpm / 1000);
}
export function formatUnitEconomics(economics: UnitEconomics): string {
const pct = (value: number) => `${(value * 100).toFixed(2)}%`;
return `pay_rate=${pct(economics.payRate)} arppu=¥${economics.arppu.toFixed(2)} arpu=¥${economics.arpu.toFixed(2)} iap_arpu=¥${economics.iapArpu.toFixed(2)} ad_arpu=¥${economics.adArpu.toFixed(2)}`;
}
读一段输出
把消消的绝对额与发布值喂进去,输出是:
pay_rate=2.80% arppu=¥67.97 arpu=¥3.08 iap_arpu=¥1.90 ad_arpu=¥1.18
对账时,发布值 2.8%、¥68、¥3.1 与计算值 2.80%、¥67.97、¥3.08 的相对偏差分别是 0%、0.05%、0.5%,都在 1% 容差内,reconcile 返回空 notes。若把 ARPU 改成 4.0,偏差约 29.7%,notes 里会出现 arpu_drift,说明分子分母至少有一个不是同一口径。
这个函数刻意不做的三件事:不判断哪个数字「对」,不修正数据,不处理多币种。它只回答一个问题:这组数字能不能用同一套定义推出来。推不出来,就先查口径,再谈结论。
口径漂移的四个来源
| 来源 | 表现 | 处理方式 |
|---|---|---|
| 含税与不含税 | 流水含增值税,到手不含 | 报表标明层级,不跨层比较 |
| 分成前与分成后 | 内购流水与到手收入混用 | 分子写清是否扣渠道 |
| 退款与掉单 | 已计入流水后又退回 | 单列减项,不回写分子 |
| MAU 去重窗口 | 跨月登录用户被重复计入 | 固定自然月,跨月单独说明 |
以消消为例,退款率 1.2%、掉单率 0.35%、欺诈率 0.08%,三项合计约 1.63%,在 609 万内购上约 9.9 万元/月。如果月报只扣退款、不扣掉单与欺诈,ARPPU 会被高估约 1.6%;如果三项都扣,又要把「扣减前」与「扣减后」两个 ARPPU 分列,否则同比数字不可比。
对照:方舟的恒等式几乎闭合
方舟内购占 96.8%,付费率 6.5% 乘 ARPPU ¥420 得 27.3 元,加上广告 ARPU 0.9 元正好是 28.2 元,恒等式几乎闭合。
消消内购只占 61.7%,2.8% 乘 ¥68 得 1.90 元,与总 ARPU 3.08 元之间隔着 1.18 元广告收入,直接用 ARPPU 推总会少算近四成。
代价与取舍
统一口径的代价是时效。订单口径当天可出,财务口径要等渠道结算与退款窗口,通常滞后一个月。把两者强行对齐,要么牺牲实时性,要么牺牲准确性。可接受的折中是分层:日报用订单口径,只做趋势与异常;月报用财务口径,做预算与考核;两套数字在月报里做一次差异说明,差异超过 1% 就查原因。
另一项取舍是精度。本节的对账容差是 1%,它能抓住口径错配,但抓不住千分之几的舍入。把容差调到 0.1% 会让大量正常的四舍五入报错,调到 5% 又会放过真正的分母错配。1% 是一个工程折中,不是财务标准。
与同章的关系
1.1 讲收入线怎么分,本节讲每条线的分子分母怎么定。1.3 会把本节的定义写进 mga-biz-lab 的基线:任何一个月报数字与基线不一致,都会在 reconcile 的 notes 里留下记录。再往后,第 2 章的账号与支付会直接消费这里的付费率与订单口径,不再重复定义。
小结
- 付费率的分子是去重后的月付费用户,不是订单数;订单口径会把消消的 2.8% 算成 13.1%。
- ARPPU 的分子只能是内购流水,不能混入广告收入。
- 混合变现下总 ARPU = 付费率 × ARPPU + 广告 ARPU,消消的广告项占 38%。
- eCPM 是千次曝光单价,先算有效曝光再乘单价,不能把它当收入总额。
- 消消激励视频按 3.5 次、92% 填充、88% 完成、¥45 eCPM 折算,约 306 万元/月,与报表 378 万元的差额来自插屏与 Banner。
- 对账函数只检查自洽性,不判断业务对错,也不替代财务结算。
- 1% 容差是发现口径漂移的工程折中,不是精确性保证。
阅读导航:上一节:1.1 收入结构:内购、广告与混合变现的分界 · 下一节:1.3 mga-biz-lab 的商业化基线 。
常见错误与边界
- 用订单数当付费用户数。 消消 42 万订单除以 320 万 MAU 得 13.1%,真实付费率 2.8%,虚高近五倍。
- 用 DAU 做 ARPU 分母却不标注。 987 万除以 80 万得 12.3 元,与 MAU 口径的 3.1 元差四倍,汇报时会被当成增长。
- 用 ARPPU 直接推混合变现产品的总 ARPU。 消消会少算 1.18 元广告 ARPU,占总额 38%,预算会系统性偏低。
- 把 eCPM 当收入。 eCPM 是千次曝光单价,必须乘曝光次数再除以 1000,否则数值差三个数量级。
- 把填充率或完成率乘在 eCPM 之后。 数值不变但口径错,eCPM 已按曝光结算,重复调整会误导优化方向。
- 付费率分子不去重。 复购用户被重复计数,付费率会随订单密度而不是付费人数波动。
- 分子用流水、分母用 MAU、结论用净收入。 三层口径混用,同比与环比都不可解释。
- 忽略退款、掉单与欺诈。 消消三项合计约 1.63%,在 609 万内购上约 9.9 万元/月,不扣会高估 ARPPU。
- 用不同月份的 MAU 与付费用户交叉。 月活去重窗口不同,比率会随统计边界漂移。
- 把 1% 的对账容差当成精确性保证。 它只用于发现口径漂移,不替代渠道结算与财务审计。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。