|
| 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}") |
0 commit comments