feat: explain income dispersion within comparable sample groups

This commit is contained in:
2026-10-31 21:34:38 +08:00
parent 3c631a43a6
commit 21fe8ded32
3 changed files with 65 additions and 2 deletions
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import type { ResearchSnapshot } from "@/lib/ai-research";
import { groupIncome, platformIncomeSample } from "@/lib/research-statistics";
import { compareIncomeGroups, groupIncome, platformIncomeSample } from "@/lib/research-statistics";
const usd = (value: number | null) => value === null ? "—" : new Intl.NumberFormat("zh-CN", {
style: "currency", currency: "USD", maximumFractionDigits: 0,
@@ -23,6 +23,7 @@ export default function IncomeDistribution({ data, onSelect }: {
const categoryGroups = [...new Set(groups.map((group) => group.category))].map((category) => ({
category, rows: groups.filter((group) => group.category === category),
}));
const comparisons = compareIncomeGroups(groups);
const categories = new Map(data.categories.map((category) => [category.id, category.name]));
const metric = annualize ? "MRR×12参考年化" : "平台MRR";
const values = sample.points.map((point) => point.value);
@@ -47,6 +48,20 @@ export default function IncomeDistribution({ data, onSelect }: {
<p>{splitModels ? "分组:业务类别 × 支付平台 × 收费与交付模式。同一类别下的多行是不同统计组,各自计算均值和中位数。" : "分组:业务类别 × 支付平台。同一平台内的收费模式已合并,不同支付平台仍分别统计。"}</p>
<p className="research-notice">资料截至 {data.asOf},纳入 {sample.points.length} 个产品,另有 {sample.excluded.length} 个 MRR 观测未纳入。{annualize ? "乘12只改变显示单位,不证明全年收入、续约或ARR;CV与相对敏感性不变。" : "MRR不等于近30日收款;平台数据连接也不等于财务审计。"}{dateWindow === "all" && " 当前包含不同时间的历史观测,不能称为同期对照。"}</p>
{sample.points.length ? <>
<aside aria-label="当前窗口样本解读" className="research-notice">
<strong>当前窗口的样本解读</strong>
{comparisons.length ? comparisons.map((comparison) => <div key={comparison.key}>
<p>{comparison.provider} · {comparison.model}(仅比较各组 n≥3):
{comparison.leastDispersed.stats.cv === comparison.mostDispersed.stats.cv
? <>各组 CV 相同({comparison.leastDispersed.stats.cv?.toFixed(3)}),没有离散度高低差异。</>
: <>{categories.get(comparison.mostDispersed.category)}更离散(CV {comparison.mostDispersed.stats.cv?.toFixed(3)}),{categories.get(comparison.leastDispersed.category)}较集中(CV {comparison.leastDispersed.stats.cv?.toFixed(3)})。</>}
{comparison.leastSensitive.stats.largestRemovalDrop === comparison.mostSensitive.stats.largestRemovalDrop
? <>各组移除最大值后的均值下降幅度相同({percent(comparison.leastSensitive.stats.largestRemovalDrop)})。</>
: <>{categories.get(comparison.mostSensitive.category)}的均值更受最大值影响,移除后下降 {percent(comparison.mostSensitive.stats.largestRemovalDrop)};{categories.get(comparison.leastSensitive.category)}下降 {percent(comparison.leastSensitive.stats.largestRemovalDrop)}。</>}
</p>
</div>) : <p>当前没有至少两个可对照的统计组。需要相同支付平台、明确的收费模式标签,且各组至少 3 个产品;可调整同步窗口或创立年,不能将单个产品或两点均值当作领域典型。</p>}
<p>这些是所选产品样本内的描述,较集中或较不敏感也不证明行业均值可靠。最后同步日并非统一财务结账日,产品年龄、主体独立性、成本和留存仍待核;参考年化也不是已披露 ARR。</p>
</aside>
<figure>
<div className="research-table-wrap"><svg className="income-distribution-chart" viewBox={`0 0 900 ${groups.length * 70 + 65}`} role="group" aria-label={`${metric}分布,${logScale ? "对数" : "线性"}横轴;圆点为产品,竖线为均值,菱形为中位数`}>
{ticks.map((tick) => <g key={tick}><line x1={x(tick)} x2={x(tick)} y1={15} y2={groups.length * 70 + 10} stroke="#dfe6e0" /><text x={x(tick)} y={groups.length * 70 + 35} textAnchor="middle" fontSize="11" fill="#637066">{usd(tick)}</text></g>)}
+21
View File
@@ -110,3 +110,24 @@ export function groupIncome(points: IncomePoint[], splitModels = true) {
}
return [...groups.entries()].map(([key, group]) => ({ key, ...group, stats: describeIncome(group.points.map((point) => point.value))! }));
}
/** Compare descriptive statistics only within the same provider and delivery model. */
export function compareIncomeGroups(groups: ReturnType<typeof groupIncome>) {
const buckets = new Map<string, typeof groups>();
for (const group of groups) {
if (group.stats.n < 3 || group.stats.cv === null || group.stats.largestRemovalDrop === null ||
group.model === "全部收费模式" || group.model === "收费模式未核验") continue;
const key = JSON.stringify([group.provider, group.model]);
const bucket = buckets.get(key) ?? [];
bucket.push(group);
buckets.set(key, bucket);
}
return [...buckets.entries()].flatMap(([key, rows]) => {
if (rows.length < 2) return [];
const byCV = [...rows].sort((a, b) => a.stats.cv! - b.stats.cv! || a.key.localeCompare(b.key));
const bySensitivity = [...rows].sort((a, b) => a.stats.largestRemovalDrop! - b.stats.largestRemovalDrop! || a.key.localeCompare(b.key));
return [{ key, provider: rows[0].provider, model: rows[0].model,
leastDispersed: byCV[0], mostDispersed: byCV[byCV.length - 1],
leastSensitive: bySensitivity[0], mostSensitive: bySensitivity[bySensitivity.length - 1] }];
});
}
+28 -1
View File
@@ -11,7 +11,34 @@ import { openSiteDatabase } from "../src/lib/site-database";
import { categorySummary, evidenceUrl, reviewCompanyIds, validateResearch, type ResearchSnapshot } from "../src/lib/ai-research";
import { consumeCode, initializeStore } from "../src/lib/auth-store";
import { newAuthConfig } from "../src/lib/auth";
import { arrDisclosureCoverage, arrDisclosures, describeIncome, groupIncome, platformIncomeSample } from "../src/lib/research-statistics";
import { arrDisclosureCoverage, arrDisclosures, compareIncomeGroups, describeIncome, groupIncome, platformIncomeSample } from "../src/lib/research-statistics";
test("income interpretations compare sufficient samples within a provider and known billing model", () => {
const snapshot = structuredClone(seed) as ResearchSnapshot;
const makePoints = (cat: string, values: number[], provider = "Stripe", model = "软件订阅") => values.map((value, index) => ({
company: { ...snapshot.companies[0], id: `${cat}-${provider}-${model}-${index}`, cat, businessModel: model },
observation: { ...snapshot.observations[0], provider }, value,
}));
const points = [
...makePoints("C04", [100, 100, 100]),
...makePoints("C06", [10, 10, 1000]),
...makePoints("C08", [1, 10000]),
...makePoints("C09", [1, 10, 10000], "RevenueCat"),
...makePoints("C10", [1, 10, 10000], "Stripe", "硬件服务"),
...makePoints("C07", [0, 0, 0]),
...makePoints("C01", [1, 10, 100], "Stripe", "收费模式未核验"),
];
const comparisons = compareIncomeGroups(groupIncome(points));
assert.equal(comparisons.length, 1);
assert.equal(comparisons[0].leastDispersed.category, "C04");
assert.equal(comparisons[0].mostDispersed.category, "C06");
assert.equal(comparisons[0].leastSensitive.category, "C04");
assert.equal(comparisons[0].mostSensitive.category, "C06");
assert.deepEqual(compareIncomeGroups(groupIncome(points, false)), [], "merged billing models are not comparable here");
const annualized = compareIncomeGroups(groupIncome(points.map((point) => ({ ...point, value: point.value * 12 }))));
assert.equal(annualized[0].mostSensitive.category, comparisons[0].mostSensitive.category);
assert.ok(Math.abs(annualized[0].mostDispersed.stats.cv! - comparisons[0].mostDispersed.stats.cv!) < 1e-12);
});
test("ARR coverage counts companies once and separates missing disclosures from zero income", () => {
const snapshot = structuredClone(seed) as ResearchSnapshot;