賃金の都道府県格差をボックスプロット|外れ値ハイライトを Claude Code で

ClaudeCode
e-Stat
賃金
ボックスプロット
統計

棒グラフで「1 位の東京がぶっちぎり、最下位の沖縄」と並べるのは確かにわかりやすい。でも、47 都道府県の分布全体がどう散らばっているか を一発で見せたいときは、ボックスプロット(箱ひげ図)の方が圧倒的に強い。中央値・四分位数・外れ値が同時に視覚化できるので、「東京は外れ値、それ以外は意外と団子」という構造が一目で出ます。

連載 Part 10 の今回は、業種別の所定内給与額(賃金構造基本統計調査)を題材に、Claude Code で四分位数の計算 → 外れ値判定 → D3 ボックスプロット描画までを一気通貫でやります。「箱ひげって計算が面倒で書いたことない」というエンジニアこそ、Claude Code に投げると 10 分で終わるのを体感してほしい回。

連載全体の流れは Part 1 の冒頭にまとまっています。Part 10 は 「分布を見る」セクション の本命チャート。Part 6 で散布図、Part 9 でレーダーチャートを扱いましたが、今回は「複数カテゴリ × 47 県の分布」を 1 枚に圧縮するのが目的です。

なぜ業種別の賃金格差は面白いのか

賃金の議論をすると、たいてい「東京 vs 地方」の構図に収束します。それは間違いではないんですが、業種ごとに格差の構造が全然違う ことは意外と知られていない。たとえばこんな感じ。

  • 金融・保険業: 東京が異常値レベルで突出。Q3 と max の差が大きい
  • 宿泊業・飲食サービス業: 全国どこでも低く、47 県の箱がぺちゃんこ
  • 製造業: 中央値は中くらいだが、IQR(箱の高さ)が大きい=県によってバラツキ
  • 医療・福祉: 中央値高め、外れ値少なめ=ほぼフラット

これを 47 県の棒グラフ × 業種数だけ並べると、何枚あっても足りない。ボックスプロットなら 1 枚で 10 業種 × 47 県 = 470 データポイントの分布構造 が見える。これがボックスプロットを選ぶ理由です。

そして可視化の主役は、実は 外れ値 です。東京や大阪が他県と桁違いに離れている業種では、外れ値の点だけが箱の外にぴょこんと飛び出す。そこにラベルを付けてあげると、「あ、この業種は首都圏一極集中なんだ」が直感でわかるようになる。今回の最大のテーマです。

使うデータ: 賃金構造基本統計調査

厚生労働省が毎年 6 月時点で実施する 賃金構造基本統計調査(通称「賃金センサス」)が今回のデータソース。e-Stat 上の statsDataId は 0003445758(実在)。一般労働者の都道府県・産業中分類別の所定内給与額が取得できます。

項目内容
統計名賃金構造基本統計調査
統計表 ID0003445758
集計単位都道府県 × 産業中分類 × 性別 × 企業規模
主な指標所定内給与額(千円)、年間賞与その他特別給与額、平均年齢、勤続年数
更新頻度年 1 回(毎年 3 月公表)
注意産業分類は日本標準産業分類に準拠。年度によって中分類のコードが変わることあり

「所定内給与額」は残業代を含まない月額の基本給ベース。賞与・残業を入れた年収換算をしたいときは、別カラムの「年間賞与」と組み合わせる必要があります。Part 10 では話を簡単にするため、所定内給与額 1 本で議論します。

データの粒度は「都道府県 × 産業中分類」。中分類はざっくり 20 業種くらいに分かれていて、たとえば「建設業」「製造業」「情報通信業」「金融業,保険業」「医療,福祉」「宿泊業,飲食サービス業」など。今回は読み手の馴染みやすさ優先で 10 業種に絞って 可視化します。

Step 1: Claude Code に「業種別賃金を全県取って」と頼む

スキル化済みの /fetch-estat-data を使う前提で進めます(連載 Part 2 でスキル化の手順を解説済み)。Claude Code への依頼はこんな感じ。

/fetch-estat-data
statsDataId: 0003445758
切り口: 都道府県 × 産業中分類
対象: 一般労働者 / 男女計 / 企業規模計
指標: 所定内給与額(千円)
年度: 最新年
出力: data/wage-by-industry.json

