「日本の高齢化、本当に見たことありますか?」と聞かれて、まあデータは知っているけど 動画で見たことはない、と気付いた。年スライダ + 自動再生で 1950 から 2070 まで人口ピラミッドの形が崩れていくのが見える Web ツールを書いた。約 250 行。
🌐 デモ: https://sen.ltd/portfolio/jp-population-pyramid/
📦 GitHub: https://github.com/sen-ltd/jp-population-pyramid
▶ ボタンを押すと 1 年あたり 120 ms で進む。第一次ベビーブーマーの塊が下から上に流れて、最終的に画面上部 (90+) で消えていくのが見える。中位年齢は 20 歳から 56 歳に、高齢化率は 7% から 38% に進む。
同じ問題を抱える人がいたら
Web で人口ピラミッドを見せようとするとき、ふつうは Plotly か D3 か Chart.js を使えば 30 行で書ける。が、
- 生のデータをどこから引いてくるか
- 過去 70 年と未来 50 年を一つのストーリーで連続させるか
の 2 点が地味に面倒で、書き出す前に止まる。本記事はそこに回答した実装。
データ: 関数で出して総数で締める
data.json は出力されたものだが、生成は generate-data.py 内の関数 が担当している。
-
annual_births(year)— 1850-2070 の単年出生数 (千人)。第一次 / 第二次ベビーブームを Gaussian bump で乗せ、ベースラインは時代ごとに区分線形に下げる -
survival(age, sex, birth_year)— 出生コホート別の年齢別生存率 -
cohort_size(birth_year, age, sex)— 上の 2 つを掛け合わせて、ある出生年・性別・年齢の生存人数を返す -
bin_population(year, bin_idx, sex)— 5 歳階級ごとに 5 つの単年コホートを足す -
snapshot(year)— 21 階級 × 男女の matrix を作り、最後に総数を IPSS 公式値に揃えるために係数倍する
最後のステップが重要で、出生関数と生存関数を真面目にやっても総数は実態の 92-105% くらいでブレる。各 snapshot 単位で target_total / raw_total をかけて合わせる。これで 1950 = 8,320万、2020 = 1.26億、2070 = 8,700万 と公式値にピッタリ。
def snapshot(year: int) -> dict:
raw_male = [bin_population(year, i, "M") for i in range(N_BINS)]
raw_female = [bin_population(year, i, "F") for i in range(N_BINS)]
raw_total = sum(raw_male) + sum(raw_female)
target = TARGET_TOTALS[year]
scale = target / raw_total
male = [round(m * scale) for m in raw_male]
female = [round(f * scale) for f in raw_female]
return {"year": year, "male": male, "female": female}
形 (raw_*) は demographic 関数任せ、サイズ (scale) は IPSS の値に従う、という分担。
やらかし: 1950 がピラミッドにならない
最初に書いた survival(age, sex) は 現代日本の生命表 をベースにしていて、出生年を考慮していなかった。
def survival(age, sex):
if sex == "F":
return math.exp(-((age / 90) ** 7))
return math.exp(-((age / 85) ** 7))
これで 1950 の snapshot を作ると、1950 時点で 75 歳の人 (1875 年生まれ) の生存率が exp(-(75/85)^7) ≈ 0.42 となる。42% が 75 歳まで生きている という想定。1875 年生まれの男性で 75 歳まで生きた人の実際の割合は 5-10% 程度なので、桁が違う。
結果として 1950 のピラミッドが「やや三角形に近い、でも上もそこそこ厚い、台形」になる。中位年齢 36 歳 (実際は 22 歳)、高齢化率 18% (実際は 5%)。ピラミッドではない。
修正は出生コホート別の attenuation を入れるだけ:
def cohort_factor(birth_year):
if birth_year >= 1945:
return 1.0
if birth_year >= 1925:
# 戦時期も含むが、近代医療がある程度効いている
return 0.55 + 0.45 * (birth_year - 1925) / 20
if birth_year >= 1890:
# 結核・乳幼児死亡・戦争で大幅減
return 0.25 + 0.30 * (birth_year - 1890) / 35
return 0.20
def survival(age, sex, birth_year):
base = math.exp(-((age / 90 if sex == "F" else 85) ** 7))
return base * cohort_factor(birth_year)
これで 1950 の中位年齢が 20.4 歳、高齢化率が 7.2% に落ちて、ちゃんとピラミッド になる。1875 年生まれの 75 歳まで生存率は 0.42 × 0.20 = 0.084 (8.4%)、現実に近い。
教訓: 「生命表は時代によって違う」というのは demographer なら常識だが、Python で軽く可視化を作っているだけだとうっかり忘れる。出生年を引数に持っていない survival(age) は嘘。
線形補間で中間年を出す
データは 10 年刻みの 13 スナップショット (1950, 1960, ..., 2070) しか持っていない。スライダで 2005 を選ぶと、2000 と 2010 の間を 50:50 で混ぜて返す。
export function interpolateSnapshots(a, b, year) {
const t = (year - a.year) / (b.year - a.year);
return {
year,
male: interpolateArrays(a.male, b.male, t),
female: interpolateArrays(a.female, b.female, t),
};
}
export function getSnapshot(snapshots, year) {
if (year <= snapshots[0].year) return clone(snapshots[0]);
if (year >= snapshots.at(-1).year) return clone(snapshots.at(-1));
for (let i = 0; i < snapshots.length - 1; i++) {
const a = snapshots[i];
const b = snapshots[i + 1];
if (year >= a.year && year <= b.year) return interpolateSnapshots(a, b, year);
}
}
線形補間の物理的意味は「コホートが 10 年で隣の bin に滑らかに移動する」と仮定するのと等価。実際のデモグラフィックダイナミクスではないが、画面上は十分滑らか に見える。コホートを別々に追跡したい場合は時間積分が必要で、別のツールの仕事になる。
SVG diverging bar chart の実装
各 5 歳階級が 1 本の水平バー。中央 (x = 400) を境に男性が左、女性が右に伸びる。
function renderBars(snapshot, globalMax) {
const halfPlotW = (VIEW_W - PAD_LEFT - PAD_RIGHT - CENTER_GAP) / 2;
const cx = VIEW_W / 2;
for (let i = 0; i < snapshot.male.length; i++) {
const m = snapshot.male[i];
const f = snapshot.female[i];
const mw = (m / globalMax) * halfPlotW;
const fw = (f / globalMax) * halfPlotW;
barCache.male[i].setAttribute("x", cx - CENTER_GAP / 2 - mw);
barCache.male[i].setAttribute("width", mw);
barCache.female[i].setAttribute("width", fw);
}
}
ポイント 2 つ:
-
globalMaxは全 snapshot の最大値を使う。各年で最大値を取るとバーの長さがフレームごとに再正規化されて「ベビーブーマーの bulge が動かないかのように見える」現象が起きる。固定スケールにすると ピーク世代が時間とともに小さくなって右上に動いていく のが正しく見える -
CSS transitions で滑らかに:
transition: x 0.15s, width 0.15sを<rect>に当てれば、setAttribute の更新が 150ms かけて補間される。スライダを動かしたときも自然に動く
<rect> 要素は最初の描画時にプールに作っておき、年が変わるたびに setAttribute("width", ...) だけ叩く。再生成しない。
統計の即値計算
中位年齢・高齢化率・生産年齢人口比は描画のたびに JS 側で計算する。
export function medianAge(snapshot, binWidth = 5) {
const total = totalPopulation(snapshot);
const half = total / 2;
let cumulative = 0;
for (let i = 0; i < snapshot.male.length; i++) {
const binSize = snapshot.male[i] + snapshot.female[i];
const next = cumulative + binSize;
if (next >= half) {
const fraction = binSize === 0 ? 0 : (half - cumulative) / binSize;
return i * binWidth + fraction * binWidth;
}
cumulative = next;
}
return (snapshot.male.length - 1) * binWidth;
}
中位年齢 (median age) は「人口の半分がそれより若く、半分がそれより上」の年齢。bin 単位の累積和を取って 50% を超えた bin の中で線形補間する。21 個の bin × 13 snapshot = 273 個の累積和なので毎フレーム再計算で何の問題もない。
数値検証用に、人口パターンが分かりやすい toy snapshot (4 階級、男女合計 2000) を作ってテストを書いた:
const TINY = [
{ year: 2000, male: [400, 300, 200, 100], female: [400, 300, 200, 100] },
...
];
// 累積: 800, 1400, 1800, 2000. 半分 = 1000 → bin 1 に入る。
// bin 1 までの累積 = 800、必要なのはあと 200 / 600 = 1/3。
// → median = 1*5 + (1/3)*5 ≈ 6.667 歳
assert.ok(Math.abs(medianAge(TINY[0]) - 6.667) < 0.01);
数値フォーマットの罠
「総人口 1.26億」「総人口 8,320万」のような表示を実装したら最初に 2 回バグった:
- 千 (thousand) を単位にしたデータで、1万 = 10 千 = 10,000 人。83202 千 = 8,320万。最初
Math.round(thousands / 10) + "0万"と書いて83200万(832 億) を出した - 1億 = 1万 × 1万 = 100 × 100,000 千。128097 千 = 1.28億。最初
thousands / 10000と書いて12.81億を出した
正解:
export function formatJpPopulation(thousands) {
if (thousands >= 100_000) { // 100,000 千 = 1 億
return `${(thousands / 100_000).toFixed(2)}億`;
}
if (thousands >= 10) { // 10 千 = 1 万
const man = Math.round(thousands / 10);
return `${man.toLocaleString("en-US")}万`;
}
return `${thousands}千`;
}
千 → 万 → 億 の単位繰り上がりが × 10, × 10000 という非対称なのが原因。テストで境界値 (83_202, 128_097, 5) を全部固定してから安心して書ける。
触る
- Demo: https://sen.ltd/portfolio/jp-population-pyramid/
- Code: https://github.com/sen-ltd/jp-population-pyramid
▶ を押して 1950 → 2070 を眺めるだけで、第一次ブーマーの塊が画面下から上に流れる「動画」を見ることになる。普段「日本の高齢化」というキーワードで頭に入れている内容が、目で見てショックの強さで残るタイプの可視化になっている、と作者は思っている。
ソース: MIT、合計 ~250 行 (JS) + ~120 行 (Python ジェネレータ)、16 ユニットテスト、ビルド不要。実データ (UN WPP / IPSS / e-Stat) への差し替えは data.json を置き換えるだけ。
🛠 本記事は SEN 合同会社 が公開している小さな開発者ツール群の 1 つ。他は portfolio 一覧 から。
