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
Visualisasi output notebook Pasuruan Lens
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()
Visualisasi output notebook Pasuruan Lens

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.