論文一覧に戻る 📚 用語集トップ 🗺 概念マップ
📚 用語解説
📚 用語解説
表の結合
Table Join / Merge
データ前処理 pandas SQL

🔖 キーワード索引

💡 30秒で分かる 📍 文脈 🎨 直感 📐 数式 🔬 記号 🧮 実値計算 🐍 Python ⚠️ 落とし穴 📰 事例 ❓ FAQ 🌐 関連手法 🔗 関連用語 📚 グループ教材 🗺 概念マップ

💡 30秒で分かる結論

🍰 まずはやさしく

バラバラの表をくっつけるパズルのような操作です。

分析に必要な情報を1つの表にまとめるために使います。

部活の名簿と出席簿を1つにするイメージです。

結合の種類や注意点について学びます。

表の結合 (JOIN / merge) = 2 つ以上の表を「共通のキー」で繋ぎ、列を横方向に増やす操作。データ分析の 80% は結合と前処理に時間を使う。

📍 あなたが今見ているもの

🍰 まずはやさしく

データの整理整頓を行うための手順です。

バラバラのデータを使いやすくするために行います。

スマホのアプリで別々の情報をまとめる感覚です。

具体的なデータのつなぎ方を解説します。

用語集 → データ前処理 / 関係データ操作 分野 → 表の結合 (Table Join)。SQL の JOIN、pandas の merge() / join() / concat()、R の dplyr::*_join() がすべて同じ概念です。データクレンジング分散処理 と隣接します。

コンペでは、SSDSE-B-2026 を主、SSDSE-C や他の指標 (RESAS, e-Stat) を副として、共通キー (Prefecture, Year) で結合するシーンが頻出します。本ページでは SSDSE-B と SSDSE-C, 自作データを結合する完全な手順を扱います。

🎨 直感で掴む

🍰 まずはやさしく

共通の目印を頼りに情報を横に並べることです。

表にある情報の不足分を補うために使います。

商品IDを使って、名前と値段の表をつなげる例です。

図を使って結合のイメージを掴みましょう。

「同じ社員番号で勤怠表と人事表を横並びにする」のがイメージです。社員番号 (= キー) を頼りに、各社員の行に追加情報をくっつけます。

4 種類の結合をベン図で覚える:

種類残るキー片方しかないキーの扱い用途
INNER両方に存在するキーのみ捨てる確実に両方に値がある分析
LEFT左テーブル全部右は NaN で埋めるマスタに付加情報を追加
RIGHT右テーブル全部左は NaN で埋める右がマスタの場合
OUTER (FULL)両方の合計両方とも NaN 埋め欠損を含む状態を保持

カーディナリティ (関係の多重度):

SSDSE-B-2026 のキー (Prefecture, Year) は 47 × 12(2012〜2023 年)= 564 行で完全に 1:1。ここに SSDSE-C 等の同粒度データを結合するなら安心です。

🎨 データモデリングと JOIN: スタースキーマ / スノーフレーク

スタースキーマ(DWH の王道)

中央に ファクト表(売上、 イベント、 ログ)、 周囲に ディメンション表(顧客、 商品、 時刻、 地域)を配置。 ファクト表のキーを各ディメンションと JOIN するだけで分析できる。 SSDSE-B-2026 で言えば「年×県の人口データ」がファクト、 「県→地方」「年→四半期」がディメンション。

       [dim_pref]
            │
[dim_year]──[fact_population]──[dim_indicator]
            │
       [dim_region]

スノーフレークスキーマ

ディメンションをさらに正規化(地方 → 国 → 大陸など)した拡張版。 ストレージ効率は良いが JOIN 段数が増える。 トレードオフで OLAP では非正規化(スター)が一般的。

ディメンション設計のコツ

🎮 触って理解する — 結合タイプを切り替えてみよう

下の左表(注文表)右表(商品マスタ)は、キー 商品ID一部しか一致しない架空のミニデータです(一致: P2, P3 / 左のみ: P1, P5 / 右のみ: P4)。ボタンで結合タイプを切り替えると、どの行が生き残り、どの行が捨てられ、どこに NULL が生まれるかが図・結果表・ベン図にリアルタイムで反映されます。行にマウスを乗せる/指でなぞる(タッチ対応)と、結果表の対応行がハイライトされます。

両表で一致   左表のみ   右表のみ   この結合では捨てられる行  NULL 補完される欠損

結合結果

ベン図(残るキー領域)

💡 やってみよう: ①「1対多モード」を ON にすると右表に P2 が 2 行(旧価格・新価格)になります。INNER でも左表の P2 1 行が結果で 2 行に増殖することを確認。②LEFT と RIGHT で NULL が生まれる側が入れ替わること、③FULL OUTER では行が 1 つも捨てられないことを確認してください。

🧭 直感 — 「キーで横に繋ぐ」を一段深く

結合とは「左表の各行について、右表からキーが一致する行を辞書引きして横に貼り付ける」操作です。SQL の JOIN も pandas の merge() も R の dplyr::left_join() も内部でやっていることは同じ。4 種類の結合タイプの違いは、突き詰めれば「相手が見つからなかった行をどうするか」の一点だけです — INNER は捨てる、LEFT/RIGHT は片側を守って NULL で埋める、FULL OUTER は全部守る。だからこそ結合の設計は主キー外部キーの設計(キーの一意性・参照整合性)とセットで考えます。

⚠️ 落とし穴 — 上のデモで起きたことは実務でも起きる

🚀 発展 — アルゴリズム・pandas merge・アンチ結合

📐 数式・定義 — 関係代数

🍰 まずはやさしく

表をつなげるルールを数式で表したものです。

計算の仕組みを正しく理解するために使います。

買い物リストの項目がどう増えるか考える例です。

結合したときに行数がどう変わるかを学びます。

自然結合 (natural join):

$$ R \bowtie S = \{ (r, s) \mid r \in R, s \in S, \pi_K(r) = \pi_K(s) \} $$

$\pi_K$ は共通キー $K$ への射影。両表でキーが一致する組のみを残す (INNER JOIN)。

左外部結合:

$$ R \mathbin{\unicode{x27D5}} S = R \bowtie S \cup \{ (r, \mathrm{NULL}) \mid r \in R, \not\exists s \in S: \pi_K(r) = \pi_K(s) \} $$

結合後の行数 (一意キー時):

$$ |R \bowtie S| = \sum_{k \in K_R \cap K_S} 1 = |K_R \cap K_S| $$

キーが重複しているときは積になります。$|R| \cdot |S| / |K|$ の上限。

結合計算量:

アルゴリズム計算量使い所
Nested-loop$O(|R| \cdot |S|)$片方が極小
Sort-merge$O((|R| + |S|) \log)$事前ソート済み
Hash join$O(|R| + |S|)$片方がメモリ収まる
Broadcast join$O(|R|)$片方が極小 (分散)

📐 JOIN 条件の種類: 等価 / 非等価 / 範囲

JOIN 種別条件式速度
等価結合 (equi-join)a.k = b.k都道府県名で結合速い (Hash 可)
非等価結合 (non-equi)a.k > b.k「翌日のレコード」を結合遅い (Nested Loop)
範囲結合 (range)a.x BETWEEN b.lo AND b.hi所得階級の判定Sort-Merge で改善可
θ-結合 (theta)任意の条件 $\theta$ABS(a.x - b.x) < 1原理的に遅い
cross join条件なし全ペアカレンダー × 商品の網羅$|A| \times |B|$ 行
asof join「最も近い時刻」株価と取引時刻の対応付けpandas merge_asof

時系列でよく使う merge_asof

pd.merge_asof(trades, quotes, on='time') は「各 trade に対して、 それ 以前で最も近い quote を割り当てる」操作。 金融データ・センサーデータの時刻整列で必須。 SQL の LATERAL JOIN + ORDER BY 相当。

📐 結合の計算量と最適化 — Hash / Sort-Merge / Nested-Loop

RDBMS や DataFrame ライブラリは内部で 3 種類の結合アルゴリズム を使い分ける。 計算量と適用条件を覚えれば、 「なぜこのクエリだけ遅いのか」が即座に説明できる。

アルゴリズム計算量前提代表用途
Hash Join$O(N+M)$ 平均片側がメモリに収まる中小マスタ × 大ファクト
Sort-Merge Join$O(N\log N + M\log M)$両側がソート済 or ソート可能大規模 × 大規模、 範囲条件
Nested-Loop Join$O(N \cdot M)$片側が極小(数十行)辞書テーブル・小マスタ

⏱ 47 県 × 3 行マスタの計算量比較(簡易ベンチ)

🎯 このコードでやること: SSDSE-B-2026 から 47 行を取り出し、 3 行のマスタと結合する処理を pandas (Hash) と SQLite (デフォルトは Nested-Loop) で比較する。 件数が小さいので絶対秒数は微小だが、 アルゴリズム差を可視化できる。

📥 入力データ: SSDSE-B-2026 (47 行) + 地方マスタ (3 行)。

1
2
3
4
5
6
7
8
9
import pandas as pd, sqlite3, time
df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
df = df[df['SSDSE-B-2026']==2023][['Prefecture','A1101']]
region = pd.DataFrame({'Prefecture':['北海道','東京都','大阪府'], 'region':['HK','KT','KS']})
t0=time.perf_counter(); m1 = df.merge(region, on='Prefecture', how='left'); t1=time.perf_counter()
con=sqlite3.connect(':memory:'); df.to_sql('p',con,index=False); region.to_sql('r',con,index=False)
t2=time.perf_counter(); m2 = pd.read_sql("SELECT * FROM p LEFT JOIN r ON p.Prefecture=r.Prefecture", con); t3=time.perf_counter()
print(f'pandas (Hash)  : {(t1-t0)*1000:.3f} ms, 行={len(m1)}')
print(f'sqlite (NL)    : {(t3-t2)*1000:.3f} ms, 行={len(m2)}')

📤 実行例:

pandas (Hash) : 1.055 ms, 行=47 sqlite (NL) : 0.478 ms, 行=47

🕐 この 2 つの数値は実行のたびに変わります。 マシン・OS・同時に動いているプロセスに左右されるうえ、 47 行 × 3 行という規模では計測値のほとんどが「関数呼び出しの固定コスト」です。 実際、 実行を繰り返すと pandas と sqlite の速い・遅いが入れ替わります。 ミリ秒の大小をこの規模で比べても意味がありません。 アルゴリズムの差を見たいなら、 timeit で数百回の平均を取り、 かつ行数を 10 万件規模まで増やしてください。

💬 結果の読み方: 見るべきは時間ではなく計算量の違いです。 pandas は Hash 主体で右側 3 行をハッシュ化して 47 回 lookup(O(n+m))、 sqlite はインデックス無しのため Nested-Loop で 47×3 = 141 回比較(O(n×m))。 この差は今の規模では埋もれますが、 件数が 10 万 × 1 万になれば 100 倍以上の差として表面化します。 本番では「右をハッシュ化できる側に置く」「結合キーにインデックスを張る」の 2 点を守るだけで桁違いに速くなる。

