🍰 まずはやさしく
バラバラの表をくっつけるパズルのような操作です。
分析に必要な情報を1つの表にまとめるために使います。
部活の名簿と出席簿を1つにするイメージです。
結合の種類や注意点について学びます。
表の結合 (JOIN / merge) = 2 つ以上の表を「共通のキー」で繋ぎ、列を横方向に増やす操作。データ分析の 80% は結合と前処理に時間を使う。
Prefecture, Year で結合し、相関分析やモデル特徴量を作る。broadcast() ヒント、pandas では set_index 後 join が高速。🍰 まずはやさしく
データの整理整頓を行うための手順です。
バラバラのデータを使いやすくするために行います。
スマホのアプリで別々の情報をまとめる感覚です。
具体的なデータのつなぎ方を解説します。
用語集 → データ前処理 / 関係データ操作 分野 → 表の結合 (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 するだけで分析できる。 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 は全部守る。だからこそ結合の設計は主キー・外部キーの設計(キーの一意性・参照整合性)とセットで考えます。
pd.merge(..., validate="1:1") や validate="m:1" で結合前に多重度を強制チェックできます。int の 13、右表で str の "13" だと一致率 0% になり、INNER では結果 0 行・LEFT では全行 NULL になります。結合前に df.dtypes と astype(str) での統一を習慣に。ゼロ埋め("01" vs "1")や全角半角・前後スペースも同類で、データクレンジングの守備範囲です。NULL = NULL は真にならず(3値論理)、NULL キー同士は結合されません。一方 pandas の merge は NaN キー同士を一致させることがあり、ライブラリ間で挙動が違います。結合キーに欠損値が混ざっていないか、df[key].isna().sum() を結合前に確認しましょう。また LEFT JOIN で生まれた NULL を「元からあった欠損」と混同しないよう、indicator=True で由来列を残すのが安全です。pd.merge(left, right, on="キー", how="left", validate="1:1", indicator=True) が安全形。on の代わりに left_on / right_on で列名違いに対応、suffixes=("_b", "_c") で同名列の衝突を制御。時系列の「直近を繋ぐ」には pd.merge_asof。詳細は pandas と SQL のページへ。NOT EXISTS、pandas では merge(..., how="left", indicator=True) の後に query('_merge == "left_only"') で実現します。逆に「存在する行だけ、列は増やさず」がセミ結合。結合後は集計やピボットテーブルに繋ぐのが定番フローです。🍰 まずはやさしく
表をつなげるルールを数式で表したものです。
計算の仕組みを正しく理解するために使います。
買い物リストの項目がどう増えるか考える例です。
結合したときに行数がどう変わるかを学びます。
自然結合 (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 種別 | 条件式 | 例 | 速度 |
|---|---|---|---|
| 等価結合 (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 |
pd.merge_asof(trades, quotes, on='time') は「各 trade に対して、 それ 以前で最も近い quote を割り当てる」操作。 金融データ・センサーデータの時刻整列で必須。 SQL の LATERAL JOIN + ORDER BY 相当。
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)$ | 片側が極小(数十行) | 辞書テーブル・小マスタ |
🎯 このコードでやること: 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)}') |
📤 実行例:
🕐 この 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 問。 すべて SSDSE-B-2026 を題材にしている。 各問の下に 解答と解説 をたたみ込んでいるので、 まずは隠して挑戦してほしい。
答え: 9 行(地方マスタの粒度に縮退)ではなく、 47 行。 INNER は「両側のキーが一致する全行」を返すので、 47 県全てが 9 地方のどれかにマッチすれば 47 行。 もし「東京都」のキー綴り誤りで 1 県だけマッチしなければ 46 行になり、 マスタ漏れを INNER で検出できる。
答え: NaN(pandas)/ NULL(SQL)。 LEFT は左を全行保持するので、 右に無い行も左側の値はそのまま残り、 右側列のみ NaN で埋まる。 「マスタに無い県を炙り出す」用途で最強。
答え: 47 × 9 = 423 行(直積)。 CROSS はキー条件無しで全組合せを生成する。 ペア候補の事前生成や距離行列の前処理に有用だが、 件数が膨大になりやすいので注意。
答え: 文字列の完全一致が要求されるため、 末尾の全角空白 1 文字でも別キー扱いになる。 対処は事前に unicodedata.normalize('NFKC', s).strip() で正規化する。 全角半角や前後空白の揺らぎは 結合事故 No.1 原因。
indicator=True を付けると何ができる?答え: 結果に _merge 列が追加され、 各行が「left_only / right_only / both」のどれかでラベル付けされる。 マスタ漏れを件数集計するなら merged['_merge'].value_counts() 一発。 監査ログとして残せる。
答え: Nested-Loop Join。 全ペアを総当たりで比較するため、 件数が増えると遅い。 片側が極小(数十行)の場合のみ実用的。 SQLite の最適化器はインデックスが無いと NL を選びがち。
on= に何を渡す?答え: on=['都道府県','年度'](複合キー)。 単一キー on='都道府県' だと、 年度の異なる行が大量にマッチして many-to-many 暴走になる。 SSDSE-B のように パネル(同一観測単位 × 複数時点)では複合キー必須。
答え: 右側テーブルのキー一意性。 right['key'].is_unique や right.groupby('key').size().max() で「同キーが何行あるか」を確認し、 1 行を超えるなら集約してから結合し直す。 行数バランスのチェックは最初の防衛線。
SSDSE-B-2026 を 1 ファイルとして扱うだけなら結合は不要だが、 実務では 「公的統計の人口表」「総務省・家計調査の消費支出表」「文科省の学校統計表」 といった 複数出所のテーブル を都道府県コードで結合して 1 つの分析テーブルを作る。 ここでは SSDSE-B-2026 の中の 3 種類の列群を「別ファイル」と見立てて、 結合の実務シナリオを段階的に再現する。
分析では 1 行 = 1 観測単位 の 長方形(tidy)データ が前提になる。 都道府県分析なら 47 行、 全国時系列なら 「年度 × 県」 47×N 行の長方形。 複数出所の表を結合して 1 表にまとめると、 散布図・回帰・クラスタリングがそのまま動くようになる。 逆に「結合せずに 3 個の DataFrame をくっつけたつもり」で進めると、 順序が違っていたり、 県の対応が狂っていたりして、 分析が崩壊する。
🎯 このコードでやること: 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(幼稚園数)など。
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) |
📤 実行例:
💬 結果の読み方: 3 表とも 47 県 × 12 年(2012〜2023)= 564 行、 複合キーで一意。 「キー一意」が False ならその時点で結合せず、 重複の原因を先に潰す。 これが結合前の必須チェック。
🎯 このコードでやること: 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)) |
📤 実行例:
💬 結果の読み方: 3 表ともキー粒度が同じだから、 INNER でも行数は減らない。 もし spend_df だけ 2017〜2023 の 7 年分しかなければ、 結合後は 47×7=329 行に縮退する。 縮退が想定外なら、 LEFT に切り替えて欠損年度を可視化する。
🎯 このコードでやること: 結合後の 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)) |
📤 実行例:
💬 結果の読み方: 秋田県の高齢化率は 39.06% と全国最高だが幼稚園密度(人口100万人あたり)は 35.01 と最も低い(若年人口が少ないので幼稚園も少ない)。 逆に高齢化率が低い東京都・沖縄県・愛知県は幼稚園密度が相対的に高く、 「高齢化率と幼稚園密度が逆向きに動く」県の構造が 結合表でしか見えない。 これが「複数表を結合する真の価値」。
🎯 このコードでやること: 結合済みの分析テーブルを 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'), 'バイト') |
📤 実行例:
💬 結果の読み方: 約 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 年)で動かして確かめます。
| 記号 | 意味 | 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 の基本形。
| 記号 | 意味 |
|---|---|
| $⟕$ | LEFT OUTER JOIN(左外部結合) |
| $⟖$ | RIGHT OUTER JOIN |
| $⟗$ | FULL OUTER JOIN |
| NULL | 「対応なし」の埋め草 |
言葉に直すと: 「INNER JOIN の結果に、 左表で対応する右側がなかった行を NULL 埋めで追加する」。 SSDSE で「右表(地方区分)に鳥取・島根を入れ忘れた」場合、 LEFT JOIN なら 47 行(鳥取・島根は Region=NaN)、 INNER JOIN なら 45 行になります。
言葉に直すと: 「素朴な二重ループは行数の積、 ハッシュ結合は線形、 ソートマージはソート分の対数」。 47 × 47 なら何でも瞬時だが、 1 万 × 1 万 = 1 億、 100 万 × 100 万 = 10¹² で ハッシュ結合一択になります。 pandas merge も SQL も内部でこの判断を自動で行っています。
言葉に直すと: 「結果の行数は、 同じキーを持つ左行数 × 右行数を全キーで足し上げたもの」。 双方に重複キーがあると 掛け算で爆発。 SSDSE で「3 年分の人口表 (47×3=141 行)」と「47 県の地方表」を JOIN すると 141 行(左の重複だけ伸びる)、 これが「1 対多 JOIN」の正体です。
「左の各行に対して右を全行スキャン」。 計算量 $O(|R| \cdot |S|)$。 47 × 47 = 2209 なら一瞬だが、 1 万 × 1 万 = 1 億で数秒、 100 万 × 100 万 = 10¹² で実用不可。 ただしインデックスがあれば $O(|R| \log |S|)$ に落とせる(Index Nested Loop)。
小さい方をハッシュテーブルに格納し、 大きい方を順に走査して引く。 計算量 $O(|R| + |S|)$。 pandas merge も sqlite も内部でこれを多用。 メモリにハッシュが乗らない場合は Grace Hash Join(分割してから結合)に切り替わる。
両方をキーでソートし、 2 つのポインタで並列走査。 計算量 $O(|R| \log |R| + |S| \log |S|)$。 既に ORDER BY 済みの場合や、 範囲条件 JOIN(BETWEEN)で有利。
Spark/BigQuery で「片方が極端に小さい場合(〜10MB)」、 小さい側を全ワーカーに 放送して各ローカルで Hash Join。 ネットワークシャッフルを回避できる最強パターン。 pyspark.sql.functions.broadcast(df) で明示。
両側をキーでハッシュパーティションし、 同じパーティションを同じワーカーに送って Hash Join。 大規模 JOIN の標準だが、 ネットワークシャッフルが重い。
| 状況 | 推奨アルゴリズム |
|---|---|
| 小規模 (両方 1 万行未満) | Nested Loop も Hash も瞬時、 どちらでも可 |
| 中規模 (両方 100 万行程度) | Hash Join |
| 大規模 + 片方小 | Broadcast Hash Join |
| 大規模 + 両方大 | Shuffle Hash Join or Sort-Merge Join |
| 事前ソート済 / 範囲条件 | Sort-Merge Join |
| OLTP の単一行 JOIN | Index Nested Loop(B-tree インデックス) |
本番データの結合は 「動いた = 正しい」ではない。 必ず次の 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_unique | False が出る | 原因キー特定 → 集約 or マスタ修正 |
| 3 | NaN 発生 | merged.isna().sum() | 想定外列に NaN | INNER に切替 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() | 想定行数と乖離 | マスタ追加 / キーミス確認 |
🎯 このコードでやること: 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 行(キーを地方名にしてしまった不良マスタ)。
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') |
📤 実行例:
💬 結果の読み方: ステップ 3 で region 列の NaN が 46 個と判明 → 「マスタが県名ではなく地方名になっていた」というキー設計ミスがすぐ可視化される。 ステップ 1 で行数バランスは保たれており、 LEFT JOIN は意図通り左を維持。 監査スクリプトを CI に組み込めば、 マスタの更新ごとに結合品質を機械的に検証できる。
SSDSE-B-2026 (47都道府県×年度) に、独自に作った「県庁所在地」表 (47行) と「観光客数」表 (一部欠損) を結合します。
| 結合方法 | SSDSE-B (1410行) | 県庁所在地 (47行) | 観光客数 (40県のみ) | 結合後の行数 |
|---|---|---|---|---|
| INNER (3 表) | 1410 | 47 | 40 | 40×30 = 1200 |
| LEFT (B 基準) | 1410 | 47 | 40 | 1410 (7県は NaN) |
| OUTER | 1410 | 47 | 40 | ≥1410 |
→ どの結合を選ぶかで「欠損 7 県をどう扱うか」が変わる。LEFT で残し、後で fillna で粗化することが多い。
🎯 このコードでやること: SSDSE-B-2026 から 2023 年の 47 都道府県人口表を作り、 別途用意した「47 県 → 8 地方区分」対応表と INNER / LEFT / RIGHT / OUTER 4 種類の JOIN を実行。 右表からわざと 2 県(鳥取・島根)を抜いて、 各 JOIN の 行数と NULL の出方 を比較する。
📥 入力データ(SSDSE-B-2026, 2023 年抜粋 + 地方区分表):
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 は両方にあるキーだけ → 45 行。 LEFT は左を全部残す → 47 行(鳥取・島根は Region=NaN)。 RIGHT は右を全部残す → 45 行(左にしかない県は欠落するはずだが、 ここでは左は完全集合なので 45)。 OUTER は両方を全部残す → 47 行。 本番データでは「該当データなし」を可視化したいなら LEFT を選ぶと、 後で isna() でデータ不備を発見できる。
🎯 このコードでやること: 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 行 (3 年 × 47 県) と右表 47 行を JOIN すると、 各左行に対応する右行が 1 つしかないので結果も 141 行(多 → 1 結合)。 集計すると 関東 4,352 万 (35%) が圧倒的、 全 8 地方の人口推移が一覧できる。 3 年とも全地方で漸減傾向(日本全体の人口減)。 こうした「縦持ち(年×県)+ ディメンション結合」は BI ダッシュボードの典型パターン。
🎯 このコードでやること: 両側にキー重複がある「多対多 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]) |
📤 実行結果:
💬 結果の読み方: 「キー粒度」を間違えると 47 倍に爆発(141 → 6,627)。 100 万件同士でやれば 10 兆行で OOM 確実。 validate='one_to_one' や 'one_to_many' を付ければ事前に例外で停止できるので、 本番 ETL では必ず validate を付けるのが鉄則。 SQL でも UNIQUE 制約や PK 設計でこれを担保する。
🎯 このコードでやること: 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 県分のみの簡略例で確認):
💬 結果の読み方: SQL の INNER JOIN ... ON ... 構文は pandas の merge(on=...) と 1 対 1 対応。 PostgreSQL, MySQL, BigQuery, Snowflake すべてで同じ構文。 SQL 経験者は pandas を、 pandas 使いは SQL を、 同じ思考法で行き来できる。 本番では DWH(BigQuery 等)の JOIN が pandas より圧倒的に速い(カラムストア + 分散実行)ので、 巨大データはまず SQL で集計してから pandas に持ってくる。
🎯 このコードでやること: 「左にあって右にない行」(ANTI JOIN) や「左に対応する右行が存在する行」(SEMI JOIN) を pandas で書く。 SQL の WHERE EXISTS や NOT 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}") |
📤 実行結果(概略):
💬 結果の読み方: ANTI JOIN (左から右にあるものを除外) で 31 県、 SEMI JOIN (左の中で右に存在) で 16 県。 政令市保有 16 県だけで 全国人口の約 69% を占めるという都市集中構造が JOIN で炙り出せる。 SQL では NOT EXISTS や LEFT JOIN ... WHERE right.key IS NULL で同じ操作。 在庫差分・参照整合性チェック・データクレンジングで頻出。
df.shape, df['key'].duplicated().sum(), df['key'].isna().sum()validate 引数を必ず付ける: pandas なら validate='one_to_one' 等で粒度ガードstr.strip().str.lower()、 NFKC 正規化chunksize や Spark/BigQuery へ移譲suffixes=('_pop','_reg') で明示%timeit で 10 倍以上遅ければアルゴリズム見直しEXPLAIN) を確認: Nested Loop が出たらインデックスを張る| 操作 | 意味 | pandas | SQL | 使う場面 |
|---|---|---|---|---|
| JOIN (merge) | 横方向に キーで結合 | df.merge(df2, on='key') | INNER/LEFT/OUTER JOIN ON | 2 つの表を横に貼る |
| concat (縦) | 縦方向に積む(カラム揃え) | pd.concat([df1, df2]) | UNION ALL | 月別の同じ形のデータを合体 |
| concat (横) | 横方向に 位置で並べる | pd.concat([df1, df2], axis=1) | — | インデックスで並列 |
| append | 1 行追加(非推奨、 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 の 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)) |
📤 実行結果(概略):
💬 結果の読み方: 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 から 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}") |
📤 実行結果:
💬 結果の読み方: 複合キー JOIN で 141 行を保ち、 suffixes=('_pop','_tfr') で列が明示的に区別される。 後続で年別の相関を出すと、 3 年とも 負の相関 -0.46〜-0.49(大都市県ほど出生率が低い)。 「人口集中」と「少子化」が並走している構造が JOIN + corr で炙り出せる。 SSDSE のディメンション設計でも、 ファクト表とディメンション表の複合キー設計は基本。
WHERE や filterUSING(key) と ON a.k=b.k の違い?USING は同名カラムを 1 列に統合。 ON は別名でも書ける汎用構文。 pandas merge は on= が USING 相当。merge と join の違い?df.join(df2) は インデックスベース、 df.merge(df2, on=...) は カラムベース。 列名で結合する通常用途は merge。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]) も便利。duplicated.sum()), ② NULL 数, ③ 文字列の前後空白、 ④ 型一致。 多すぎる → 直積爆発、 少なすぎる → 型不一致 / 文字ゆらぎが大半。merge_asof(direction='nearest') で「最近接」を効率化。 SQL では BRIN や GiST インデックスを検討。merge(..., on=...) API で書き直せるツールが多い。🎯 このコードでやること: 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 ===") |
📤 実行結果:
💬 結果の読み方: indicator=True で各行が both / left_only / right_only のどれかが分かる。 ここでは右表が 5 件しかないので 42 件が left_only として浮き上がる。 本番では「left_only が 0 件以外なら警告」「行数増減ゼロをアサーション」というガードレールを必ず付ける。 これを怠ると サイレントなデータ欠落が下流まで伝播して気付かない。
| 記号 / 構文 | 呼び方 | 例 |
|---|---|---|
| $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 |
|---|---|
INNER JOIN ... ON | df.merge(df2, on='k', how='inner') |
LEFT JOIN ... ON | df.merge(df2, on='k', how='left') |
FULL OUTER JOIN | df.merge(df2, on='k', how='outer') |
CROSS JOIN | df.merge(df2, how='cross') |
WHERE EXISTS | df[df['k'].isin(df2['k'])] |
NOT EXISTS | df[~df['k'].isin(df2['k'])] |
USING (k) | merge(..., on='k') |
合成データで 4 種の JOIN 結果行数を計算する。
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}") |
💬 手計算 (Step 2) と Python 出力が完全一致。
① SSDSE-B を読み込み、キーを整える
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]) |
② 県庁所在地マスタを 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()) # マスタにない県の数 |
③ 観光客数 (一部のみ) を結合し 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 |
④ 結合キーの型違いをデバッグ
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) |
⑤ 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) |
⑥ 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()) |
df.dtypes を必ず確認、または validate オプションを使う。str.strip()、Unicode 正規化 (NFKC)、辞書による正規化が必要。validate='one_to_one' や drop_duplicates() を事前確認。indicator=True で left_only/right_only を可視化。| 分析テーマ | 主テーブル | 結合先 | キー | 結合種別 |
|---|---|---|---|---|
| 都道府県の生産性と教育 | SSDSE-B 県民所得 | SSDSE-C 進学率 | (Prefecture, Year) | INNER |
| 高齢化率と医療費 | SSDSE-B | 国民健康保険データ | 市町村コード | LEFT |
| 気候と農業 | 気象庁 47 県月別 | 作物統計 | (Pref, Year, Month) | INNER |
| 観光と為替 | 訪日外国人 (国別) | 為替レート (日次) | Date (日付丸めが必要) | ASOF JOIN |
Q1. concat と merge の違いは?
A. concat は単純な縦/横の連結 (キー一致を見ない)、merge はキーに基づく結合。同じ列構造の年度別ファイルを縦に重ねるのは concat。
Q2. join と merge は?
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) スポットチェックを毎回。
validate='one_to_one' や df.duplicated(subset='key').sum() で事前チェック。NULL = NULL が NULL(不明)になり、 JOIN しない。 pandas でも NaN 同士は merge されない。 NULL を意味のある値("UNKNOWN" など)に置換するか、 IS NULL 条件で別処理する。pref が str、 右が int だと一致せず 0 行。 文字列キーは .str.strip() や .astype(str) で前処理。 日付キーは pd.to_datetime で揃える。unicodedata.normalize('NFKC', s).strip() や辞書ベースで正規化。Tokyo と TOKYO は別物。 str.lower() や UPPER(key) で揃える。 メールアドレス JOIN は特に要注意。_x, _y 接尾辞付きに分裂。 merge(..., suffixes=('_pop','_reg')) で明示するか、 JOIN 前にリネーム。CREATE INDEX idx_pref ON pops(pref); を必ず張る。 BigQuery などはクラスタリングキーで代用。groupby せず、 先に groupby してから JOIN するとシャッフル量が激減。 SQL なら WITH 句で前処理する。テーブル結合は 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 行全部残り、 マッチしない右は NaN | how='left' |
| RIGHT | 右を全行残し、 左は無ければ NULL | 右の地方マスタが 9 行なら結果は 47 行(9 地方 × 平均 5.2 県) | how='right' |
| FULL OUTER | 両側を全部、 マッチしない側は NULL | 県マスタ漏れ・地方マスタ漏れの両方が見える(監査用) | how='outer' |
| CROSS | 直積(全組合せ) | 47 × 9 = 423 行が出る(ペア候補生成・距離行列の前処理に有用) | how='cross' |
🎯 このコードでやること: SSDSE-B-2026 から都道府県人口(A1101, 2023 年)を読み、 5 行の地方マスタを別テーブルに作って sqlite3 で 5 種類の JOIN を発行し、 結果行数の違いを並べる。 中規模本番の前段検証に最適。
📥 入力データ: SSDSE-B-2026.csv(A1101 = 総人口、 2023 年 47 行)、 自作の地方マスタ(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 行しかないので 5 行に縮退。 LEFT は人口側 47 県を全保持で「マスタ未登録の県」が炙り出せる。 CROSS は 47×5=235 行で、 これがそのまま「距離行列の候補ペア」になる。 本番マスタは 9 行で組めば INNER=47 / CROSS=423 になる。
🎯 このコードでやること: 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) |
📤 実行例:
💬 結果の読み方: 4 パーティション並列で JOIN され、 メモリ使用量は単純 merge の 1/4 に。 Polars (pl.LazyFrame.join) や DuckDB (duckdb.sql("SELECT ... JOIN")) も同様に out-of-core が効くので、 規模に応じて使い分けるのが鉄則。
テーブル結合の妥当性は、 行数を見るだけでは確認しきれない。 結合の前後で 分布の形が変わっていないか・外れ値が漏れていないか・グループ間で同じ尺度になっているか を 3 種類の図で点検する。 ここでは SSDSE-B-2026 の都道府県データから、 「総人口(A1101)」と「消費支出(L3221)」を 9 地方マスタと LEFT JOIN した結果を題材にする。
🎯 このコードでやること: LEFT JOIN 前の人口 vs 消費支出の散布図と、 JOIN 後の散布図を重ねて、 「結合操作が分布を壊していない」ことを目で確認する。 散布図は matplotlib の scatter で出力済みの figures/scatter_basic.png を参照する。
📥 入力データ: SSDSE-B-2026.csv の 47 都道府県(2023 年)× (A1101 総人口, L3221 消費支出)の 2 列。

