# -*- coding: utf-8 -*-
"""
X03: 観光統計 4 指標のペアプロット解析 — 散布図行列・PCA・階層クラスタで広島県市町を読み解く
================================================================================

DoBoX が公開する観光関連データセット 4 件:
    #1567 広島県クルーズ船観光客分析データ (令和6年度)
    #1282 瀬戸内しまたびライン利用状況
    #1280 せとうちモニタークルーズ実施結果 (2022年12月)
    #1447 インフラツーリズム_施設情報

これらを **市町別 4 次元ベクトル** に統合し、
    1) pandas の散布図行列 (scatter_matrix) で全 4×4 ペアを俯瞰
    2) 単回帰直線 + R² を各ペアに重ね描き
    3) 相関ヒートマップで相関行列を 1 枚に圧縮
    4) **PCA** で 4 次元 → 2 次元、PC1×PC2 散布 + ローディング棒
    5) **重回帰** (OLS) でインフラツーリズム掲載数を他 3 指標から予測
    6) **階層クラスタリング** (Ward 法) で市町を 3〜4 群に分ける
の 6 分析を 10 セクション L01 水準で展開する。

データの実態:
    - #1567 / #1282 / #1280 は DoBoX 限定で resource_download が無く、
      ローカル CSV として取得できない (S35/S47/S57 でも同じ制約を確認済み)。
    - そこで本記事では **「実データ + カタログメタ + 物理インフラの代理指標」**
      の 3 層で 4 指標を構築する:

        X1 クルーズ寄港強度  = sea_route.csv (#1278) の市町別寄港地数 (広島県内のみ)
        X2 しまたび航路強度  = sea_route.csv のうち港名 or 住所に「島」を含む寄港地数
                              (#1282 「瀬戸内しまたび」 = 島嶼間航路の代理)
        X3 モニター実施強度  = sea_route.csv の市町別住所末端ユニーク数
                              (#1280 「モニタークルーズ」 = 多地点実証の代理)
        X4 インフラツーリズム = infra_tourism.csv (#1447, 40 施設) を最近傍寄港地に
                              紐付け、市町別の施設数

    すべての X1-X4 は **sea_route.csv (実データ) + infra_tourism.csv (実データ) +
    dataset_index.csv のカタログメタ** から派生しており、
    数値そのものは本物の物理インフラ・施設配置を反映する。

実行:
    cd "2026 DoBoX 教材"
    py -X utf8 lessons/X03_tourism_4indicators_pairplot.py

制約:
    - 並列処理なし、メモリ 50MB 未満
    - DoBoX 限定データの取得は試行のみ (resource_download なしを確認後フォールバック)
"""
from __future__ import annotations
import re
import math
import json
import warnings
from pathlib import Path

import numpy as np
import pandas as pd
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
from pandas.plotting import scatter_matrix

from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression
from scipy.cluster.hierarchy import linkage, fcluster, dendrogram
from scipy.spatial.distance import pdist

from _common import ROOT, ASSETS, LESSONS, render_lesson, code, figure, data_recipe

plt.rcParams["font.family"] = "Yu Gothic"
plt.rcParams["axes.unicode_minus"] = False
warnings.filterwarnings("ignore", category=UserWarning)

RNG = np.random.default_rng(42)

# === パス定義 =================================================================
IDX_CSV   = ROOT / "data" / "dataset_index.csv"
ROUTE_CSV = ROOT / "data" / "extras" / "sea_route.csv"
INFRA_CSV = ROOT / "data" / "extras" / "infra_tourism.csv"

# 中間 CSV (assets 配下 = HTML から直リンク)
OUT_INDICATORS = ASSETS / "X03_indicators_matrix.csv"      # 市町×4指標
OUT_CORR       = ASSETS / "X03_correlation_matrix.csv"     # 4×4 相関行列
OUT_REG        = ASSETS / "X03_regression_pairs.csv"       # 6 ペアの単回帰係数
OUT_PCA_LOAD   = ASSETS / "X03_pca_loadings.csv"           # PCA ローディング 4×2
OUT_PCA_SCORE  = ASSETS / "X03_pca_scores.csv"             # 市町×PC1/PC2
OUT_OLS        = ASSETS / "X03_ols_coefficients.csv"       # 重回帰係数
OUT_CLUSTER    = ASSETS / "X03_cluster_assignments.csv"    # 市町→クラスタ番号

# 図 PNG
PNG_PAIR    = ASSETS / "X03_pairplot.png"          # 散布図行列 4×4
PNG_HEAT    = ASSETS / "X03_correlation_heatmap.png"
PNG_PCA     = ASSETS / "X03_pca_scatter.png"       # PC1×PC2 + ローディング
PNG_DENDRO  = ASSETS / "X03_dendrogram.png"
PNG_OLS     = ASSETS / "X03_ols_bars.png"          # 係数バー

# 4 指標とそのカラー (本記事で固定)
IND_COLS  = ["X1_cruise", "X2_shimatabi", "X3_monitor", "X4_infra"]
IND_LABEL = {
    "X1_cruise":    "X1 クルーズ寄港強度",
    "X2_shimatabi": "X2 しまたび航路強度",
    "X3_monitor":   "X3 モニター実施強度",
    "X4_infra":     "X4 インフラツーリズム掲載数",
}
IND_COLOR = {
    "X1_cruise":    "#0969da",
    "X2_shimatabi": "#1f883d",
    "X3_monitor":   "#fb8500",
    "X4_infra":     "#cf222e",
}


def haversine_km(lat1, lon1, lat2, lon2):
    """2点 (緯度,経度) 間の距離 (km)。WGS84 球近似。"""
    R = 6371.0
    p1, p2 = math.radians(lat1), math.radians(lat2)
    dp = math.radians(lat2 - lat1)
    dl = math.radians(lon2 - lon1)
    a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
    return 2 * R * math.asin(math.sqrt(a))


def extract_city(addr):
    """住所から「広島県 + 市町村」を抜く。郡を含む町は『町』を採用。"""
    s = str(addr).replace("広島県", "")
    # まず市
    m = re.match(r"^([^市町村郡]+市)", s)
    if m:
        return m.group(1)
    # 次に郡XX町
    m = re.match(r"^[^市町村郡]+郡([^市町村郡]+町)", s)
    if m:
        return m.group(1)
    m = re.match(r"^([^市町村郡]+町)", s)
    if m:
        return m.group(1)
    m = re.match(r"^([^市町村郡]+村)", s)
    if m:
        return m.group(1)
    return None


# === STEP 1: カタログメタ + 実データ読込 =======================================
print("=== STEP 1: カタログ + 実データ読込 ===")
idx = pd.read_csv(IDX_CSV, encoding="utf-8-sig")
idx["Id"] = pd.to_numeric(idx["Id"], errors="coerce").astype("Int64")
idx = idx.dropna(subset=["Id"]).reset_index(drop=True)
idx = idx.rename(columns={"Desc": "Description"}) if "Desc" in idx.columns else idx
print(f"  カタログ: {len(idx)} 件")

TARGETS = {1567: "X1_cruise", 1282: "X2_shimatabi", 1280: "X3_monitor", 1447: "X4_infra"}
catalog_meta = idx[idx["Id"].isin(TARGETS)].copy()
catalog_meta["indicator"] = catalog_meta["Id"].map(TARGETS)
catalog_meta["desc_len"]  = catalog_meta["Description"].fillna("").str.len()
catalog_meta["title_len"] = catalog_meta["Title"].fillna("").str.len()
print(catalog_meta[["Id", "Title", "indicator", "desc_len"]].to_string(index=False))

route = pd.read_csv(ROUTE_CSV, encoding="utf-8-sig")
print(f"  sea_route (#1278): {len(route)} 港")
infra = pd.read_csv(INFRA_CSV, encoding="utf-8-sig")
print(f"  infra_tourism (#1447): {len(infra)} 施設")

# === STEP 2: 市町抽出 (sea_route の住所を正解辞書とする) ======================
print("\n=== STEP 2: 市町抽出 (広島県のみ) ===")
# 広島県の港のみに絞る (sea_route には対岸の愛媛県の港も含まれるため)
hiroshima_mask = route["住所"].astype(str).str.startswith("広島県")
n_total = len(route)
route = route[hiroshima_mask].reset_index(drop=True)
print(f"  全 {n_total} 港 → 広島県内 {len(route)} 港 に絞り込み")
route["city"] = route["住所"].apply(extract_city)
route = route.dropna(subset=["city"]).reset_index(drop=True)
city_set = sorted(route["city"].unique())
print(f"  市町数: {len(city_set)} ({city_set})")

# === STEP 3: 4 指標を市町別に構築 ============================================
print("\n=== STEP 3: 4 指標を市町別に構築 ===")

# X1: クルーズ寄港強度 = 寄港地数 (=広島県内に存在する物理寄港インフラ容量)
X1 = route.groupby("city").size().rename("X1_cruise")

# X2: しまたび航路強度 = 「港名」(住所ではなく寄港地名) に「島」を含む寄港地数
#     (#1282「瀬戸内しまたびライン = 島嶼間航路」を、島嶼を直接指す港名の数で近似。
#      住所も含めると「広島市・江田島市・大崎上島町」など市町名側の "島" がカウント
#      されてしまうので、寄港地名のみに限定する)
def is_island_port(row):
    name = str(row["寄港地"])
    # 「広島」港名は除外 (これは市の中心港で島嶼航路ではない)
    if name.strip() == "広島":
        return 0
    return int("島" in name)
route["island_flag"] = route.apply(is_island_port, axis=1)
X2 = route.groupby("city")["island_flag"].sum().rename("X2_shimatabi")

# X3: モニター実施強度 = 住所末端パターンのユニーク数
#     (モニタークルーズは「複数地点を巡る実証」が本質なので、
#      市町内の地名バリエーション数で近似する)
def addr_tail(addr):
    s = str(addr).replace("広島県", "")
    s = re.sub(r"[0-9０-９ーー\-丁目番地号]", "", s)
    # 市町以下の部分のみ取り出して末端 4 文字
    s = re.sub(r"^[^市町村郡]+(市|町|村|郡[^市町村郡]+町)", "", s)
    return s[:4] if len(s) >= 4 else s
route["addr_tail"] = route["住所"].apply(addr_tail)
X3 = route.groupby("city")["addr_tail"].nunique().rename("X3_monitor").astype(float)

# X4: インフラツーリズム掲載数 = 各施設を最近傍寄港地の市町に紐付け
print("  infra_tourism 施設を最近傍寄港地に逆ジオコーディング...")
port_coords = route[["city", "緯度", "経度"]].dropna().reset_index(drop=True)
infra = infra.dropna(subset=["緯度", "経度"]).reset_index(drop=True)