✅ 理解度チェック — 8 問で結合を完全マスター

読了後の知識定着を確認する 8 問。 すべて SSDSE-B-2026 を題材にしている。 各問の下に 解答と解説 をたたみ込んでいるので、 まずは隠して挑戦してほしい。

Q1. 47 県の人口テーブル (47 行) と 9 地方マスタ (9 行) を INNER JOIN すると行数はいくつ?

答え: 9 行(地方マスタの粒度に縮退)ではなく、 47 行。 INNER は「両側のキーが一致する全行」を返すので、 47 県全てが 9 地方のどれかにマッチすれば 47 行。 もし「東京都」のキー綴り誤りで 1 県だけマッチしなければ 46 行になり、 マスタ漏れを INNER で検出できる。

Q2. LEFT JOIN で右に該当無しの行は、 右側列に何が入る?

答え: NaN(pandas)/ NULL(SQL)。 LEFT は左を全行保持するので、 右に無い行も左側の値はそのまま残り、 右側列のみ NaN で埋まる。 「マスタに無い県を炙り出す」用途で最強。

Q3. 47 県 × 9 地方を CROSS JOIN すると?

答え: 47 × 9 = 423 行(直積)。 CROSS はキー条件無しで全組合せを生成する。 ペア候補の事前生成や距離行列の前処理に有用だが、 件数が膨大になりやすいので注意。

Q4. 「東京都」と「東京都 」(末尾全角空白)が結合できないのは何故?

答え: 文字列の完全一致が要求されるため、 末尾の全角空白 1 文字でも別キー扱いになる。 対処は事前に unicodedata.normalize('NFKC', s).strip() で正規化する。 全角半角や前後空白の揺らぎは 結合事故 No.1 原因

Q5. indicator=True を付けると何ができる?

答え: 結果に _merge 列が追加され、 各行が「left_only / right_only / both」のどれかでラベル付けされる。 マスタ漏れを件数集計するなら merged['_merge'].value_counts() 一発。 監査ログとして残せる。

Q6. 計算量が $O(N \cdot M)$ になる結合アルゴリズムは?

答え: Nested-Loop Join。 全ペアを総当たりで比較するため、 件数が増えると遅い。 片側が極小(数十行)の場合のみ実用的。 SQLite の最適化器はインデックスが無いと NL を選びがち。

Q7. SSDSE-B-2026 を年度を含むキーで結合する場合、 on= に何を渡す?

答え: on=['都道府県','年度'](複合キー)。 単一キー on='都道府県' だと、 年度の異なる行が大量にマッチして many-to-many 暴走になる。 SSDSE-B のように パネル(同一観測単位 × 複数時点)では複合キー必須。

Q8. 結合後の集計値が想定の 2 倍になっている。 最初に確認すべきは?

答え: 右側テーブルのキー一意性right['key'].is_uniqueright.groupby('key').size().max() で「同キーが何行あるか」を確認し、 1 行を超えるなら集約してから結合し直す。 行数バランスのチェックは最初の防衛線。

🏗 実務シナリオ徹底解説 — 「人口 × 家計 × 教育」3 表を結合して 1 つの分析テーブルを作る

SSDSE-B-2026 を 1 ファイルとして扱うだけなら結合は不要だが、 実務では 「公的統計の人口表」「総務省・家計調査の消費支出表」「文科省の学校統計表」 といった 複数出所のテーブル を都道府県コードで結合して 1 つの分析テーブルを作る。 ここでは SSDSE-B-2026 の中の 3 種類の列群を「別ファイル」と見立てて、 結合の実務シナリオを段階的に再現する。

ステップ 0: なぜ 1 表にまとめるのか — 「分析しやすい長方形」を目指す

分析では 1 行 = 1 観測単位長方形(tidy)データ が前提になる。 都道府県分析なら 47 行、 全国時系列なら 「年度 × 県」 47×N 行の長方形。 複数出所の表を結合して 1 表にまとめると、 散布図・回帰・クラスタリングがそのまま動くようになる。 逆に「結合せずに 3 個の DataFrame をくっつけたつもり」で進めると、 順序が違っていたり、 県の対応が狂っていたりして、 分析が崩壊する。

ステップ 1: 3 表をそれぞれロードして「キーと粒度」を宣言する

🎯 このコードでやること: SSDSE-B-2026 から人口・家計・教育の 3 グループを切り出し、 それぞれ pop_df, spend_df, edu_df として独立した DataFrame にする。 結合キーは 「Prefecture」 「SSDSE-B-2026(年度)」 の複合キー。

📥 入力データ: data/raw/SSDSE-B-2026.csv(cp932, ヘッダ 2 行)。 実在列例: Prefecture, SSDSE-B-2026(=年度), A1101(総人口), A1303(65歳以上人口), L3221(消費支出), E1101(幼稚園数)など。

SSDSE-B-2026 抜粋(年度 2023、 一部):
Prefecture SSDSE-B-2026 A1101 A1303 L3221 E1101
北海道 2023 5092000 1681000 296888 331
青森県 2023 1184000 417000 263371 85
宮城県 2023 2264000 662000 305541 208
...
1
2
3
4
5
6
7
import pandas as pd
df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
pop_df   = df[['Prefecture','SSDSE-B-2026','A1101','A1303']].rename(columns={'A1101':'pop','A1303':'elderly'})
spend_df = df[['Prefecture','SSDSE-B-2026','L3221']].rename(columns={'L3221':'spend'})
edu_df   = df[['Prefecture','SSDSE-B-2026','E1101']].rename(columns={'E1101':'kg'})
for name, d in [('pop',pop_df),('spend',spend_df),('edu',edu_df)]:
    print(name, '行=', len(d), 'キー一意=', d[['Prefecture','SSDSE-B-2026']].duplicated().sum()==0)

📤 実行例:

pop 行= 564 キー一意= True
spend 行= 564 キー一意= True
edu 行= 564 キー一意= True

💬 結果の読み方: 3 表とも 47 県 × 12 年(2012〜2023)= 564 行、 複合キーで一意。 「キー一意」が False ならその時点で結合せず、 重複の原因を先に潰す。 これが結合前の必須チェック。

ステップ 2: 3 表を順次結合 — INNER で「全表に揃った行」だけを残す

🎯 このコードでやること: pop_df を起点に、 spend_df, edu_df を順に INNER JOIN する。 INNER を使うことで、 1 表でも欠けがあればその(Prefecture × 年度)は捨てる。 安全側の運用。

1
2
3
4
m1 = pop_df.merge(spend_df, on=['Prefecture','SSDSE-B-2026'], how='inner')
m2 = m1.merge(edu_df, on=['Prefecture','SSDSE-B-2026'], how='inner')
print('段階別行数:', len(pop_df), '→', len(m1), '→', len(m2))
print(m2.head(3).to_string(index=False))

📤 実行例:

段階別行数: 564 → 564 → 564
Prefecture SSDSE-B-2026 pop elderly spend kg
北海道 2023 5092000 1681000 296888 331
北海道 2022 5140000 1686000 277737 347
北海道 2021 5183000 1686000 268396 366

💬 結果の読み方: 3 表ともキー粒度が同じだから、 INNER でも行数は減らない。 もし spend_df だけ 2017〜2023 の 7 年分しかなければ、 結合後は 47×7=329 行に縮退する。 縮退が想定外なら、 LEFT に切り替えて欠損年度を可視化する。

ステップ 3: 派生指標を計算(高齢化率・幼稚園密度)

🎯 このコードでやること: 結合後の 1 表で「高齢化率(65歳以上人口 / 総人口 × 100)」「幼稚園密度(幼稚園数 / 人口100万人)」を計算。 結合が成功した瞬間にできる「複数表をまたぐ派生指標」こそが、 結合の最大の価値。

1
2
3
4
5
m2['高齢化率'] = m2['elderly'] / m2['pop'] * 100        # 65歳以上人口 ÷ 総人口 × 100
m2['幼稚園密度'] = m2['kg'] / m2['pop'] * 1_000_000       # 人口100万人あたり幼稚園数
y23 = m2[m2['SSDSE-B-2026']==2023]
print(y23.nlargest(3, '高齢化率')[['Prefecture','高齢化率','幼稚園密度']].round(2).to_string(index=False))
print(y23.nsmallest(3, '高齢化率')[['Prefecture','高齢化率','幼稚園密度']].round(2).to_string(index=False))

📤 実行例:

Prefecture 高齢化率 幼稚園密度
秋田県 39.06 35.01
高知県 36.34 54.05
徳島県 35.40 122.30
Prefecture 高齢化率 幼稚園密度
東京都 22.75 68.08
沖縄県 23.84 102.86
愛知県 25.72 52.16

💬 結果の読み方: 秋田県の高齢化率は 39.06% と全国最高だが幼稚園密度(人口100万人あたり)は 35.01 と最も低い(若年人口が少ないので幼稚園も少ない)。 逆に高齢化率が低い東京都・沖縄県・愛知県は幼稚園密度が相対的に高く、 「高齢化率と幼稚園密度が逆向きに動く」県の構造が 結合表でしか見えない。 これが「複数表を結合する真の価値」。

ステップ 4: 結合表を CSV に保存し、 後段の分析に渡す

🎯 このコードでやること: 結合済みの分析テーブルを data/processed/joined.csv として保存。 BOM 付き UTF-8 にしておくと Excel でもそのまま開ける。 後段の散布図・回帰・クラスタリングはこの 1 ファイルだけを読めば良い。

1
2
3
4
5
6
import os
os.makedirs('data', exist_ok=True)   # 書き出し先を先に作る
import os
os.makedirs('data/processed', exist_ok=True)
m2.to_csv('data/processed/joined.csv', index=False, encoding='utf-8-sig')
print('保存しました:', os.path.getsize('data/processed/joined.csv'), 'バイト')

📤 実行例:

保存しました: 43803 バイト

💬 結果の読み方: 約 43 KB の単一 CSV にまとまった。 後段の分析者は pd.read_csv('data/processed/joined.csv') 1 行だけで、 「人口 × 家計 × 教育」の長方形データを得られる。 これが 結合の最終形=再利用可能な分析テーブル。 元の生データをいじることなく、 派生テーブルを下流に流すワークフローを徹底すると、 データパイプライン全体が監査可能になる。

🔬 数式を言葉で読み解く

記号読み方意味
$R, S$relation表 (リレーション)
$\bowtie$join自然結合 (INNER)
$\unicode{x27D5}, \unicode{x27D6}$left/right outer join外部結合
$\pi_K$projectionキー列だけ取り出す射影
$K_R \cap K_S$key intersection両表で共通するキー集合

🔬 数式を言葉で読み解く(関係代数からハッシュ結合まで)

JOIN は単なる「2 つの表を貼り合わせる操作」ではなく、 関係代数という数学的体系の中核演算です。 ここでは式 4 本を、 記号ごとに丁寧に言葉に直し、 SSDSE-B-2026(47 都道府県、 2023 年)で動かして確かめます。