Claude Code は内部でこういう動きをします。

  1. getMetaInfo0003445758 のメタ情報を取得し、cat01(労働者区分)/ cat02(性別)/ cat03(企業規模)/ cat04(産業分類)/ cat05(指標)のコードを確認
  2. 一般労働者・男女計・企業規模計・所定内給与額に対応するコードを抽出
  3. getStatsDatacdCat0X 指定で叩く(年度は最新を cdTime から推定)
  4. 47 都道府県 × 全業種のレスポンスを JSON に整形して保存

返ってくる JSON はこんな形になります(抜粋)。

{
  "year": "2024",
  "indicator": "所定内給与額(千円)",
  "items": [
    {
      "areaCode": "13000",
      "areaName": "東京都",
      "industryCode": "J",
      "industryName": "金融業,保険業",
      "value": 521.4
    },
    {
      "areaCode": "13000",
      "areaName": "東京都",
      "industryCode": "G",
      "industryName": "情報通信業",
      "value": 478.2
    },
    {
      "areaCode": "47000",
      "areaName": "沖縄県",
      "industryCode": "M",
      "industryName": "宿泊業,飲食サービス業",
      "value": 198.7
    }
  ]
}

value の単位は 千円(月額)。後段の計算では基本このまま使い、画面表示時だけ「万円」変換しても良いです。

つまずきポイント 1: 業種コードの統一

e-Stat の業種コードは年度によって細分化されたり、統合されたりします。たとえば「情報通信業」が「J」だったり「39」だったりする。コードではなく業種名でマージする のが最も事故が少ないやり方です。Claude Code には次のように頼みます。

取得した JSON で、業種名を以下の 10 業種に正規化して。それ以外は除外して。
- 建設業
- 製造業
- 情報通信業
- 運輸業,郵便業
- 卸売業,小売業
- 金融業,保険業
- 不動産業,物品賃貸業
- 学術研究,専門・技術サービス業
- 宿泊業,飲食サービス業
- 医療,福祉
業種名の表記ゆれ(「金融業, 保険業」「金融・保険業」など)は吸収して。

Claude が industryName の正規化マップを書いてくれます。

Step 2: 業種ごとに四分位数(Q1, median, Q3)と IQR を計算

ここがボックスプロットの計算コア。業種 × 47 県の値配列 から、業種ごとに次の 5 つを算出します。

統計量意味
min47 県の最小値(外れ値除外後)
Q1第 1 四分位数(下から 25%)
median第 2 四分位数(中央値)
Q3第 3 四分位数(下から 75%)
max47 県の最大値(外れ値除外後)
IQRQ3 - Q1(箱の高さ)
outliersQ1 - 1.5 × IQR より下、または Q3 + 1.5 × IQR より上の値

外れ値判定は Tukey の方法(1.5 × IQR ルール)が標準。これも Claude Code に頼むだけで OK です。

Claude Code への依頼例。

data/wage-by-industry.json を読み込んで、業種ごとに以下を計算する Node.js
スクリプトを書いて。

- 47 県の所定内給与額配列をソート
- Q1, median, Q3 を線形補間で算出
- IQR = Q3 - Q1
- 下側ヒゲ: max(min, Q1 - 1.5 * IQR)
- 上側ヒゲ: min(max, Q3 + 1.5 * IQR)
- 外れ値: ヒゲの外側にある値 + 県名

出力: data/wage-box-stats.json (業種ごとの統計量配列)
業種の並び順は median 昇順。

Claude が書いたコードはだいたいこうなります(一部抜粋・整形)。

// scripts/calc-box-stats.mjs
import fs from "node:fs";

const SOURCE = "data/wage-by-industry.json";
const OUTPUT = "data/wage-box-stats.json";

/** 線形補間版の四分位数計算(D3.quantile と同等) */
function quantile(sorted, p) {
  if (sorted.length === 0) return null;
  const i = (sorted.length - 1) * p;
  const lo = Math.floor(i);
  const hi = Math.ceil(i);
  if (lo === hi) return sorted[lo];
  return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo);
}

function calcBoxStats(rows) {
  const sortedValues = rows.map((r) => r.value).sort((a, b) => a - b);
  const q1 = quantile(sortedValues, 0.25);
  const median = quantile(sortedValues, 0.5);
  const q3 = quantile(sortedValues, 0.75);
  const iqr = q3 - q1;
  const lowerFence = q1 - 1.5 * iqr;
  const upperFence = q3 + 1.5 * iqr;

  const outliers = rows
    .filter((r) => r.value < lowerFence || r.value > upperFence)
    .map((r) => ({ areaName: r.areaName, value: r.value }));

  const insideValues = sortedValues.filter(
    (v) => v >= lowerFence && v <= upperFence
  );

  return {
    min: insideValues[0],
    q1,
    median,
    q3,
    max: insideValues[insideValues.length - 1],
    iqr,
    outliers,
    n: rows.length,
  };
}

