03 — Typology Modeling¶
Reproduksi StandardScaler → PCA → K-Means dan audit robustness
Notebook ini adalah POC portfolio yang dapat dijalankan ulang dari artefak lokal Pasuruan Lens. Kode produksi tetap berada di python/scripts/ dan python/src/pasuruan365/; notebook berfungsi sebagai narasi analitik yang ringkas, transparan, dan mudah dipresentasikan.
from pathlib import Path
import json
import sys
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
def find_project_root(start: Path | None = None) -> Path:
start = (start or Path.cwd()).resolve()
for candidate in (start, *start.parents):
if (candidate / "package.json").exists() and (candidate / "data").exists():
return candidate
raise RuntimeError("Root Pasuruan Lens tidak ditemukan. Jalankan notebook dari repository ini.")
ROOT = find_project_root()
sys.path.insert(0, str(ROOT / "python" / "src"))
pd.set_option("display.max_columns", 100)
pd.set_option("display.max_colwidth", 100)
sns.set_theme(style="whitegrid", context="notebook")
COLORS = {"A": "#2563eb", "B": "#f59e0b", "C": "#10b981"}
print(f"Project root: {ROOT}")
print(f"Python: {sys.version.split()[0]}")
Project root: PASURUAN365 Python: 3.12.6
Pertanyaan modeling¶
Bisakah 365 desa/kelurahan dikelompokkan berdasarkan kemiripan struktural lintas domain? Hasilnya adalah tipologi eksploratif, bukan ranking pembangunan dan bukan label benar/salah.
Model V2 memilih delapan fitur dari enam domain, StandardScaler, PCA tujuh komponen, dan K-Means k=3 setelah membandingkan beberapa spesifikasi serta sensitivitas model V1.
from sklearn.cluster import KMeans
from sklearn.decomposition import PCA
from sklearn.metrics import adjusted_rand_score, silhouette_score
from sklearn.preprocessing import StandardScaler
eda = pd.read_csv(ROOT / "data/analytics/village_eda_features.csv", dtype={"village_id": str})
saved = pd.read_csv(ROOT / "data/modeling/cluster_assignments_v2.csv", dtype={"village_id": str})
config = json.loads((ROOT / "data/modeling/selected_model_config_v2.json").read_text())
manifest = pd.read_csv(ROOT / "data/modeling/review/model_feature_manifest_v2.csv")
manifest[["feature_name", "domain", "model_role", "transform", "coverage"]]
| feature_name | domain | model_role | transform | coverage | |
|---|---|---|---|---|---|
| 0 | population_share_of_district | DEMOGRAPHY | ROBUST_CORE | NONE | 365/365 |
| 1 | sex_ratio | DEMOGRAPHY | ROBUST_CORE | NONE | 365/365 |
| 2 | log1p_population_density | GEOGRAPHY | ROBUST_CORE | ALREADY_LOG1P | 365/365 |
| 3 | log1p_bts_per_10000_population | CONNECTIVITY | ROBUST_CORE | ALREADY_LOG1P | 365/365 |
| 4 | cellular_operator_count | CONNECTIVITY | ROBUST_CORE | NONE | 365/365 |
| 5 | has_banking_facility | ECONOMY | ROBUST_CORE | NONE | 365/365 |
| 6 | any_documented_disaster_event_2024 | DISASTER | ROBUST_CORE | NONE | 365/365 |
| 7 | has_polyclinic | HEALTHCARE | BINARY_PRESENCE_CORE | NONE | 365/365 |
POC 1 — Reproduksi pipeline model¶
model_frame = eda.copy()
model_frame["has_polyclinic"] = model_frame["polyclinic_count"].gt(0).astype(int)
X = model_frame[config["features"]].astype(float)
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
pca = PCA(n_components=config["pca_components"], random_state=config["random_state"])
X_pca = pca.fit_transform(X_scaled)
model = KMeans(n_clusters=config["k"], random_state=config["random_state"], n_init=20)
reproduced_labels = model.fit_predict(X_pca)
comparison = model_frame[["village_id"]].merge(saved[["village_id", "cluster_id"]], on="village_id")
ari = adjusted_rand_score(comparison["cluster_id"], reproduced_labels)
pd.Series({
"Adjusted Rand Index vs frozen assignment": ari,
"Silhouette reproduced": silhouette_score(X_pca, reproduced_labels),
"Silhouette frozen config": config["metrics"]["silhouette"],
"PCA variance retained": pca.explained_variance_ratio_.sum(),
}).round(6).to_frame("nilai")
| nilai | |
|---|---|
| Adjusted Rand Index vs frozen assignment | 1.000000 |
| Silhouette reproduced | 0.175881 |
| Silhouette frozen config | 0.175881 |
| PCA variance retained | 0.938386 |
assert ari > 0.999, "Reproduksi model berbeda dari artefak V2."
print("✓ Assignment V2 berhasil direproduksi (ARI permutation-invariant ≈ 1).")
✓ Assignment V2 berhasil direproduksi (ARI permutation-invariant ≈ 1).
POC 2 — PCA adalah representasi model, PC1–PC2 hanya visualisasi¶
variance = pd.DataFrame({
"component": np.arange(1, len(pca.explained_variance_ratio_) + 1),
"explained": pca.explained_variance_ratio_,
})
variance["cumulative"] = variance["explained"].cumsum()
ax = variance.plot(x="component", y="cumulative", marker="o", ylim=(0, 1.05), figsize=(8, 4), legend=False)
ax.axhline(.80, color="#dc2626", linestyle="--", linewidth=1)
ax.set(title="Cumulative explained variance", xlabel="Jumlah komponen", ylabel="Proporsi kumulatif")
plt.tight_layout()
variance.round(3)
| component | explained | cumulative | |
|---|---|---|---|
| 0 | 1 | 0.275 | 0.275 |
| 1 | 2 | 0.159 | 0.434 |
| 2 | 3 | 0.129 | 0.562 |
| 3 | 4 | 0.111 | 0.673 |
| 4 | 5 | 0.109 | 0.782 |
| 5 | 6 | 0.088 | 0.870 |
| 6 | 7 | 0.069 | 0.938 |
plot_frame = pd.DataFrame({"PC1": X_pca[:, 0], "PC2": X_pca[:, 1]})
plot_frame["typology"] = saved["cluster_label"].str.replace("Typology ", "", regex=False)
fig, ax = plt.subplots(figsize=(8, 6))
for label, group in plot_frame.groupby("typology"):
ax.scatter(group.PC1, group.PC2, s=28, alpha=.72, label=f"Tipologi {label}", color=COLORS[label])
ax.set(title="Proyeksi PC1–PC2 (bukan keseluruhan ruang model)", xlabel="PC1", ylabel="PC2")
ax.legend()
plt.tight_layout()
POC 3 — Separation harus dibaca bersama stability¶
metrics = pd.Series(config["metrics"])
metrics[[
"silhouette", "seed_stability_ari", "bootstrap_stability_ari",
"scaler_robustness", "feature_ablation_robustness", "cross_specification_robustness",
]].round(3).to_frame("nilai")
| nilai | |
|---|---|
| silhouette | 0.176 |
| seed_stability_ari | 0.983 |
| bootstrap_stability_ari | 0.839 |
| scaler_robustness | 0.549 |
| feature_ablation_robustness | 0.622 |
| cross_specification_robustness | 0.615 |
ambiguity = saved["ambiguity_class"].value_counts().rename_axis("kelas").to_frame("desa_kelurahan")
ambiguity["persen"] = (ambiguity["desa_kelurahan"] / len(saved) * 100).round(1)
ambiguity
| desa_kelurahan | persen | |
|---|---|---|
| kelas | ||
| HIGH_CONFIDENCE | 214 | 58.6 |
| MODERATE_AMBIGUITY | 97 | 26.6 |
| HIGH_AMBIGUITY | 54 | 14.8 |
Kesimpulan¶
Model dapat direproduksi, seed/bootstrap stability kuat, tetapi robustness lintas scaler dan spesifikasi tidak sempurna. Karena itu UI mempertahankan kelas ambiguitas dan tidak mempresentasikan tipologi sebagai kebenaran absolut. Kesederhanaan k=3 dipilih sebagai keputusan komunikasi sekaligus robustness, bukan sekadar mengejar silhouette tertinggi.