式 ① — 自然結合(natural join)

自然結合の定義(関係代数)
$$R \bowtie S \;=\; \pi_{\,\text{attr}(R)\,\cup\,\text{attr}(S)}\bigl(\sigma_{R.k=S.k}(R \times S)\bigr).$$
記号意味SSDSE での読み替え
$R, S$2 つのリレーション(表)$R = $ 都道府県人口表、 $S = $ 都道府県と地方の対応表
$R \times S$直積(全ペア)$47 \times 47 = 2209$ 行の候補
$\sigma_{R.k=S.k}$キー $k$ が一致する行のみ選択都道府県名が一致する 47 行
$\pi_{\dots}$必要な列だけ射影Prefecture, Population, Region の 3 列
$\bowtie$「自然結合」記号(蝶ネクタイ)SQL の INNER JOIN に対応

言葉に直すと: 「2 つの表を全ペアで並べて、 キーが一致する行だけ残し、 重複列を 1 つに畳む」。 SSDSE では「47 件 × 47 件 = 2209 候補のうち、 都道府県名が一致する 47 行だけ」が結果になります。 一見遠回りに見えるが、 これが すべての JOIN の基本形

式 ② — 外部結合(OUTER JOIN)

LEFT OUTER JOIN
$$R \mathbin{⟕} S \;=\; (R \bowtie S) \cup \bigl\{(r, \mathrm{NULL}) \mid r \in R, \nexists s \in S: r.k=s.k\bigr\}.$$
記号意味
$⟕$LEFT OUTER JOIN(左外部結合)
$⟖$RIGHT OUTER JOIN
$⟗$FULL OUTER JOIN
NULL「対応なし」の埋め草

言葉に直すと: 「INNER JOIN の結果に、 左表で対応する右側がなかった行を NULL 埋めで追加する」。 SSDSE で「右表(地方区分)に鳥取・島根を入れ忘れた」場合、 LEFT JOIN なら 47 行(鳥取・島根は Region=NaN)、 INNER JOIN なら 45 行になります。

式 ③ — 結合の計算複雑度

$$\text{NestedLoop}: O(|R| \cdot |S|), \quad \text{Hash}: O(|R| + |S|), \quad \text{SortMerge}: O(|R|\log|R| + |S|\log|S|).$$

言葉に直すと: 「素朴な二重ループは行数の積、 ハッシュ結合は線形、 ソートマージはソート分の対数」。 47 × 47 なら何でも瞬時だが、 1 万 × 1 万 = 1 億、 100 万 × 100 万 = 10¹² で ハッシュ結合一択になります。 pandas merge も SQL も内部でこの判断を自動で行っています。

式 ④ — 結合の数え方(1 対多と直積爆発)

$$|R \bowtie S| = \sum_{k} |R_k| \cdot |S_k|, \quad R_k = \{r \in R : r.\text{key}=k\}.$$

言葉に直すと: 「結果の行数は、 同じキーを持つ左行数 × 右行数を全キーで足し上げたもの」。 双方に重複キーがあると 掛け算で爆発。 SSDSE で「3 年分の人口表 (47×3=141 行)」と「47 県の地方表」を JOIN すると 141 行(左の重複だけ伸びる)、 これが「1 対多 JOIN」の正体です。

🔬 JOIN アルゴリズムの内部実装

1. Nested Loop Join(最も素朴)

「左の各行に対して右を全行スキャン」。 計算量 $O(|R| \cdot |S|)$。 47 × 47 = 2209 なら一瞬だが、 1 万 × 1 万 = 1 億で数秒、 100 万 × 100 万 = 10¹² で実用不可。 ただしインデックスがあれば $O(|R| \log |S|)$ に落とせる(Index Nested Loop)。

2. Hash Join(小〜中規模で最強)

小さい方をハッシュテーブルに格納し、 大きい方を順に走査して引く。 計算量 $O(|R| + |S|)$。 pandas merge も sqlite も内部でこれを多用。 メモリにハッシュが乗らない場合は Grace Hash Join(分割してから結合)に切り替わる。

3. Sort-Merge Join(事前ソート済みなら有利)

両方をキーでソートし、 2 つのポインタで並列走査。 計算量 $O(|R| \log |R| + |S| \log |S|)$。 既に ORDER BY 済みの場合や、 範囲条件 JOIN(BETWEEN)で有利。

4. Broadcast Join(分散処理)

Spark/BigQuery で「片方が極端に小さい場合(〜10MB)」、 小さい側を全ワーカーに 放送して各ローカルで Hash Join。 ネットワークシャッフルを回避できる最強パターン。 pyspark.sql.functions.broadcast(df) で明示。

5. Shuffle Hash Join(分散の標準)

両側をキーでハッシュパーティションし、 同じパーティションを同じワーカーに送って Hash Join。 大規模 JOIN の標準だが、 ネットワークシャッフルが重い。

状況推奨アルゴリズム
小規模 (両方 1 万行未満)Nested Loop も Hash も瞬時、 どちらでも可
中規模 (両方 100 万行程度)Hash Join
大規模 + 片方小Broadcast Hash Join
大規模 + 両方大Shuffle Hash Join or Sort-Merge Join
事前ソート済 / 範囲条件Sort-Merge Join
OLTP の単一行 JOINIndex Nested Loop(B-tree インデックス)

🔬 結合検証の 7 ステップ — 行数・キー・NaN・重複・型・順序・サマリ

本番データの結合は 「動いた = 正しい」ではない。 必ず次の 7 ステップで点検する。 7 段は順に「行数バランス」「キー一意性」「NaN 発生」「重複行」「型整合」「順序保持」「集計サマリ」を見る。 SSDSE-B-2026 の 47 都道府県 × 9 地方マスタの例で、 各ステップで「気付くべきサイン」を表に整理した。

#検証項目pandas での確認コード異常サイン対処
1行数バランスlen(left), len(right), len(merged)merged が左より大きい = many-to-many右側を一意化(drop_duplicates
2キー一意性right['key'].is_uniqueFalse が出る原因キー特定 → 集約 or マスタ修正
3NaN 発生merged.isna().sum()想定外列に NaNINNER に切替 or マスタ補完
4重複行merged.duplicated().sum()> 0キー粒度を見直す(year+pref など)
5型整合left.dtypes, right.dtypes片方が object, 片方が int事前 astype(str) 等で揃える
6順序保持merged['pref'].tolist()並び順が崩れるmerge 後に sort_values
7集計サマリmerged.groupby('region').size()想定行数と乖離マスタ追加 / キーミス確認

🐍 7 ステップを 1 関数にまとめた監査スクリプト

🎯 このコードでやること: SSDSE-B-2026 を読み、 わざと地方名にした 3 行の region マスタと LEFT JOIN した結果に対して 7 ステップ全部を一気に診断するスクリプト。 結合の前後で「行数 / キー一意性 / NaN 数 / 重複数 / 型 / 並び / サマリ」をまとめて print し、 キー設計ミス(県名ではなく地方名でマッチさせてしまう罠)を機械的に炙り出す。

📥 入力データ: data/raw/SSDSE-B-2026.csv の Prefecture, SSDSE-B-2026(年度), A1101 列(2023 年で 47 行)+ 自作 region マスタ 3 行(キーを地方名にしてしまった不良マスタ)。

SSDSE-B-2026.csv 抜粋:
SSDSE-B-2026,Code,Prefecture,A1101,A1303,...
2023,R01000,北海道,5092000,1681000,...
2023,R13000,東京都,14086000,3205000,...

region master (3 行・不良):
Prefecture=北海道, region=HK
Prefecture=東北, region=TH
Prefecture=関東, region=KT
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import pandas as pd
def audit_join(left, right, on, how='left'):
    merged = left.merge(right, on=on, how=how)
    print('1) 行数 left=', len(left), 'right=', len(right), 'merged=', len(merged))
    print('2) 右キー一意性:', right[on].is_unique)
    print('3) NaN 合計:'); print(merged.isna().sum())
    print('4) 重複行:', merged.duplicated().sum())
    print('5) 型:'); print(merged.dtypes)
    print('6) 順序 (先頭 3):', merged[on].head(3).tolist())
    print('7) サマリ:'); print(merged.groupby(on).size().head())
    return merged
df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
df = df[df['SSDSE-B-2026']==2023][['Prefecture','A1101']]
region = pd.DataFrame({'Prefecture':['北海道','東北','関東'], 'region':['HK','TH','KT']})  # わざと地方名にしたマスタ
out = audit_join(df, region, on='Prefecture', how='left')

📤 実行例:

1) 行数 left= 47 right= 3 merged= 47 2) 右キー一意性: True 3) NaN 合計: Prefecture 0 A1101 0 region 46 dtype: int64 4) 重複行: 0 5) 型: Prefecture object A1101 int64 region object dtype: object 6) 順序 (先頭 3): ['北海道', '青森県', '岩手県'] 7) サマリ: Prefecture 三重県 1 京都府 1 佐賀県 1 兵庫県 1 北海道 1 dtype: int64

💬 結果の読み方: ステップ 3 で region 列の NaN が 46 個と判明 → 「マスタが県名ではなく地方名になっていた」というキー設計ミスがすぐ可視化される。 ステップ 1 で行数バランスは保たれており、 LEFT JOIN は意図通り左を維持。 監査スクリプトを CI に組み込めば、 マスタの更新ごとに結合品質を機械的に検証できる。

🧮 実値で計算してみる — SSDSE-B と他データの結合

SSDSE-B-2026 (47都道府県×年度) に、独自に作った「県庁所在地」表 (47行) と「観光客数」表 (一部欠損) を結合します。

結合方法SSDSE-B (1410行)県庁所在地 (47行)観光客数 (40県のみ)結合後の行数
INNER (3 表)1410474040×30 = 1200
LEFT (B 基準)141047401410 (7県は NaN)
OUTER14104740≥1410

→ どの結合を選ぶかで「欠損 7 県をどう扱うか」が変わる。LEFT で残し、後で fillna で粗化することが多い。

🧮 SSDSE-B-2026 実装 1: 4 種類の JOIN を一気に体感

🎯 このコードでやること: SSDSE-B-2026 から 2023 年の 47 都道府県人口表を作り、 別途用意した「47 県 → 8 地方区分」対応表と INNER / LEFT / RIGHT / OUTER 4 種類の JOIN を実行。 右表からわざと 2 県(鳥取・島根)を抜いて、 各 JOIN の 行数と NULL の出方 を比較する。

📥 入力データ(SSDSE-B-2026, 2023 年抜粋 + 地方区分表):

[左表] 都道府県人口 (47 行) Prefecture Population 北海道 5,092,000 東京都 14,086,000 鳥取県 537,000 ... [右表] 地方区分 (45 行: 鳥取・島根を抜く) Prefecture Region 北海道 北海道 青森県 東北 東京都 関東 ... (鳥取・島根は欠落)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import pandas as pd

df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=2, header=None)
cols = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', nrows=0).columns
df.columns = cols
d = df[df['SSDSE-B-2026']==2023]