def assign_city(lat, lon):
    """infra 施設(緯度,経度)の最近傍寄港地の市町を返す"""
    if pd.isna(lat) or pd.isna(lon):
        return None
    dists = port_coords.apply(
        lambda r: haversine_km(lat, lon, r["緯度"], r["経度"]), axis=1
    )
    return port_coords.loc[dists.idxmin(), "city"]


infra["city"] = [assign_city(la, lo) for la, lo in zip(infra["緯度"], infra["経度"])]
X4 = infra.groupby("city").size().rename("X4_infra")

# 4 指標を 1 表に統合 — 市町ごとに NaN は 0 で埋める
M = pd.concat([X1, X2, X3, X4], axis=1).fillna(0).astype(float)
M.index.name = "city"
M = M.loc[city_set].reset_index()  # 11 市町固定順
print("\n  指標行列 M (城市 × 4指標):")
print(M.to_string(index=False))
M.to_csv(OUT_INDICATORS, index=False, encoding="utf-8-sig")
print(f"  → {OUT_INDICATORS.name}")

# === STEP 4: 散布図行列 + 単回帰直線 + R² =====================================
print("\n=== STEP 4: 散布図行列 + 各ペア単回帰 ===")
data4 = M[IND_COLS].copy()

# 単回帰: 6 ペア (i<j) について R² を計算
reg_records = []
n_vars = len(IND_COLS)
for i in range(n_vars):
    for j in range(n_vars):
        if i == j:
            continue
        x = data4[IND_COLS[i]].values.reshape(-1, 1)
        y = data4[IND_COLS[j]].values
        lr = LinearRegression().fit(x, y)
        r2 = lr.score(x, y)
        reg_records.append({
            "x": IND_COLS[i], "y": IND_COLS[j],
            "slope": float(lr.coef_[0]), "intercept": float(lr.intercept_),
            "R2": float(r2),
        })
reg_df = pd.DataFrame(reg_records)
reg_df.to_csv(OUT_REG, index=False, encoding="utf-8-sig")
print(f"  6 ペア 単回帰: \n{reg_df[reg_df['x']<reg_df['y']].to_string(index=False)}")

# scatter_matrix を描いて、上三角に R² を、下三角に回帰直線を重ねる
fig, axes = plt.subplots(n_vars, n_vars, figsize=(11.5, 10.5))
for i in range(n_vars):
    for j in range(n_vars):
        ax = axes[i, j]
        xi = data4[IND_COLS[j]].values
        yi = data4[IND_COLS[i]].values
        if i == j:
            ax.hist(xi, bins=8, color=IND_COLOR[IND_COLS[i]], alpha=0.75,
                    edgecolor="black", linewidth=0.4)
            ax.set_yticks([])
        else:
            ax.scatter(xi, yi, s=46, c=IND_COLOR[IND_COLS[i]], alpha=0.75,
                       edgecolor="black", linewidth=0.4)
            # 回帰直線
            lr = LinearRegression().fit(xi.reshape(-1, 1), yi)
            xx = np.linspace(xi.min(), xi.max(), 50)
            ax.plot(xx, lr.predict(xx.reshape(-1, 1)),
                    color="#222", linewidth=1.2, alpha=0.85)
            r2 = lr.score(xi.reshape(-1, 1), yi)
            ax.text(0.05, 0.92, f"R²={r2:.2f}",
                    transform=ax.transAxes, fontsize=9.5,
                    bbox=dict(boxstyle="round,pad=0.25",
                              facecolor="white", edgecolor="#888", alpha=0.85))
        if i == n_vars - 1:
            ax.set_xlabel(IND_LABEL[IND_COLS[j]], fontsize=8.5)
        else:
            ax.set_xticklabels([])
        if j == 0:
            ax.set_ylabel(IND_LABEL[IND_COLS[i]], fontsize=8.5)
        else:
            ax.set_yticklabels([])
        ax.tick_params(axis="both", labelsize=7.5)
        ax.grid(alpha=0.25)
fig.suptitle(f"散布図行列  4 観光指標 × {len(M)} 市町  "
             f"(対角=ヒスト, 非対角=散布+単回帰直線+R²)",
             fontsize=12, y=0.995)
plt.tight_layout()
plt.savefig(PNG_PAIR, dpi=140, bbox_inches="tight")
plt.close()
print(f"  → {PNG_PAIR.name}")

# === STEP 5: 相関ヒートマップ ================================================
print("\n=== STEP 5: 相関ヒートマップ ===")
corr = data4.corr()  # ピアソン相関
print(corr.round(3).to_string())
corr.to_csv(OUT_CORR, encoding="utf-8-sig")

fig, ax = plt.subplots(figsize=(7.0, 6.0))
im = ax.imshow(corr.values, vmin=-1, vmax=1, cmap="RdBu_r")
ax.set_xticks(range(n_vars))
ax.set_yticks(range(n_vars))
ax.set_xticklabels([IND_LABEL[c] for c in IND_COLS], rotation=30, ha="right", fontsize=10)
ax.set_yticklabels([IND_LABEL[c] for c in IND_COLS], fontsize=10)
for i in range(n_vars):
    for j in range(n_vars):
        v = corr.values[i, j]
        ax.text(j, i, f"{v:.2f}", ha="center", va="center",
                color="white" if abs(v) > 0.55 else "black", fontsize=11)
cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
cbar.set_label("ピアソン相関係数", fontsize=10)
ax.set_title("4 観光指標の相関行列  (ピアソン)")
plt.tight_layout()
plt.savefig(PNG_HEAT, dpi=140, bbox_inches="tight")
plt.close()
print(f"  → {PNG_HEAT.name}")

# === STEP 6: PCA (4 次元 → 2 次元) ============================================
print("\n=== STEP 6: PCA ===")
scaler = StandardScaler()
Xs = scaler.fit_transform(data4.values)  # 11 × 4 標準化
pca = PCA(n_components=2, random_state=42)
Z = pca.fit_transform(Xs)  # 11 × 2 スコア
loadings = pca.components_.T  # 4 × 2 ローディング
evr = pca.explained_variance_ratio_
print(f"  EVR = {evr.round(3).tolist()}, cum = {evr.cumsum().round(3).tolist()}")

load_df = pd.DataFrame(loadings, index=IND_COLS, columns=["PC1", "PC2"])
load_df["abs_PC1"] = load_df["PC1"].abs()
load_df["abs_PC2"] = load_df["PC2"].abs()
load_df.to_csv(OUT_PCA_LOAD, encoding="utf-8-sig")
print("  ローディング:\n", load_df.round(3).to_string())

score_df = pd.DataFrame(Z, columns=["PC1", "PC2"])
score_df.insert(0, "city", M["city"].values)
score_df.to_csv(OUT_PCA_SCORE, index=False, encoding="utf-8-sig")

# 図: 上=PC1×PC2 散布, 下=ローディング棒
fig = plt.figure(figsize=(13.5, 5.6))
ax1 = fig.add_subplot(1, 2, 1)
ax2 = fig.add_subplot(1, 2, 2)

# 左: PC1 × PC2 スコア散布
ax1.axhline(0, color="#999", lw=0.5)
ax1.axvline(0, color="#999", lw=0.5)
for k, city in enumerate(M["city"]):
    ax1.scatter(Z[k, 0], Z[k, 1], s=110, c="#0969da",
                edgecolor="black", linewidth=0.6, alpha=0.85)
    ax1.annotate(city, (Z[k, 0], Z[k, 1]),
                 xytext=(6, 4), textcoords="offset points", fontsize=9)
# ローディングをベクトルで重ね描き (バイプロット風)
arrow_scale = max(np.abs(Z).max(axis=0)) * 0.85
for v, name in zip(loadings, IND_COLS):
    ax1.arrow(0, 0, v[0] * arrow_scale, v[1] * arrow_scale,
              head_width=0.10, head_length=0.10,
              color=IND_COLOR[name], alpha=0.85, length_includes_head=True)
    ax1.text(v[0] * arrow_scale * 1.13, v[1] * arrow_scale * 1.13,
             IND_LABEL[name], color=IND_COLOR[name], fontsize=9.5, weight="bold")
ax1.set_xlabel(f"PC1 ({evr[0]*100:.1f}%)")
ax1.set_ylabel(f"PC2 ({evr[1]*100:.1f}%)")
ax1.set_title(f"PCA バイプロット — {len(M)} 市町 × 4 指標 → 2 次元", pad=14)
# 軸の余白を矢印が突き抜けないよう少し広げる
zr = max(np.abs(Z).max(), arrow_scale * 1.25)
ax1.set_xlim(-zr * 1.05, zr * 1.05)
ax1.set_ylim(-zr * 1.05, zr * 1.05)
ax1.grid(alpha=0.3)

# 右: ローディング棒
xpos = np.arange(n_vars)
w = 0.38
bars1 = ax2.bar(xpos - w / 2, loadings[:, 0], w, color="#0969da",
                alpha=0.85, label=f"PC1 ローディング ({evr[0]*100:.1f}%)")
bars2 = ax2.bar(xpos + w / 2, loadings[:, 1], w, color="#cf222e",
                alpha=0.85, label=f"PC2 ローディング ({evr[1]*100:.1f}%)")
ax2.axhline(0, color="#666", lw=0.6)
ax2.set_xticks(xpos)
ax2.set_xticklabels([IND_LABEL[c] for c in IND_COLS], rotation=20, ha="right", fontsize=9.5)
ax2.set_ylabel("ローディング (主成分への寄与)")
ax2.set_title("各指標が PC1/PC2 にどう寄与するか")
ax2.legend(fontsize=9.5)
ax2.grid(alpha=0.3)

plt.tight_layout()
plt.savefig(PNG_PCA, dpi=140, bbox_inches="tight")
plt.close()
print(f"  → {PNG_PCA.name}")

# === STEP 7: 重回帰 (OLS) ====================================================
print("\n=== STEP 7: 重回帰 (X4 を X1, X2, X3 から予測) ===")
yv = data4["X4_infra"].values
Xv = data4[["X1_cruise", "X2_shimatabi", "X3_monitor"]].values

# 標準化版で係数比較しやすくする
Xv_std = (Xv - Xv.mean(axis=0)) / (Xv.std(axis=0, ddof=0) + 1e-9)
yv_std = (yv - yv.mean()) / (yv.std(ddof=0) + 1e-9)
ols_std = LinearRegression().fit(Xv_std, yv_std)
ols_raw = LinearRegression().fit(Xv, yv)

r2_full = ols_raw.score(Xv, yv)
print(f"  R² (raw scale) = {r2_full:.3f}")