const raw = JSON.parse(fs.readFileSync(SOURCE, "utf8"));
const byIndustry = new Map();
for (const item of raw.items) {
  if (!byIndustry.has(item.industryName)) {
    byIndustry.set(item.industryName, []);
  }
  byIndustry.get(item.industryName).push(item);
}

const result = [];
for (const [industryName, rows] of byIndustry.entries()) {
  result.push({
    industryName,
    ...calcBoxStats(rows),
  });
}

// median 昇順
result.sort((a, b) => a.median - b.median);

fs.writeFileSync(OUTPUT, JSON.stringify(result, null, 2));
console.log(`Wrote ${OUTPUT} (${result.length} industries)`);

これで data/wage-box-stats.json が次のような形で出力されます。

[
  {
    "industryName": "宿泊業,飲食サービス業",
    "min": 198.7,
    "q1": 211.3,
    "median": 218.6,
    "q3": 226.8,
    "max": 248.2,
    "iqr": 15.5,
    "outliers": [
      { "areaName": "東京都", "value": 271.4 }
    ],
    "n": 47
  },
  {
    "industryName": "金融業,保険業",
    "min": 312.1,
    "q1": 334.6,
    "median": 358.9,
    "q3": 391.2,
    "max": 442.5,
    "iqr": 56.6,
    "outliers": [
      { "areaName": "東京都", "value": 521.4 },
      { "areaName": "大阪府", "value": 463.7 }
    ],
    "n": 47
  }
]

この段階で既に発見があります。金融業の IQR は 56.6 千円(5.7 万円)、宿泊飲食の IQR は 15.5 千円(1.6 万円)。金融業は地域差が大きい業種、宿泊飲食は全国どこでも低いフラットな業種 という構造が数字に出ています。

つまずきポイント 2: 線形補間 vs 旧式の四分位数

四分位数の計算方法は実は数種類あります。エクセルの QUARTILE.INC と R の quantile(type=7) と D3 の d3.quantile() は同じ(線形補間)。一方、QUARTILE.EXC や R の type=6 は微妙に違います。今回は D3 の標準と同じ線形補間 に揃えています。理由は単純で、後段で D3 を使って描くので両者の値がズレるとデバッグが地獄になるからです。

Step 3: D3 でボックスプロット(box + whisker + outlier circles)

ここからはチャート実装。D3.js v7 想定で、業種を Y 軸、賃金を X 軸に取った 横向きボックスプロット を描きます。横向きにする理由は、業種名が日本語で長いから(縦向きだとラベルが斜めになって読みにくい)。

実装の骨格。

// charts/wage-boxplot.mjs
import * as d3 from "d3";
import fs from "node:fs";

const stats = JSON.parse(fs.readFileSync("data/wage-box-stats.json", "utf8"));

const margin = { top: 32, right: 120, bottom: 48, left: 220 };
const width = 880 - margin.left - margin.right;
const height = stats.length * 44;

const svg = d3.create("svg")
  .attr("viewBox", `0 0 ${width + margin.left + margin.right} ${height + margin.top + margin.bottom}`);

const g = svg.append("g")
  .attr("transform", `translate(${margin.left},${margin.top})`);

// X scale(賃金): 全業種の min と外れ値を含めて domain を取る
const allValues = stats.flatMap((s) => [
  s.min, s.max, ...s.outliers.map((o) => o.value),
]);
const x = d3.scaleLinear()
  .domain([Math.floor(d3.min(allValues) / 50) * 50, Math.ceil(d3.max(allValues) / 50) * 50])
  .range([0, width])
  .nice();

// Y scale(業種)
const y = d3.scaleBand()
  .domain(stats.map((s) => s.industryName))
  .range([0, height])
  .padding(0.35);

// 軸
g.append("g")
  .attr("transform", `translate(0,${height})`)
  .call(d3.axisBottom(x).tickFormat((d) => `${d}千円`));

g.append("g").call(d3.axisLeft(y));

// 各業種の箱・ひげ・中央線・外れ値を描画
const row = g.selectAll(".row")
  .data(stats)
  .join("g")
  .attr("class", "row")
  .attr("transform", (d) => `translate(0,${y(d.industryName)})`);