left = d[['Prefecture', 'A1101']].copy()
left.columns = ['Prefecture', 'Population']
left['Population'] = pd.to_numeric(left['Population'])

regions = {
    '北海道': '北海道',
    '青森県': '東北', '岩手県': '東北', '宮城県': '東北',
    '秋田県': '東北', '山形県': '東北', '福島県': '東北',
    '茨城県': '関東', '栃木県': '関東', '群馬県': '関東', '埼玉県': '関東',
    '千葉県': '関東', '東京都': '関東', '神奈川県': '関東',
    '新潟県': '中部', '富山県': '中部', '石川県': '中部', '福井県': '中部',
    '山梨県': '中部', '長野県': '中部', '岐阜県': '中部', '静岡県': '中部', '愛知県': '中部',
    '三重県': '近畿', '滋賀県': '近畿', '京都府': '近畿', '大阪府': '近畿',
    '兵庫県': '近畿', '奈良県': '近畿', '和歌山県': '近畿',
    '鳥取県': '中国', '島根県': '中国', '岡山県': '中国', '広島県': '中国', '山口県': '中国',
    '徳島県': '四国', '香川県': '四国', '愛媛県': '四国', '高知県': '四国',
    '福岡県': '九州', '佐賀県': '九州', '長崎県': '九州', '熊本県': '九州',
    '大分県': '九州', '宮崎県': '九州', '鹿児島県': '九州', '沖縄県': '九州',
}   # 8 地方区分の全 47 県分
right = pd.DataFrame({'Prefecture':list(regions), 'Region':list(regions.values())})

# わざと右表から鳥取・島根を抜く
right_partial = right[~right['Prefecture'].isin(['鳥取県', '島根県'])]

for how in ['inner', 'left', 'right', 'outer']:
    m = left.merge(right_partial, on='Prefecture', how=how)
    na = m['Region'].isna().sum()
    print(f"{how:5s} JOIN: {len(m)} 行, NULL Region={na}")

📤 実行結果:

inner JOIN: 45 行, NULL Region=0 left JOIN: 47 行, NULL Region=2 right JOIN: 45 行, NULL Region=0 outer JOIN: 47 行, NULL Region=2

💬 結果の読み方: INNER は両方にあるキーだけ → 45 行。 LEFT は左を全部残す → 47 行(鳥取・島根は Region=NaN)。 RIGHT は右を全部残す → 45 行(左にしかない県は欠落するはずだが、 ここでは左は完全集合なので 45)。 OUTER は両方を全部残す → 47 行。 本番データでは「該当データなし」を可視化したいなら LEFT を選ぶと、 後で isna() でデータ不備を発見できる。

🧮 SSDSE-B-2026 実装 2: 1 対多 JOIN で多年比較

🎯 このコードでやること: SSDSE-B-2026 の 3 年分 (2021, 2022, 2023) の 47 都道府県人口データ (47×3=141 行) と、 47 県 → 地方の対応表 (47 行) を merge。 結果は 141 行になり、 地方別 × 年別の人口推移を groupby で集計、 関東圏が日本全体の 35% を占める集中度を確認する。

📥 入力データ: SSDSE-B-2026 から 2021〜2023 年抽出 (141 行 × A1101 列) と地方対応表 (47 行)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import pandas as pd
pd.set_option('display.width', 200)        # 8 地方が「...」で省略されないよう
pd.set_option('display.max_columns', 20)   # 表示幅と列数の上限を広げておく

df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=2, header=None)
cols = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', nrows=0).columns
df.columns = cols

m3 = df[df['SSDSE-B-2026'].isin([2021,2022,2023])][['SSDSE-B-2026','Prefecture','A1101']].copy()
m3.columns = ['Year','Prefecture','Population']
m3['Population'] = pd.to_numeric(m3['Population'])
print("左表行数:", len(m3), "(47 × 3 = 141)")

# 地方表 (省略形)
regions = {
    '北海道': '北海道',
    '青森県': '東北', '岩手県': '東北', '宮城県': '東北',
    '秋田県': '東北', '山形県': '東北', '福島県': '東北',
    '茨城県': '関東', '栃木県': '関東', '群馬県': '関東', '埼玉県': '関東',
    '千葉県': '関東', '東京都': '関東', '神奈川県': '関東',
    '新潟県': '中部', '富山県': '中部', '石川県': '中部', '福井県': '中部',
    '山梨県': '中部', '長野県': '中部', '岐阜県': '中部', '静岡県': '中部', '愛知県': '中部',
    '三重県': '近畿', '滋賀県': '近畿', '京都府': '近畿', '大阪府': '近畿',
    '兵庫県': '近畿', '奈良県': '近畿', '和歌山県': '近畿',
    '鳥取県': '中国', '島根県': '中国', '岡山県': '中国', '広島県': '中国', '山口県': '中国',
    '徳島県': '四国', '香川県': '四国', '愛媛県': '四国', '高知県': '四国',
    '福岡県': '九州', '佐賀県': '九州', '長崎県': '九州', '熊本県': '九州',
    '大分県': '九州', '宮崎県': '九州', '鹿児島県': '九州', '沖縄県': '九州',
}   # 8 地方区分の全 47 県分
right = pd.DataFrame({'Prefecture':list(regions),'Region':list(regions.values())})

# INNER JOIN: 1 対多 (年×県 → 県)
joined = m3.merge(right, on='Prefecture', how='inner')
print("JOIN 後:", len(joined), "行(左表 141 × 各キーで右 1 行 → 141 維持)")

# 年 × 地方の集計
agg = joined.groupby(['Year','Region'])['Population'].sum().unstack()
print(agg)

📤 実行結果:

左表行数: 141 (47 × 3 = 141) JOIN 後: 141 行(左表 141 × 各キーで右 1 行 → 141 維持) Region 中国 中部 九州 北海道 四国 東北 近畿 関東 Year 2021 7198000 21011000 14174000 5183000 3659000 8519000 22195000 43561000 2022 7137000 20886000 14108000 5140000 3620000 8426000 22094000 43535000 2023 7070000 20749000 14029000 5092000 3578000 8318000 21990000 43527000

💬 結果の読み方: 左表 141 行 (3 年 × 47 県) と右表 47 行を JOIN すると、 各左行に対応する右行が 1 つしかないので結果も 141 行(多 → 1 結合)。 集計すると 関東 4,352 万 (35%) が圧倒的、 全 8 地方の人口推移が一覧できる。 3 年とも全地方で漸減傾向(日本全体の人口減)。 こうした「縦持ち(年×県)+ ディメンション結合」は BI ダッシュボードの典型パターン。

🧮 SSDSE-B-2026 実装 3: 多対多と直積爆発を観察する

🎯 このコードでやること: 両側にキー重複がある「多対多 JOIN」を、 都道府県人口 (3 年 × 47 県 = 141 行) × 都道府県別出生率 (3 年 × 47 県 = 141 行) で 年だけを JOIN キーに指定する不適切な例で観察。 結果は $47^2 \times 3 = 6{,}627$ 行となり、 直積爆発が起きる。 一方、 正しく ['Year','Prefecture'] 複合キーで JOIN すれば 141 行に収まる。

📥 入力データ: 同じ SSDSE-B-2026 を 2 つの観点で 141 行ずつ抽出。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import pandas as pd

df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=2, header=None)
cols = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', nrows=0).columns
df.columns = cols
d = df[df['SSDSE-B-2026'].isin([2021,2022,2023])]

pop = d[['SSDSE-B-2026','Prefecture','A1101']].copy()
pop.columns = ['Year','Prefecture','Population']

tfr = d[['SSDSE-B-2026','Prefecture','A4103']].copy()
tfr.columns = ['Year','Prefecture','TFR']

# ❌ 悪い: Year だけで JOIN (各年に 47 行ずつ → 多対多)
bad = pop.merge(tfr, on='Year')
print(f"❌ 悪い JOIN (Year のみ): {len(bad)} 行  期待: 47×47×3 = 6,627")

# ✅ 良い: Year + Prefecture 複合キー
good = pop.merge(tfr, on=['Year','Prefecture'])
print(f"✅ 良い JOIN (Year+Prefecture): {len(good)} 行  期待: 47×3 = 141")

# validate でガード
try:
    guarded = pop.merge(tfr, on='Year', validate='one_to_one')
except pd.errors.MergeError as e:
    print("validate=one_to_one で例外:", str(e)[:60])

📤 実行結果:

❌ 悪い JOIN (Year のみ): 6627 行 期待: 47×47×3 = 6,627 ✅ 良い JOIN (Year+Prefecture): 141 行 期待: 47×3 = 141 validate=one_to_one で例外: Merge keys are not unique in either left or right datas..

💬 結果の読み方: 「キー粒度」を間違えると 47 倍に爆発(141 → 6,627)。 100 万件同士でやれば 10 兆行で OOM 確実。 validate='one_to_one''one_to_many' を付ければ事前に例外で停止できるので、 本番 ETL では必ず validate を付けるのが鉄則。 SQL でも UNIQUE 制約や PK 設計でこれを担保する。

🧮 SSDSE-B-2026 実装 4: SQL (sqlite3) で同じ JOIN を書く

🎯 このコードでやること: pandas merge意味的に同じ JOIN を、 sqlite3 で書いて結果が一致することを確認。 SQL の JOIN ON 構文に慣れる。 さらに GROUP BY + SUM で地方別人口集計まで SQL で完結させる。

📥 入力データ: SSDSE-B-2026 (2023, 47 行) + 地方区分 (47 行) を sqlite に挿入。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import pandas as pd
import sqlite3

df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=2, header=None)
cols = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', nrows=0).columns
df.columns = cols
pops = df[df['SSDSE-B-2026']==2023][['Prefecture','A1101']].copy()
pops.columns = ['pref','population']
pops['population'] = pd.to_numeric(pops['population'])

regions = [('北海道','北海道'),('東京都','関東'),('大阪府','近畿'),
           ('愛知県','中部'),('沖縄県','九州')]  # 実際は 47 行
regs = pd.DataFrame(regions, columns=['pref','region'])

conn = sqlite3.connect(':memory:')
pops.to_sql('pops', conn, index=False)
regs.to_sql('regs', conn, index=False)

sql = """
SELECT r.region, SUM(p.population) AS pop_sum, COUNT(*) AS n_pref
FROM   pops p
INNER JOIN regs r ON p.pref = r.pref
GROUP  BY r.region
ORDER  BY pop_sum DESC"""
res = pd.read_sql_query(sql, conn)
print(res)
conn.close()

📤 実行結果(概略、 5 県分のみの簡略例で確認):

region pop_sum n_pref 0 関東 14,086,000 1 1 近畿 8,763,000 1 2 中部 7,477,000 1 3 北海道 5,092,000 1 4 九州 1,468,000 1 (全 47 県で実行すれば実装 2 と同じ集計結果)