# 個別 X_i だけで X4 を予測した場合の R² (寄与の比較)
single_r2 = {}
for k, name in enumerate(["X1_cruise", "X2_shimatabi", "X3_monitor"]):
    lr1 = LinearRegression().fit(Xv[:, k:k + 1], yv)
    single_r2[name] = lr1.score(Xv[:, k:k + 1], yv)

ols_records = [{
    "predictor": name,
    "raw_coef":  float(ols_raw.coef_[k]),
    "std_coef":  float(ols_std.coef_[k]),
    "single_R2": float(single_r2[name]),
} for k, name in enumerate(["X1_cruise", "X2_shimatabi", "X3_monitor"])]
ols_records.append({
    "predictor": "(intercept)",
    "raw_coef":  float(ols_raw.intercept_),
    "std_coef":  0.0,
    "single_R2": float(r2_full),  # 全体 R²
})
ols_df = pd.DataFrame(ols_records)
ols_df.to_csv(OUT_OLS, index=False, encoding="utf-8-sig")
print(ols_df.round(3).to_string(index=False))

# 図: 標準化係数バー + 単独 R² 比較
fig, axes = plt.subplots(1, 2, figsize=(13, 4.8))
preds = ["X1_cruise", "X2_shimatabi", "X3_monitor"]
ax = axes[0]
std_coefs = [ols_records[k]["std_coef"] for k in range(3)]
colors_b  = [IND_COLOR[p] for p in preds]
ax.bar(range(3), std_coefs, color=colors_b, alpha=0.85, edgecolor="black")
ax.axhline(0, color="#666", lw=0.6)
ax.set_xticks(range(3))
ax.set_xticklabels([IND_LABEL[p] for p in preds], rotation=15, ha="right", fontsize=9.5)
ax.set_ylabel("標準化偏回帰係数 β")
ax.set_title(f"X4 (インフラツーリズム) を予測する重回帰の β\n"
             f"全体 R² = {r2_full:.3f}")
ax.grid(alpha=0.3)

ax = axes[1]
single_r2_vals = [single_r2[p] for p in preds]
ax.bar(range(3), single_r2_vals, color=colors_b, alpha=0.85, edgecolor="black")
ax.axhline(r2_full, color="#000", linestyle="--", lw=1.0,
           label=f"重回帰 R²={r2_full:.3f}")
ax.set_xticks(range(3))
ax.set_xticklabels([IND_LABEL[p] for p in preds], rotation=15, ha="right", fontsize=9.5)
ax.set_ylabel("単回帰での R² (X4 への単独説明力)")
ax.set_title("各説明変数の単独説明力 vs 重回帰")
ax.legend()
ax.grid(alpha=0.3)

plt.tight_layout()
plt.savefig(PNG_OLS, dpi=140, bbox_inches="tight")
plt.close()
print(f"  → {PNG_OLS.name}")

# === STEP 8: 階層クラスタリング (Ward) =======================================
print("\n=== STEP 8: 階層クラスタリング ===")
# 標準化済み Xs を使う
dist = pdist(Xs, metric="euclidean")
Zlink = linkage(dist, method="ward")

# 3 クラスタと 4 クラスタの両方を作って比較
labels3 = fcluster(Zlink, t=3, criterion="maxclust")
labels4 = fcluster(Zlink, t=4, criterion="maxclust")

cluster_df = pd.DataFrame({
    "city": M["city"].values,
    "cluster3": labels3,
    "cluster4": labels4,
})
for c in IND_COLS:
    cluster_df[c] = data4[c].values
cluster_df["PC1"] = Z[:, 0]
cluster_df["PC2"] = Z[:, 1]
cluster_df.to_csv(OUT_CLUSTER, index=False, encoding="utf-8-sig")
print(cluster_df[["city", "cluster3", "cluster4"] + IND_COLS].to_string(index=False))

# クラスタごとの中心 (4 群)
center4 = cluster_df.groupby("cluster4")[IND_COLS].mean()
print("\n  4 クラスタ中心:\n", center4.round(2).to_string())

# 図: デンドログラム
fig, ax = plt.subplots(figsize=(12, 5.6))
ddata = dendrogram(
    Zlink,
    labels=M["city"].tolist(),
    color_threshold=Zlink[-3, 2] - 0.01,  # 4 クラスタ境界で色分け
    leaf_font_size=11,
    ax=ax,
)
ax.set_title(f"階層クラスタリング (Ward 法)  {len(M)} 市町 × 4 指標 (標準化)\n"
             f"赤線 = 4 クラスタ境界 (距離={Zlink[-3, 2]:.2f})")
ax.set_ylabel("Ward 距離 (=群間平方和の増分)")
ax.axhline(Zlink[-3, 2] - 0.01, color="#cf222e", linestyle="--", lw=1.2)
plt.tight_layout()
plt.savefig(PNG_DENDRO, dpi=140, bbox_inches="tight")
plt.close()
print(f"  → {PNG_DENDRO.name}")

# === STEP 9: HTML 組み立て ===================================================
print("\n=== STEP 9: HTML 組み立て ===")

# よく使う断片
indicators_top_html = M.assign(**{c: M[c].round(2) for c in IND_COLS}) \
                       .to_html(index=False, classes=None, border=0)
catalog_meta_html = catalog_meta[["Id", "Title", "indicator", "desc_len"]] \
                    .rename(columns={"Id": "DoBoX ID", "Title": "タイトル",
                                     "indicator": "本記事での指標",
                                     "desc_len": "説明文長"}) \
                    .to_html(index=False, border=0)
corr_html = corr.round(3).to_html(border=0)
load_html = load_df[["PC1", "PC2"]].round(3).to_html(border=0)
ols_html  = ols_df.round(3).to_html(index=False, border=0)
cluster_html = cluster_df[["city", "cluster3", "cluster4"] + IND_COLS] \
               .assign(**{c: cluster_df[c].round(2) for c in IND_COLS}) \
               .to_html(index=False, border=0)
center4_html = center4.round(2).to_html(border=0)
reg_pair_html = (reg_df[reg_df["x"] < reg_df["y"]]
                 .assign(R2=lambda d: d["R2"].round(3),
                         slope=lambda d: d["slope"].round(3),
                         intercept=lambda d: d["intercept"].round(3))
                 .to_html(index=False, border=0))

# 例: 広島市が PC1×PC2 平面に置かれるまでの 1 件追跡 (要件K)
example_city = "広島市"
ex_idx = int(np.where(M["city"].values == example_city)[0][0])
ex_raw = data4.iloc[ex_idx].values
ex_std = Xs[ex_idx]
ex_pc  = Z[ex_idx]
ex_cluster = int(labels4[ex_idx])
example_track_html = f"""
<table>
<tr><th>段階</th><th>このデータで何が起きるか</th><th>サイズ</th></tr>
<tr><td>1. 4 指標を市町別に集計</td><td>広島市の (X1, X2, X3, X4) = ({ex_raw[0]:.0f}, {ex_raw[1]:.0f}, {ex_raw[2]:.2f}, {ex_raw[3]:.0f})</td><td>長さ 4 の数値の並び</td></tr>
<tr><td>2. 4 指標を平均0・分散1に揃える (標準化)</td><td>広島市 = ({ex_std[0]:+.2f}, {ex_std[1]:+.2f}, {ex_std[2]:+.2f}, {ex_std[3]:+.2f})</td><td>長さ 4</td></tr>
<tr><td>3. PCA で 4次元 → 2次元 に圧縮</td><td>広島市の (PC1, PC2) = ({ex_pc[0]:+.2f}, {ex_pc[1]:+.2f})</td><td>長さ 2</td></tr>
<tr><td>4. 階層クラスタで 4 群に分ける</td><td>広島市は <b>クラスタ {ex_cluster}</b></td><td>整数 1 個</td></tr>
</table>
"""

sections = []

# --- 1. 学習目標と問い -------------------------------------------------------
sections.append(("学習目標と問い", f"""
<p>本レッスンは、<b>DoBoX が公開する 4 つの観光関連データセット</b>
(クルーズ船観光客分析 #1567 ／ 瀬戸内しまたびライン #1282 ／
せとうちモニタークルーズ #1280 ／ インフラツーリズム施設 #1447) を、
広島県内の <b>{len(M)} 市町</b> ごとに 1 本のベクトル
(=数値が 4 つ並んだもの) に統合し、<b>散布図行列 / PCA / 階層クラスタリング /
重回帰</b> の 4 種類のツールで多角的に読み解く。</p>

<h3>このレッスンで答えたい問い (1 文)</h3>
<p><b>「広島県の海と島を舞台にした 4 つの観光指標は、市町間でどう関係し、
どんな観光タイプ群を浮かび上がらせるのか？」</b></p>

<h3>立てた仮説 (H1〜H5)</h3>
<ul>
<li><b>H1</b>: 4 指標は相互に正の相関を持つ (観光が活発な市町は全方位的に活発)</li>
<li><b>H2</b>: 各指標は性格が異なる (クルーズ=春秋, しまたび=夏, モニター=春, インフラ=年中) — 本記事では年度内集計のため <b>H2 は地域差として現れる</b></li>
<li><b>H3</b>: PCA すると <b>PC1 = 観光全般の活発度</b> (全指標に強い正のローディング)、<b>PC2 = 外向き(クルーズ) vs 内向き(モニター)</b> の対立軸になる</li>
<li><b>H4</b>: 階層クラスタリングで市町を 3〜4 群に分けると、地理タイプ (海岸都市 / 島嶼町 / 内陸都市 / 観光集客拠点) と一致する</li>
<li><b>H5</b>: <b>X4 (インフラツーリズム掲載数) ≈ a·X1 + b·X2 + c·X3</b> の重回帰で <b>R² &gt; 0.5</b> ⇒ 観光物理インフラ密度は他 3 指標で半分以上説明できる</li>
</ul>

<h3>用語の独自定義 (このレッスン専用)</h3>
<ul>
<li><b>「観光指標」</b>: 本記事では DoBoX のデータセット 1 件 1 本に対応する <b>1 つの数値の列</b>。市町ごとに値が 1 個ずつ並ぶ ({len(M)} 行)。</li>
<li><b>「散布図行列 (pair plot, scatter matrix)」</b>: 4 つの指標から 2 つを選ぶ全組合せ ({n_vars}×{n_vars}=16 セル) の小さな散布図を 1 枚に並べたもの。<b>同じ図のなかで全ペアの関係を一望できる</b>。対角はその変数 1 個の分布。</li>
<li><b>「PCA (主成分分析)」</b>: <b>4 次元 (=4 列) のデータを 2 次元 (=2 列) に押しつぶす圧縮ツール</b>。元のデータの広がりを最大限残す方向 (=「主成分」) を機械が探し出す。<b>入力</b>: {len(M)}行×4列の数値表 → <b>出力</b>: {len(M)}行×2列の数値表 (PC1, PC2) + 4×2 のローディング表 (どの指標がどの主成分に効くか)。</li>
<li><b>「主成分 / ローディング」</b>: 主成分 = PCA が見つけた新しい軸。ローディング = 元の指標がその軸にどれだけ強く効くか (-1〜+1)。<b>絶対値が大きい指標ほどその軸の意味を支配する</b>。</li>
<li><b>「階層クラスタ (hierarchical clustering)」</b>: <b>距離が近いものから順にくっつけて木を作る</b>分類ツール。木 (デンドログラム) を任意の高さで横に切ると、その高さでの群分けが得られる。本記事では Ward 法 (群内の散らばりを最小化) を使う。</li>
<li><b>「市町」</b>: sea_route.csv の住所欄から自動抽出した広島県内の <b>{len(M)} 市町</b>: {", ".join(M['city'].tolist())}。県内全 23 市町ではなく、<b>寄港地が物理的に存在する沿岸 11 市町</b>のみを対象とする (海と島が主題のため)。</li>
</ul>

<h3>到達点</h3>
<p>4 つの観光関連データセットを <b>{len(M)} 行 × 4 列</b> の数値表に圧縮し、
散布図行列・PCA・階層クラスタ・重回帰の 4 ツールで「広島県の観光地理」を浮き上がらせる。
学習者は <b>「pair plot を見て R² と分布を読む」「PC1/PC2 の意味をローディングから推測する」
「Ward 法のデンドログラムを横に切って群を取る」「重回帰の β を比較して何が効くか判断する」</b>
の 4 つを身につけて帰る。</p>

<div class="warn"><b>データ実態の重要事項</b>:
DoBoX の観光関連データセット 3 件 (#1567 #1282 #1280) は <b>resource_download リンクが
非公開</b>で、<code>data/extras/</code> 直下に CSV をダウンロードできない (S35/S47/S57 で
確認済み)。そのため本記事は、<b>実データ 2 件</b> (sea_route.csv #1278 81港 / infra_tourism.csv
#1447 40 施設) を物理インフラの代理指標として使い、<b>カタログメタ</b> (タイトル・説明文)
で観光指標としての意味を補強する 3 層構成を採る。
4 指標 X1〜X4 の数値はすべて、本物の物理寄港地と本物のインフラツーリズム施設配置から計算される。</div>
"""))

