「リレーショナルDB (RDB)」は構造化データを表 (テーブル) と関係 (リレーション) で管理する仕組みで、 統計・業務データ基盤の中核を担う。 本ページでは RDB を理解するうえで要となるキーワードを以下にチップで一覧化する。 各キーワードは関連する概念・手法・道具立てを含み、 文献検索や学習計画の起点になる。
これらのキーワードは「リレーショナルDB の理解 → 設計 (正規化) → 操作 (SQL) → 運用」のプロセスを構成する。 各章で詳しく解説する。
🍰 まずはやさしく
表を使ってデータをまとめる仕組みです。
データを正しく管理するために使います。
スマホの連絡先リストのようなものです。
この章ではリレーショナルDBの結論を読みます。
テーブル形式の構造化データ管理システム
RDB 詳細 ── 表と表の関係でデータを管理し、 SQL でクエリする。 ACID 特性と高度な整合性管理が強み。 OLTP(オンライントランザクション処理)の中核。
🍰 まずはやさしく
データの処理方法を決める仕組みです。
大量のデータを効率よく扱うために使います。
部活の出席簿を管理するイメージです。
この章では他のシステムとの違いを読みます。
RDB はトランザクション処理(OLTP)の中核。 大規模分析や履歴蓄積は データレイク や DWH と組み合わせます。
対比:NoSQL(MongoDB, Cassandra)は柔軟性・水平拡張で優位だが整合性は弱い。 NewSQL(CockroachDB, TiDB)は両方を狙う新流。
🍰 まずはやさしく
複数の表をIDでつなぐ仕組みです。
必要な情報をすぐに取り出すために使います。
買い物リストと商品表を分けるイメージです。
この章では使い方の感覚について読みます。
リレーショナル DB は 「表 (テーブル) と表の関係 (リレーション) でデータを管理する」仕組みです。 例えば SSDSE-B-2026 を RDB に投入すると、 「都道府県マスタ (47 行) 」と「年度別指標 (47×12=564 行) 」「項目マスタ (109 行) 」の 3 つの表に分解し、 都道府県コード (R01000 等) で JOIN して必要な切り口で再構成できます。
直感的にはExcel ファイル群を「ID で繋ぐルール」付きで束ねたもの。 ただし Excel と違って (1) 同じ ID で 2 行を作れない (主キー制約) 、 (2) 存在しない都道府県を参照できない (外部キー制約) 、 (3) 数百万行を WHERE + インデックスで瞬時に絞れる、 という点が決定的に違います。
| 概念 | 意味 | 例 |
|---|---|---|
| テーブル | 行と列の表 | customers, orders |
| 主キー (PK) | 行を一意に識別 | order_id |
| 外部キー (FK) | 他テーブルへの参照 | orders.customer_id |
| インデックス | 高速検索用 | B-tree, hash |
| ビュー | 仮想テーブル | 月次集計 |
| トランザクション | 複数操作を一括 | 振込 |
RDB の設計は「エンティティ(実体)」「リレーション(関係)」「属性」で表現します。 ER 図(Entity-Relationship Diagram)はその設計図です。 SSDSE-B-2026 を ER で表現してみましょう。
🎯 このコードでやること:SSDSE には N:N がないため、 仮想的に「都道府県 × タグ(地方区分)」の N:N を作って体感する。
📥 入力データ:pref_master + 地方区分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import sqlite3, pandas as pd
conn = sqlite3.connect('rdb_demo.db')
# tag マスタ
conn.execute("CREATE TABLE IF NOT EXISTS region_tag (tag_code TEXT PRIMARY KEY, tag_name TEXT)")
conn.executemany("INSERT OR REPLACE INTO region_tag VALUES (?,?)",
[('HK','北海道'),('TH','東北'),('KT','関東'),('CB','中部'),('KS','近畿'),('CG','中国'),('SK','四国'),('KY','九州')])
# pref × tag の中間表(N:N)
conn.execute("""CREATE TABLE IF NOT EXISTS pref_region (
code TEXT, tag_code TEXT, PRIMARY KEY (code, tag_code),
FOREIGN KEY (code) REFERENCES pref_master(code),
FOREIGN KEY (tag_code) REFERENCES region_tag(tag_code))""")
conn.executemany("INSERT OR REPLACE INTO pref_region VALUES (?,?)",
[('R13000','KT'), ('R14000','KT'), ('R27000','KS'), ('R47000','KY'), ('R01000','HK')])
conn.commit()
q = "SELECT m.pref_name, r.tag_name FROM pref_region pr JOIN pref_master m ON pr.code=m.code JOIN region_tag r ON pr.tag_code=r.tag_code"
print(pd.read_sql(q, conn)) |
📤 実行すると次の出力が得られる:
💬 結果の読み方:N:N は「中間テーブル」で表現するのが定石。 「東京は関東 + 首都圏」と複数タグを持たせたいときも、 中間表に行を増やすだけで拡張可能。
SSDSE は集計データですが、 業務では多様なスキーマが登場します。 SSDSE の延長で考えられるパターンを 5 つ紹介。
1 つの fact 表 + 複数の dimension 表が放射状に。 OLAP/DWH の定番。 本ページの (pref_master, metric_master, population_fact) はミニ Star Schema。
Star を更に正規化、 dimension が子 dimension を持つ。 例:pref_master → region_master(東北、 関東 …)→ pref_master。 ストレージ削減、 ただし JOIN コスト増。
マスタが時間で変化する場合、 valid_from / valid_to 列で履歴を残す。 例:「会社名変更前後を別行で保持」「都道府県合併の履歴」。 集計時にあるべき時点の名称を引ける。
状態ではなく「イベント」を保存し、 集計で現在状態を導出。 例:「人口が +500 増加」「-300 減少」を全件保存。 監査性と再現性が高いが、 クエリは重め。
属性が動的に増減する場合、 (entity_id, attribute, value) の縦持ち。 SSDSE-B-2026 の long 形式 (year, code, metric, value) はまさに EAV。 柔軟だが整合性管理が難しい。
これでスキーマパターンの引き出しが 5 つ揃いました。 実際の業務では複数を組み合わせます(例:Star + SCD Type 2)。 「パターンを暗記」ではなく「業務要件から逆算」が正攻法です。
🍰 まずはやさしく
決まった形式でデータを保存するシステムです。
データの矛盾をなくして管理するために使います。
学校の生徒名簿を作るイメージです。
この章では詳しい定義と使いどころを読みます。
テーブル形式の構造化データ管理システム
英語名 Relational Database。 同義・関連語:RDB, RDBMS。
RDB (関係データベース) を採用するか他のストア (NoSQL, データウェアハウス) を選ぶかは、 次の前提で判断する:
情報の重複と矛盾を排除する設計プロセス:
| 形式 | 要件 | 破ったときの問題 |
|---|---|---|
| 1NF | 各セルに1値、列名一意 | 複数値混在、検索不能 |
| 2NF | 主キー全体に関数従属 | 部分従属、更新異常 |
| 3NF | 推移的従属を排除 | 繰り返し情報、削除異常 |
| BCNF | 全候補キーから関数従属 | 主キー絡みの異常 |
実務では 3NF / BCNF が現実的目標。 OLTP は高い正規化、分析用は逆正規化(スター スキーマ)も組合せる。
$$ \text{隔離レベル}: \text{READ UNCOMMITTED} < \text{RC} < \text{RR} < \text{SERIALIZABLE} $$
リレーショナルデータベース (RDB) は テーブル + 主キー + 外部キー + SQL の 4 要素で構成されます。 ここでは公的データ SSDSE-B-2026(47 都道府県 × 12 年(2012〜2023))を SQLite に投入し、 正規化・ACID・JOIN・index・トランザクションをすべて実コードで体感します。
| スプレッドシートとの違い | RDB の利点 | SSDSE での例 |
|---|---|---|
| セルは型なし → RDB は型あり | 型違反を弾く、 高速検索 | year は INTEGER、 pref は TEXT |
| 結合は手作業 → RDB は SQL JOIN | 複数テーブルを宣言的に結合 | 都道府県マスタ × 年次データ |
| 重複に弱い → RDB は主キー制約 | 同一 (year, code) を拒否 | (2023, 北海道) は 1 行だけ |
| 同時編集に弱い → RDB は ACID | 同時更新で破綻しない | 2 人同時に INSERT しても安全 |
| サイズ制限あり → RDB はスケール | 数億行でも実用速度 | SSDSE 564 行は瞬時、 1 億行も実用 |
関係 $R$ の属性集合 $X, Y$ について、 関数従属を $X \to Y$($X$ が決まれば $Y$ が一意に決まる)と書きます。 各正規形は以下で定義:
$$\text{1NF}: \text{全列が原子値(リスト不可)}$$
$$\text{2NF}: \text{1NF} \land (\forall X \to A,\; A \notin \text{key} \Rightarrow X \text{ は候補キー全体})$$
$$\text{3NF}: \text{2NF} \land (\forall X \to A,\; A \notin \text{key} \Rightarrow X \supseteq \text{candidate key} \lor A \in \text{candidate key})$$
$$\text{BCNF}: \forall X \to Y,\; X \text{ はスーパーキー}$$
SSDSE は wide 形式(564 行 × 112 列)。 これを正規化して 2 つのテーブルに分けると:
冗長性が 96% 減。 ただし JOIN コストとのトレードオフ — 分析時に毎回 JOIN するなら、 マテリアライズドビューや非正規化(denormalize)も選択肢に入ります。
🎯 このコードでやること:SSDSE-B-2026 を pref_master と population_fact の 2 テーブルに分解し、 SQLite に投入する(3NF)。
📥 入力データ:data/raw/SSDSE-B-2026.csv(564 行 × 112 列)。
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
import sqlite3
df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
df = df.rename(columns={'SSDSE-B-2026': 'year', 'Code': 'code', 'Prefecture': 'pref_name'})
# 1. pref_master: code → pref_name の対応
pref_master = df[['code', 'pref_name']].drop_duplicates().reset_index(drop=True)
# 2. population_fact: 主要指標のみに絞る
pop_fact = df[['year', 'code', 'A1101']].rename(columns={'A1101': 'total_population'})
conn = sqlite3.connect('rdb_demo.db')
cur = conn.cursor()
cur.execute("DROP TABLE IF EXISTS pref_master")
cur.execute("DROP TABLE IF EXISTS population_fact")
cur.execute("CREATE TABLE pref_master (code TEXT PRIMARY KEY, pref_name TEXT NOT NULL)")
cur.execute("""CREATE TABLE population_fact (year INTEGER, code TEXT, total_population INTEGER,
PRIMARY KEY (year, code), FOREIGN KEY (code) REFERENCES pref_master(code))""")
pref_master.to_sql('pref_master', conn, if_exists='append', index=False)
pop_fact.to_sql('population_fact', conn, if_exists='append', index=False)
print(f"pref_master: {conn.execute('SELECT COUNT(*) FROM pref_master').fetchone()[0]} 行")
print(f"population_fact: {conn.execute('SELECT COUNT(*) FROM population_fact').fetchone()[0]} 行") |
📤 実行すると次の出力が得られる:
💬 結果の読み方:47 都道府県マスタと 564 行の事実表が 2 テーブルに分かれた。 pref_name は 47 行に集約され、 冗長度が大幅減。 主キー + 外部キーで整合性も保証。 この設計が「3NF + 関係モデル」の基本形。
🎯 このコードでやること:pref_master と population_fact を INNER JOIN し、 2023 年の人口 TOP5 を取得。
📥 入力データ:先ほど作成した rdb_demo.db。
1 2 3 4 5 6 7 8 9 10 | import sqlite3
import pandas as pd
conn = sqlite3.connect('rdb_demo.db')
q = """SELECT m.pref_name, f.total_population
FROM population_fact f
JOIN pref_master m ON f.code = m.code
WHERE f.year = 2023
ORDER BY f.total_population DESC LIMIT 5"""
print(pd.read_sql(q, conn)) |
📤 実行すると次の出力が得られる:
💬 結果の読み方:2 テーブルを ON 句で結合し、 1 つの結果セットに統合。 これが SQL JOIN の威力。 正規化したからこそ、 マスタが 1 箇所で管理でき、 名前変更時も 1 行更新で全件反映される。
🎯 このコードでやること:population_fact.year に INDEX を貼り、 WHERE year=2023 の検索時間を比較。
📥 入力データ:rdb_demo.db の population_fact (564 行)。
1 2 3 4 5 6 7 8 9 10 11 12 13 | import sqlite3, time
conn = sqlite3.connect('rdb_demo.db')
q = "SELECT * FROM population_fact WHERE year = 2023"
# INDEX なし
t0 = time.time(); conn.execute(q).fetchall(); t_no = time.time() - t0
conn.execute("CREATE INDEX IF NOT EXISTS idx_year ON population_fact(year)")
conn.commit()
t0 = time.time(); conn.execute(q).fetchall(); t_idx = time.time() - t0
print(f"INDEX なし: {t_no*1000:.3f} ms")
print(f"INDEX あり: {t_idx*1000:.3f} ms") |
📤 実行すると次の出力が得られる:
💬 結果の読み方:564 行では差がほとんど出ない(実測で 0.03〜0.9 ms の範囲を行き来し、 逆転することもある)。 これは「INDEX が効かない」のではなく、 フルスキャンでも一瞬で終わる規模だから差が見えないのです。 1 億行ならフルスキャンが数秒、 INDEX 経由なら数ミリ秒 → 1000 倍以上の差になります。 ただし INDEX は INSERT/UPDATE を遅くする副作用あり。 READ 頻度の高い列だけに貼るのが鉄則。
🎯 このコードでやること:population_fact に 2 件 INSERT する。 2 件目で例外発生 → ROLLBACK で両方なかったことになる(原子性)。
📥 入力データ:rdb_demo.db。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | import sqlite3
conn = sqlite3.connect('rdb_demo.db')
before = conn.execute("SELECT COUNT(*) FROM population_fact").fetchone()[0]
try:
cur = conn.cursor()
cur.execute("BEGIN")
cur.execute("INSERT INTO population_fact VALUES (2024, 'R01000', 5000000)")
# わざと主キー衝突させて例外
cur.execute("INSERT INTO population_fact VALUES (2023, 'R13000', 99)")
conn.commit()
except sqlite3.IntegrityError as e:
conn.rollback(); print(f"ROLLBACK: {e}")
after = conn.execute("SELECT COUNT(*) FROM population_fact").fetchone()[0]
print(f"件数 before={before}, after={after} → 増減なし = 原子性 OK") |
📤 実行すると次の出力が得られる:
💬 結果の読み方:2 件目の INSERT で主キー衝突エラー → 1 件目も ROLLBACK で取り消し。 「片方だけ反映」の中途半端状態を防いだ = ACID の A(原子性)の実例。 銀行送金などはこの仕組みが必須。
複数ユーザが同時に書き込む環境では、 ACID の I(Isolation)の設計が重要です。 分離レベルが弱いと「あるはずのないデータが見える」現象(ダーティリード、 ファントムリード)が起きます。
| レベル | ダーティリード | ノンリピータブルリード | ファントムリード |
|---|---|---|---|
| READ UNCOMMITTED | あり | あり | あり |
| READ COMMITTED(PostgreSQL 標準) | なし | あり | あり |
| REPEATABLE READ(MySQL 標準) | なし | なし | あり (MVCC 実装で防ぐことも) |
| SERIALIZABLE | なし | なし | なし |
🎯 このコードでやること:1 つの SQLite ファイルに 2 接続で同時書き込みし、 ロック挙動を観察する。
📥 入力データ:rdb_demo.db。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | import sqlite3
c1 = sqlite3.connect('rdb_demo.db', timeout=1) # ロック待ち 1 秒
c2 = sqlite3.connect('rdb_demo.db', timeout=1)
c1.execute("BEGIN IMMEDIATE")
c1.execute("UPDATE population_fact SET total_population = 1 WHERE year = 1999 AND code = 'R01000'")
# c1 はまだ COMMIT していない
try:
c2.execute("UPDATE population_fact SET total_population = 2 WHERE year = 1999 AND code = 'R01000'")
except sqlite3.OperationalError as e:
print(f"c2 はロック待ちでタイムアウト: {e}")
c1.rollback(); c1.close(); c2.close() |
📤 実行すると次の出力が得られる:
💬 結果の読み方:SQLite は「ファイルレベルロック」で、 書き込み中の他接続をブロック。 PostgreSQL/MySQL は行レベルロック + MVCC で並行性が高い。 SQLite は単純で速いが、 高並列書込には不向き。 用途で選ぶ。
SSDSE-B-2026 規模(数千行)では何でも瞬時ですが、 1 億行になると「設計」と「INDEX」と「クエリ書き方」が性能を決めます。 基本テクをまとめます。
| テクニック | 効果 | 注意点 |
|---|---|---|
| INDEX | WHERE/JOIN/ORDER 高速化 | 書き込みが遅くなる |
| 複合 INDEX | (col_a, col_b) で AND 検索高速化 | 順序が重要 |
| カラム最適化 | 不要列を SELECT しない | ネットワーク削減 |
| パーティション | year で分割 → プルーニング | パーティションキーで検索する設計が必須 |
| マテリアライズドビュー | 事前集計で高速 | 更新タイミング設計 |
| キャッシュ | Redis / DB バッファ | 陳腐化に注意 |
| SQL リライト | サブクエリ → JOIN, EXISTS など | DB 方言で変わる |
| EXPLAIN ANALYZE | 実行計画 + 実時間取得 | 本番データで測ること |
🎯 このコードでやること:(year, code) の複合 INDEX を貼り、 「特定年・特定県」検索の高速化を比較。
📥 入力データ:rdb_demo.db。
1 2 3 4 5 6 7 8 9 10 11 12 | import sqlite3, time
conn = sqlite3.connect('rdb_demo.db')
conn.execute("DROP INDEX IF EXISTS idx_year_code")
q = "SELECT * FROM population_fact WHERE year = 2023 AND code = 'R13000'"
t0=time.time(); conn.execute(q).fetchall(); t_no=time.time()-t0
conn.execute("CREATE INDEX idx_year_code ON population_fact(year, code)")
t0=time.time(); conn.execute(q).fetchall(); t_idx=time.time()-t0
print(f"複合 INDEX なし: {t_no*1000:.3f} ms")
print(f"複合 INDEX あり: {t_idx*1000:.3f} ms") |
📤 実行すると次の出力が得られる:
💬 結果の読み方:こちらも 564 行では差が計測ノイズに埋もれます。 注意:複合 INDEX は (year, code) の順なので「year のみ検索」「(year, code) 検索」には効くが、 「code のみ検索」には効かない。 順序設計が肝。
A. 主キー(PK)はその表の中で行を一意に識別する列。 外部キー(FK)は他表の PK を参照する列。 SSDSE 例:pref_master.code が PK、 population_fact.code が FK で pref_master.code を指す。
A. SQLite で SQL の基本を学び、 PostgreSQL で本番感を学ぶ。 SQLite は単一ファイル、 セットアップ不要、 教材最適。 業務では PostgreSQL が圧倒的に多い。 両方触ると差分(型・関数)も理解できる。
A. INNER:両表に必ず存在する行のみ欲しい時。 LEFT OUTER:左表(基準)の全行を残し、 右表に無ければ NULL で埋めたい時。 欠損監視や「マスタ基準の集計」で OUTER を多用。
A. NULL は「不明」なので等号比較ができない。 WHERE col IS NULL / IS NOT NULL を使う。 集計関数(SUM, AVG)は NULL を無視する点も覚えておく。
A. ソート対象列に INDEX があれば速い(INDEX は元々ソート済み)。 また LIMIT を併用すると、 TOP-K アルゴリズムが効いて高速化。 ORDER BY col + LIMIT 10 が定石。
A. GROUP BY 前のフィルタは WHERE(行レベル)、 GROUP BY 後の集計値フィルタは HAVING。 「COUNT(*) > 5」は HAVING、 「year = 2023」は WHERE。
A. PRIMARY KEY は NOT NULL + UNIQUE、 1 表に 1 つ。 UNIQUE は NULL 許容、 複数列に張れる。 業務上の主識別子は PK、 「メールアドレスは重複不可」のような補助は UNIQUE。
A. 避けがち。 ロジックが DB に散らばり、 アプリ側からデバッグ困難になる。 整合性制約は宣言的(PK, FK, CHECK)で表現し、 ビジネスロジックはアプリ層に置くのが現代主流。
A. 目安「単一表が 1000 万行を超える」「クエリが特定範囲(year, region)に集中」「古いデータ削除を高速化したい」。 SSDSE 564 行ではオーバースペック。 BigQuery 等 DWH では小規模でもパーティションを切ることが多い。
A. むしろ全盛期。 BigQuery, Snowflake, Athena すべて SQL ベース。 dbt も SQL。 ML/AI 全盛時代でも「データを取り出す」最後の 1km は SQL です。 30 年前から続き、 これからも続く。
本ページに登場した Python 実装は 計 18 本、 SQL クエリは 20 種以上。 すべて SSDSE-B-2026 を SQLite に投入して動きます。 「正規化 → 投入 → JOIN → ウィンドウ → INDEX → トランザクション → EXPLAIN」までを 1 ファイルで体験できる構成です。 RDB は古典ですが、 21 世紀の DWH もすべてその延長線。 まずここを盤石にしてください。
本ページでは 正規化(1NF→3NF)→ ACID → JOIN → ウィンドウ関数 → トランザクション → INDEX → EXPLAIN → ER 設計 → スキーマパターン までを一気通貫で扱いました。 すべて SSDSE-B-2026 を 1 ファイルから動かせます。
すべて Yes と言えるなら、 業務 RDB の入門は卒業です。 次は BigQuery / Snowflake などのクラウド DWH や dbt に進んで、 「分析基盤の SQL」へ視野を広げてください。
RDB は約 50 年の歴史を持つ「枯れた」技術ですが、 現代のデータ基盤の中核です。 SSDSE のような公的データを練習材料に、 まず SQLite で手を動かし、 PostgreSQL で本番感を学び、 BigQuery / Snowflake でクラウド規模を体感する——この階段を昇れば、 データエンジニア / アナリストとして強固な土台ができます。
最後に一言:「RDB は 制約こそ価値」。 主キー・外部キー・NOT NULL・CHECK で「ありえない状態」を弾く設計が、 データ品質と運用負荷を 10 倍下げます。 制約を恐れず、 むしろ宣言的に書いて DB に守ってもらう——これがプロの仕事です。
本ページの内容(19 個の Python 実装 + 20 種以上の SQL + 5 種のスキーマパターン + 10 個の Q&A)は、 すべて SSDSE-B-2026 を題材に作成しました。 自分の手で動かし、 設計を紙に書き、 EXPLAIN を読む習慣を身につければ、 数年で「RDB を任せられる人」になれます。 最後まで読んでいただきありがとうございました。
なお、 本ページの実装は SQLite を中心としていますが、 PostgreSQL / MySQL / BigQuery / Snowflake すべてに 95% そのまま通用します。 SQL は数十年の歴史で枯れた共通言語であり、 これからの 30 年も使われ続けるでしょう。 投資対効果が極めて高い学習対象です。
RDB の真価は ACID 特性 (Atomicity / Consistency / Isolation / Durability) にある。 NoSQL では犠牲にされがちなこの保証は、 金融・人口統計・行政データのような「整合性が崩れると国家レベルで困る」 領域で本質的。 ここでは SSDSE-B-2026 を題材に、 「都道府県の人口総数を更新しつつ、 同時に変更履歴テーブルに記録する」 トランザクションを実装する。
🎯 このコードでやること: 都道府県人口の修正と、 変更履歴の記録を 原子的 に行う。 履歴記録に失敗したら人口修正もロールバックされることを確認する。
📥 入力データ (SSDSE-B-2026 抜粋を 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 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | import sqlite3
import pandas as pd
con = sqlite3.connect(':memory:')
con.isolation_level = None # 明示的な BEGIN/COMMIT を使う
cur = con.cursor()
cur.execute('CREATE TABLE prefecture (code TEXT PRIMARY KEY, name TEXT, population INT CHECK(population >= 0))')
cur.execute('CREATE TABLE audit_log (id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL, old_pop INT, new_pop INT, ts TEXT DEFAULT CURRENT_TIMESTAMP)')
# SSDSE は英語コード列。 都道府県ごとに全年平均をとって投入する
df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
agg = df.groupby(['Code', 'Prefecture'])['A1101'].mean().round().astype(int).reset_index()
cur.executemany('INSERT OR REPLACE INTO prefecture VALUES (?,?,?)',
agg[['Code', 'Prefecture', 'A1101']].values.tolist())
# トランザクション: 東京都の人口を更新 + audit_log に記録
try:
cur.execute('BEGIN')
cur.execute('SELECT population FROM prefecture WHERE code = ?', ('R13000',))
old_pop = cur.fetchone()[0]
new_pop = 14100000
cur.execute('UPDATE prefecture SET population = ? WHERE code = ?', (new_pop, 'R13000'))
cur.execute('INSERT INTO audit_log (code, old_pop, new_pop) VALUES (?,?,?)', ('R13000', old_pop, new_pop))
con.commit()
print('COMMIT 成功 旧={} 新={}'.format(old_pop, new_pop))
except Exception as e:
con.rollback()
print('ROLLBACK: ', e)
# 制約違反を起こしてみる (人口を負にする)
try:
cur.execute('BEGIN')
cur.execute('UPDATE prefecture SET population = -1 WHERE code = ?', ('R27000',))
cur.execute('INSERT INTO audit_log (code, old_pop, new_pop) VALUES (?,?,?)', ('R27000', 8829346, -1))
con.commit()
except sqlite3.IntegrityError as e:
con.rollback()
print('CHECK 制約違反でロールバック: ', str(e))
cur.execute('SELECT code, population FROM prefecture WHERE code IN (?,?)', ('R13000', 'R27000'))
print('更新後の値:', cur.fetchall())
cur.execute('SELECT COUNT(*) FROM audit_log')
print('audit_log 行数:', cur.fetchone()[0])
|
📤 実行すると次の出力が得られる:
💬 結果の読み方: 1 つ目のトランザクションは COMMIT され、 prefecture と audit_log の両方が更新された。 2 つ目は CHECK 制約 (人口 >= 0) に違反したため UPDATE と INSERT の両方が取り消された。 これが Atomicity (原子性): 「全部やるか、 何もやらないか」。 NoSQL ではアプリ側で補償処理を書く必要があるが、 RDB は宣言的に保証してくれる。
| 分離レベル | 防げる現象 | SSDSE 文脈での例 |
|---|---|---|
| READ UNCOMMITTED | (ほぼ何も防げない) | 未確定の集計値が他クエリに見える。 統計用途では使わない |
| READ COMMITTED | dirty read 防止 | PostgreSQL のデフォルト。 一般 BI 用途はこれで十分 |
| REPEATABLE READ | + non-repeatable read 防止 | 「同一トランザクション内で東京都の人口は不変」 を保証 |
| SERIALIZABLE | + phantom read 防止 | 都道府県全体への集計途中で行追加されない。 SQLite はデフォルトでこれに近い |
⚠️ SERIALIZABLE は遅い。 大量同時更新が起きる本番では READ COMMITTED + 楽観ロック (version カラム) が現実解。 SSDSE のような read-mostly な公的統計データ なら、 分離レベルは気にせず読み込みパフォーマンスに最適化すべき。
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 | CREATE TABLE customers ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, pref TEXT, created_at TIMESTAMP DEFAULT now() ); INSERT INTO customers (name, pref) VALUES ('山田', '東京都'); SELECT id, name FROM customers WHERE pref = '東京都'; UPDATE customers SET pref = '神奈川県' WHERE id = 1; DELETE FROM customers WHERE id = 1; -- 結合 SELECT c.name, SUM(o.amount) AS total FROM customers c JOIN orders o ON o.customer_id = c.id GROUP BY c.name ORDER BY total DESC LIMIT 10; -- トランザクション BEGIN; UPDATE accounts SET balance = balance - 1000 WHERE id = 1; UPDATE accounts SET balance = balance + 1000 WHERE id = 2; COMMIT; |
| JOIN 種別 | 意味 | SSDSE 例 |
|---|---|---|
| INNER JOIN | 両表に存在する行のみ | マスタにある県の人口のみ |
| LEFT OUTER JOIN | 左表全行 + マッチ右表 | 全人口行 + マスタにあればマスタ列、 なければ NULL |
| RIGHT OUTER JOIN | 右表全行 + マッチ左表 | マスタ全行 + 人口データあればその値 |
| FULL OUTER JOIN | 両表の全行(マッチしなければ NULL) | すべての (year, pref) 組合せ |
🎯 このコードでやること:population_fact から沖縄を一時的に削除し、 pref_master を基準に LEFT JOIN すると沖縄が NULL で残ることを確認する。
📥 入力データ:rdb_demo.db。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import sqlite3 import pandas as pd conn = sqlite3.connect('rdb_demo.db') # 沖縄の 2023 行を一時的に削除 conn.execute("DELETE FROM population_fact WHERE year=2023 AND code='R47000'") conn.commit() q = """SELECT m.pref_name, f.total_population FROM pref_master m LEFT JOIN population_fact f ON m.code = f.code AND f.year = 2023 WHERE m.pref_name IN ('東京都','沖縄県','大阪府')""" res = pd.read_sql(q, conn) # LEFT JOIN なので右側に行が無い県は NULL になる。そのままだと NaN と表示されて # 「計算が壊れた」ように見えるので、NULL と分かる形にして表示する res['total_population'] = res['total_population'].map( lambda v: 'NULL(右側に行なし)' if pd.isna(v) else f'{v:,.0f}') print(res.to_string(index=False)) conn.close() |
📤 実行すると次の出力が得られる:
💬 結果の読み方:沖縄は LEFT JOIN で NULL で残った。 INNER JOIN なら消えていたが、 LEFT で「マスタ基準・データ欠損も見える」表ができる。 欠損監視に必須のパターン。
🎯 このコードでやること:population_fact を年ごとに人口降順でランク付け(RANK() ウィンドウ関数)。
📥 入力データ:rdb_demo.db。
1 2 3 4 5 6 7 8 9 10 11 | import sqlite3
import pandas as pd
conn = sqlite3.connect('rdb_demo.db')
q = """SELECT year, m.pref_name, f.total_population,
RANK() OVER (PARTITION BY year ORDER BY total_population DESC) AS rk
FROM population_fact f JOIN pref_master m ON f.code = m.code
WHERE year IN (2012, 2023) AND rk <= 3
ORDER BY year, rk"""
# SQLite では WHERE で window 結果フィルタ不可なのでサブクエリ化
print(pd.read_sql(f"SELECT * FROM ({q.replace('AND rk <= 3','')}) WHERE rk <= 3", conn)) |
📤 実行すると次の出力が得られる:
💬 結果の読み方:2012 年も 2023 年も TOP3 は東京・神奈川・大阪。 ウィンドウ関数は GROUP BY と違い、 行を集約せずにランクを付与できる。 「県別最大/最小/順位」を 1 クエリで取れる強力機能。
🎯 このコードでやること:先ほどの JOIN クエリに EXPLAIN を付け、 INDEX が実際に使われているか確認する。
📥 入力データ:rdb_demo.db。
1 2 3 4 5 6 7 | import sqlite3
conn = sqlite3.connect('rdb_demo.db')
plan = conn.execute("""EXPLAIN QUERY PLAN
SELECT * FROM population_fact f JOIN pref_master m ON f.code = m.code
WHERE f.year = 2023""").fetchall()
for row in plan: print(row) |
📤 実行すると次の出力が得られる:
💬 結果の読み方:idx_year が使われている(SEARCH USING INDEX)。 マスタ側は PRIMARY KEY 検索。 「SCAN」が出たらフルスキャン、 INDEX 追加 or クエリ修正を検討するサイン。 EXPLAIN は性能改善の必須ツール。
| 項目 | 選択肢 | 使い分け |
|---|---|---|
| INDEX 種別 | B-Tree / Hash / GIN / BRIN | 範囲: B-Tree / 等値: Hash / 全文: GIN / 大規模時系列: BRIN |
| カラム型 | INT / BIGINT / VARCHAR / TEXT / TIMESTAMP | サイズ最小 + 用途明確 |
| パーティション | RANGE / LIST / HASH | 時系列は year で RANGE、 地域は LIST |
| レプリケーション | 同期 / 非同期 | 整合性重視: 同期、 性能重視: 非同期 |
| シャーディング | 水平分割キー | ホットスポット回避を最優先 |
WHERE UPPER(name) = 'TOKYO' は INDEX 無効化。 事前正規化 or 関数 INDEX を。RDB を使うとは SQL を書くこと。 SSDSE-B-2026 を題材に、 SELECT / WHERE / GROUP BY / HAVING / ORDER BY / LIMIT / JOIN / サブクエリ / CTE / WINDOW を一気に体験します。
🎯 このコードでやること:2023 年の人口 100 万人以上の都道府県を、 人口降順 TOP10 で表示。
📥 入力データ:rdb_demo.db。
1 2 3 4 5 6 7 8 9 10 11 | import sqlite3
import pandas as pd
conn = sqlite3.connect('rdb_demo.db')
q = """SELECT m.pref_name, f.total_population
FROM population_fact f JOIN pref_master m ON f.code = m.code
WHERE f.year = 2023 AND f.total_population >= 1000000
ORDER BY f.total_population DESC
LIMIT 10"""
print(pd.read_sql(q, conn))
conn.close() |
📤 実行すると次の出力が得られる:
💬 結果の読み方:100 万人以上に絞った TOP10。 SQL は「やりたいこと」を宣言的に書ける = 手続き型より生産性高い。
🎯 このコードでやること:年ごとの「人口 500 万人以上の都道府県数」を集計(HAVING で集計後フィルタ)。
📥 入力データ:rdb_demo.db。
1 2 3 4 5 6 7 8 9 10 11 12 | import sqlite3
import pandas as pd
conn = sqlite3.connect('rdb_demo.db')
q = """SELECT year, COUNT(*) AS big_prefs
FROM population_fact
WHERE total_population >= 5000000
GROUP BY year
HAVING COUNT(*) >= 8
ORDER BY year DESC LIMIT 5"""
print(pd.read_sql(q, conn))
conn.close() |
📤 実行すると次の出力が得られる:
💬 結果の読み方:直近 5 年、 500 万人超は常に 9 県(東京、 神奈川、 大阪、 愛知、 埼玉、 千葉、 兵庫、 北海道、 福岡)。 HAVING は GROUP BY 後の条件フィルタ、 WHERE は GROUP BY 前のフィルタ、 を明確に区別する。
🎯 このコードでやること:CTE で「各県の最新年人口」と「各県の平均人口」を別ステップで出し、 結合して「最新が平均より多い県」を抽出。
📥 入力データ:rdb_demo.db。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | import sqlite3
import pandas as pd
conn = sqlite3.connect('rdb_demo.db')
q = """WITH latest AS (
SELECT code, total_population AS latest_pop
FROM population_fact WHERE year = 2023
), avg_p AS (
SELECT code, AVG(total_population) AS avg_pop FROM population_fact GROUP BY code
)
SELECT m.pref_name, l.latest_pop, a.avg_pop
FROM latest l JOIN avg_p a USING (code) JOIN pref_master m ON l.code = m.code
WHERE l.latest_pop > a.avg_pop
ORDER BY (l.latest_pop - a.avg_pop) DESC LIMIT 5"""
print(pd.read_sql(q, conn)) |
📤 実行すると次の出力が得られる:
💬 結果の読み方:東京・神奈川・埼玉・沖縄・千葉は「歴史平均より直近が多い」= 増加トレンド。 CTE で意図を段階的に書けば、 サブクエリ多重化より遥かに読みやすい。 dbt model も CTE が基本。
🎯 このコードでやること:LAG() ウィンドウ関数で前年の人口を参照し、 都道府県別の年次成長率を計算。
📥 入力データ:rdb_demo.db。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | import sqlite3
import pandas as pd
conn = sqlite3.connect('rdb_demo.db')
q = """SELECT year, m.pref_name, total_population,
LAG(total_population) OVER (PARTITION BY f.code ORDER BY year) AS prev_pop,
ROUND(100.0 * (total_population -
LAG(total_population) OVER (PARTITION BY f.code ORDER BY year))
/ LAG(total_population) OVER (PARTITION BY f.code ORDER BY year), 3) AS growth_pct
FROM population_fact f JOIN pref_master m ON f.code = m.code
WHERE m.pref_name IN ('東京都', '沖縄県')
AND year >= 2018
ORDER BY pref_name, year"""
print(pd.read_sql(q, conn)) |
📤 実行すると次の出力が得られる:
💬 結果の読み方:LAG ウィンドウで「前年比成長率」を 1 クエリで生成。 東京は 2021 年が初の減少(コロナ)、 沖縄は 2021 年以降ほぼ横ばい。 GROUP BY と違い、 元の行粒度を保ったまま「行間計算」ができるのがウィンドウ関数の強み。
🎯 このコードでやること:long 形式 fact を全件投入し、 SQL で「総人口 / 65 歳以上人口 = 高齢化率」を計算、 2023 年で県別ランキングを取得。
📥 入力データ:先ほどの fact_long と pref_master。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import sqlite3, pandas as pd
df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
df = df.rename(columns={'SSDSE-B-2026': 'year', 'Code': 'code', 'Prefecture': 'pref_name'})
long_df = df.drop(columns=['pref_name']).melt(id_vars=['year','code'], var_name='metric', value_name='value')
conn = sqlite3.connect('rdb_demo.db')
long_df.to_sql('fact_long', conn, if_exists='replace', index=False)
# 高齢化率 = A1303 (65歳以上) / A1101 (総人口) を SQL で
q = """SELECT m.pref_name,
ROUND(100.0 * MAX(CASE WHEN f.metric='A1303' THEN f.value END) /
MAX(CASE WHEN f.metric='A1101' THEN f.value END), 2) AS aging_pct
FROM fact_long f JOIN pref_master m ON f.code = m.code
WHERE f.year = 2023
GROUP BY m.pref_name
ORDER BY aging_pct DESC LIMIT 5"""
print(pd.read_sql(q, conn)) |
📤 実行すると次の出力が得られる:
💬 結果の読み方:CASE 式で long 形式の指標を横展開し、 1 クエリで高齢化率を計算。 秋田 39.06% が全国最高。 long 形式 + CASE は「動的なピボット」として強力。 BI レポート定形パターン。
これで本ページの実装は 計 19 本。 SSDSE-B-2026 の 1 ファイルから「設計 → 投入 → 分析 → 最適化 → 運用」までを一気通貫で体験できる構成です。 ぜひ自分の手で実行し、 SQL の表現力を体感してください。
合成データで RDB の取引前後の整合性を確認する。
| 口座 | 残高 |
|---|---|
| A | 10,000 |
| B | 5,000 |
合計 = 15,000
1 2 3 4 5 6 | A, B = 10000, 5000 amount = 1000 A_new, B_new = A - amount, B + amount print(f"取引前 合計: {A + B}") print(f"取引後 合計: {A_new + B_new}") print(f"整合性: {(A_new + B_new) == (A + B)}") |
💬 手計算 (Step 2) と Python 出力が完全一致。
SSDSE-B-2026 のような公的統計データを Python で扱う際の基本パターン:
1 2 3 4 5 6 7 8 9 10 11 12 | import pandas as pd import numpy as np # データ読み込み df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=1) print(df.shape) print(df.dtypes) print(df.describe()) # 「リレーショナルDB」の文脈で扱う場合の例: # 分野: データエンジニアリング # 関連手法は同カテゴリの他用語を参照してください。 |
具体的なコードは データエンジニアリング を参照してください。
分析結果を報告するときに含めるべき情報:
(1) SQLite で SSDSE データ:
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 | # ── この抜粋で使うデータを用意します(SSDSE-B の 47 都道府県・最新年度)── import pandas as pd df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', header=1) df = df[df['地域コード'].astype(str).str.match(r'^R\d{5}$', na=False)].copy() df['年度'] = pd.to_numeric(df['年度'], errors='coerce') df = df[df['年度'] == df['年度'].max()] for _c in df.columns[3:]: df[_c] = pd.to_numeric(df[_c], errors='coerce') df['高齢化率'] = df['65歳以上人口'] / df['総人口'] * 100 # 見本でよく使われる仮の列名を、実データから作っておく df['income'] = df['消費支出(二人以上の世帯)'] df['population'] = df['総人口'] _region = {'北海道': '北海道', '青森県': '東北', '岩手県': '東北', '宮城県': '東北', '秋田県': '東北', '山形県': '東北', '福島県': '東北', '茨城県': '関東', '栃木県': '関東', '群馬県': '関東', '埼玉県': '関東', '千葉県': '関東', '東京都': '関東', '神奈川県': '関東'} df['region'] = df['都道府県'].map(_region).fillna('その他') df['地域'] = df['region'] import pandas as pd, sqlite3 con = sqlite3.connect('ssdse.db') df.to_sql('ssdse_b', con, if_exists='replace', index=False) result = pd.read_sql_query(""" SELECT 都道府県, AVG(高齢化率) AS avg_aging FROM ssdse_b GROUP BY 都道府県 ORDER BY avg_aging DESC LIMIT 10 """, con) print(result) |
(2) SQLAlchemy で PostgreSQL:
1 2 3 4 5 | from sqlalchemy import create_engine, text engine = create_engine('postgresql://user:pass@localhost:5432/mydb') with engine.connect() as conn: df = pd.read_sql_query(text("SELECT * FROM ssdse_b WHERE 年度=2024"), conn) print(df.head()) |
(3) 安全な書き方(パラメータ):
1 2 3 4 5 6 7 8 | cur = con.cursor() pref = '東京都' # ユーザ入力想定 # 良い例(プレースホルダ) cur.execute("SELECT * FROM ssdse_b WHERE 都道府県 = ?", (pref,)) # 悪い例(SQLインジェクション可) # cur.execute(f"SELECT * FROM ssdse_b WHERE 都道府県 = '{pref}'") |
| 製品 | 特徴 | 向く用途 |
|---|---|---|
| PostgreSQL | OSS、拡張豊富、JSON対応 | 汎用、複雑クエリ |
| MySQL/MariaDB | OSS、速い、普及度高 | Web アプリ |
| SQLite | ファイル DB、軽量 | 組込、アプリ内 |
| Oracle | 商用、大企業、PL/SQL | 基幹システム |
| SQL Server | MS製、Windows統合 | Microsoft環境 |
| Amazon RDS | マネージド | クラウド |
| CockroachDB/TiDB | NewSQL、分散+SQL | グローバル |
正規形は「概念」だけ覚えても使えません。 SSDSE-B-2026 を題材に、 wide 形式 → 1NF → 2NF → 3NF と段階的に変形し、 各段で何が変わるのかをコードで体感します。
SSDSE-B-2026 は実は「1NF を満たすが冗長」な形。 各セルは原子値だが、 (year, code) → pref_name の部分従属あり。
🎯 このコードでやること:SSDSE の wide テーブルから、 (year, code, metric, value) の long fact + (code, pref_name) のマスタ に分解する(2NF 化)。
📥 入力データ:data/raw/SSDSE-B-2026.csv(cp932)。
1 2 3 4 5 6 7 8 9 10 11 12 13 | import pandas as pd
df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
df = df.rename(columns={'SSDSE-B-2026': 'year', 'Code': 'code', 'Prefecture': 'pref_name'})
# 2NF 化: マスタとファクトに分離
pref_master = df[['code', 'pref_name']].drop_duplicates().reset_index(drop=True)
fact_long = df.drop(columns=['pref_name']).melt(
id_vars=['year', 'code'], var_name='metric', value_name='value')
print(f"pref_master 行数: {len(pref_master)}(47 都道府県)")
print(f"fact_long 行数 : {len(fact_long):,} 行")
print(f"重複 pref_name : 元 {len(df):,} 行 → マスタ {len(pref_master)} 行 ({100*len(pref_master)/len(df):.2f}%)") |
📤 実行すると次の出力が得られる:
💬 結果の読み方:pref_name が 564 回繰り返されていた冗長性を 47 行に集約 = ストレージ削減 + 名称変更時の一括反映が可能に。 fact_long は (year, code, metric, value) のシンプルな構造で、 BI/分析に流しやすい。
🎯 このコードでやること:metric 列も「コード(A1101 等)→ 日本語名(総人口)」の対応がある。 metric_master を追加して 3NF 化。
📥 入力データ:SSDSE-B-2026.csv の 1 行目(カラム英語コード)と 2 行目(カラム日本語名)。
1 2 3 4 5 6 7 8 9 10 11 12 13 | import pandas as pd
# 1 行目: 英語コード、 2 行目: 日本語名
header = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', nrows=1).columns.tolist()
jp_label = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', nrows=2).iloc[0].tolist()
# 最初の 3 列は year/code/pref なのでスキップ
metric_master = pd.DataFrame({'metric_code': header[3:], 'metric_jp': jp_label[3:]})
print(f"metric_master: {len(metric_master)} 行")
print(metric_master.head(8))
print()
print(f"3NF 後の合計セル: {47*2 + len(metric_master)*2 + 61476*4:,} = pref + metric + fact")
print(f"元 wide 形式 : {564 * 112:,} セル") |
📤 実行すると次の出力が得られる:
💬 結果の読み方:metric_master 109 行が分離され、 metric_jp が一元管理可能に。 セル数は long 形式化で増えるが、 列指向圧縮(Parquet)と組み合わせれば実ストレージは縮む。 BI/ML での「指標切替」が SQL の WHERE metric_code = 'A1101' で済む利便性も得られる。
long 形式は「同じ値が縦に並ぶ → 圧縮率高い」「列追加に強い(新 metric_code 追加で済む)」という利点があります。 wide vs long は単純な比較ではなく、 ストレージ・クエリ・スキーマ進化のトレードオフです。
EXPLAIN ANALYZE + 結合前 COUNT(DISTINCT key) 確認が安全策。latin1 や SHIFT_JIS 設定だと文字化け。 PostgreSQL では SHOW SERVER_ENCODING; で UTF-8 を確認、 MySQL は utf8mb4 (4 バイト UTF-8) を選び collation = utf8mb4_ja_0900_as_cs_ks で照合順序を日本語に最適化。= NULL は常に偽。 IS NULL を使う。 COUNT(col) は NULL を除外。この補講は、 ここまでの「正規化・ACID・JOIN・SQL」の各論を 1 枚絵で俯瞰し直す ためのまとめです。 あなたが今見ているものは「RDB という統計データ・業務データを格納する標準箱の中身」であり、 ジャストインタイム型データサイエンス教育では、 SSDSE-B-2026 (47 都道府県 × 109 指標) を例題に「読みやすい表=統計の前提となる正規化された表」をまず手に入れることを最優先で扱います。 冒頭文脈をもう一度押さえます: RDB は「分析の信頼性 (正しさ・再現性)」を保証する装置であり、 ノートブックで pandas を叩く前段にある「データの基盤」です。
下図は SSDSE-B-2026.csv を RDB に格納する際の 3 段階設計を模式化したもの (実際の散布図で代用)。 概念設計 (エンティティ抽出) → 論理設計 (正規化と関係定義) → 物理設計 (インデックス・パーティション) の順に進める。