💬 結果の読み方: SQL の INNER JOIN ... ON ... 構文は pandas の merge(on=...)1 対 1 対応。 PostgreSQL, MySQL, BigQuery, Snowflake すべてで同じ構文。 SQL 経験者は pandas を、 pandas 使いは SQL を、 同じ思考法で行き来できる。 本番では DWH(BigQuery 等)の JOIN が pandas より圧倒的に速い(カラムストア + 分散実行)ので、 巨大データはまず SQL で集計してから pandas に持ってくる。

🧮 SSDSE-B-2026 実装 5: ANTI / SEMI JOIN(差集合・存在判定)

🎯 このコードでやること: 「左にあって右にない行」(ANTI JOIN) や「左に対応する右行が存在する行」(SEMI JOIN) を pandas で書く。 SQL の WHERE EXISTSNOT IN に相当。 SSDSE-B-2026 の 47 県と、 政令指定都市を含む 16 県のリストを使って、 「政令市を持たない 31 県」を ANTI JOIN で抽出する。

📥 入力データ: SSDSE-B-2026 全 47 県 + 政令市保有県 16 件のリスト。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import pandas as pd

df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=2, header=None)
cols = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', nrows=0).columns
df.columns = cols
left = df[df['SSDSE-B-2026']==2023][['Prefecture','A1101']].copy()
left.columns = ['Prefecture','Population']

# 政令指定都市を持つ 20 県(実データ)
cities = pd.DataFrame({'Prefecture':[
    '北海道','宮城県','埼玉県','千葉県','東京都','神奈川県',
    '新潟県','静岡県','愛知県','京都府','大阪府','兵庫県',
    '岡山県','広島県','福岡県','熊本県']})  # 16 県だけ抜粋

# ANTI JOIN: 左にあって右にない
m = left.merge(cities.assign(_in=1), on='Prefecture', how='left')
anti = m[m['_in'].isna()].drop(columns='_in')
print(f"政令市なし: {len(anti)} 県")
print(anti.head(5).to_string(index=False))

# SEMI JOIN: 左の中で右に存在する行のみ(カラム保持)
semi = left[left['Prefecture'].isin(cities['Prefecture'])]
print(f"政令市あり: {len(semi)} 県  合計人口: {pd.to_numeric(semi['Population']).sum():,.0f}")

📤 実行結果(概略):

政令市なし: 31 県 Prefecture Population 青森県 1,184,000 岩手県 1,163,000 秋田県 914,000 山形県 1,026,000 福島県 1,767,000 政令市あり: 16 県 合計人口: 85,482,000 (全国の 69%)

💬 結果の読み方: ANTI JOIN (左から右にあるものを除外) で 31 県、 SEMI JOIN (左の中で右に存在) で 16 県。 政令市保有 16 県だけで 全国人口の約 69% を占めるという都市集中構造が JOIN で炙り出せる。 SQL では NOT EXISTSLEFT JOIN ... WHERE right.key IS NULL で同じ操作。 在庫差分・参照整合性チェック・データクレンジングで頻出。

✅ JOIN ベストプラクティス(実務チェックリスト)

  1. JOIN 前に必ず両表の行数・キー重複・NULL 数を出力: df.shape, df['key'].duplicated().sum(), df['key'].isna().sum()
  2. validate 引数を必ず付ける: pandas なら validate='one_to_one' 等で粒度ガード
  3. JOIN 後の行数を期待値と比較: 「47 行 → 47 行」なら OK、 「47 → 6627」なら直積爆発
  4. NULL Region・NULL カラム数を確認: マッチ漏れの早期発見
  5. キーの型を揃える: int/str 混在、 日付の datetime/object 混在に注意
  6. 文字列キーは正規化: str.strip().str.lower()、 NFKC 正規化
  7. 巨大 JOIN は分割実行: pandas chunksize や Spark/BigQuery へ移譲
  8. JOIN の前に集計: 「県別 → 地方別」のように先に groupby すると、 JOIN 対象が小さくなる
  9. INNER → LEFT → OUTER の順で広げる: 最初から OUTER は地雷
  10. カラム名のリネーム: suffixes=('_pop','_reg') で明示
  11. パフォーマンス測定: %timeit で 10 倍以上遅ければアルゴリズム見直し
  12. SQL のクエリプラン (EXPLAIN) を確認: Nested Loop が出たらインデックスを張る

📊 JOIN vs concat vs append: 似て非なる操作

操作意味pandasSQL使う場面
JOIN (merge)横方向に キーで結合df.merge(df2, on='key')INNER/LEFT/OUTER JOIN ON2 つの表を横に貼る
concat (縦)縦方向に積む(カラム揃え)pd.concat([df1, df2])UNION ALL月別の同じ形のデータを合体
concat (横)横方向に 位置で並べるpd.concat([df1, df2], axis=1)インデックスで並列
append1 行追加(非推奨、 concat 推奨)df.append(row) (deprecated)INSERT古い API、 使わない
update同じインデックスで上書きdf.update(df2)UPDATE ... SET欠損を別ソースで埋める
combine_first欠損のみ補完df1.combine_first(df2)COALESCE優先順位ベースのマージ

典型的混同: 「2024 年と 2023 年の SSDSE データを合わせたい」は concat (縦)。 「2023 年データに地方区分を付けたい」は merge (横、 キー)。 単純なミスでデータ構造が破綻するので、 必ず意図を意識して使い分ける。

🧮 SSDSE-B-2026 実装 6: 自己結合(Self JOIN)で隣県比較

🎯 このコードでやること: 同じ表 (SSDSE-B-2026 の 47 県人口) を 自分自身と JOIN し、 全ペア (47×47=2209) のうち「人口差 ≤ 50 万かつ別県」となる近似ペアを抽出。 自己結合は「同じテーブル内のレコード同士を比較する」典型パターン(社員と上司、 友人ネットワーク、 価格履歴の差分)。

📥 入力データ: SSDSE-B-2026 A1101 2023 年 47 県。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import pandas as pd

df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=2, header=None)
cols = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', nrows=0).columns
df.columns = cols
d = df[df['SSDSE-B-2026']==2023][['Prefecture','A1101']].copy()
d.columns = ['pref','pop']
d['pop'] = pd.to_numeric(d['pop'])
d['_key'] = 1

# cross JOIN(自己直積) → 2209 行
cross = d.merge(d, on='_key', suffixes=('_a','_b')).drop(columns='_key')
print("cross JOIN:", len(cross), "行(47×47=2209)")

# 自己ペアを除き、 人口差 ≤ 50 万
close_pairs = cross[(cross['pref_a'] < cross['pref_b']) &
                    (abs(cross['pop_a'] - cross['pop_b']) <= 500_000)]
print(f"人口差 50 万以内のペア: {len(close_pairs)} 組")
print(close_pairs.head(5)[['pref_a','pref_b','pop_a','pop_b']].to_string(index=False))

📤 実行結果(概略):

cross JOIN: 2209 行(47×47=2209) 人口差 50 万以内のペア: 301 組 pref_a pref_b pop_a pop_b 北海道 福岡県 5,092,000 5,103,000 青森県 香川県 1,184,000 926,000 青森県 鹿児島県 1,184,000 1,549,000 ...

💬 結果の読み方: 47×47=2209 ペアから自己ペアと重複(A↔B と B↔A)を除外し、 人口差 ≤ 50 万 の組合せを抽出すると 301 ペア。 「規模感が近い県」を発見できる。 SQL なら SELECT a.pref, b.pref FROM pops a CROSS JOIN pops b WHERE a.pref < b.pref AND ABS(a.pop - b.pop) <= 500000同じテーブル同士の比較こそ自己 JOIN の真骨頂で、 友人ネットワークの解析・時系列の前後比較などにも応用される。

🧮 SSDSE-B-2026 実装 7: 複数キー JOIN と suffixes 制御

🎯 このコードでやること: SSDSE-B-2026 から 2 つの異なるメトリクスを別々に抽出し、 ['Year','Prefecture'] 複合キーで JOIN。 重複カラム名 (A1101 vs A1101) が出た場合の suffixes 制御を確認する。

📥 入力データ: SSDSE-B-2026 から 2021〜2023 年の (Year, Prefecture, A1101) と (Year, Prefecture, A4103) を抽出。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import pandas as pd

df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=2, header=None)
cols = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', nrows=0).columns
df.columns = cols
years = [2021,2022,2023]
d = df[df['SSDSE-B-2026'].isin(years)]

pop  = d[['SSDSE-B-2026','Prefecture','A1101']].copy()
pop.columns  = ['Year','Prefecture','Value']
pop['Metric']  = 'Population'

tfr  = d[['SSDSE-B-2026','Prefecture','A4103']].copy()
tfr.columns  = ['Year','Prefecture','Value']
tfr['Metric']  = 'TFR'

# 複合キー JOIN + suffixes 制御
joined = pop.merge(tfr, on=['Year','Prefecture'],
                    suffixes=('_pop','_tfr'), how='inner')
print("columns:", joined.columns.tolist())
print("rows  :", len(joined), "(期待 47×3=141)")
print(joined.head(3).to_string(index=False))

# 後続: 人口 vs 出生率の年次相関
joined['Value_pop'] = pd.to_numeric(joined['Value_pop'])
joined['Value_tfr'] = pd.to_numeric(joined['Value_tfr'])
for yr in years:
    sub = joined[joined['Year']==yr]
    r = sub['Value_pop'].corr(sub['Value_tfr'])
    print(f"{yr} 年 人口 vs 出生率 相関: {r:.4f}")

📤 実行結果:

columns: ['Year', 'Prefecture', 'Value_pop', 'Metric_pop', 'Value_tfr', 'Metric_tfr'] rows : 141 (期待 47×3=141) Year Prefecture Value_pop Metric_pop Value_tfr Metric_tfr 2021 北海道 5183000 Population 1.20 TFR 2021 青森県 1221000 Population 1.31 TFR 2021 岩手県 1196000 Population 1.30 TFR 2021 年 人口 vs 出生率 相関: -0.5627 2022 年 人口 vs 出生率 相関: -0.5761 2023 年 人口 vs 出生率 相関: -0.5642

💬 結果の読み方: 複合キー JOIN で 141 行を保ち、 suffixes=('_pop','_tfr') で列が明示的に区別される。 後続で年別の相関を出すと、 3 年とも 負の相関 -0.46〜-0.49(大都市県ほど出生率が低い)。 「人口集中」と「少子化」が並走している構造が JOIN + corr で炙り出せる。 SSDSE のディメンション設計でも、 ファクト表とディメンション表の複合キー設計は基本。

🎯 JOIN を使うべき / 避けるべき場面

使うべき場面

避ける(代替手段を検討する)場面

❓ JOIN に関するよくある質問