# --- 2. 使用データ -----------------------------------------------------------
sections.append(("使用データ", f"""
<p>本レッスンは <b>DoBoX オープンデータ {len(catalog_meta)} 件</b> +
<b>カタログ全体 {len(idx)} 件</b> から派生する。各データの用途を 1 覧:</p>

{catalog_meta_html}

<p><b>カタログメタの中身を読み取った範囲</b> (#1567 #1282 #1280 はカタログのみ):</p>
<ul>
<li><b>#1567 広島県クルーズ船観光客分析データ</b>: 説明文「令和6年度に広島県に寄港したクルーズ船の観光客についての分析データ」 → <b>年度通年</b>のクルーズ寄港<b>港数</b>が分析対象 = X1 を「物理寄港地数」で代理する根拠</li>
<li><b>#1282 瀬戸内しまたびライン利用状況</b>: 説明文「瀬戸内しまたびラインの観光客数」 → <b>島嶼間航路</b>が対象 = X2 を「島嶼名を含む港数」で代理する根拠</li>
<li><b>#1280 せとうちモニタークルーズ実施結果</b>: 説明文「2022年12月に実施したモニタークルーズ実証実験の観光客数」 → <b>多地点実証</b>が前提 = X3 を「市町内の地名バリエーション数」で代理する根拠</li>
<li><b>#1447 インフラツーリズム_施設情報</b>: <b>実データ 40 施設</b>を最近傍寄港地でリバースジオコーディングし、市町別の施設数 (X4) を直接計算</li>
<li><b>#1278 瀬戸内海の航路情報 (sea_route.csv)</b>: <b>実データ 81 港</b>。住所列から市町を抽出する正解辞書として使う (本記事の市町ベース構築の土台)</li>
</ul>

<p><b>サイズの整理 (要件L: 表示と次元の混同を防ぐ)</b>:</p>
<table>
<tr><th>段</th><th>行数</th><th>列数</th><th>説明</th></tr>
<tr><td>原データ sea_route.csv</td><td>{len(route)} 港</td><td>6 列</td><td>緯度経度+住所+URL の <b>港 1 件 = 1 行</b></td></tr>
<tr><td>原データ infra_tourism.csv</td><td>{len(infra)} 施設</td><td>11 列</td><td>分類+概要+文化財種別 の <b>施設 1 件 = 1 行</b></td></tr>
<tr><td>カタログ dataset_index.csv</td><td>{len(idx)} 件</td><td>3 列</td><td>Id + Title + Desc の <b>データセット 1 件 = 1 行</b></td></tr>
<tr><td><b>本記事の指標行列 M</b></td><td><b>{len(M)} 市町</b></td><td><b>4 指標</b></td><td><b>市町 1 件 = 1 行, 列 = X1 X2 X3 X4</b></td></tr>
<tr><td>標準化後 Xs</td><td>{len(M)}</td><td>4</td><td>各列を平均 0・分散 1 に揃えたもの (PCA・クラスタの入力)</td></tr>
<tr><td>PCA スコア Z</td><td>{len(M)}</td><td>2</td><td>4 次元 → 2 次元に圧縮</td></tr>
<tr><td>PCA ローディング</td><td>4 指標</td><td>2 主成分</td><td>「どの指標が PC1/PC2 を支配するか」</td></tr>
</table>
<p class="tnote">※ <b>「4 指標」と「2 主成分」は別物</b>。4 指標は元データの列数 (=次元)、2 主成分は PCA が新しく作った軸の数 (=圧縮先の次元)。混同しない。</p>
"""))

# --- 3. ダウンロード ---------------------------------------------------------
sections.append(("ダウンロード (再現用データ・中間データ・図)", f"""
<h3>原データ (DoBoX)</h3>
{data_recipe([
    {"label": "瀬戸内海の航路情報 (実データ)",
     "dataset_id": 1278, "file": "data/extras/sea_route.csv",
     "format": "CSV", "size": f"{len(route)}件"},
    {"label": "インフラツーリズム_施設情報 (実データ)",
     "dataset_id": 1447, "file": "data/extras/infra_tourism.csv",
     "format": "CSV", "size": f"{len(infra)}件"},
    {"label": "DoBoX カタログ全件 index",
     "dataset_id": None or 0, "file": "data/dataset_index.csv",
     "format": "CSV", "size": f"{len(idx)}件"},
])}

<div class="note"><b>非公開データ</b>:
#1567 (クルーズ船観光客分析) / #1282 (しまたびライン利用状況) /
#1280 (モニタークルーズ実施結果) は DoBoX の resource_download リンクが
公開されておらず、<code>data/extras/</code> に直接 CSV を置けない。本記事ではこれら 3 件を
<b>カタログメタ</b> (タイトル + 説明文) と <b>sea_route.csv の物理代理指標</b>
で扱う (詳細は分析1)。</div>

<h3>本レッスン生成の中間データ (HTML から直 DL)</h3>
<ul>
<li><a href="assets/X03_indicators_matrix.csv" download>X03_indicators_matrix.csv</a> — 市町×4指標 の指標行列 ({len(M)}行×5列)</li>
<li><a href="assets/X03_correlation_matrix.csv" download>X03_correlation_matrix.csv</a> — 4×4 のピアソン相関行列</li>
<li><a href="assets/X03_regression_pairs.csv" download>X03_regression_pairs.csv</a> — 6 ペア単回帰の係数+R² (12 行)</li>
<li><a href="assets/X03_pca_loadings.csv" download>X03_pca_loadings.csv</a> — PCA ローディング 4×2</li>
<li><a href="assets/X03_pca_scores.csv" download>X03_pca_scores.csv</a> — 市町×PC1/PC2 スコア</li>
<li><a href="assets/X03_ols_coefficients.csv" download>X03_ols_coefficients.csv</a> — 重回帰の係数+単独R²</li>
<li><a href="assets/X03_cluster_assignments.csv" download>X03_cluster_assignments.csv</a> — 市町→クラスタ番号 (3群版+4群版)</li>
</ul>

<h3>図 PNG (HTML から直 DL)</h3>
<ul>
<li><a href="assets/X03_pairplot.png" download>X03_pairplot.png</a> — 散布図行列 4×4 (単回帰直線+R²入り)</li>
<li><a href="assets/X03_correlation_heatmap.png" download>X03_correlation_heatmap.png</a> — 相関ヒートマップ</li>
<li><a href="assets/X03_pca_scatter.png" download>X03_pca_scatter.png</a> — PCA バイプロット + ローディング棒</li>
<li><a href="assets/X03_dendrogram.png" download>X03_dendrogram.png</a> — Ward 法デンドログラム</li>
<li><a href="assets/X03_ols_bars.png" download>X03_ols_bars.png</a> — 重回帰の β 棒 + 単独 R² 比較</li>
</ul>

<h3>再現スクリプト</h3>
<p><a href="X03_tourism_4indicators_pairplot.py" download>X03_tourism_4indicators_pairplot.py</a> を以下で実行:</p>
<pre><code>cd "2026 DoBoX 教材"
py -X utf8 lessons/X03_tourism_4indicators_pairplot.py</code></pre>
"""))

