Skip to content

Commit 25f5fb3

Browse files
committed
feat: implement ablation study for shelter siting strategies with multiple candidate-generation methods
1 parent 99a6a6c commit 25f5fb3

2 files changed

Lines changed: 319 additions & 4 deletions

File tree

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Ablation: compare candidate-generation strategies for shelter siting.
4+
5+
Variants (same greedy non-overlap selection for all):
6+
1. original_dbscan — single DBSCAN (eps=radius, min_samples=5), centroids
7+
2. enhanced_dbscan — multi-eps DBSCAN + original DBSCAN (no K-means)
8+
3. kmeans_k500 — K-means only with k=500 (matches planning ceiling)
9+
4. kmeans_only — K-means only (k=750,1500 × 2 seeds; production setting)
10+
5. ensemble — full pipeline (enhanced DBSCAN + K-means)
11+
12+
Writes output/ablation_ensemble_methods.json and a short CSV summary.
13+
Run from repo root:
14+
python scripts/ablation_ensemble_methods.py
15+
K-means k justification only (faster):
16+
python scripts/ablation_ensemble_methods.py --modes kmeans_k500 kmeans_only --out output/ablation_kmeans_k.json
17+
Optional: restrict radii for a quicker smoke test:
18+
python scripts/ablation_ensemble_methods.py --radii 200
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import argparse
24+
import csv
25+
import json
26+
import os
27+
import sys
28+
from copy import deepcopy
29+
30+
import numpy as np
31+
32+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
33+
from shelter_optimizer_ensemble import EnhancedShelterOptimizer
34+
35+
36+
# Default Table A.1 variants (kmeans_k500 is opt-in for k-justification)
37+
VARIANTS = (
38+
"original_dbscan",
39+
"enhanced_dbscan",
40+
"kmeans_only",
41+
"ensemble",
42+
)
43+
ALL_VARIANTS = VARIANTS + ("kmeans_k500",)
44+
45+
46+
class AblationOptimizer(EnhancedShelterOptimizer):
47+
"""Same optimizer with pluggable candidate-generation modes."""
48+
49+
def optimize_single_run_for_buildings(self, building_coords, coverage_radius_m, pbar=None, mode="ensemble"):
50+
all_candidates = []
51+
all_candidate_sources = {}
52+
53+
def progress_callback(msg):
54+
if pbar:
55+
if msg == "step_complete":
56+
pbar.update(1)
57+
else:
58+
pbar.set_description(f" {msg}")
59+
60+
if mode == "original_dbscan":
61+
progress_callback("Original DBSCAN only...")
62+
cands, sources = self.generate_original_dbscan_candidates(
63+
building_coords, coverage_radius_m, progress_callback=progress_callback
64+
)
65+
all_candidates.extend(cands)
66+
all_candidate_sources.update(sources)
67+
elif mode == "enhanced_dbscan":
68+
progress_callback("Enhanced DBSCAN (no K-means)...")
69+
cands, sources = self.generate_dbscan_candidates(
70+
building_coords, coverage_radius_m, run_id=0, progress_callback=progress_callback
71+
)
72+
all_candidates.extend(cands)
73+
all_candidate_sources.update(sources)
74+
elif mode == "kmeans_k500":
75+
progress_callback("K-means k=500 only...")
76+
cands, sources = self.generate_kmeans_candidates(
77+
building_coords,
78+
coverage_radius_m,
79+
run_id=0,
80+
progress_callback=progress_callback,
81+
k_values=[500],
82+
)
83+
all_candidates.extend(cands)
84+
all_candidate_sources.update(sources)
85+
elif mode == "kmeans_only":
86+
progress_callback("K-means only...")
87+
cands, sources = self.generate_kmeans_candidates(
88+
building_coords, coverage_radius_m, run_id=0, progress_callback=progress_callback
89+
)
90+
all_candidates.extend(cands)
91+
all_candidate_sources.update(sources)
92+
elif mode == "ensemble":
93+
progress_callback("Starting DBSCAN...")
94+
dbscan_candidates, dbscan_sources = self.generate_dbscan_candidates(
95+
building_coords, coverage_radius_m, run_id=0, progress_callback=progress_callback
96+
)
97+
all_candidates.extend(dbscan_candidates)
98+
all_candidate_sources.update(dbscan_sources)
99+
100+
progress_callback("Starting K-means...")
101+
kmeans_candidates, kmeans_sources = self.generate_kmeans_candidates(
102+
building_coords, coverage_radius_m, run_id=0, progress_callback=progress_callback
103+
)
104+
all_candidates.extend(kmeans_candidates)
105+
all_candidate_sources.update(kmeans_sources)
106+
else:
107+
raise ValueError(f"Unknown mode: {mode}")
108+
109+
if not all_candidates:
110+
return None
111+
112+
coverage_radius_deg, _ = self.meters_to_degrees(coverage_radius_m)
113+
deduplicated = self.remove_duplicate_candidates_fast(
114+
all_candidates, coverage_radius_deg * 0.1
115+
)
116+
selected_shelters = self.optimal_shelter_selection(
117+
deduplicated, coverage_radius_m, self.TARGET_SHELTERS
118+
)
119+
120+
return {
121+
"run_id": 0,
122+
"mode": mode,
123+
"shelters": selected_shelters,
124+
"total_coverage": sum(s["buildings_covered"] for s in selected_shelters),
125+
"candidates_generated": len(all_candidates),
126+
"candidates_after_dedup": len(deduplicated),
127+
"candidate_sources": all_candidate_sources,
128+
"method_counts": _count_methods(selected_shelters),
129+
}
130+
131+
def run_ablation_for_radius(
132+
self, building_coords, building_features, shelter_features, coverage_radius_m, modes
133+
):
134+
coverage_radius_deg, _ = self.meters_to_degrees(coverage_radius_m)
135+
existing_shelters, _ = self.process_existing_shelters(shelter_features)
136+
uncovered_buildings, _ = self.filter_existing_coverage(
137+
building_coords, existing_shelters, coverage_radius_deg
138+
)
139+
already_covered = len(building_coords) - len(uncovered_buildings)
140+
n_buildings = len(building_coords)
141+
142+
rows = []
143+
for mode in modes:
144+
print(f"\n --- mode={mode} @ {coverage_radius_m}m ---")
145+
result = self.optimize_single_run_for_buildings(
146+
uncovered_buildings, coverage_radius_m, pbar=None, mode=mode
147+
)
148+
if not result:
149+
rows.append(
150+
{
151+
"radius_m": coverage_radius_m,
152+
"mode": mode,
153+
"shelters_selected": 0,
154+
"new_buildings_covered": 0,
155+
"total_buildings_covered": already_covered,
156+
"coverage_percentage": round(100.0 * already_covered / n_buildings, 2),
157+
"candidates_generated": 0,
158+
"candidates_after_dedup": 0,
159+
"method_counts": {},
160+
}
161+
)
162+
continue
163+
164+
new_covered = result["total_coverage"]
165+
total_covered = new_covered + already_covered
166+
rows.append(
167+
{
168+
"radius_m": coverage_radius_m,
169+
"mode": mode,
170+
"shelters_selected": len(result["shelters"]),
171+
"new_buildings_covered": new_covered,
172+
"total_buildings_covered": total_covered,
173+
"coverage_percentage": round(100.0 * total_covered / n_buildings, 2),
174+
"candidates_generated": result["candidates_generated"],
175+
"candidates_after_dedup": result["candidates_after_dedup"],
176+
"method_counts": result["method_counts"],
177+
}
178+
)
179+
print(
180+
f" → {rows[-1]['coverage_percentage']:.1f}% coverage "
181+
f"({rows[-1]['shelters_selected']} shelters, "
182+
f"{rows[-1]['candidates_after_dedup']} unique candidates)"
183+
)
184+
return rows
185+
186+
187+
def _count_methods(shelters):
188+
counts = {}
189+
for s in shelters:
190+
m = s.get("method", "unknown")
191+
if m.startswith("kmeans"):
192+
family = "kmeans"
193+
elif m.startswith("original_dbscan"):
194+
family = "original_dbscan"
195+
elif m.startswith("dbscan"):
196+
family = "enhanced_dbscan"
197+
else:
198+
family = m
199+
counts[family] = counts.get(family, 0) + 1
200+
return counts
201+
202+
203+
def main():
204+
parser = argparse.ArgumentParser(description="Ablation of ensemble candidate sources")
205+
parser.add_argument(
206+
"--radii",
207+
type=int,
208+
nargs="+",
209+
default=None,
210+
help="Accessibility radii to test (default: all five)",
211+
)
212+
parser.add_argument(
213+
"--modes",
214+
nargs="+",
215+
default=list(VARIANTS),
216+
choices=list(ALL_VARIANTS),
217+
help="Which variants to run (include kmeans_k500 to test k=planning ceiling)",
218+
)
219+
parser.add_argument(
220+
"--buildings",
221+
default="data/buildings_light.geojson",
222+
)
223+
parser.add_argument(
224+
"--shelters",
225+
default="data/shelters.geojson",
226+
)
227+
parser.add_argument(
228+
"--out",
229+
default="output/ablation_ensemble_methods.json",
230+
)
231+
args = parser.parse_args()
232+
233+
opt = AblationOptimizer()
234+
radii = args.radii or deepcopy(opt.RADII_TO_TEST)
235+
236+
print("ABLATION: candidate-generation strategies")
237+
print(f"Radii: {radii}")
238+
print(f"Modes: {args.modes}")
239+
print(f"Target shelters: {opt.TARGET_SHELTERS}")
240+
241+
building_coords, building_features = opt.load_geojson(args.buildings)
242+
_, shelter_features = opt.load_geojson(args.shelters)
243+
print(f"Buildings: {len(building_features)}; shelter features: {len(shelter_features)}")
244+
245+
all_rows = []
246+
for radius_m in radii:
247+
print(f"\n===== RADIUS {radius_m}m =====")
248+
all_rows.extend(
249+
opt.run_ablation_for_radius(
250+
building_coords,
251+
building_features,
252+
shelter_features,
253+
radius_m,
254+
args.modes,
255+
)
256+
)
257+
258+
os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True)
259+
payload = {
260+
"description": (
261+
"Coverage after greedy non-overlap selection (min separation = 2×radius). "
262+
"buildings_covered scores are precomputed per candidate on uncovered buildings; "
263+
"non-overlap makes summed coverage a valid total under the model."
264+
),
265+
"parameters": {
266+
"target_shelters": opt.TARGET_SHELTERS,
267+
"dbscan_eps_multipliers": opt.DBSCAN_EPS_MULTIPLIERS,
268+
"dbscan_min_samples": opt.DBSCAN_MIN_SAMPLES,
269+
"kmeans_k_values": opt.KMEANS_K_VALUES,
270+
"n_kmeans_seeds": opt.N_KMEANS_SEEDS,
271+
"min_buildings_per_cluster": opt.MIN_BUILDINGS_PER_CLUSTER,
272+
},
273+
"results": all_rows,
274+
}
275+
with open(args.out, "w") as f:
276+
json.dump(payload, f, indent=2)
277+
278+
csv_path = args.out.replace(".json", ".csv")
279+
with open(csv_path, "w", newline="") as f:
280+
writer = csv.DictWriter(
281+
f,
282+
fieldnames=[
283+
"radius_m",
284+
"mode",
285+
"shelters_selected",
286+
"new_buildings_covered",
287+
"total_buildings_covered",
288+
"coverage_percentage",
289+
"candidates_generated",
290+
"candidates_after_dedup",
291+
],
292+
)
293+
writer.writeheader()
294+
for row in all_rows:
295+
writer.writerow({k: row[k] for k in writer.fieldnames})
296+
297+
print("\n=== SUMMARY (coverage %) ===")
298+
by_radius = {}
299+
for row in all_rows:
300+
by_radius.setdefault(row["radius_m"], {})[row["mode"]] = row["coverage_percentage"]
301+
header = ["radius_m"] + list(args.modes)
302+
print("\t".join(header))
303+
for r in radii:
304+
vals = [str(r)] + [str(by_radius.get(r, {}).get(m, "")) for m in args.modes]
305+
print("\t".join(vals))
306+
307+
print(f"\nSaved {args.out}")
308+
print(f"Saved {csv_path}")
309+
310+
311+
if __name__ == "__main__":
312+
main()