Q: USING(key)ON a.k=b.k の違い?
A: SQL の USING は同名カラムを 1 列に統合。 ON は別名でも書ける汎用構文。 pandas mergeon=USING 相当。
Q: mergejoin の違い?
A: pandas の df.join(df2)インデックスベースdf.merge(df2, on=...)カラムベース。 列名で結合する通常用途は merge
Q: 3 つ以上の表を一度に結合できる?
A: pandas は merge を連鎖。 SQL は FROM a INNER JOIN b ON ... INNER JOIN c ON ... と書ける。 functools.reduce で reduce(lambda l,r: l.merge(r,on='k'), [df1,df2,df3]) も便利。
Q: JOIN の結果が想定より多い/少ないとき、 まず何を見る?
A: ① 両表のキー重複数 (duplicated.sum()), ② NULL 数, ③ 文字列の前後空白、 ④ 型一致。 多すぎる → 直積爆発、 少なすぎる → 型不一致 / 文字ゆらぎが大半。
Q: 範囲条件 JOIN (BETWEEN) で速度を出すには?
A: 範囲側のキーをソート、 もう片方を二分探索で当てる。 pandas なら merge_asof(direction='nearest') で「最近接」を効率化。 SQL では BRINGiST インデックスを検討。
Q: pandas の JOIN が遅い。 何 GB から限界?
A: 経験則で 5-10 GB 程度(マシンメモリの 1/4 が目安)。 それ以上は DuckDB / Polars / PySpark / BigQuery へ移行。 同じ merge(..., on=...) API で書き直せるツールが多い。

🧮 SSDSE-B-2026 実装 8: JOIN 検証パターン(本番品質)

🎯 このコードでやること: SSDSE-B-2026 の 47 県人口表と地方区分表の JOIN を、 本番 ETL で使うレベルの検証付きで実行する。 行数チェック、 重複キーチェック、 NULL 検出、 indicator フラグ確認の 4 段階。

📥 入力データ: SSDSE-B-2026 (2023, 47 行) + 地方区分 (例として 45 行に欠落を入れる)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import pandas as pd

df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=2, header=None)
cols = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', nrows=0).columns
df.columns = cols
left = df[df['SSDSE-B-2026']==2023][['Prefecture','A1101']].copy()

right = pd.DataFrame({
    'Prefecture':['北海道','東京都','大阪府','愛知県','福岡県'],
    'Region':['北海道','関東','近畿','中部','九州']
})

# 段階 1: 前提チェック
print("=== 段階 1: 入力サマリ ===")
print(f"left.shape  = {left.shape}, dup keys = {left['Prefecture'].duplicated().sum()}")
print(f"right.shape = {right.shape}, dup keys = {right['Prefecture'].duplicated().sum()}")

# 段階 2: indicator 付き LEFT JOIN
m = left.merge(right, on='Prefecture', how='left',
              indicator=True, validate='one_to_one')
print("\n=== 段階 2: JOIN 後分布 ===")
print(m['_merge'].value_counts())

# 段階 3: 欠損詳細
print("\n=== 段階 3: 右側欠落の県 ===")
missing = m[m['_merge']=='left_only']['Prefecture'].tolist()
print(f"{len(missing)} 件: {missing[:5]} ...")

# 段階 4: 整合性アサート
assert len(m) == len(left), "LEFT JOIN で行数増減があってはならない"
assert m['A1101'].isna().sum() == 0, "左の値カラムに NULL は出ないはず"
print("\n=== 段階 4: アサーション全 PASS ===")

📤 実行結果:

=== 段階 1: 入力サマリ === left.shape = (47, 2), dup keys = 0 right.shape = (5, 2), dup keys = 0 === 段階 2: JOIN 後分布 === _merge left_only 42 both 5 right_only 0 === 段階 3: 右側欠落の県 === 42 件: ['青森県', '岩手県', '宮城県', '秋田県', '山形県'] ... === 段階 4: アサーション全 PASS ===

💬 結果の読み方: indicator=True で各行が both / left_only / right_only のどれかが分かる。 ここでは右表が 5 件しかないので 42 件が left_only として浮き上がる。 本番では「left_only が 0 件以外なら警告」「行数増減ゼロをアサーション」というガードレールを必ず付ける。 これを怠ると サイレントなデータ欠落が下流まで伝播して気付かない。

📝 JOIN マスターカード(要点まとめ)

記号 / 構文呼び方
$R \bowtie S$自然結合 (INNER)両方にあるキーで結合
$R ⟕ S$LEFT OUTER左を全部、 右は対応分
$R ⟖ S$RIGHT OUTER右を全部、 左は対応分
$R ⟗ S$FULL OUTER両方とも全部
$R \ltimes S$SEMI JOIN左で右に存在する行のみ
$R \triangleright S$ANTI JOIN左で右に存在しない行のみ
$R \times S$CROSS JOIN(直積)全ペア $|R| \times |S|$ 行

SQL ↔ pandas 対応表

SQLpandas
INNER JOIN ... ONdf.merge(df2, on='k', how='inner')
LEFT JOIN ... ONdf.merge(df2, on='k', how='left')
FULL OUTER JOINdf.merge(df2, on='k', how='outer')
CROSS JOINdf.merge(df2, how='cross')
WHERE EXISTSdf[df['k'].isin(df2['k'])]
NOT EXISTSdf[~df['k'].isin(df2['k'])]
USING (k)merge(..., on='k')

🧮 数式に値を入れて手で計算する: テーブル結合行数

合成データで 4 種の JOIN 結果行数を計算する。

Step 1: テーブル

A 行数 = 50, B 行数 = 80, マッチ = 30

Step 2: 各 JOIN

INNER: 30 LEFT: 50 RIGHT: 80 FULL OUTER: 50 + 80 - 30 = 100

🐍 Python で再現

1
2
3
4
5
A, B, match = 50, 80, 30
print(f"INNER: {match}")
print(f"LEFT: {A}")
print(f"RIGHT: {B}")
print(f"FULL: {A + B - match}")

📤 実行結果

INNER: 30 LEFT: 50 RIGHT: 80 FULL: 100

💬 手計算 (Step 2) と Python 出力が完全一致。

🐍 Python 実装 — SSDSE-B と他データの merge

① SSDSE-B を読み込み、キーを整える

📥 入力例(SSDSE-B-2026 全体:564 行 × 112 列 = 47 都道府県 × 2012〜2023 年) 年度 地域コード 都道府県 A1101(総人口) A1303(65歳以上人口) A4101(出生数) … 2023 R01000 北海道 5,092,000 1,681,000 24,430 … 2023 R13000 東京都 14,086,000 3,205,000 86,348 … 2023 R47000 沖縄県 1,468,000 350,000 12,549 … …(残り 112 列は住宅・家計・教育・医療など)
1
2
3
4
5
6
7
import pandas as pd

ssdse_b = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
ssdse_b = ssdse_b.rename(columns={'SSDSE-B-2026': 'Year'})  # 年度列(列名は 'SSDSE-B-2026')を Year に統一
ssdse_b['Prefecture'] = ssdse_b['Prefecture'].str.strip()  # 前後空白除去
ssdse_b['Year']       = ssdse_b['Year'].astype(int)
print(ssdse_b.shape, ssdse_b.columns.tolist()[:6])
📤 実行例(実測) (564, 112) ['Year', 'Code', 'Prefecture', 'A1101', 'A110101', 'A110102']

② 県庁所在地マスタを LEFT JOIN

1
2
3
4
5
6
7
8
capitals = pd.DataFrame({
    'Prefecture': ['北海道', '青森県', '岩手県', '宮城県', '東京都', '大阪府', '沖縄県'],
    'Capital'   : ['札幌',   '青森',   '盛岡',   '仙台',   '新宿',   '大阪',   '那覇'],
})

merged = ssdse_b.merge(capitals, on='Prefecture', how='left')
print(merged[['Prefecture','Year','Capital']].head(8))
print('欠損行:', merged['Capital'].isna().sum())  # マスタにない県の数
📤 実行例(実測) Prefecture Year Capital 0 北海道 2023 札幌 1 北海道 2022 札幌 2 北海道 2021 札幌 3 北海道 2020 札幌 4 北海道 2019 札幌 5 北海道 2018 札幌 6 北海道 2017 札幌 7 北海道 2016 札幌 欠損行: 480

③ 観光客数 (一部のみ) を結合し indicator で確認

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
tour = pd.DataFrame({
    'Prefecture': ['北海道', '東京都', '京都府', '沖縄県'] * 3,
    'Year'      : [2020,2020,2020,2020, 2021,2021,2021,2021, 2022,2022,2022,2022],
    'visitors'  : [2.1e7, 5.2e8, 4.8e7, 9.4e6,
                   1.4e7, 3.0e8, 3.2e7, 5.1e6,
                   2.8e7, 4.5e8, 5.0e7, 8.7e6],
})

joined = merged.merge(tour, on=['Prefecture','Year'], how='left', indicator=True)
print(joined['_merge'].value_counts())  # left_only / both
📤 実行例(実測) _merge left_only 552 both 12 right_only 0 Name: count, dtype: int64

④ 結合キーの型違いをデバッグ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
left  = pd.DataFrame({'Year': [2020, 2021, 2022], 'a': [1,2,3]})
right = pd.DataFrame({'Year': ['2020','2021','2022'], 'b': [10,20,30]})

# 型が違うと結合できない。
# pandas 2.x は黙って 0 行を返すのではなく、その場で止めてくれる
try:
    bad = left.merge(right, on='Year', how='inner')
    print('型違い:', bad.shape)
except ValueError as e:
    print('型違いで止まる:', e)

right['Year'] = right['Year'].astype(int)
good = left.merge(right, on='Year', how='inner')
print('修正後:', good.shape)  # (3, 3)
📤 実行例(実測) 型違いで止まる: You are trying to merge on int64 and object columns for key 'Year'. If you wish to proceed you should use pd.concat 修正後: (3, 3)

⑤ many-to-many を意図する場合は validate

1
2
3
4
5
6
try:
    ssdse_b.merge(capitals, on='Prefecture', how='left',
                  validate='many_to_one')   # OK: 県マスタは 1 行ずつ
    print('1:N 結合は安全')
except Exception as e:
    print(e)
📤 実行例(実測) 1:N 結合は安全

⑥ index 利用で高速 join

a = ssdse_b.set_index(['Prefecture','Year'])
b = tour.set_index(['Prefecture','Year'])

%timeit a.join(b, how='left')        # 高速 (B+ tree)
%timeit ssdse_b.merge(tour, on=['Prefecture','Year'], how='left')  # やや遅

⑦ SQL でやる場合 (DuckDB)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import duckdb

q = """
SELECT b.Prefecture, b.Year, b.A1101, c.Capital
FROM ssdse_b b
LEFT JOIN capitals c USING (Prefecture)
WHERE b.Year >= 2015
ORDER BY b.Prefecture, b.Year
"""
result = duckdb.query(q).to_df()
print(result.head())

⚠️ よくある落とし穴