// ひげ(横線)
row.append("line")
  .attr("x1", (d) => x(d.min))
  .attr("x2", (d) => x(d.max))
  .attr("y1", y.bandwidth() / 2)
  .attr("y2", y.bandwidth() / 2)
  .attr("stroke", "#94a3b8")
  .attr("stroke-width", 1.2);

// ひげの両端キャップ
row.append("line")
  .attr("x1", (d) => x(d.min)).attr("x2", (d) => x(d.min))
  .attr("y1", y.bandwidth() * 0.25).attr("y2", y.bandwidth() * 0.75)
  .attr("stroke", "#94a3b8");
row.append("line")
  .attr("x1", (d) => x(d.max)).attr("x2", (d) => x(d.max))
  .attr("y1", y.bandwidth() * 0.25).attr("y2", y.bandwidth() * 0.75)
  .attr("stroke", "#94a3b8");

// 箱(Q1〜Q3)
row.append("rect")
  .attr("x", (d) => x(d.q1))
  .attr("width", (d) => x(d.q3) - x(d.q1))
  .attr("y", 0)
  .attr("height", y.bandwidth())
  .attr("fill", "#bfdbfe")
  .attr("stroke", "#1d4ed8")
  .attr("stroke-width", 1.2);

// 中央値の線
row.append("line")
  .attr("x1", (d) => x(d.median))
  .attr("x2", (d) => x(d.median))
  .attr("y1", 0).attr("y2", y.bandwidth())
  .attr("stroke", "#1e3a8a")
  .attr("stroke-width", 2.2);

// 外れ値の点
row.selectAll(".outlier")
  .data((d) => d.outliers.map((o) => ({ ...o, industryName: d.industryName })))
  .join("circle")
  .attr("class", "outlier")
  .attr("cx", (o) => x(o.value))
  .attr("cy", y.bandwidth() / 2)
  .attr("r", 4.5)
  .attr("fill", "#ef4444");

fs.writeFileSync("out/wage-boxplot.svg", svg.node().outerHTML);

ここまでが「素のボックスプロット」。実行すると業種ごとに箱が並んで、赤い点で外れ値が出る画になります。これだけでも普通に綺麗なんですが、肝心の外れ値が「どの県?」がわからない。次のステップでラベルを付けます。

Step 4: 外れ値ハイライト(東京 / 大阪などラベル付け)

ボックスプロットの「読ませ方」を一段引き上げるのが、外れ値の県名ラベル。Claude Code に次のように頼みます。

さっきのコードに、外れ値の右側に areaName のテキストラベルを追加して。
重なりが出る場合はオフセットを取って読めるようにして。
東京都 / 大阪府 など主要都市は太字、それ以外は通常太さで。

Claude が書き足してくる差分はこんな感じ。

const HIGHLIGHT_AREAS = new Set(["東京都", "大阪府", "神奈川県", "愛知県"]);

row.selectAll(".outlier-label")
  .data((d) => d.outliers.map((o) => ({ ...o, industryName: d.industryName })))
  .join("text")
  .attr("class", "outlier-label")
  .attr("x", (o) => x(o.value) + 8)
  .attr("y", y.bandwidth() / 2)
  .attr("dy", "0.32em")
  .attr("font-size", 11)
  .attr("font-weight", (o) => HIGHLIGHT_AREAS.has(o.areaName) ? 700 : 400)
  .attr("fill", (o) => HIGHLIGHT_AREAS.has(o.areaName) ? "#b91c1c" : "#475569")
  .text((o) => `${o.areaName} ${o.value.toFixed(0)}`);

これで「金融業の外れ値: 東京都 521, 大阪府 464」のように、点の隣に県名と数値が並びます。読み手は箱とラベルだけで「金融業は東京と大阪が外れ値、それ以外の 45 県は団子」が瞬時にわかる。

外れ値が多すぎてラベルが重なるとき

業種によっては外れ値が 5 個以上出ることがあります。ラベルが重なって読めなくなるので、対処は 2 つ。

対処やり方
上位 N 件だけラベルoutliers.sort((a,b) => b.value - a.value).slice(0, 3) でトップ 3 のみ表示
d3-textwrap / d3-annotation重なり回避ライブラリで自動配置

連載で扱う粒度では「上位 3 件のみ表示」で十分。残りは点だけ打って、ツールチップ(hover)で県名を出すのが UX として無難です。Claude Code に「ラベルは値の大きい上位 3 件のみ、残りは tooltip で」と頼めばその通りに直してくれます。

Step 5: 業種を並べる順番(昇順 median)

