「一人あたり GDP が高い国は平均寿命も長いのか?」みたいな問いを、48 カ国の統計データで散布図にして眺めるツールを作った。実装の hinge は 2 つ: (1) 人口 (シンガポール 5.6M 〜 インド 1,417M で 250 倍) や GDP のように桁が大きく異なる指標は対数スケールで配置・相関しないと点が片側に潰れる、(2) ピアソンの相関係数を自前で実装して、軸を変えるたびに即時計算する。ライブラリ無し・vanilla JS で、計算層は Node テスト 34 個。
🌐 デモ: https://sen.ltd/portfolio/global-stats/
📦 GitHub: https://github.com/sen-ltd/global-stats
データモデル
48 カ国 × 5 指標 (人口・一人あたり GDP・平均寿命・一人あたり CO2・国土面積):
{ name: "日本", code: "JP", region: "アジア",
population: 125.1, gdpPerCapita: 33800, lifeExpectancy: 84.5,
co2PerCapita: 8.5, area: 378 },
指標の定義は別テーブルに切り出し、log フラグを持たせる:
export const METRICS = [
{ key: "population", label: "人口 (百万人)", log: true },
{ key: "gdpPerCapita", label: "一人あたり GDP (USD)", log: true },
{ key: "lifeExpectancy", label: "平均寿命 (年)", log: false },
{ key: "co2PerCapita", label: "一人あたり CO2 (t)", log: true },
{ key: "area", label: "国土面積 (千km²)", log: true },
];
寿命だけ log: false。これが効いてくる。
なぜ対数スケールが必要か
線形軸で「人口 vs GDP」を散布すると悲惨なことになる。人口はシンガポール 5.6M からインド 1,417M まで 250 倍、GDP/人 はエチオピア $1,030 からノルウェー $106,150 まで 100 倍の開きがある。線形軸だと:
- ほとんどの点が原点付近の左下に潰れる
- 中国・インドだけが右端に張り付く
- 相関係数も大きな外れ値に引っ張られて歪む
解決策は対数変換。10 倍ごとに等間隔にすることで、桁の違う国を同じ視野で比較できる。寿命 (52〜85 年、せいぜい 1.6 倍) のような線形指標はそのまま。
export function normalize(value, metric, domainMin, domainMax) {
if (metric.log) {
const lv = Math.log10(value);
const lmin = Math.log10(domainMin);
const lmax = Math.log10(domainMax);
if (lmax === lmin) return 0.5;
return (lv - lmin) / (lmax - lmin);
}
// linear
if (domainMax === domainMin) return 0.5;
return (value - domainMin) / (domainMax - domainMin);
}
テストで「対数スケールでは幾何平均が中央 (0.5) に来る」ことを確認:
test("log: geometric midpoint → 0.5", () => {
const m = getMetric("gdpPerCapita");
// domain 1000..100000、幾何平均 = 10000 → 0.5
assert.ok(Math.abs(normalize(10000, m, 1000, 100000) - 0.5) < 1e-9);
});
線形なら 50500 が中央だが、対数では幾何平均の 10000 が中央。この違いが「桁で見る」ということ。
ピアソン相関を自前で実装
2 軸を選んだら相関係数 r を出す。ピアソンの積率相関係数の定義そのまま:
export function pearson(xs, ys) {
const n = xs.length;
if (n < 2 || ys.length !== n) return null;
const meanX = xs.reduce((a, b) => a + b, 0) / n;
const meanY = ys.reduce((a, b) => a + b, 0) / n;
let num = 0, denX = 0, denY = 0;
for (let i = 0; i < n; i++) {
const dx = xs[i] - meanX;
const dy = ys[i] - meanY;
num += dx * dy;
denX += dx * dx;
denY += dy * dy;
}
const den = Math.sqrt(denX * denY);
if (den === 0) return null; // 分散ゼロ → 相関は未定義
return num / den;
}
den === 0 (どちらかの分散がゼロ) を null で返すのが大事。0/0 = NaN を可視化に流すと軸ラベルが壊れる。未定義を明示的に扱う。
エッジケースをテスト:
test("perfect positive correlation = 1", () => {
assert.ok(Math.abs(pearson([1, 2, 3], [2, 4, 6]) - 1) < 1e-9);
});
test("no correlation ≈ 0", () => {
// 対称な V 字は線形相関ゼロ
assert.ok(Math.abs(pearson([-2, -1, 0, 1, 2], [4, 1, 0, 1, 4])) < 1e-9);
});
test("zero variance → null", () => {
assert.equal(pearson([5, 5, 5], [1, 2, 3]), null);
});
V 字のテストが地味に重要: 「相関ゼロ = 無関係」ではなく「線形相関ゼロ」。完全な放物線でも線形相関は 0 になる。ピアソンが捉えるのは線形関係だけ、という限界を理解した上でのテスト。
相関も対数空間で取る
ここが一番のポイント。散布図を対数で表示するなら、相関係数も対数変換した値で計算しないと一致しない。べき乗則 (y = ax^b) の関係は log-log 空間で直線 (log y = b·log x + log a) になるので、対数変換後にピアソンを取ると関係の強さが正しく出る:
export function metricCorrelation(keyX, keyY, pool) {
const mx = getMetric(keyX), my = getMetric(keyY);
const xs = [], ys = [];
for (const c of pool) {
let x = c[keyX], y = c[keyY];
if (mx.log) x = Math.log10(x); // power-law → linear
if (my.log) y = Math.log10(y);
xs.push(x); ys.push(y);
}
return pearson(xs, ys);
}
実データで確認できる相関:
test("GDP vs life expectancy is a strong positive correlation", () => {
const r = metricCorrelation("gdpPerCapita", "lifeExpectancy");
assert.ok(r > 0.5, `got ${r}`);
});
実際に出る値は r ≈ 0.84 (強い正の相関)。「プレストン曲線」として知られる、所得と寿命の有名な関係がデータから再現される。GDP は対数・寿命は線形なので、片対数で相関を取っているのもポイント (寿命は所得の対数に比例するという経済学の知見と整合する)。
散布図の Y 軸反転
SVG は左上原点なので、「値が大きいほど上」にするには Y を反転する:
export function scatterPoints(keyX, keyY, pool) {
// ...
return pool.map((c) => ({
country: c,
cx: normalize(c[keyX], mx, dx.min, dx.max),
cy: 1 - normalize(c[keyY], my, dy.min, dy.max), // ← 反転
}));
}
これもテストで保証:
test("y is inverted: highest life-expectancy country has smallest cy", () => {
const top = pts.reduce((a, b) => (b.country.lifeExpectancy > a.country.lifeExpectancy ? b : a));
for (const p of pts) {
if (p.country.code !== top.country.code) {
assert.ok(p.cy >= top.cy - 1e-9);
}
}
});
データ整合性テスト
公的データのハードコードなので、整合性テストをセットで:
test("no duplicate ISO codes", () => {
const codes = COUNTRIES.map((c) => c.code);
assert.deepEqual(codes.filter((c, i) => codes.indexOf(c) !== i), []);
});
test("every metric field is present and positive", () => {
for (const c of COUNTRIES) {
for (const m of METRICS) {
assert.ok(typeof c[m.key] === "number" && c[m.key] > 0);
}
}
});
test("life expectancy in a sane range (40-90)", () => {
for (const c of COUNTRIES) {
assert.ok(c.lifeExpectancy >= 40 && c.lifeExpectancy <= 90);
}
});
対数スケールを使う以上「全指標が正の値」は単なる整合性チェックではなく前提条件 (log10(0) = -∞、log10(負) = NaN)。テストで守る価値がある。
設計
data.js ← 48 カ国 × 5 指標 (出典: World Bank / UN / OWID ~2022)
core.js ← pearson, normalize (log対応), scatter scaling, 地域集計 (DOM-free, 34 tests)
app.js ← SVG 散布図 + ソート可能テーブル
試してみる
軸を「CO2 vs GDP」にすると正の相関 (豊かな国ほど排出が多い) が見える。「人口 vs 寿命」にすると無相関 (大国でも小国でも寿命は変わらない) になる。色は地域分け。
まとめ
- 桁が大きく異なる指標 (人口・GDP・面積) は対数スケールで散布しないと点が潰れる。線形指標 (寿命) はそのまま。
logフラグで指標ごとに切り替える。 - ピアソン相関は定義通り実装できる。分散ゼロは
nullで返して NaN を可視化に流さない。 - 対数表示するなら相関も対数変換した値で計算する。べき乗則が log-log で直線になるため。
- ピアソンは線形相関のみ。V 字テストでその限界を明示する。
- 対数スケール採用時は「全指標が正」は整合性チェックではなく前提条件。テストで守る。
- GDP と寿命は r≈0.84。プレストン曲線がデータから再現される。
これは SEN 合同会社の OSS ポートフォリオ #262 です。https://sen.ltd/portfolio/
