1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

日本の人口ピラミッドを 1950→2070 でアニメーションさせる Web ツールを作った — 出生コホート別の生存率を入れないと 1950 年がピラミッドにならない話

1
Posted at

「日本の高齢化、本当に見たことありますか?」と聞かれて、まあデータは知っているけど 動画で見たことはない、と気付いた。年スライダ + 自動再生で 1950 から 2070 まで人口ピラミッドの形が崩れていくのが見える Web ツールを書いた。約 250 行。

jp-population-pyramid: 2020 年の日本の人口ピラミッド。両側に分かれた diverging bar chart で、男性が左 (青) と女性が右 (ピンク) に伸びている。70-74 歳のところに第一次ベビーブーマーの bulge、45-49 歳のところに第二次ベビーブーマーの bulge が二つはっきり見える。下部に「総人口 1.26 億 / 中位年齢 51.2 歳 / 高齢化率 28.8% / 生産年齢比 61.2%」の統計が並ぶ

🌐 デモ: 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 行で書ける。が、

  1. 生のデータをどこから引いてくるか
  2. 過去 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 つ:

  1. globalMax は全 snapshot の最大値を使う。各年で最大値を取るとバーの長さがフレームごとに再正規化されて「ベビーブーマーの bulge が動かないかのように見える」現象が起きる。固定スケールにすると ピーク世代が時間とともに小さくなって右上に動いていく のが正しく見える
  2. 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) を全部固定してから安心して書ける。

触る

を押して 1950 → 2070 を眺めるだけで、第一次ブーマーの塊が画面下から上に流れる「動画」を見ることになる。普段「日本の高齢化」というキーワードで頭に入れている内容が、目で見てショックの強さで残るタイプの可視化になっている、と作者は思っている。

ソース: MIT、合計 ~250 行 (JS) + ~120 行 (Python ジェネレータ)、16 ユニットテスト、ビルド不要。実データ (UN WPP / IPSS / e-Stat) への差し替えは data.json を置き換えるだけ


🛠 本記事は SEN 合同会社 が公開している小さな開発者ツール群の 1 つ。他は portfolio 一覧 から。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?