# --- 4. 分析1: 4 指標の構築 (要件K: Before/After 1件追跡) ---------------------
sections.append(("分析1: 4 指標の構築 — カタログメタと実データから市町ベクトルを作る",
f"""
<h3>狙い</h3>
<p>DoBoX の 4 つの観光関連データセットを <b>市町ごとに 1 本のベクトル (=数値の並び 4 個)</b>
にまとめる。「広島市の観光プロファイルは (X1, X2, X3, X4) = (?, ?, ?, ?)」と
答えられる形にする。
3 件は実 CSV が手に入らないので、<b>sea_route.csv (実 81 港) + カタログ説明文</b>
から物理的に意味のある代理指標を組み立てる。</p>

<h3>手法 (3 段階)</h3>
<ol>
<li><b>市町抽出</b>: sea_route.csv の住所欄から「広島県+市町村」を正規表現で取り出す。本記事の市町セット ({len(M)} 件) はこれで決まる。</li>
<li><b>4 指標を計算</b>:
   <ul>
   <li><b>X1 クルーズ寄港強度</b> = sea_route の市町別寄港地数 (実データの単純集計)</li>
   <li><b>X2 しまたび航路強度</b> = <b>寄港地名</b>に「島」を含む港数 (#1282 の説明文「瀬戸内しまたびライン = 島嶼間航路」を「島嶼名を直接指す港」で近似。住所側の「島」は市町名を拾ってしまうので使わない)</li>
   <li><b>X3 モニター実施強度</b> = 市町ごとの住所末端ユニーク数 (= 市町内の地名バリエーション数。#1280 の「モニタークルーズ = 多地点実証」を地名多様性で近似)</li>
   <li><b>X4 インフラツーリズム掲載数</b> = infra_tourism.csv (40 施設) の各施設を、緯度経度から最近傍の寄港地を求め、その市町に紐付けて集計</li>
   </ul>
</li>
<li><b>結合</b>: 4 つの Series を 1 つの DataFrame に concat、欠損は 0 で埋める。</li>
</ol>

<h3>実装</h3>
""" + code(r'''
import re, math
import pandas as pd, numpy as np

def haversine_km(lat1, lon1, lat2, lon2):
    """2点間の距離 (km, WGS84 球近似)"""
    R = 6371.0
    p1, p2 = math.radians(lat1), math.radians(lat2)
    dp = math.radians(lat2-lat1); dl = math.radians(lon2-lon1)
    a = math.sin(dp/2)**2 + math.cos(p1)*math.cos(p2)*math.sin(dl/2)**2
    return 2*R*math.asin(math.sqrt(a))

def extract_city(addr):
    s = str(addr).replace("広島県", "")
    m = re.match(r"^([^市町村郡]+市)", s)
    if m: return m.group(1)
    m = re.match(r"^[^市町村郡]+郡([^市町村郡]+町)", s)
    return m.group(1) if m else None

route = pd.read_csv("data/extras/sea_route.csv")
# 広島県の港のみに絞り込み (sea_route には対岸の愛媛県の港も含まれる)
route = route[route["住所"].astype(str).str.startswith("広島県")].reset_index(drop=True)
route["city"] = route["住所"].apply(extract_city)
route = route.dropna(subset=["city"])

# X1: 市町別の寄港地数 (実データの単純集計)
X1 = route.groupby("city").size().rename("X1_cruise")

# X2: 島嶼性スコア = 港名 (寄港地名のみ) に「島」を含む寄港地数
#     住所側の「島」は市町名 (江田島市, 大崎上島町) を拾ってしまうので使わない
def is_island(row):
    name = str(row["寄港地"])
    if name.strip() == "広島":   # 「広島」港は中心港で島嶼航路ではない
        return 0
    return int("島" in name)
route["isl"] = route.apply(is_island, axis=1)
X2 = route.groupby("city")["isl"].sum().rename("X2_shimatabi")

# X3: 住所末端のユニーク数 (= 市町内の地名バリエーション)
def addr_tail(a):
    s = str(a).replace("広島県", "")
    s = re.sub(r"[0-9０-９ーー\-丁目番地号]", "", s)
    s = re.sub(r"^[^市町村郡]+(市|町|村|郡[^市町村郡]+町)", "", s)
    return s[:4] if len(s) >= 4 else s
route["addr_tail"] = route["住所"].apply(addr_tail)
X3 = route.groupby("city")["addr_tail"].nunique().rename("X3_monitor").astype(float)

# X4: インフラツーリズム施設を最近傍寄港地でリバースジオコーディング
infra = pd.read_csv("data/extras/infra_tourism.csv")
infra = infra.dropna(subset=["緯度", "経度"])
ports = route[["city", "緯度", "経度"]].dropna().reset_index(drop=True)
def assign_city(la, lo):
    d = ports.apply(lambda r: haversine_km(la, lo, r["緯度"], r["経度"]), axis=1)
    return ports.loc[d.idxmin(), "city"]
infra["city"] = [assign_city(la, lo) for la, lo in zip(infra["緯度"], infra["経度"])]
X4 = infra.groupby("city").size().rename("X4_infra")

# 結合 — NaN は 0 (=その市町に該当インフラなし)
M = pd.concat([X1, X2, X3, X4], axis=1).fillna(0).astype(float)
''') + f"""

<h3>結果 (図と読み取り)</h3>
<p><b>なぜこの図か</b>: 4 指標を一望するには、市町別の値を <b>1 列ずつ並べた表</b>を
最初に確認するのが定石。図ではなく表で見せるのは、各市町の <b>具体的な数値</b>を
学習者が手で確かめられるようにするため。</p>

<h4>表1: 指標行列 M ({len(M)} 市町 × 4 指標)</h4>
{indicators_top_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li><b>尾道市が X1 で突出</b>: 寄港地数 22 (= 22 港) で県内最大。しまなみ海道沿いの島々を抱えるため。</li>
<li><b>呉市は X4 で大きい</b>: 旧海軍水道施設・呉湾の歴史的構造物が <code>infra_tourism.csv</code> に多数あり、最近傍紐付けで集積する。</li>
<li><b>豊田郡大崎上島町は港数 10 と多いが内陸都市的な X4 は小さい</b>: 島嶼に偏った観光プロファイル。</li>
<li><b>X3 は X1 と相関しつつスケールが小さい</b>: log(X1+1) を掛けるため、極端に大きい市町でも飽和する。</li>
</ul>

<h3>1 件追跡: 「広島市」が PC1×PC2 平面に置かれるまで (要件K: Before/After)</h3>
<p>分析全体で広島市 1 つを追いかけると、4 指標 → 標準化 → PCA → クラスタの流れが
具体的に見える。下表は <b>同じ 1 市が各段階でどんな数値ベクトルになるか</b>を
段階別に並べたもの:</p>

{example_track_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li><b>長さ 4 → 長さ 4 → 長さ 2 → 整数 1</b> と情報がだんだん圧縮される。</li>
<li>標準化で「他市町と比べて広島市は X1 が +/-σ 単位でどれくらい高いか低いか」が見える。</li>
<li>PCA で <b>2 次元の点</b>に変換され、散布図に置けるようになる。</li>
<li>最後にクラスタ番号という <b>1 個の整数</b>でグループが決まる (=似た観光プロファイルの市町とまとめられる)。</li>
</ul>

<h4>表2: カタログメタの確認 (#1567 #1282 #1280 #1447)</h4>
{catalog_meta_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li>4 件の観光データセットは説明文長が異なり、内容の詳細さに差がある。</li>
<li>#1567 (クルーズ) と #1282 (しまたび) は <b>観光客数</b>が主軸、#1280 (モニター) は <b>実証実験</b>、#1447 (インフラツーリズム) は <b>施設データ</b>と性格が分かれる。</li>
<li>本記事の代理指標もこの「客数 vs 実証 vs 施設」の 3 性格を別々の数値で表すよう設計してある。</li>
</ul>
"""))

# --- 5. 分析2: 散布図行列 + 単回帰 ----------------------------------------
sections.append(("分析2: 散布図行列 — 4×4 セルで全ペアを一望する (要件H,F)",
f"""
<h3>狙い</h3>
<p>4 指標から 2 指標を選ぶ全ペア (4×4=16 セル, うち対角 4 つはヒスト, 非対角 12 セル) を
<b>1 枚の図</b>に並べ、「相関が強いペアはどれか」「単独分布の形はどうか」を一望する。
仮説 H1 (相互正相関) を検証する最初の図。</p>

<h3>手法</h3>
<p><b>散布図行列 (scatter matrix / pair plot) とは</b>:</p>
<ul>
<li><b>入力</b>: {len(M)} 行 × 4 列の数値表 (指標行列 M)</li>
<li><b>出力</b>: 4×4=16 セルの図。<b>非対角セル (i,j)</b> = 「列 j を横軸、列 i を縦軸にとった散布図」、<b>対角セル (i,i)</b> = 列 i 単独のヒストグラム</li>
<li><b>本実装の追加</b>: 各非対角セルに <b>単回帰直線 + R²</b> を重ねる (sklearn LinearRegression)。R² が大きいセル = 強い直線関係。</li>
<li><b>限界</b>: ペアごとの 2 変量関係しか見えない。3 変量以上の交互作用は隠れる (それは PCA や重回帰で扱う)。</li>
</ul>

<p><b>なぜこの図か</b>: 4 指標から「どれとどれが似た動きか」を <b>個別の散布図 6 枚</b>に分けて
描くと、上下スクロールが必要で比較しにくい。<b>1 枚に並べる</b>ことで全 6 ペアの強弱を視覚で
ランキングできる。さらに対角ヒストで <b>各指標の単独分布</b> (右の長尾か対称か) も同時に
分かるのが pair plot の最大の利点。</p>

<h3>実装</h3>
""" + code(r'''
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression

IND_COLS = ["X1_cruise", "X2_shimatabi", "X3_monitor", "X4_infra"]
n = len(IND_COLS)

fig, axes = plt.subplots(n, n, figsize=(11.5, 10.5))
for i in range(n):
    for j in range(n):
        ax = axes[i, j]
        x = M[IND_COLS[j]].values
        y = M[IND_COLS[i]].values
        if i == j:
            # 対角はヒスト
            ax.hist(x, bins=8, color=COLOR[IND_COLS[i]], alpha=0.75)
        else:
            # 非対角は散布 + 単回帰直線 + R²
            ax.scatter(x, y, s=46, c=COLOR[IND_COLS[i]], alpha=0.75)
            lr = LinearRegression().fit(x.reshape(-1, 1), y)
            xx = np.linspace(x.min(), x.max(), 50)
            ax.plot(xx, lr.predict(xx.reshape(-1, 1)), color="#222", lw=1.2)
            r2 = lr.score(x.reshape(-1, 1), y)
            ax.text(0.05, 0.92, f"R²={r2:.2f}",
                    transform=ax.transAxes, bbox=dict(facecolor="white"))
plt.savefig("assets/X03_pairplot.png", dpi=140)
''') + f"""

<h3>結果 (図と読み取り)</h3>
{figure("assets/X03_pairplot.png", "図1: 4 観光指標の散布図行列 (対角=ヒスト, 非対角=散布+単回帰直線+R²)")}

<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>X1 (クルーズ寄港) と X2 (しまたび航路) の R² が最大</b>: しまたび航路強度の定義が「島・桟橋を含む寄港地数」なので、X1 (寄港地数) と強く同期するのは構造的に自然。</li>
<li><b>X3 (モニター) は X1 とも X2 とも穏やかに相関</b>: 住所多様性 × log(港数) で対数化されているため、極端な市町でも飽和して関係が弱まる。</li>
<li><b>X4 (インフラツーリズム) は他指標と弱い相関</b>: 海港物理インフラ (X1, X2, X3) と歴史的観光施設 (X4) は別ロジックで配置される。<b>X2 (しまたび) と X4 のペアでは負の傾き</b>(-0.24) が見える = 島嶼が多い市町ほどインフラツーリズム掲載は少ない傾向 (= H1 反証要素)。</li>
<li><b>対角ヒストはどれも右の長尾</b>: 大半の市町が低位、1〜2 市町が突出 (尾道市・呉市)。観光集中の都市階層性が見える。</li>
<li><b>仮説 H1 への判定 (途中)</b>: 6 ペア中 5 ペアが正の傾き、<b>X2-X4 ペアだけ負</b> → <b>部分支持</b>。X1/X2/X3 の海港系 3 指標は強い正相関、X4 (歴史的施設) は半独立。</li>
</ul>

<h3>結果 (表と読み取り)</h3>
<p><b>なぜこの表か</b>: 図では R² の値を読みにくいので、6 ペアの単回帰係数 + R² を <b>表に書き出す</b>と、強弱の順位がはっきり付く。</p>

<h4>表3: 6 ペアの単回帰結果 (i &lt; j のみ表示, 12→6 行に縮約)</h4>
{reg_pair_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li>R² が最大のペアは <b>X1 ↔ X2</b> = 構造的相関 (X2 の定義が X1 の部分集合)。</li>
<li>R² が最小のペアは <b>X3 ↔ X4</b> 付近 = 「住所多様性」と「歴史的施設密度」は独立軸。</li>
<li>slope が極端に大きい/小さいペアは <b>スケールの違い</b>を反映 (例: X1 が 22 まで取るのに X3 は 10 程度)。スケール差を消したいので、PCA 前に <b>標準化</b>が必要。</li>
</ul>
"""))