💬 図 1 の読み方: 47 点のうち東京・大阪・愛知・神奈川が右上に大きく外れている。 LEFT JOIN によって「地方マスタに無い県」が NaN になっていないかを散布図で確認すると、 NaN は描画されず点数が減るので即座に検出できる(47 点 → 例えば 42 点に減ると「マスタ漏れ 5 件」が判明)。 結合の前後で点の数が変わらない・分布の形(右上がりの直線傾向)が崩れていない、 この 2 点で「結合は妥当」と確認できる。
🎯 このコードでやること: 結合後の総人口列について、 ヒストグラムで分布の偏り(右に裾を引く対数正規分布的形状)を確認する。 結合の不具合があると、 ヒストグラムにスパイク(同じ値の重複)や欠落(穴)が出る。
📥 入力データ: LEFT JOIN 結果から人口列(A1101, 単位: 千人)を 47 行抽出。

💬 図 2 の読み方: 47 県の人口は右に強く裾を引く(東京・神奈川が極端)。 結合バグで 同じ県を 2 回結合(many-to-many 暴走) すると、 ヒストグラムの一部の階級が突然 2 倍になる。 また CROSS JOIN を間違って使うと階級が均等化(フラット)するため、 「ヒストグラムが妙に均された」ら結合方式の誤りを疑う。 LEFT JOIN は左の度数分布を保つはずなので、 元データと一致するのが正解。
🎯 このコードでやること: 9 地方を結合キーとして付与した後、 地方ごとの人口分布を箱ひげ図で並べる。 結合により「地方ラベル」が正しく付与されたか、 各地方の中央値と四分位範囲が想定通りかを確認する。
📥 入力データ: LEFT JOIN 結果(47 行 × pref, pop_2021, region)。 region は 9 値(北海道/東北/関東/中部/近畿/中国/四国/九州/沖縄)。

