Skip to content

Commit 9cad900

Browse files
authored
Merge pull request #38 from henryspatialanalysis/feature/static_data_viz
Add static viz and table creation scripts.
2 parents fd11bfe + 2241883 commit 9cad900

7 files changed

Lines changed: 1055 additions & 31 deletions

File tree

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
#!/usr/bin/env python
2+
"""
3+
Plot confidence-weighted source contributions to the conflated dataset.
4+
5+
Reads conflated.parquet and draws a horizontal stacked bar chart. Each row
6+
is a shared_label; the 15 most common shared labels (by POI count) in the
7+
most recent database are shown, ordered descending so the largest sits at
8+
the top. Each bar is split left-to-right into the three provenance classes
9+
of the conflated dataset:
10+
11+
Overture only (left) — source == "overture"
12+
Both (mid) — source == "matched"
13+
OSM only (right) — source == "osm"
14+
15+
Bar lengths are **confidence-weighted** observation counts: each POI is
16+
weighted by its combined confidence score (``conf_mean``, the final
17+
post-change-detection blended confidence carried in the published dataset)
18+
rather than counted as 1. Each colored sub-bar is annotated, in white, with
19+
its confidence-weighted contribution rounded roughly to the nearest
20+
thousand.
21+
22+
Blank/null shared labels are excluded — they are not a meaningful label
23+
type — so the 15 rows are the 15 most common *named* labels.
24+
25+
Config keys used (config.yaml):
26+
conflation.conflated — input GeoParquet path (conflated.parquet)
27+
conflation — directory; output PNG lands in its viz/ subdir
28+
29+
Prerequisites:
30+
Run scripts/conflation/conflate.py (and apply_change_detection.py) first.
31+
32+
Output file (in conflation/<version>/viz/):
33+
source_contributions.png
34+
"""
35+
from __future__ import annotations
36+
37+
import numpy as np
38+
import pandas as pd
39+
from config_versioned import Config
40+
41+
from pathlib import Path # noqa: E402
42+
43+
import matplotlib
44+
matplotlib.use("Agg") # noqa: E402
45+
import matplotlib.font_manager as fm # noqa: E402
46+
import matplotlib.pyplot as plt # noqa: E402
47+
from matplotlib.ticker import FuncFormatter, MultipleLocator # noqa: E402
48+
49+
# ----------------------------------------------------------------------------------------
50+
# Configuration constants
51+
# ----------------------------------------------------------------------------------------
52+
53+
config = Config("~/repos/openpois/config.yaml")
54+
INPUT_PATH = config.get_file_path("conflation", "conflated")
55+
VIZ_DIR = config.get_dir_path("conflation") / "viz"
56+
OUTPUT_PATH = VIZ_DIR / "source_contributions.png"
57+
58+
TOP_N = 14
59+
60+
# Shared labels to drop before ranking: every "Other ..." catch-all plus a
61+
# few named labels we don't want surfaced here.
62+
EXCLUDE_EXACT = {
63+
"Home Service", "Swimming Pool", "Real Estate", "Specialty Store",
64+
"Hotel", "Recreation",
65+
}
66+
67+
# Register the Figtree variable font from the first candidate path that
68+
# exists, then make it the default family. Falls back to matplotlib's
69+
# default if none are found.
70+
FIGTREE_CANDIDATES = [
71+
Path("/mnt/c/Users/nathe/AppData/Local/Microsoft/Windows/Fonts/"
72+
"Figtree-VariableFont_wght.ttf"),
73+
Path("/mnt/d/Users/Lenovo/AppData/Local/Microsoft/Windows/Fonts/"
74+
"Figtree-VariableFont_wght.ttf"),
75+
]
76+
for _font in FIGTREE_CANDIDATES:
77+
if _font.exists():
78+
fm.fontManager.addfont(str(_font))
79+
plt.rcParams["font.family"] = "Figtree"
80+
break
81+
82+
# Base font size, bumped 20% over the matplotlib default of 10.
83+
plt.rcParams["font.size"] = 12
84+
85+
# source value -> (legend label, color). Listed left-to-right.
86+
SOURCES = [
87+
("overture", "Overture only", "#2e86c9"),
88+
("matched", "Both", "#3d00a5"),
89+
("osm", "OSM only", "#a0d787"),
90+
]
91+
92+
# Skip the in-bar number when a sub-bar is narrower than this fraction of the
93+
# widest total bar — the text would overflow its segment and collide.
94+
LABEL_MIN_FRAC = 0.018
95+
96+
# ----------------------------------------------------------------------------------------
97+
# Helpers
98+
# ----------------------------------------------------------------------------------------
99+
100+
101+
def fmt_weighted(value: float) -> str:
102+
"""Format a confidence-weighted count roughly to the nearest thousand.
103+
104+
>= 1k -> integer thousands with a "k" suffix ("837k", "17k", "9k")
105+
< 1k -> raw integer ("751")
106+
"""
107+
if value >= 1_000:
108+
return f"{round(value / 1000):d}k"
109+
return f"{round(value):d}"
110+
111+
112+
# ----------------------------------------------------------------------------------------
113+
# Main workflow
114+
# ----------------------------------------------------------------------------------------
115+
116+
if __name__ == "__main__":
117+
print(f"Reading {INPUT_PATH} ...")
118+
df = pd.read_parquet(
119+
INPUT_PATH,
120+
columns = ["shared_label", "source", "conf_mean"],
121+
)
122+
print(f" {len(df):,} rows")
123+
124+
# Drop blank/null shared labels — not a meaningful label type — plus the
125+
# excluded "Other ..." catch-alls and named labels.
126+
label = df["shared_label"]
127+
keep = (
128+
label.notna()
129+
& (label != "")
130+
& ~label.str.startswith("Other ")
131+
& ~label.isin(EXCLUDE_EXACT)
132+
)
133+
df = df[keep]
134+
135+
# Confidence-weighted sum and raw count per (shared_label, source).
136+
agg = df.groupby(["shared_label", "source"], observed = True).agg(
137+
wsum = ("conf_mean", "sum"),
138+
n = ("conf_mean", "size"),
139+
)
140+
wsum = agg["wsum"].unstack(fill_value = 0.0)
141+
counts = agg["n"].unstack(fill_value = 0)
142+
for source, _, _ in SOURCES:
143+
if source not in wsum.columns:
144+
wsum[source] = 0.0
145+
counts[source] = 0
146+
147+
# Top N shared labels by total confidence-weighted POI count; largest at
148+
# the top of the chart.
149+
top_labels = (
150+
wsum.sum(axis = 1).sort_values(ascending = False).head(TOP_N).index
151+
)
152+
wsum = wsum.loc[top_labels]
153+
154+
# ----------------------------------------------------------------------------
155+
# Draw the horizontal stacked bar chart
156+
# ----------------------------------------------------------------------------
157+
y = np.arange(len(top_labels))
158+
max_total = wsum.sum(axis = 1).max()
159+
label_threshold = max_total * LABEL_MIN_FRAC
160+
161+
fig, ax = plt.subplots(figsize = (13.33, 7.5))
162+
163+
left = np.zeros(len(top_labels))
164+
for source, _, color in SOURCES:
165+
widths = wsum[source].to_numpy()
166+
ax.barh(y, widths, left = left, color = color, height = 0.90)
167+
# White in-bar annotation at each segment's center.
168+
for yi, w, x0 in zip(y, widths, left):
169+
if w >= label_threshold:
170+
ax.text(
171+
x0 + w / 2,
172+
yi,
173+
fmt_weighted(w),
174+
ha = "center",
175+
va = "center",
176+
color = "white",
177+
fontsize = 10,
178+
fontweight = "bold",
179+
)
180+
left += widths
181+
182+
ax.set_yticks(y)
183+
ax.set_yticklabels(top_labels)
184+
ax.invert_yaxis() # largest label at the top
185+
ax.set_xlabel("Confidence weighted POI count")
186+
# Title 20% larger than matplotlib's default (1.2x base -> 1.44x base).
187+
ax.set_title(
188+
"Confidence-weighted records in the conflated dataset, by source",
189+
fontsize = plt.rcParams["font.size"] * 1.44,
190+
)
191+
192+
# Light-grey panel with white gridlines at each 100k tick, drawn behind
193+
# the bars. Ticks labelled "100k", "200k", ... rather than in millions.
194+
ax.set_xlim(0, left.max() * 1.02)
195+
ax.xaxis.set_major_locator(MultipleLocator(100_000))
196+
ax.xaxis.set_major_formatter(
197+
FuncFormatter(lambda v, _: "0" if v == 0 else f"{v / 1000:g}k")
198+
)
199+
ax.set_facecolor("white")
200+
ax.set_axisbelow(True)
201+
ax.grid(axis = "x", color = "#DDDDDD", linewidth = 1.0)
202+
ax.grid(axis = "y", visible = False)
203+
204+
# Drop the border (all four spines) and tick marks.
205+
for spine in ax.spines.values():
206+
spine.set_visible(False)
207+
ax.tick_params(length = 0)
208+
209+
# Single-row legend at the bottom.
210+
handles = [
211+
plt.Rectangle((0, 0), 1, 1, color = color)
212+
for _, _, color in SOURCES
213+
]
214+
labels = [legend for _, legend, _ in SOURCES]
215+
ax.legend(
216+
handles,
217+
labels,
218+
loc = "upper center",
219+
bbox_to_anchor = (0.5, -0.065),
220+
ncol = len(SOURCES),
221+
frameon = False,
222+
)
223+
224+
# Low padding so the plot fills the fixed 13.33 x 7.5" canvas.
225+
fig.subplots_adjust(
226+
left = 0.14, right = 0.99, top = 0.94, bottom = 0.11,
227+
)
228+
VIZ_DIR.mkdir(parents = True, exist_ok = True)
229+
# No bbox_inches="tight" — keep the canvas at the exact 13.33 x 7.5".
230+
fig.savefig(OUTPUT_PATH, dpi = 300)
231+
print(f"\nSaved to {OUTPUT_PATH}")
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#!/usr/bin/env python
2+
"""
3+
Confidence quartiles by city x shared_label for cities over 100k population.
4+
5+
Uses the per-POI ``conf_mean`` written onto the rated OSM snapshot by the best
6+
random-effects turnover model (``2026-06-05-nationwide-full``; ``conf_mean`` =
7+
1 - p_change at the POI's age, for that POI's shared_label x MSA x urbanicity
8+
cell). Each POI's ``model_group`` encodes ``"shared_label | msa_code |
9+
urban_rural"``; the shared_label is the first field.
10+
11+
Steps
12+
-----
13+
1. Census places (cb_2023_us_place_500k) joined to 2020 decennial population,
14+
filtered to places with population > 100,000.
15+
2. Each rated POI is assigned to a place by its centroid (point-in-polygon).
16+
3. For each city, compute confidence quartiles (min, Q1, median, Q3, max) plus
17+
count and mean, both overall (all POIs in the city, ``shared_label =
18+
__ALL__``) and per shared_label.
19+
20+
Output: one row per city x shared_label (plus one __ALL__ row per city).
21+
"""
22+
import os
23+
import sys
24+
from pathlib import Path
25+
26+
os.environ.setdefault("PROJ_DATA", str(Path(sys.prefix) / "share" / "proj"))
27+
os.environ.setdefault("PROJ_LIB", str(Path(sys.prefix) / "share" / "proj"))
28+
29+
import numpy as np
30+
import pandas as pd
31+
import geopandas as gpd
32+
33+
CENSUS_DIR = Path("~/data/openpois/census_areas").expanduser()
34+
PLACE_SHP = CENSUS_DIR / "cb_2023_us_place_500k.shp"
35+
POP_CSV = CENSUS_DIR / "place_population_2020.csv"
36+
RATED_SNAPSHOT = Path(
37+
"~/data/openpois/snapshots/osm/20260521/osm_snapshot_rated.parquet"
38+
).expanduser()
39+
POP_THRESHOLD = 100_000
40+
OUT_CSV = Path(
41+
"~/data/openpois/osm_turnover_model/2026-06-05-nationwide-full/"
42+
"city_shared_label_confidence_quartiles.csv"
43+
).expanduser()
44+
45+
ALL_LABEL = "__ALL__"
46+
47+
48+
def load_big_cities() -> gpd.GeoDataFrame:
49+
"""Census places with 2020 population > threshold, in EPSG:4326."""
50+
places = gpd.read_file(PLACE_SHP)[
51+
["GEOID", "NAME", "NAMELSAD", "STUSPS", "STATE_NAME", "geometry"]
52+
]
53+
pop = pd.read_csv(POP_CSV, dtype = {"place_geoid": str})
54+
pop["population"] = pd.to_numeric(pop["population"], errors = "coerce")
55+
places = places.merge(
56+
pop, left_on = "GEOID", right_on = "place_geoid", how = "inner"
57+
)
58+
big = places[places["population"] > POP_THRESHOLD].copy()
59+
big = big.to_crs("EPSG:4326")
60+
print(f"{len(big)} places with population > {POP_THRESHOLD:,}")
61+
return big
62+
63+
64+
def quantile_table(df: pd.DataFrame, group_cols: list[str]) -> pd.DataFrame:
65+
"""count / min / Q1 / median / Q3 / max / mean of conf_mean per group."""
66+
g = df.groupby(group_cols, observed = True)["conf_mean"]
67+
out = g.agg(
68+
n_pois = "size",
69+
conf_min = "min",
70+
conf_q1 = lambda s: s.quantile(0.25),
71+
conf_median = "median",
72+
conf_q3 = lambda s: s.quantile(0.75),
73+
conf_max = "max",
74+
conf_mean = "mean",
75+
).reset_index()
76+
return out
77+
78+
79+
def main() -> None:
80+
big = load_big_cities()
81+
82+
print(f"Reading rated snapshot from {RATED_SNAPSHOT} ...")
83+
pois = gpd.read_parquet(
84+
RATED_SNAPSHOT, columns = ["geometry", "conf_mean", "model_group"]
85+
)
86+
print(f" {len(pois):,} POIs")
87+
88+
# Assign every POI a representative point (building polygons -> centroid).
89+
pois["geometry"] = pois.geometry.centroid
90+
pois["shared_label"] = (
91+
pois["model_group"].str.split(" | ", regex = False).str[0]
92+
)
93+
pois = pois.drop(columns = "model_group")
94+
95+
print("Spatial join POIs -> big cities ...")
96+
joined = gpd.sjoin(
97+
pois, big[["GEOID", "NAME", "STUSPS", "population", "geometry"]],
98+
how = "inner", predicate = "within",
99+
)
100+
print(f" {len(joined):,} POIs fall within a >100k city")
101+
joined = pd.DataFrame(joined.drop(columns = "geometry"))
102+
103+
city_cols = ["GEOID", "NAME", "STUSPS", "population"]
104+
105+
# Per city x shared_label, plus an __ALL__ row per city (all POIs in city).
106+
by_label = quantile_table(joined, city_cols + ["shared_label"])
107+
overall = quantile_table(joined, city_cols)
108+
overall["shared_label"] = ALL_LABEL
109+
110+
result = pd.concat([overall, by_label], ignore_index = True)
111+
result = result.rename(
112+
columns = {"GEOID": "city_geoid", "NAME": "city_name", "STUSPS": "state"}
113+
)
114+
# Order: __ALL__ first within each city, then labels by descending median.
115+
result["_is_all"] = (result["shared_label"] == ALL_LABEL).astype(int)
116+
result = result.sort_values(
117+
["city_name", "state", "_is_all", "conf_median"],
118+
ascending = [True, True, False, False],
119+
).drop(columns = "_is_all")
120+
121+
cols = [
122+
"city_geoid", "city_name", "state", "population", "shared_label",
123+
"n_pois", "conf_min", "conf_q1", "conf_median", "conf_q3", "conf_max",
124+
"conf_mean",
125+
]
126+
result = result[cols]
127+
OUT_CSV.parent.mkdir(parents = True, exist_ok = True)
128+
result.to_csv(OUT_CSV, index = False)
129+
print(f"\nWrote {len(result):,} rows to {OUT_CSV}")
130+
131+
# Brief summary: highest / lowest median-confidence shared_label per city,
132+
# aggregated nationally for a quick read.
133+
lab = by_label.rename(columns = {"NAME": "city_name"})
134+
lab = lab[lab["n_pois"] >= 20]
135+
nat = (
136+
lab.groupby("shared_label")
137+
.apply(
138+
lambda d: np.average(d["conf_median"], weights = d["n_pois"]),
139+
include_groups = False,
140+
)
141+
.rename("wtd_median_conf")
142+
.reset_index()
143+
.sort_values("wtd_median_conf", ascending = False)
144+
)
145+
print("\nMost stable shared_labels (pop-weighted median conf, n>=20/city):")
146+
print(nat.head(8).to_string(index = False))
147+
print("\nLeast stable shared_labels:")
148+
print(nat.tail(8).to_string(index = False))
149+
150+
151+
if __name__ == "__main__":
152+
main()

0 commit comments

Comments
 (0)