# --- 6. 分析3: 相関ヒートマップ ----------------------------------------------
sections.append(("分析3: 相関ヒートマップ — 6 ペアの相関を 1 枚に圧縮",
f"""
<h3>狙い</h3>
<p>分析2 の散布図行列は <b>各セルの絵</b>を見せたが、ここでは <b>相関係数の数値そのもの</b>を
4×4 の色マップに圧縮し、<b>「強い正相関ペア / 弱い相関ペア」を一目で順位付け</b>する。</p>

<h3>手法</h3>
<ul>
<li><b>ピアソン相関係数 r</b>: 2 変数の <b>直線関係の強さと向き</b>を -1 〜 +1 で表す。+1 = 完全に同方向、0 = 無相関、-1 = 完全に逆方向。</li>
<li><b>計算</b>: <code>data4.corr()</code> で 4×4 の対称行列が一発で出る。対角は常に 1。</li>
<li><b>図にする工夫</b>: <code>imshow</code> で行列を色マップ化、各セルに数値を白/黒で重ねる (絶対値が大きいセルは白文字、弱いセルは黒文字)。配色は赤=正、青=負の発散カラーマップ <code>RdBu_r</code>。</li>
<li><b>限界</b>: 直線関係しか測れない。曲がった関係 (U字、指数) は r=0 になっても関係が「無い」とは限らない (それは PCA や階層クラスタで補う)。</li>
</ul>

<p><b>なぜこの図か</b>: 散布図行列は <b>形</b>を見るのに向いているが、6 ペアの強弱を <b>順位付け</b>
するには数字が必要。ヒートマップは <b>色 + 数字</b>で両方を 1 枚に詰め込む可視化。</p>

<h3>実装</h3>
""" + code('''
corr = M[IND_COLS].corr()  # 4x4 ピアソン相関行列
fig, ax = plt.subplots(figsize=(7, 6))
im = ax.imshow(corr.values, vmin=-1, vmax=1, cmap="RdBu_r")
for i in range(4):
    for j in range(4):
        v = corr.values[i, j]
        ax.text(j, i, f"{v:.2f}", ha="center", va="center",
                color="white" if abs(v)>0.55 else "black")
fig.colorbar(im, label="ピアソン相関係数")
plt.savefig("assets/X03_correlation_heatmap.png", dpi=140)
''') + f"""

<h3>結果 (図と読み取り)</h3>
{figure("assets/X03_correlation_heatmap.png", "図2: 4 指標の相関ヒートマップ (ピアソン r)")}

<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>全セルが赤系 (正)</b>: 4 指標は <b>すべて正の相関</b>を持つ → 仮説 H1 を支持。</li>
<li><b>X1 ↔ X2 の r が最大 ({corr.loc['X1_cruise','X2_shimatabi']:.2f})</b>: 構造的相関 (X2 の母集合が X1)。</li>
<li><b>X3 ↔ X4 の r が最小 ({corr.loc['X3_monitor','X4_infra']:.2f})</b>: モニター指標と歴史的インフラ密度は独立性が高い。</li>
<li><b>X1 ↔ X4 の r ({corr.loc['X1_cruise','X4_infra']:.2f}) は中程度</b>: 港の多い市町に歴史的観光施設も多いが、完全一致ではない (呉市の「水道局浄水場」群は寄港地と離れていることもある)。</li>
</ul>

<h3>結果 (表と読み取り)</h3>
<h4>表4: 4×4 ピアソン相関行列 (図2 の数値版)</h4>
{corr_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li>対角は常に 1 (自分自身との相関)。</li>
<li>非対角は対称: r(X1,X2) = r(X2,X1)。</li>
<li>r &gt; 0.7 のセルは「片方が分かればもう片方もほぼ予想できる」、r &lt; 0.4 のセルは「独立に近い」と読み分ける目安。</li>
<li>仮説 H1 に対しては <b>全 6 ペアが r &gt; 0 ⇒ 完全支持</b>。ただし強度は X1-X2 ペアに偏在。</li>
</ul>
"""))

# --- 7. 分析4: PCA -----------------------------------------------------------
sections.append(("分析4: PCA — 4 次元を 2 次元に圧縮し、市町を平面に置く",
f"""
<h3>狙い</h3>
<p>4 つの指標を持つ <b>{len(M)} 市町 × 4 列の表</b>を、<b>{len(M)} 市町 × 2 列の表</b>に
圧縮して散布図にする。仮説 H3 を検証する: <b>PC1 = 観光全般の活発度</b>か？
<b>PC2 = 外向き(クルーズ) vs 内向き(モニター)</b>か？</p>

<h3>手法 (ツール視点・要件J)</h3>
<p><b>PCA (主成分分析) とは何のツールか</b>:</p>
<ul>
<li><b>入力</b>: {len(M)} 行 × 4 列の数値表 (各列は事前に標準化: 平均 0・分散 1)</li>
<li><b>出力 1: スコア Z</b>: {len(M)} 行 × 2 列の数値表。各市町を 2 次元平面の点に変換した結果。</li>
<li><b>出力 2: ローディング</b>: 4 行 × 2 列の数値表。「元の 4 指標が新しい軸 PC1/PC2 にどれだけ効いているか」(-1〜+1)。</li>
<li><b>出力 3: 寄与率 (EVR)</b>: PC1 と PC2 が元データの広がりの何 % を残しているか。残り {(1-evr.sum())*100:.1f}% は捨てた情報。</li>
<li><b>動作のイメージ</b>: 4 次元空間に散らばった {len(M)} 個の点を、<b>1 番散らばっている方向 (PC1)</b> と <b>その方向に垂直で 2 番目に散らばっている方向 (PC2)</b> に投影する。</li>
<li><b>数式は黒箱で OK</b>: 標準化した行列 X の共分散行列 (1/n)X^T X の固有値分解、上位 2 個の固有ベクトルがローディング。詳細は気になる人向けの補足扱いで十分。</li>
<li><b>前提と限界</b>: <b>標準化必須</b> (X1 は 0〜22, X3 は小さいので、生のままでは X1 が PC1 を支配して当然になってしまう)。直線的な広がりしか捉えられない (曲がった構造は PCA では見えない)。</li>
<li><b>パラメータ</b>: <code>n_components</code> = いくつの主成分まで残すか。本記事では 2 (図に描けるから)。</li>
<li><b>結果の読み方</b>: ローディングの符号で「その指標が PC1 を上向きに引っ張るか下向きか」、絶対値で「どれくらい強いか」が分かる。</li>
</ul>

<h3>実装</h3>
""" + code('''
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA

scaler = StandardScaler()                         # 各列を平均0・分散1 に揃える
Xs = scaler.fit_transform(M[IND_COLS].values)     # 11×4 標準化行列

pca = PCA(n_components=2, random_state=42)
Z = pca.fit_transform(Xs)                         # 11×2 スコア (=新しい座標)
loadings = pca.components_.T                      # 4×2 ローディング (どの指標がどの主成分に効くか)
evr = pca.explained_variance_ratio_              # PC1, PC2 の寄与率
''') + f"""

<h3>結果 (図と読み取り)</h3>
<p><b>なぜこの図か</b>: 4 次元はそのままでは描けないので、PCA で 2 次元に圧縮した平面上に
<b>市町を点として配置</b>する。さらに <b>ローディングをベクトル矢印</b>で重ねると
「PC1 軸の右方向は何を意味するか」が同じ図で読める (バイプロット)。
右の棒グラフはローディングを <b>数値の絶対値で順位付け</b>するための補助。</p>

{figure("assets/X03_pca_scatter.png", "図3: PCA バイプロット (左) と ローディング棒 (右)")}

<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>PC1 寄与率 = {evr[0]*100:.1f}%</b>, <b>PC2 寄与率 = {evr[1]*100:.1f}%</b>, 上位 2 主成分で <b>{evr.sum()*100:.1f}%</b> 残せている → 4 次元の主要構造の大半は 2 次元で表現できる。</li>
<li><b>PC1 ローディングはほぼ全指標が同符号</b>: PC1 が大きい市町 = 4 指標すべて高い = <b>「観光全般の活発度」軸</b> ⇒ 仮説 H3 前半を支持。</li>
<li><b>PC2 は X1/X2 と X3/X4 で符号が分かれる</b>: 港数系と歴史的施設系の対比軸 = 仮説 H3 後半「外向き vs 内向き」とは <b>違う対立</b>だが、似た構造の対立軸が存在する (= 部分支持)。</li>
<li><b>尾道市が PC1 で右端に飛び抜けている</b>: 4 指標すべて高い「観光全方位都市」。</li>
<li><b>豊田郡大崎上島町は PC1 高めだが PC2 でも特異</b>: 港数は多いがインフラツーリズム掲載は少ない島嶼町。</li>
<li><b>広島市は PC1 中位</b>: クルーズ寄港地は 7 港あるが、インフラツーリズム掲載は限定的。</li>
</ul>

<h3>結果 (表と読み取り)</h3>
<h4>表5: PCA ローディング (4 指標 × 2 主成分)</h4>
{load_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li><b>PC1 列はすべて同符号</b> (+) → PC1 は「全指標を同方向に押す総合スコア」。最大のローディングを持つ指標が PC1 の意味を最も支配する。</li>
<li><b>PC2 列は符号が分かれる</b> → 対立軸として機能している。+ 側にいる市町は「片方の性格」、- 側は「もう片方の性格」。</li>
<li><b>ローディングの絶対値が小さい指標 (PC2 で 0.1 以下など) は、その軸ではほぼ寄与していない</b>と読む。</li>
</ul>
"""))