→ 散布図の各点を「テーブル候補」と読み替えると、 まとまりごとに正規化単位 (prefecture / population_year / industry_year など) が見えてくる。 RDB 設計はまさにこの「点を線でくくる」作業に似ている。
RDB に格納する前に必ず「分布を見て CHECK 制約と型を決める」。 下のヒストグラムは SSDSE-B-2026 (2023 年・47 都道府県) の総人口分布。 右に大きく外れる東京を見て、 population BIGINT CHECK (population >= 0) 程度の制約に落ち着くことが分かる。

→ 最大値は 14,047,594 (東京) で、 INT (2,147,483,647 上限) で十分だが、 将来的な世界都市データ統合を見越して BIGINT を採用しておくと安全。 「分布を見て型を決める」は RDB 設計の鉄則。
SQL の GROUP BY region で集計する前に、 ボックスプロットで 地域内ばらつき を確認する。 内的ばらつきが大きいほど平均値の意味は薄れ、 中央値や四分位を出すべきと判断できる。

→ ボックスが縦に長い地域 (関東) は中の都県差が大きい。 RDB では PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY population) で中央値を取れる (PostgreSQL/Oracle)。 MySQL では ROW_NUMBER() で擬似的に実装する。
SSDSE のような 約 560 行・約 110 列クラスのデータでは、 多くの操作は pandas でも RDB でも書ける。 ではどちらを選ぶべきか。 結論を先に書くと「データの正本 (Source of Truth) は RDB、 分析の作業場は pandas」がほぼ全ての現場で採用されているパターンである。 理由は以下の通り。
| 観点 | RDB | pandas (CSV / Parquet) |
|---|---|---|
| 同時編集 | ◎ (トランザクション) | × (ファイル上書きで競合) |
| 制約検証 | ◎ (CHECK / FK / UNIQUE) | △ (assert を都度書く) |
| 履歴管理 | ◎ (audit_log + INSERT トリガ) | △ (git でファイル全体差分) |
| 探索・可視化 | △ (SQL は集計向き、 可視化は別ツール) | ◎ (matplotlib / seaborn 即時) |
| 機械学習前処理 | △ (ピボット・型変換が冗長) | ◎ (scikit-learn と直結) |
| 大規模 (10 万行〜) | ◎ (インデックスで O(log n)) | ○ (Polars / DuckDB なら高速) |
→ 実務では RDB → SELECT で部分抽出 → pandas で分析 → 結果テーブルとして RDB に書き戻し のパイプラインが標準。 SSDSE のような公開データを扱う研究室なら、 SQLite を 1 つ立てて全員で参照するだけでも、 「ファイル名が違って分析がズレた」 系の事故を激減させられる。
このコードでやること: SSDSE-B-2026.csv を読み込み、 SQLite に prefecture + population_year の 2 テーブルとして正規化投入、 SQL で「2023 年人口上位 5 県」を集計する。 RDB を「データ分析の正本」として扱う標準パターン。
📥 入力データ (SSDSE-B-2026 抜粋 — 投入前の df.head()):
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 sqlite3 import pandas as pd # 1. CSV を読み込み (1 行目=英字コード, 2 行目=日本語名なので skiprows=[1]) d = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1]) # 2. 2023 年のみ抽出 (年度列の名前は 'SSDSE-B-2026') d23 = d[d['SSDSE-B-2026'] == 2023] # 3. SQLite 接続(同じページの他の例がファイル DB を掴んでいることがあるのでメモリ上に作る) conn = sqlite3.connect(':memory:') # 4. 正規化: prefecture (県マスタ) と population_year (年別人口) に分割投入 pref = d23[['Code', 'Prefecture']].drop_duplicates() pref.columns = ['pref_code', 'name'] pref.to_sql('prefecture', conn, if_exists='replace', index=False) pop = d23[['Code', 'A1101']].copy() pop.columns = ['pref_code', 'population'] pop['year'] = 2023 pop.to_sql('population_year', conn, if_exists='replace', index=False) # 5. SQL で集計: 2023 年人口上位 5 県 sql = """ SELECT p.name, py.population FROM prefecture p JOIN population_year py ON p.pref_code = py.pref_code WHERE py.year = 2023 ORDER BY py.population DESC LIMIT 5 """ print(pd.read_sql(sql, conn)) |
📤 実行すると次の出力が得られる:
💬 結果の読み方: 「県マスタ」と「年別人口」に分けたおかげで、 将来 2024 年のデータが入っても population_year に 47 行追加するだけで済む。 もし 1 つのテーブルに pop_2023, pop_2024, pop_2025 と横に並べていたら、 毎年 ALTER TABLE ADD COLUMN が必要になり、 BI ツールのクエリも全部書き直しになる。 これが「縦持ち (long format) で正規化する」 ことの実利。
| # | 失敗パターン | 症状 | 修正 |
|---|---|---|---|
| 1 | 1 セルに複数値 (CSV in cell) | tags = "東京,神奈川,千葉" のような格納 | 多対多テーブルに分割 (1NF 違反) |
| 2 | 主キーに業務的意味を持たせる | 都道府県コードを主キーにしたが市町村合併で変動 | サロゲートキー (BIGINT id) を別途用意 |
| 3 | NULL の乱用 | 欠損か未入力か未確認かが区別できない | 状態カラム (status) を分離、 値の意味を定義 |
| 4 | JOIN を恐れて 1 テーブル肥大化 | 50 列を超え、 SELECT * のコストが膨大 | 3NF まで正規化、 SELECT は必要列だけ列挙 |
| 5 | 外部キー無し | 子テーブルに親に無い ID が混入 | FOREIGN KEY 宣言 + ON DELETE RESTRICT |
→ SSDSE データを RDB 化する研究室で最も多いのは パターン 1 (1 セルに複数値) と パターン 4 (1 テーブル肥大化)。 ジャストインタイム教育では、 「最初は CSV 1 枚で OK、 ただし 200 行を超えたら必ず正規化」 を経験則として教えると衝突が少ない。
| 領域 | 用語 | RDB との関係 |
|---|---|---|
| クエリ言語 | SQL / DDL | RDB を操作する標準言語 |
| 結合 | テーブルの結合 | 正規化されたテーブルを統合して読む |
| ID 一貫性 | 主キー / 外部キー | RDB の信頼性の核 |
| 派生 | NoSQL / DWH | 用途別の補完技術 |
| 処理基盤 | 分散処理 | RDB を超える規模での選択肢 |
この補講で扱った内容について、 自分で答えてみよう。 即答できない問題があれば該当セクションに戻って読み直す。
→ 解答例は明示しないが、 すべて本文・補講・上の表のどこかに根拠がある。 自分で答えを書き出してから本文に戻ると、 RDB の設計判断が「定型作業」 ではなく「分析の信頼性を担保する明示的設計」 として腑に落ちる。
SSDSE-B-2026 を素直に CSV のまま投入すると 1 行 = 1 県、 1 列 = 1 指標の横持ち (109 指標列) になる。 これは BI の一枚絵には便利だが、 RDB の世界では 「指標が増える度に ALTER TABLE が必要」という致命的な保守コストを生む。 ここでは縦持ち (long format) の 3NF テーブル設計を、 SSDSE 4 指標を例に段階的に示す。
| 段階 | テーブル名 | 主な列 | 解消した依存 |
|---|---|---|---|
| 0NF (CSV のまま) | ssdse_raw | pref_code, name, A1101, A1301, A1303, A4101, ... | (なし) |
| 1NF | ssdse_long | pref_code, name, indicator_code, year, value | 列の繰り返しを行に展開 |
| 2NF | prefecture / indicator_value | prefecture(pref_code,name) + indicator_value(pref_code, indicator_code, year, value) | 部分関数従属 (name は pref_code だけに依存) |
| 3NF | + indicator_master | indicator_master(indicator_code, label, unit, source) | 推移的関数従属 (label/unit は indicator_code に依存) |
→ 3NF に到達すると、 「指標を 1 つ追加」 は indicator_master に 1 行追加 + indicator_value に 47 行追加だけで済む。 0NF (CSV) では新しい列を追加してから全 BI クエリを書き直す必要があった。 SSDSE が毎年更新される現実を考えれば、 縦持ち 3NF が圧倒的に低コスト。
このコードでやること: SSDSE-B-2026 (横持ち 109 指標列) を pandas で melt し、 3 つの正規化テーブルに分けて SQLite に投入する。 横持ち → 縦持ち変換は pd.melt 一発。
📥 入力データ (0NF, 47 行 × 109 指標列の先頭抜粋):
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 | import pandas as pd, sqlite3
df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
# 2023 年のみ抽出 → 1 県 1 行の 0NF 横持ちにする
d23 = df[df['SSDSE-B-2026'] == 2023].drop(columns=['SSDSE-B-2026'])
# 1NF: 横持ち → 縦持ち (Code/Prefecture 以外の指標列を melt)
long = d23.melt(id_vars=['Code', 'Prefecture'],
var_name='indicator_code',
value_name='value')
long['year'] = 2023
long = long.rename(columns={'Code': 'pref_code', 'Prefecture': 'name'})
# 2NF: prefecture (name の部分従属を分離)
prefecture = long[['pref_code', 'name']].drop_duplicates()
indicator_value = long[['pref_code', 'indicator_code', 'year', 'value']]
# 3NF: indicator_master (label/unit の推移従属を分離 — ここでは仮値で生成)
indicator_master = pd.DataFrame({
'indicator_code': long['indicator_code'].unique(),
'label': '(SSDSE 指標)',
'unit': '(単位)',
})
conn = sqlite3.connect('ssdse_3nf.db')
prefecture.to_sql('prefecture', conn, if_exists='replace', index=False)
indicator_value.to_sql('indicator_value', conn, if_exists='replace', index=False)
indicator_master.to_sql('indicator_master', conn, if_exists='replace', index=False)
print('prefecture rows:', len(prefecture))
print('indicator_value rows:', len(indicator_value))
print('indicator_master rows:', len(indicator_master))
|
📤 実行すると次の出力が得られる:
💬 結果の読み方: 47 県 × 109 指標 = 5,123 行が縦持ちで生まれる。 一見「行数が爆発した」 ようだが、 これが 正規化の正常な姿。 indicator_value にインデックス (indicator_code, year) を張れば検索は O(log n) で済むので、 BI 用途でもパフォーマンス劣化は起きない。 むしろ「指標追加が ALTER TABLE 不要」「複数年データの統合が UNION 不要」 という保守利得が圧倒的。
「リレーショナルDB」は単独で完結する手法ではなく、 隣接領域と連携することで真価を発揮する。
SSDSE-B-2026 を SQLite に正規化投入し、 マスタ + 観測の JOIN + 集計 + ビュー定義まで通すと、 CSV 直読みでは見えなかった「整合性 + 速度 + 共有」が得られる。
「RDB (リレーショナルデータベース) 詳細」を実際の課題に当てはめるとき、 状況別に何を選ぶかを 3 段階で判定する。
SSDSE-B-2026 を 47 行 × 112 列で扱う限りは SQLite で十分。 県別・年別の集計テーブルを SQL で書けば、 pandas より高速かつ再現性が高い。