04 — GIS & Spatial Analytics¶

Exact-code spatial join, Queen adjacency, dan permutation test

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

Tujuan¶

Geometri dipakai sebagai konteks analitik setelah model selesai. Lokasi, kecamatan, dan adjacency tidak dimasukkan ke clustering. Dengan begitu, pola spasial berfungsi sebagai bukti deskriptif eksternal—bukan target yang secara otomatis dipaksakan oleh model.

import geopandas as gpd

geo = gpd.read_file(ROOT / "public/data/gis/village_boundaries.geojson")
geo["village_id"] = geo["villageId"].astype(str)
assignments = pd.read_csv(ROOT / "data/modeling/cluster_assignments_v2.csv", dtype={"village_id": str})
edges = pd.read_csv(ROOT / "data/spatial/weights/village_adjacency.csv", dtype=str)
summary = json.loads((ROOT / "data/spatial/statistics/spatial_analysis_summary.json").read_text())

pd.Series({
    "polygon": len(geo),
    "unique GIS ID": geo.village_id.nunique(),
    "matched assignment": geo.village_id.isin(assignments.village_id).sum(),
    "CRS browser": str(geo.crs),
    "Queen directed edges": edges.weight_type.eq("QUEEN").sum(),
}).to_frame("nilai")
nilai
polygon 365
unique GIS ID 365
matched assignment 365
CRS browser EPSG:4326
Queen directed edges 2084

POC 1 — Visual join memakai village_id¶

mapped = geo.merge(assignments[["village_id", "cluster_label"]], on="village_id", validate="one_to_one")
mapped["typology"] = mapped.cluster_label.str.replace("Typology ", "", regex=False)

fig, ax = plt.subplots(figsize=(9, 9))
mapped.plot(color=mapped["typology"].map(COLORS), edgecolor="white", linewidth=.18, ax=ax)
ax.set_title("Pasuruan Lens — tipologi pada 365 poligon BIG")
ax.set_axis_off()
plt.tight_layout()
Visualisasi output notebook Pasuruan Lens

POC 2 — Hitung ulang same-type adjacency¶

queen = edges.loc[edges.weight_type.eq("QUEEN"), ["village_id", "neighbor_id"]].copy()
labels = assignments.set_index("village_id")["cluster_id"]
queen["same_type"] = queen.village_id.map(labels).eq(queen.neighbor_id.map(labels))
observed_directed = queen["same_type"].mean()

# Karena file adjacency menyimpan dua arah, pasangan unik dipakai untuk permutation.
queen["pair"] = queen.apply(lambda row: tuple(sorted((row.village_id, row.neighbor_id))), axis=1)
unique_pairs = queen.drop_duplicates("pair")[["village_id", "neighbor_id"]]
observed = np.mean([
    labels.at[left] == labels.at[right]
    for left, right in unique_pairs.itertuples(index=False, name=None)
])

pd.Series({
    "unique Queen edges": len(unique_pairs),
    "observed from data": observed,
    "published observed": summary["queen"]["observed"],
    "published expected": summary["queen"]["expected"],
    "published p-value": summary["queen"]["p_value"],
}).round(6).to_frame("nilai")
nilai
unique Queen edges 1042.000000
observed from data 0.566219
published observed 0.566219
published expected 0.376074
published p-value 0.001000

POC 3 — Fixed-count permutation¶

rng = np.random.default_rng(365)
ids = labels.index.to_numpy()
label_values = labels.to_numpy()
left_idx = pd.Series(np.arange(len(ids)), index=ids).loc[unique_pairs.village_id].to_numpy()
right_idx = pd.Series(np.arange(len(ids)), index=ids).loc[unique_pairs.neighbor_id].to_numpy()

simulated = np.empty(999)
for i in range(999):
    permuted = rng.permutation(label_values)  # jumlah A/B/C tetap
    simulated[i] = np.mean(permuted[left_idx] == permuted[right_idx])

p_value = (1 + np.sum(simulated >= observed)) / (len(simulated) + 1)

fig, ax = plt.subplots(figsize=(8, 4))
sns.histplot(simulated, bins=28, ax=ax, color="#94a3b8")
ax.axvline(observed, color="#dc2626", linewidth=2, label=f"observed = {observed:.3f}")
ax.set(title="Null distribution: fixed-count label permutation", xlabel="Proporsi pasangan bertipologi sama")
ax.legend()
plt.tight_layout()

print(f"Expected (POC): {simulated.mean():.3f}")
print(f"Observed:       {observed:.3f}")
print(f"p-value:        {p_value:.3f}")
Expected (POC): 0.376
Observed:       0.566
p-value:        0.001
Visualisasi output notebook Pasuruan Lens

Batas interpretasi¶

Tipologi yang sama lebih sering bertetangga daripada ekspektasi acak dengan jumlah label tetap. Ini tidak membuktikan sebab-akibat, kualitas model, akses jalan, interaksi ekonomi, atau kedekatan waktu tempuh. Hasil juga bergantung pada unit wilayah dan definisi bobot spasial (MAUP dan specification dependence).