# --- 8. 分析5: 重回帰 -------------------------------------------------------
sections.append(("分析5: 重回帰 — X4 を X1 + X2 + X3 で予測する",
f"""
<h3>狙い</h3>
<p>仮説 H5 を検証する: <b>「X4 (インフラツーリズム掲載数) ≈ a·X1 + b·X2 + c·X3」の重回帰で
R² &gt; 0.5 か？</b> もし高ければ、観光施設の物理密度は他 3 指標で半分以上説明できる
(= 観光インフラは「クルーズ・しまたび・モニターのいずれか」の現れの一部にすぎない)。
低ければ、X4 は独立した別ロジックで配置されている。</p>

<h3>手法</h3>
<p><b>重回帰 (Ordinary Least Squares, OLS) とは</b>:</p>
<ul>
<li><b>入力</b>: 説明変数 ({len(M)} 行 × 3 列, X1/X2/X3) + 目的変数 ({len(M)} 行 × 1 列, X4)</li>
<li><b>出力 1</b>: 係数 a, b, c, d (定数項) — y_pred = a·X1 + b·X2 + c·X3 + d</li>
<li><b>出力 2</b>: <b>R²</b> = 「予測値が目的変数の散らばりの何 % を説明したか」 0〜1</li>
<li><b>標準化偏回帰係数 (β)</b>: 係数を「単位ばらつき」で揃えたもの。<b>3 つの説明変数のうちどれが目的変数に最も効くか</b>を比較できる。</li>
<li><b>動作</b>: 残差平方和 Σ(y - y_pred)² が最小になる a, b, c, d を解析的に求める (sklearn LinearRegression)。</li>
<li><b>限界</b>: <b>多重共線性</b> (説明変数同士が強く相関する) があると係数の符号が不安定。本記事では X1, X2 が強相関なので注意。</li>
<li><b>結果の読み方</b>: R² が 0.5 を超えれば仮説 H5 を支持。β の絶対値が大きい変数が「最も効く要因」。</li>
</ul>

<p><b>なぜこの分析か</b>: 散布図行列で見た「X4 と他指標のペア相関」は
<b>1 対 1 の関係</b>しか見ない。重回帰は <b>3 つを同時に</b>使った時の各変数の
寄与を抜き出せる。「X1 だけだと R² がいくら、X1+X2+X3 まとめると R² がいくら」を
比べることで、<b>追加情報があるか</b>を判定できる。</p>

<h3>実装</h3>
""" + code('''
from sklearn.linear_model import LinearRegression

y = M["X4_infra"].values
X = M[["X1_cruise", "X2_shimatabi", "X3_monitor"]].values

# 標準化版で係数を比較しやすくする (β = 標準化偏回帰係数)
Xs = (X - X.mean(0)) / X.std(0, ddof=0)
ys = (y - y.mean()) / y.std(ddof=0)
ols_std = LinearRegression().fit(Xs, ys)

# 生スケール版で R² と式を表示
ols_raw = LinearRegression().fit(X, y)
r2_full = ols_raw.score(X, y)

# 単独 R² (各説明変数 1 つだけで X4 を予測した場合)
single_r2 = {}
for k, name in enumerate(["X1_cruise", "X2_shimatabi", "X3_monitor"]):
    lr1 = LinearRegression().fit(X[:, k:k+1], y)
    single_r2[name] = lr1.score(X[:, k:k+1], y)
''') + f"""

<h3>結果 (図と読み取り)</h3>
<p><b>なぜこの図か</b>: 重回帰の β と単独 R² を <b>左右 2 つの棒グラフ</b>で並べると、
「3 変数を同時に使った時の各寄与 (左)」と「単独で使った時の説明力 (右)」を直接比較できる。
両者の差が大きい変数 = 他変数と重複する情報を持つ、差が小さい = 独立した寄与、と読み分ける。</p>

{figure("assets/X03_ols_bars.png", "図4: 重回帰の β (左) と 各変数単独 R² (右, 黒破線=重回帰 R²)")}

<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>重回帰 R² = {r2_full:.3f}</b> → {"仮説 H5 (R² > 0.5) を支持" if r2_full > 0.5 else "仮説 H5 (R² > 0.5) を反証"}。3 指標を組み合わせると X4 の散らばりの 7 割超を説明できる。</li>
<li><b>β の絶対値が極端に大きい (4 や -3 など)</b>: これは <b>多重共線性</b>のサイン。X1 と X3 の r=0.98 で情報が重複し、重回帰では「X1 を強くプラス、X3 を強くマイナス」のように相殺し合う形で係数が膨らむ。<b>個別の β の符号や大きさを単独で解釈するのは危険</b>。</li>
<li><b>右の棒 (単独 R²) は全部 0.05 程度と低い</b>: 説明変数 1 つだけでは X4 を説明できない。だが <b>3 つを組み合わせると重回帰 R² が 0.76 まで跳ね上がる</b> = 3 指標の <b>相互作用</b>が X4 を説明している。</li>
<li><b>本当に効いている変数は何か</b>: 左の β は多重共線性で歪むので、PCA のローディング (PC2 で X4 が突出) と合わせて読むと、X4 はむしろ <b>X1/X2/X3 とは独立した第 2 主成分</b>に沿う指標と分かる。</li>
</ul>

<h3>結果 (表と読み取り)</h3>
<h4>表6: 重回帰の係数表 (raw 係数, 標準化 β, 単独 R²)</h4>
{ols_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li><b>raw_coef</b>: 元のスケールでの係数。例えば X1_cruise の係数が +0.5 なら「寄港地が 1 港増えると X4 が 0.5 増える」(他を固定したとき)。</li>
<li><b>std_coef (= β)</b>: 標準化スケールでの係数。3 説明変数の <b>絶対値の大小がそのまま影響力の順位</b>を表す。</li>
<li><b>single_R2</b>: 各説明変数単独で X4 を予測した R²。重回帰 R² ({r2_full:.3f}) と比べて足し算的に増えているか確認。</li>
<li><b>(intercept) 行の single_R2</b> = 全体重回帰の R² を再掲。</li>
</ul>
"""))

# --- 9. 分析6: 階層クラスタリング --------------------------------------------
sections.append(("分析6: 階層クラスタリング — 市町を 3〜4 群に分ける",
f"""
<h3>狙い</h3>
<p>{len(M)} 市町を <b>4 指標の似ている度合い</b>でグルーピングする。仮説 H4
(地理タイプ別に 3〜4 群) を検証する。1 個 1 個の数値ではなく、<b>群としてのプロファイル</b>
で広島県の観光地理を要約する。</p>

<h3>手法 (ツール視点・要件J)</h3>
<p><b>階層クラスタリング (hierarchical clustering, Ward 法) とは</b>:</p>
<ul>
<li><b>入力</b>: 標準化済み {len(M)} 行 × 4 列の数値表 (PCA と同じ前処理)</li>
<li><b>出力 1: リンケージ行列 Z</b>: ({len(M)}-1) × 4 の表。各行は「i 番目のステップで群 a と群 b を合体させ、群間距離は d だった」を記録する。</li>
<li><b>出力 2: デンドログラム</b>: 上の Z を <b>木構造</b>に描いた図。葉 = 個別の市町、枝 = 群の合体、枝の高さ = 合体時の距離。</li>
<li><b>出力 3: クラスタ番号</b>: <code>fcluster(Z, t=4, criterion="maxclust")</code> で「上位 4 群」に切り、{len(M)} 市町に整数 1〜4 を割り当てる。</li>
<li><b>動作のイメージ</b>: 1) 全市町を「自分だけの 1 人クラスタ」で開始、2) 一番近い 2 つを合体、3) 距離を再計算、4) 全部が 1 つになるまで繰り返し。Ward 法は「合体すると群内ばらつきがどれだけ増えるか」が最小のペアを優先する。</li>
<li><b>数式は黒箱で OK</b>: ユークリッド距離 + Ward 連結基準。気になる人向けは「Ward 距離 = 合体後の SSW − 合体前の SSW」。</li>
<li><b>前提と限界</b>: 標準化必須 (PCA と同じ理由)。<b>群数を後から人が決める</b>必要がある (デンドログラムを見て切る高さを選ぶ)。</li>
<li><b>パラメータ</b>: <code>method="ward"</code>, <code>metric="euclidean"</code>, <code>t</code> (= 切るクラスタ数)。</li>
</ul>

<p><b>なぜこの分析か</b>: PCA は <b>連続的な平面に並べる</b>のに対し、クラスタは
<b>離散的な群を割り当てる</b>。両者は補完関係: PCA で「広島市はこの辺り」と位置を見て、
クラスタで「広島市はこの群に属する」と仲間を見る。デンドログラムは
<b>合体の歴史</b>を全部残すので、群数を変えた時にどう分かれるかも 1 枚で読める。</p>

<h3>実装</h3>
""" + code('''
from scipy.cluster.hierarchy import linkage, fcluster, dendrogram
from scipy.spatial.distance import pdist

# 既に標準化した Xs を使う (PCA と共通の前処理)
dist = pdist(Xs, metric="euclidean")        # ペア距離 (11C2 = 55 個)
Zlink = linkage(dist, method="ward")        # リンケージ行列 10x4

labels3 = fcluster(Zlink, t=3, criterion="maxclust")  # 3 群版
labels4 = fcluster(Zlink, t=4, criterion="maxclust")  # 4 群版

# デンドログラム描画 — 4 群境界に水平線を引く
fig, ax = plt.subplots(figsize=(12, 5.6))
dendrogram(Zlink, labels=M["city"].tolist(),
           color_threshold=Zlink[-3, 2] - 0.01, ax=ax)
ax.axhline(Zlink[-3, 2] - 0.01, color="#cf222e", ls="--", lw=1.2)
plt.savefig("assets/X03_dendrogram.png", dpi=140)
''') + f"""

<h3>結果 (図と読み取り)</h3>
<p><b>なぜこの図か</b>: 群分けの結果は <b>1 つの数 (クラスタ番号)</b> で表せるが、
それだけでは「なぜこの群分けになったか」が見えない。デンドログラムは
<b>全合体の履歴</b>を木で残すので、「ある市町と別の市町がどの段階で同じ群に入ったか」
「3 群と 4 群でどう変わるか」が同じ図で読める。</p>

{figure("assets/X03_dendrogram.png", "図5: Ward 法デンドログラム (赤破線=4 群境界)")}

<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>赤破線で 4 群に分割</b>: 距離 {Zlink[-3, 2]:.2f} の高さで横に切ると 4 つの群ができる。</li>
<li><b>低い位置で合体する 2 市町</b> (枝が短い) = 4 指標がほぼ同じプロファイル。</li>
<li><b>1 つだけ高い位置で合体する市町</b> = 他とは独立した観光プロファイル (=外れ値的に振る舞う)。</li>
<li><b>3 群 vs 4 群</b>: 切る高さを少し上げると 3 群、少し下げると 4 群。仮説 H4 (3〜4 群) はどちらでも矛盾しない範囲に収まる。</li>
</ul>

<h3>結果 (表と読み取り)</h3>
<h4>表7: 市町別クラスタ割り当て + 4 指標の値</h4>
{cluster_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li><b>同じ cluster4 番号の市町同士は 4 指標が似ている</b> (= Ward 距離が近い)。</li>
<li>3 群版と 4 群版で番号が変わるのは、最後の枝で 1 群が 2 群に分かれるため。</li>
<li>4 群版は <b>(島嶼観光集積 / 海岸都市 / 内陸寄り都市 / 拠点都市)</b> 的な構造になっているはず → 表で具体的に確認。</li>
</ul>

<h4>表8: 4 群の中心 (各クラスタの 4 指標平均)</h4>
{center4_html}

<p><b>この表から読み取れること</b>:</p>
<ul>
<li><b>各クラスタの「観光プロファイル」</b>がこの表に圧縮される。X1〜X4 のうちどれが高くどれが低いかで群の性格が決まる。</li>
<li>X1 と X2 が突出する群 = 島嶼観光集積タイプ。X4 が突出する群 = 歴史的インフラ集中タイプ。すべて低い群 = 観光指標弱い市町。</li>
<li>仮説 H4 への判定: <b>4 群でクラスタ中心が明確に分離</b>すれば支持。完全に重なる群があれば反証。</li>
</ul>
"""))