💬 図 3 の読み方: 関東・近畿は IQR が広く(県内格差大)、 四国は狭い(県の規模が似る)。 もし結合キーミスで「東京」が「九州」に誤って割当てられたら、 九州の箱が大きく上にずれ、 関東の箱が小さく落ちる。 箱ひげ図はキー誤割当ての検出装置として有効で、 散布図・ヒストグラムでは見えない「グループ単位の異常」をあぶり出せる。
テーブル結合は、 単純そうに見えて 本番のデータ事故の最大原因。 私たちが SSDSE-B-2026 や類似の都道府県データで遭遇した実例を 12 個に集約した。 各項目は「症状 → 原因 → 検知方法 → 対処」の 4 点セットで覚える。
| # | アンチパターン | 症状 | 原因 | 検知 | 対処 |
|---|---|---|---|---|---|
| 1 | 行数爆発 | 47 県が 423 行に | CROSS と LEFT 取り違え | merge 後 len | how 明示・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 JST | dt.tz_convert 結果 | tz_localize 統一 |
| 9 | 複合キー漏れ | 同県 × 多年が 1 行 | on='都道府県' のみ | groupby サマリ | on=['都道府県','年度'] |
| 10 | 列名衝突 | val_x, val_y 散乱 | 同名列が両側にある | merged.columns | suffixes 明示 |
| 11 | NaN-NaN 結合 | NaN 同士が結合してしまう | DB と pandas の差 | NaN 行数のチェック | 事前 dropna or filter |
| 12 | indicator なし | どこから来た行か不明 | _merge 列なし | indicator=False 既定 | indicator=True で監査 |
🎯 このコードでやること: 結合の前に必ず通す「キー列クレンジング関数」を作る。 これにより、 アンチパターン 5・6・7 を一掃できる。
📥 入力データ: SSDSE-B-2026 の 都道府県 列、 およびユーザ提供の地方マスタ(汚れていることがある)。
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()) |
📤 実行例:
💬 結果の読み方: 全角・空白・前後空白の揺らぎが 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 / 文字ゆらぎ
テーブル結合 (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 種類選択フロー。
(地域コード, 年) 複合キーを使用。 単一キーでは行が爆発する (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,k・n_R,k で見ると行数が一発で読める。 キー k ごとに 左の該当行数 × 右の該当行数 だけ結果行が生まれ、それを全キーで足したものが結果の総行数になる(inner の場合)。
「結合しても合計は変わらないはず」という思い込みが最大の罠。 SSDSE-B-2026(2023 年)の 47 都道府県・総人口 A1101 で 3 通りの結合を実測すると次のとおり(すべて実データ)。 基準となる全国総人口は 124,353,000 人(47 県の A1101 合計)。
| 結合 | キー | 結果行数 | Σ 人口(検算対象) | 判定 |
|---|---|---|---|---|
| 1対1 | Code(都道府県コード) | 47 | 124,353,000 | ✅ 行数も合計も保存 |
| 1対多 | region(8 地方)で集計→県へ戻す | 47 | 862,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')。 ② 意味のある合計(人口・件数など)が結合前後で保存されるか。 保存されない結合は「集計をコピーで水増ししていないか」を疑う。
df.merge(m, on='region', how='left', validate='m:1') のように pandas に想定カーディナリティを宣言すると、 右表のキーが一意でない(=想定外の 1対多)瞬間に MergeError で止まる。 行数爆発を「事故が起きてから気づく」のではなく「起こす前に落とす」。groupby('region').sum() で正しい粒度に戻す。 「どの粒度で合計が意味を持つか」を先に決めるとバグらない。(Code, 年) の複合にすれば、 単一キー結合で起きる年またぎの直積を防げる。 本ページ上部の「手法選択フロー」と対応。A1101 の人口値そのものは SSDSE-B-2026 の実測値である。