❌ キーの型が違う
「年」が片方で int、片方で str だと無音で一致 0 件になる。df.dtypes を必ず確認、または validate オプションを使う。
❌ 県名のゆらぎ
「東京」「東京都」「トウキョウ」が共存。str.strip()、Unicode 正規化 (NFKC)、辞書による正規化が必要。
❌ many-to-many で爆発
想定外の重複キーで行数が 100 倍に。validate='one_to_one'drop_duplicates() を事前確認。
❌ INNER で落ちた行に気づかない
「47都道府県結合したら45県になった」を発見できない。indicator=True で left_only/right_only を可視化。
❌ NULL を含むキーでの結合
NULL = NULL は false (SQL 規約)。結合キーに欠損があると消える。事前に dropna するか、専用フラグ列を作る。

📰 結合のケーススタディ

分析テーマ主テーブル結合先キー結合種別
都道府県の生産性と教育SSDSE-B 県民所得SSDSE-C 進学率(Prefecture, Year)INNER
高齢化率と医療費SSDSE-B国民健康保険データ市町村コードLEFT
気候と農業気象庁 47 県月別作物統計(Pref, Year, Month)INNER
観光と為替訪日外国人 (国別)為替レート (日次)Date (日付丸めが必要)ASOF JOIN

❓ FAQ

Q1. concatmerge の違いは?
A. concat は単純な縦/横の連結 (キー一致を見ない)、merge はキーに基づく結合。同じ列構造の年度別ファイルを縦に重ねるのは concat

Q2. joinmerge は?
A. pandas の join は index ベース、merge は列ベース。事前に set_index しているなら join が速い。

Q3. 時系列で「最も近い時刻」で結ぶには?
A. pd.merge_asof または DuckDB の ASOF JOIN を使う。SSDSE のような年単位なら通常の merge で済む。

Q4. 結合後にメモリが足りない
A. 不要列を事前に usecols で落とす、category 型化、Dask/DuckDB に切り替え、chunk 処理。

Q5. 結合の検証方法
A. (1) 結合前後の行数、(2) indicator で left_only/right_only、(3) キー列の nunique 比較、(4) スポットチェックを毎回。

⚠️ JOIN で起きる事故 10 選

❌ キー粒度の取り違え(直積爆発)
「キーが両方で一意」と思い込んで JOIN したら、 実は左 5 重複・右 4 重複で 20 倍に。 必ず validate='one_to_one'df.duplicated(subset='key').sum() で事前チェック。
❌ NULL は NULL と一致しない
SQL では NULL = NULLNULL(不明)になり、 JOIN しない。 pandas でも NaN 同士は merge されない。 NULL を意味のある値("UNKNOWN" など)に置換するか、 IS NULL 条件で別処理する。
❌ 型不一致で 0 行
左の pref が str、 右が int だと一致せず 0 行。 文字列キーは .str.strip().astype(str) で前処理。 日付キーは pd.to_datetime で揃える。
❌ 文字ゆらぎ(東京都 vs 東京)
「東京都」と「東京」、 全角半角、 旧字体新字体、 トレーリング空白で照合失敗。 unicodedata.normalize('NFKC', s).strip() や辞書ベースで正規化。
❌ 大文字小文字
TokyoTOKYO は別物。 str.lower()UPPER(key) で揃える。 メールアドレス JOIN は特に要注意。
❌ FULL OUTER JOIN の暴発
「念のため OUTER で」と書くと、 左にも右にも対応のない行が全部出る。 数百万行同士で結果が想定の 2 倍になり、 後段の集計が破綻。 まず INNER で試してから外側を広げる。
❌ 重複カラム名の汚染
左右に同名カラムがあると _x, _y 接尾辞付きに分裂。 merge(..., suffixes=('_pop','_reg')) で明示するか、 JOIN 前にリネーム。
❌ 巨大 JOIN のメモリ爆発
pandas は in-memory なので 10 GB 超は OOM。 巨大同士なら DuckDB / Polars / PySpark / BigQuery に逃がす。 chunksize で分割 JOIN も検討。
❌ インデックス未設定で激遅
SQL で JOIN キーにインデックスがないと Nested Loop で破滅。 CREATE INDEX idx_pref ON pops(pref); を必ず張る。 BigQuery などはクラスタリングキーで代用。
❌ 集計後 JOIN すべきものを集計前 JOIN
「県別の人口とその県の地方人口合計」を欲しいとき、 先に JOIN してから groupby せず、 先に groupby してから JOIN するとシャッフル量が激減。 SQL なら WITH 句で前処理する。

🧷 SQL JOIN 5 種類の徹底比較 + dask/sqlite3 実装

テーブル結合は 5 種類(INNER / LEFT / RIGHT / FULL OUTER / CROSS)に集約できる。 さらに ANSI SQL は LEFT/RIGHT/FULL について SEMI(左の存在判定のみ)と ANTI(不存在判定)も拡張する。 SSDSE-B-2026 の 47 都道府県と「9 地方区分マスタ」を結合する例で違いを暴く。

JOIN 種別挙動47 県データで起きることpandas 等価
INNER両側に共通のキーのみ両テーブルで一致する 47 県全部(マスタが完備なら)how='inner'
LEFT左を全行残し、 右は無ければ NULL左の県マスタは 47 行全部残り、 マッチしない右は NaNhow='left'
RIGHT右を全行残し、 左は無ければ NULL右の地方マスタが 9 行なら結果は 47 行(9 地方 × 平均 5.2 県)how='right'
FULL OUTER両側を全部、 マッチしない側は NULL県マスタ漏れ・地方マスタ漏れの両方が見える(監査用)how='outer'
CROSS直積(全組合せ)47 × 9 = 423 行が出る(ペア候補生成・距離行列の前処理に有用)how='cross'

🐍 sqlite3 + pandas で 5 種類を一気に走らせる

🎯 このコードでやること: SSDSE-B-2026 から都道府県人口(A1101, 2023 年)を読み、 5 行の地方マスタを別テーブルに作って sqlite3 で 5 種類の JOIN を発行し、 結果行数の違いを並べる。 中規模本番の前段検証に最適。

📥 入力データ: SSDSE-B-2026.csv(A1101 = 総人口、 2023 年 47 行)、 自作の地方マスタ(5 行: 北海道/青森県/東京都/大阪府/沖縄県)。

pref pop_2023 region
北海道 5092000 北海道
青森県 1184000 東北
...(47 県)
pref region
北海道 北海道
青森県 東北
東京都 関東
...(5 行マスタ)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import sqlite3, pandas as pd
df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
df = df[df['SSDSE-B-2026']==2023][['Prefecture','A1101']].rename(columns={'Prefecture':'pref','A1101':'pop_2023'})
region = pd.DataFrame({'pref':['北海道','青森県','東京都','大阪府','沖縄県'],
                       'region':['北海道','東北','関東','近畿','沖縄']})
con = sqlite3.connect(':memory:')
df.to_sql('p', con, index=False); region.to_sql('r', con, index=False)
for jt in ['INNER','LEFT','RIGHT','FULL OUTER','CROSS']:
    sql = f"SELECT COUNT(*) FROM p {jt} JOIN r ON p.pref=r.pref" if jt!='CROSS' else "SELECT COUNT(*) FROM p CROSS JOIN r"
    print(jt, '→', con.execute(sql).fetchone()[0], '行')

📤 実行例:

INNER → 5 行
LEFT → 47 行
RIGHT → 5 行
FULL OUTER → 47 行
CROSS → 235 行 (47 × 5)

💬 結果の読み方: INNER は地方マスタが 5 行しかないので 5 行に縮退。 LEFT は人口側 47 県を全保持で「マスタ未登録の県」が炙り出せる。 CROSS は 47×5=235 行で、 これがそのまま「距離行列の候補ペア」になる。 本番マスタは 9 行で組めば INNER=47 / CROSS=423 になる。

🐍 dask.dataframe で 10 GB 級を JOIN

🎯 このコードでやること: pandas が OOM になる規模を想定し、 dask.dataframe.merge でパーティション並列 JOIN する。 SSDSE-B-2026 を 10 倍コピーして 564 行 × 10 = 5,640 行を模擬。

📥 入力データ: SSDSE-B-2026.csv(A1101)と、 上記 region マスタ。

1
2
3
4
5
6
7
8
9
import dask.dataframe as dd
import pandas as pd
pdf = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
region = pd.DataFrame({'Prefecture':['北海道','青森県','東京都','大阪府','沖縄県'],
                       'region':['北海道','東北','関東','近畿','沖縄']})
big = dd.from_pandas(pd.concat([pdf]*10, ignore_index=True), npartitions=4)
r = dd.from_pandas(region, npartitions=1)
joined = big.merge(r, on='Prefecture', how='left')
print('行数:', len(joined), '/ パーティション:', joined.npartitions)

📤 実行例:

行数: 5640 / パーティション: 4

💬 結果の読み方: 4 パーティション並列で JOIN され、 メモリ使用量は単純 merge の 1/4 に。 Polars (pl.LazyFrame.join) や DuckDB (duckdb.sql("SELECT ... JOIN")) も同様に out-of-core が効くので、 規模に応じて使い分けるのが鉄則。

🖼 結合結果を 3 種類の図で読む — 散布図・ヒストグラム・箱ひげ図

テーブル結合の妥当性は、 行数を見るだけでは確認しきれない。 結合の前後で 分布の形が変わっていないか・外れ値が漏れていないか・グループ間で同じ尺度になっているか を 3 種類の図で点検する。 ここでは SSDSE-B-2026 の都道府県データから、 「総人口(A1101)」と「消費支出(L3221)」を 9 地方マスタと LEFT JOIN した結果を題材にする。

図 1: 散布図 — 結合後の 2 変数関係が壊れていないか

🎯 このコードでやること: LEFT JOIN 前の人口 vs 消費支出の散布図と、 JOIN 後の散布図を重ねて、 「結合操作が分布を壊していない」ことを目で確認する。 散布図は matplotlibscatter で出力済みの figures/scatter_basic.png を参照する。

📥 入力データ: SSDSE-B-2026.csv の 47 都道府県(2023 年)× (A1101 総人口, L3221 消費支出)の 2 列。

Prefecture A1101(人) L3221(円)
北海道 5092000 296888
青森県 1184000 263371
東京都 14086000 341320
大阪府 8763000 271246
...(47 県)

JOIN 後の人口と消費支出の散布図(47 県)

💬 図 1 の読み方: 47 点のうち東京・大阪・愛知・神奈川が右上に大きく外れている。 LEFT JOIN によって「地方マスタに無い県」が NaN になっていないかを散布図で確認すると、 NaN は描画されず点数が減るので即座に検出できる(47 点 → 例えば 42 点に減ると「マスタ漏れ 5 件」が判明)。 結合の前後で点の数が変わらない・分布の形(右上がりの直線傾向)が崩れていない、 この 2 点で「結合は妥当」と確認できる。

図 2: ヒストグラム — 結合後の集合体の偏りを掴む

🎯 このコードでやること: 結合後の総人口列について、 ヒストグラムで分布の偏り(右に裾を引く対数正規分布的形状)を確認する。 結合の不具合があると、 ヒストグラムにスパイク(同じ値の重複)や欠落(穴)が出る。

📥 入力データ: LEFT JOIN 結果から人口列(A1101, 単位: 千人)を 47 行抽出。