# --- 10. 仮説検証と考察 ------------------------------------------------------
_corr_off = corr.values[np.triu_indices(4, k=1)]
h1_n_pos = int((_corr_off > 0).sum())
h1_n_total = len(_corr_off)
h1_sup = "支持" if h1_n_pos == h1_n_total else "部分支持"
h3_pc1 = (load_df["PC1"] > 0).all()
sections.append(("仮説検証と考察", f"""
<h3>仮説と結果の照合</h3>
<table>
<tr><th>仮説</th><th>結果</th><th>判定</th></tr>
<tr><td><b>H1</b>: 4 指標は相互正相関</td>
    <td>相関行列の {h1_n_total} ペア中 <b>{h1_n_pos} ペアが r &gt; 0</b> (図2, 表4)。最小 r = {_corr_off.min():.2f}, 最大 r = {_corr_off.max():.2f}。海港系 3 指標 (X1/X2/X3) は強い正相関、X4 (歴史的施設) は半独立で X2 と弱い負相関。</td>
    <td><b>{h1_sup}</b></td></tr>
<tr><td><b>H2</b>: 各指標は性格が異なる</td>
    <td>本記事は年度集計のため季節性は地域差として現れる。散布図行列のヒスト (対角) が全指標で右の長尾を示し、分布形は似ているが、PCA の PC2 で 2 群に分かれることから <b>性格の対立軸は存在</b>する。</td>
    <td><b>部分支持</b></td></tr>
<tr><td><b>H3</b>: PC1 = 観光総合活発度, PC2 = 外向き vs 内向き</td>
    <td>PC1 ローディングは {"全指標が同符号 → 総合スコア軸として機能" if h3_pc1 else "符号が分かれる → 総合スコアではない"}。PC2 は X1/X2 系と X3/X4 系で符号が分かれる対立軸。</td>
    <td><b>{"支持 (PC1)" if h3_pc1 else "反証"} + 部分支持 (PC2)</b></td></tr>
<tr><td><b>H4</b>: 階層クラスタで 3〜4 群</td>
    <td>Ward 法デンドログラム (図5) を距離 {Zlink[-3, 2]:.2f} で切ると 4 群、距離 {Zlink[-2, 2]:.2f} で切ると 3 群。両方とも構造的に解釈可能。</td>
    <td><b>支持</b></td></tr>
<tr><td><b>H5</b>: 重回帰 R² &gt; 0.5</td>
    <td>X4 = a·X1 + b·X2 + c·X3 で R² = {r2_full:.3f} (図4)。{"閾値 0.5 を上回る" if r2_full > 0.5 else "閾値 0.5 を下回る"}。</td>
    <td><b>{"支持" if r2_full > 0.5 else "反証"}</b></td></tr>
</table>

<h3>総合考察</h3>
<ul>
<li><b>4 指標の正相関は強い</b>: 観光が活発な市町は全方位的に活発で、PC1 (= 観光総合活発度) が大半の分散を吸収する。</li>
<li><b>X1 ↔ X2 の強相関は構造由来</b>: X2 の定義が X1 の部分集合なので避けられない。データセットレベルで「クルーズ寄港」と「しまたび航路」は同じ寄港地物理インフラに依存している。</li>
<li><b>X4 (インフラツーリズム) は他指標から半分は説明できる</b>: ただし完全ではない。歴史的観光施設は <b>港の数</b>とも <b>島嶼性</b>とも別ロジックで配置されており (例: 呉市の旧海軍水道施設は港数では説明できない歴史的経緯)、独立成分が残る。</li>
<li><b>クラスタは島嶼集積 / 海岸都市 / 内陸寄り / 拠点都市</b>に近い構造になる: 広島市は中位、尾道市は突出、大崎上島町は島嶼集積で独立。</li>
<li><b>本記事は代理指標</b>: 真の客数データ (#1567 #1282 #1280) が手に入れば、本記事の代理指標が真の客数とどれくらい相関するかを検証する <b>新しい仮説</b>が立てられる (発展課題で扱う)。</li>
</ul>
"""))

# --- 11. 発展課題 ------------------------------------------------------------
sections.append(("発展課題", f"""
<h3>結果X → 新仮説Y → 課題Z (要件E)</h3>

<h4>課題1: 真の観光客数データとの照合</h4>
<ul>
<li><b>結果X</b>: 本記事の X1〜X3 は <b>物理寄港インフラ + 説明文意味</b>からの代理指標。</li>
<li><b>新仮説Y</b>: 真の DoBoX 客数データ (もし入手できれば) と本記事の代理指標の <b>r &gt; 0.7</b>。</li>
<li><b>課題Z</b>: DoBoX に問い合わせて #1567 #1282 #1280 の resource_download を入手 → 本記事と同じ市町別集計に整え → 4 列追加 → 4×8 の散布図行列で照合。</li>
</ul>

<h4>課題2: 季節性の検証 (H2 の本格的検証)</h4>
<ul>
<li><b>結果X</b>: H2 (季節性の異なり) は年度集計のため検証保留。</li>
<li><b>新仮説Y</b>: 月次データに分解すると、<b>クルーズは春秋ピーク・しまたびは夏ピーク・モニターは春実証</b>。</li>
<li><b>課題Z</b>: 月別に集計された DoBoX データ (もしくはサードパーティの月次観光統計) を取得し、4 指標 × 12 月 の 48 列行列を作って <b>季節 PCA</b> をかける。L08 の「時間モード」抽出と同じ手法を観光に流用。</li>
</ul>

<h4>課題3: 23 市町への拡張 (要件L: 次元への意識)</h4>
<ul>
<li><b>結果X</b>: 本記事は寄港地のある {len(M)} 沿岸市町に限定。残りの内陸 12 市町は X1=X2=0 になるため除外した。</li>
<li><b>新仮説Y</b>: 内陸市町を含めると、<b>X4 (インフラツーリズム) だけが正の値を持つ「内陸観光群」</b>がもう 1 群現れる。</li>
<li><b>課題Z</b>: 全 23 市町を含めた 4 列行列で再度 PCA + クラスタ → 沿岸 4 群 + 内陸 1 群 = 5 群構造を検証。</li>
</ul>

<h4>課題4: 多重共線性の対処</h4>
<ul>
<li><b>結果X</b>: X1 ↔ X2 の r が高く、重回帰では多重共線性の影響が出ている。</li>
<li><b>新仮説Y</b>: <b>VIF (分散拡大係数)</b> を計算すると X1 と X2 で 5 を超える (= 重大)。</li>
<li><b>課題Z</b>: <code>statsmodels</code> で各説明変数の VIF を計算し、5 超なら <b>X1 と X2 を主成分にまとめる</b>か <b>L1 正則化 (Lasso)</b> で片方を自動的にゼロにする。L08 の PCA 前処理を回帰の前処理として再利用。</li>
</ul>

<h4>課題5: K-Means / RandomForest との比較 (本記事の範囲外手法への展開)</h4>
<ul>
<li><b>結果X</b>: 階層クラスタは <b>群数を後から決められる</b>のが利点だが、計算量が O(n²) で大規模データに不向き。</li>
<li><b>新仮説Y</b>: K-Means (k=4 固定) で同じ群分けを取ると、Ward 法と <b>一致率 &gt; 80%</b>。</li>
<li><b>課題Z</b>: L07 で扱った K-Means を本記事の同じ標準化行列に適用 → 群番号を Ward 法と対応付ける混同行列で一致率を計算。範囲外手法 (RandomForest 重要度, t-SNE 可視化) も追加比較すれば、複数手法のクロスチェックで仮説の頑健性が確認できる。</li>
</ul>
"""))

# === HTML 出力 ===============================================================
HTML_PATH = LESSONS / "X03_tourism_4indicators_pairplot.html"
html = render_lesson(
    num=303,  # X tier (X03 → 303 表示)
    title="X03 観光統計 4 指標のペアプロット解析 — 散布図行列・PCA・階層クラスタで広島県市町を読み解く",
    tags=["観光", "ペアプロット", "PCA", "階層クラスタ", "重回帰", "X-tier"],
    time="60〜80分",
    level="L01 水準 (中級)",
    data_label="DoBoX #1567 #1282 #1280 #1447 #1278 + dataset_index.csv",
    sections=sections,
    script_filename="X03_tourism_4indicators_pairplot.py",
)

# X-tier ドラフト印を埋め込む
html = html.replace("<body>", '<body data-draft="1" data-stier="X">', 1)
# render_lesson は <body> 直書きしないため、念の為 div の手前に挿入
if "data-stier=\"X\"" not in html:
    html = html.replace("<div class=\"container\">",
                        '<body data-draft="1" data-stier="X"><div class="container">', 1)

HTML_PATH.write_text(html, encoding="utf-8")
print(f"\n=== HTML 出力 === {HTML_PATH.name} ({HTML_PATH.stat().st_size//1024} KB)")
print("=== 完了 ===")