ボックスプロットの 並び順 は読みやすさを大きく左右します。アルファベット順や 50 音順で並べると「結局どの業種が高いの低いの」がわからない。基本は median 昇順または降順 が鉄則です。

並び順向いている用途
median 昇順(低 → 高)「業種別の格差」を全体俯瞰したい時
median 降順(高 → 低)ランキング的に上位業種から見せたい時
IQR 降順「地域差が大きい業種」を強調したい時
outliers 数降順「特異な業種」を浮かび上がらせたい時

今回は median 昇順 を採用。下から順に「宿泊飲食 → 卸売小売 → 運輸 → 製造 → 建設 → 不動産 → 医療福祉 → 学術研究 → 情報通信 → 金融保険」と並ぶことになり、業種の社会的イメージと賃金水準が一致しているか が見えます。ちなみに「医療福祉」が思ったより上に来るのが個人的に毎回ハッとするポイント。

Claude Code に並び替えを頼むのは 1 行で済みます。

data/wage-box-stats.json を median 降順(高い業種を上)に並び替えて保存しなおして。

つまずきポイント(業種コードの統一、データ年度のズレ、平均年齢の影響)

実装中に踏みやすい落とし穴を 3 つまとめます。

1. 業種コードの統一

Step 1 でも触れた話。e-Stat の業種分類は 日本標準産業分類(JSIC)に準拠しますが、改定があるたびに細かいコードが変わります。特に「情報通信業」が独立した時期、「複合サービス事業」が新設された時期などは要注意。

  • 解決策: コードではなく業種名で正規化する。Claude Code に「業種名を 10 業種に正規化して、表記ゆれを吸収して」と依頼するのが最速

2. データ年度のズレ

賃金構造基本統計調査は毎年 3 月に前年 6 月時点の調査結果が公表されます。つまり「最新年」と書いてあっても、実態は 9 ヶ月前のスナップショット。年度をまたいで比較する記事を書くときは、必ず「2024 年 6 月調査」のように調査月を明記すること。

  • 解決策: グラフのキャプションに「データ: 賃金構造基本統計調査 YYYY 年 6 月 / 厚生労働省」を必ず入れる

3. 平均年齢の影響

所定内給与額は その業種・県の労働者平均年齢に強く依存 します。東京の金融業が高いのは、もちろん業界水準も高いんですが、平均年齢が高め(管理職比率が高い) という構造もあります。一方、沖縄の宿泊業は若年労働者比率が高く、それも給与を押し下げる要因。

  • 解決策: 同じ JSON に averageAge カラムも引いておき、外れ値の解釈時に補足として参照する。可能なら年齢階級別データ(statsDataId は別になります)で「30 代前半に限定した賃金」での比較もやると、より厳密な分析になります

ボックスプロットを 単独で見せる ときは、上記 3 点を本文かキャプションで触れるのが誠実な書き方です。

業種別の発見メモ(補助テーブル)

参考までに、ボックスプロットから読み取れる典型的な発見をまとめます。

業種medianIQR外れ値読み取り
金融業,保険業東京・大阪一極集中の代表業種
情報通信業東京東京と地方のギャップが大きい
医療,福祉ほぼなし全国でフラット、公的需要由来
製造業神奈川・愛知産業集積県が箱の上端
宿泊業,飲食サービス業東京のみ全国どこでも低い構造

この表自体が記事の結論の代わりにもなるので、ボックスプロットの図と並べて掲載すると読者の理解が深まります。

次回予告(Part 11: 積み上げ棒)

ボックスプロットは「1 指標の分布を複数カテゴリで比較」する道具でした。次回 Part 11 は逆方向、「1 指標を複数の構成要素に分解」する積み上げ棒グラフを扱います。題材は「県別の歳入内訳(地方税 / 地方交付税 / 国庫支出金 / 地方債)」の予定。財政自立度を一目で見せる定番チャートです。

Part 12 以降では時系列方面に進み、移動平均、季節調整、年成長率などのチャートを順番にカバーしていく予定。連載全体のロードマップは Part 1 からどうぞ。

関連ランキング・記事

ボックスプロットで紹介した業種の中で、stats47.jp 内に既にランキング記事があるものを抜粋。記事と併せて読むと、「箱の上端にいる県は具体的にどこか」が個別ランキングで確認できます。


ボックスプロットは「派手さはないけど分布を語る最強のチャート」です。Claude Code に四分位数計算を任せれば、実装の心理的ハードルがほぼゼロになります。次回 Part 11 でまた会いましょう。

PR