scripts/shelter_optimizer_ensemble.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -286,17 +286,20 @@ def generate_dbscan_candidates(self, building_coords, coverage_radius_m, run_id=
286286

287287
return all_candidates, all_candidate_sources
288288

289-
def generate_kmeans_candidates(self, building_coords, coverage_radius_m, run_id=0, progress_callback=None):
290-
"""Generate candidates using K-means clustering"""
289+
def generate_kmeans_candidates(self, building_coords, coverage_radius_m, run_id=0, progress_callback=None, k_values=None):
290+
"""Generate candidates using K-means clustering.
291+
292+
k_values: optional override (default: self.KMEANS_K_VALUES). Used by ablation
293+
to test k matching the planning ceiling (k=500) vs oversampling.
294+
"""
291295
candidates = []
292296
candidate_sources = {}
293297
coverage_radius_deg, _ = self.meters_to_degrees(coverage_radius_m)
294298

295299
if progress_callback:
296300
progress_callback("Starting K-means...")
297301

298-
# Use fixed k values with multiple seeds
299-
k_values = self.KMEANS_K_VALUES
302+
k_values = self.KMEANS_K_VALUES if k_values is None else k_values
300303

301304
total_k_configs = len(k_values) * self.N_KMEANS_SEEDS
302305
completed_configs = 0

0 commit comments

Comments
 (0)