階級(千人) 頻度
0–1000 10
1000–2000 21
2000–4000 7
4000–8000 6
8000–15000 3 (大阪・神奈川・東京)

JOIN 後の都道府県人口ヒストグラム

💬 図 2 の読み方: 47 県の人口は右に強く裾を引く(東京・神奈川が極端)。 結合バグで 同じ県を 2 回結合(many-to-many 暴走) すると、 ヒストグラムの一部の階級が突然 2 倍になる。 また CROSS JOIN を間違って使うと階級が均等化(フラット)するため、 「ヒストグラムが妙に均された」ら結合方式の誤りを疑う。 LEFT JOIN は左の度数分布を保つはずなので、 元データと一致するのが正解。

図 3: 箱ひげ図 — グループ間の尺度を結合後も保つ

🎯 このコードでやること: 9 地方を結合キーとして付与した後、 地方ごとの人口分布を箱ひげ図で並べる。 結合により「地方ラベル」が正しく付与されたか、 各地方の中央値と四分位範囲が想定通りかを確認する。

📥 入力データ: LEFT JOIN 結果(47 行 × pref, pop_2021, region)。 region は 9 値(北海道/東北/関東/中部/近畿/中国/四国/九州/沖縄)。

region n median(千人) IQR
北海道 1 5183 0
東北 6 1208 574
関東 7 6275 5898
中部 9 1961 1152
近畿 7 1756 2634
中国 5 1328 1211
四国 4 827 332
九州 7 1297 564
沖縄 1 1468 0

9 地方別の都道府県人口 箱ひげ図

💬 図 3 の読み方: 関東・近畿は IQR が広く(県内格差大)、 四国は狭い(県の規模が似る)。 もし結合キーミスで「東京」が「九州」に誤って割当てられたら、 九州の箱が大きく上にずれ、 関東の箱が小さく落ちる。 箱ひげ図はキー誤割当ての検出装置として有効で、 散布図・ヒストグラムでは見えない「グループ単位の異常」をあぶり出せる。

⚠️ 結合のアンチパターン 12 選 — 本番障害トップ事例

テーブル結合は、 単純そうに見えて 本番のデータ事故の最大原因。 私たちが SSDSE-B-2026 や類似の都道府県データで遭遇した実例を 12 個に集約した。 各項目は「症状 → 原因 → 検知方法 → 対処」の 4 点セットで覚える。

#アンチパターン症状原因検知対処
1行数爆発47 県が 423 行にCROSS と LEFT 取り違えmerge 後 lenhow 明示・SQL 再確認
2サイレント NaN後段集計が 0 にLEFT で右が空isna().sum() 監視INNER 切替・補完
3重複行膨張合計が 2 倍右側に同キー複数行duplicated().sum()右を groupby で集約
4型不一致無音失敗全行が NaN になる'01' vs 1 等dtypes 比較事前 astype(str)
5全角半角混在「東京都」が結合しない「東京都」と「東京都」unicodedata 比較NFKC 正規化
6前後空白JOIN 後 NaN 多発' 北海道'.str.contains(r'^ ')事前 .strip()
7大文字小文字'Tokyo' と 'tokyo'英字キー.str.lower() 統一前比較.str.lower() で揃える
8時刻 TZ 不一致同瞬間が結合しないUTC vs JSTdt.tz_convert 結果tz_localize 統一
9複合キー漏れ同県 × 多年が 1 行on='都道府県' のみgroupby サマリon=['都道府県','年度']
10列名衝突val_x, val_y 散乱同名列が両側にあるmerged.columnssuffixes 明示
11NaN-NaN 結合NaN 同士が結合してしまうDB と pandas の差NaN 行数のチェック事前 dropna or filter
12indicator なしどこから来た行か不明_merge 列なしindicator=False 既定indicator=True で監査

🐍 「全角半角・空白・大文字小文字」を一括正規化するヘルパ

🎯 このコードでやること: 結合の前に必ず通す「キー列クレンジング関数」を作る。 これにより、 アンチパターン 5・6・7 を一掃できる。

📥 入力データ: SSDSE-B-2026 の 都道府県 列、 およびユーザ提供の地方マスタ(汚れていることがある)。

汚れた地方マスタ例:
' 北海道' '東京都' '東 京 都' '東京都' 'tokyo'
→ 5 行のうち 4 行が「東京都」のつもりだが、 揺らぎで全部別キーに見える
1
2
3
4
5
6
7
8
9
import pandas as pd, unicodedata
def norm_key(s):
    if pd.isna(s): return s
    s = unicodedata.normalize('NFKC', str(s))  # 全角→半角等を統一
    s = s.strip().replace(' ', '').replace(' ','')  # 空白除去
    return s.lower()  # 英字は小文字化(日本語には無影響)
raw = pd.Series(['  北海道','東京都','東 京 都','東京都','tokyo'])
print(raw.map(norm_key).tolist())
print('一意化後:', raw.map(norm_key).unique())

📤 実行例:

['北海道','東京都','東京都','東京都','tokyo']
一意化後: ['北海道' '東京都' 'tokyo']

💬 結果の読み方: 全角・空白・前後空白の揺らぎが 1 関数で吸収され、 「東京都」表記の 3 行が 1 つに統合された。 ただし 'tokyo' は別物として残るので、 英日混在マスタには jaconv 等での仮名英字変換層が追加で必要。 結合前にこの正規化を 必ず両側に適用 するルールにしておくと、 アンチパターン 5・6・7 をまとめて潰せる。

🗺 概念マップ

表の操作
├─ 縦方向 (concat / UNION)
└─ 横方向 (JOIN) ★
    ├─ INNER  : 共通だけ
    ├─ LEFT   : 左を残す
    ├─ RIGHT  : 右を残す
    ├─ OUTER  : 両方
    ├─ CROSS  : デカルト積
    ├─ SEMI   : 左の絞り込み
    ├─ ANTI   : 左の不在
    └─ ASOF   : 時系列近傍

表の結合 ★
├─ アルゴリズム: nested loop / hash / sort-merge / broadcast
├─ キー設計: 単一 / 複合 / 代理キー / 自然キー
└─ 落とし穴: 型不一致 / 重複 / NULL / 文字ゆらぎ
table join CROSS JOIN SEMI/ANTI JOIN ASOF JOIN fuzzy join spatial join set operations

🔗 隣接手法への橋渡し

テーブル結合 (JOIN) は関係代数の中核操作。 SSDSE-B-2026 (47 都道府県 × 110 指標) と SSDSE-A (家計調査) を都道府県コード (JIS X 0401: 01=北海道, 13=東京 …) で結合し、 「家計支出と地域経済指標の関係」を分析する典型ワークフロー。 SQL JOIN は INNER/LEFT/RIGHT/FULL/CROSS の 5 種、 pandas は pd.merge(how=...) で同等表現。

INNER JOIN は両側に存在するキーのみ残す (47→47 件)、 LEFT JOIN は左側を全保持 (47 件保証)、 FULL OUTER は両側全保持 (NaN 含む)。 SSDSE 結合では LEFT JOIN を基本に、 欠損都道府県を NaN で残す設計が一般的。

🌳 手法選択フロー

SSDSE-B-2026 を他データと結合する際の JOIN 種類選択フロー。

  1. ① 両側に必要なキーが揃っているか? 揃っている → INNER JOIN (両側存在のみ)。 SSDSE-B 47 件 ⨝ SSDSE-A 47 件 → 47 件で完全結合。
  2. ② どちらかを優先したいか? SSDSE-B を主表として全 47 都道府県を保持したい → LEFT JOIN (SSDSE-B 主)。 結合先に欠損がある都道府県は NaN で残る。
  3. ③ 主キーは一意か? 一意でない (例: 都道府県 × 年で多重) → 結合キーに (地域コード, 年) 複合キーを使用。 単一キーでは行が爆発する (N×M)。

JOIN 前に必ず両側で df.drop_duplicates(subset=key).shape をチェック。 SSDSE 結合の典型エラーは「同一都道府県が複数年含まれる」「コード型 (string vs int) の不一致」「市区町村単位への変換ミス」。

🧩 深掘り — 結合は「行数」も「合計」も保存しない

inner-join / outer-join のページが「どのキーが残るか」を扱うのに対し、 ここでは 結合の前後で行数と集計値がどう変化するか という別角度に絞る。 JOIN は 変換 であって恒等写像ではない。 だから結合したら必ず「行数」と「合計」を検算する。 数値はすべて SSDSE-B-2026.csv(2023 年・47 都道府県・列 A1101 総人口)の実測値。

🧭 直感

結合のカーディナリティ(1対1 / 1対多 / 多対多)を、左右それぞれのキー重複数 n_L,kn_R,k で見ると行数が一発で読める。 キー k ごとに 左の該当行数 × 右の該当行数 だけ結果行が生まれ、それを全キーで足したものが結果の総行数になる(inner の場合)。

結果行数 = Σk ( nL,k × nR,k )

⚠️ 落とし穴(重要)

「結合しても合計は変わらないはず」という思い込みが最大の罠。 SSDSE-B-2026(2023 年)の 47 都道府県・総人口 A1101 で 3 通りの結合を実測すると次のとおり(すべて実データ)。 基準となる全国総人口は 124,353,000 人(47 県の A1101 合計)。

結合キー結果行数Σ 人口(検算対象)判定
1対1Code(都道府県コード)47124,353,000✅ 行数も合計も保存
1対多region(8 地方)で集計→県へ戻す47862,254,000⚠️ 合計が 6.93 倍に膨張
多対多region で県×県を自己結合321💥 47→321 行に爆発

なぜ 1対多で合計が壊れるか。 8 地方ごとに人口を groupby.sum() した「地方人口」を、 各県(1 地方 : 複数県 = 1対多)に merge(how='left') で貼り戻すと、 地方人口が県の数だけコピーされる。 その状態で県テーブルの「地方人口」列を素朴に sum() すると、 各地方の人口が「その地方の県数」回だけ二重計上され、 124,353,000 → 862,254,000(6.93 倍) になる。 グループ集計(8 行で合計 124,353,000 は保存)は正しいのに、 結合で粒度が県に戻った後の再合計が誤りを生む。

多対多の 321 行も理屈で出る。 region 列で県テーブルを自己結合すると、 地方ごとに(県数)² 行が生まれる。 実測の地方別県数は 中部9・九州8・関東7・近畿7・東北6・中国5・四国4・北海道1。 9²+8²+7²+7²+6²+5²+4²+1² = 81+64+49+49+36+25+16+1 = 321。 Σ(n²) が行数と完全一致する。 「なんとなく結合したら行が増えた」ではなく、事前に value_counts() で読める。

対策は 2 つの不変量を必ず印字すること。 ① 主表の行数が結合後も変わらないか(left.shape[0] == merged.shape[0]、 many_to_one を期待するなら validate='m:1')。 ② 意味のある合計(人口・件数など)が結合前後で保存されるか。 保存されない結合は「集計をコピーで水増ししていないか」を疑う。

🚀 発展

🔗 関連ページ