saulsolis09 commited on
Commit
7c11a85
·
verified ·
1 Parent(s): ad4d0c9

Delete app_simple_ui.py

Browse files
Files changed (1) hide show
  1. app_simple_ui.py +0 -740
app_simple_ui.py DELETED
@@ -1,740 +0,0 @@
1
- """
2
- Subdivision Cluster / FIPS-Validation pipeline — Hugging Face Space (Gradio).
3
-
4
- Faithful port of the Colab notebook
5
- `LatestUndeterminedZipCityCensusTract70_Threshold_95_Clusters.ipynb`.
6
-
7
- Colab-specific I/O replaced:
8
- * Drive input folder -> file upload (multiple .xlsx/.xls)
9
- * google.colab.files -> Gradio file outputs (written to a temp dir)
10
- * Drive safety-list path -> get_safety_list_path() (repo file OR private dataset)
11
-
12
- All processing logic (FIPS validation, RawFile snapshot, RapidFuzz clustering,
13
- wide tables, majority-vote replacement, column reorder, OnlyBlanks warnings,
14
- chunked CSV export, self-test) matches the notebook.
15
- """
16
-
17
- import os
18
- import re
19
- import gc
20
- import math
21
- import tempfile
22
- from pathlib import Path
23
- from collections import defaultdict
24
-
25
- import numpy as np
26
- import pandas as pd
27
- import gradio as gr
28
- from rapidfuzz import fuzz
29
-
30
-
31
- # =====================================================================
32
- # REFERENCE FILE LOADER (the Subdivision Program Safety List)
33
- # ---------------------------------------------------------------------
34
- # 1. A file committed in the Space repo (override name via SAFETY_LIST_FILE).
35
- # Use ONLY if the Space is PRIVATE.
36
- # 2. A private HF *dataset* repo, downloaded with the HF_TOKEN secret. Set:
37
- # SAFETY_LIST_REPO e.g. "your-username/subdivision-safety-list"
38
- # SAFETY_LIST_REPO_FILE filename inside that dataset (optional)
39
- # HF_TOKEN read token, stored as a Space Secret
40
- # =====================================================================
41
- DEFAULT_SAFETY_LIST_NAME = "Subdivision_Program_Safety_List.xlsx"
42
- EXCEL_ROW_LIMIT = 1_048_575 # matches the notebook
43
-
44
- # Fixed pipeline settings (notebook values). Not exposed in the UI on purpose:
45
- # users should not be able to deviate from the notebook's behavior.
46
- STRICT_BLANK_CITIES = True # blank cities that are not in the safety list
47
- CLUSTER_THRESHOLD = 95 # RapidFuzz similarity for LegalSubDivision clusters
48
- REPLACE_THRESHOLD = 70 # majority-vote % required to fill a blank
49
-
50
-
51
- def get_safety_list_path() -> str:
52
- local_name = os.environ.get("SAFETY_LIST_FILE", DEFAULT_SAFETY_LIST_NAME)
53
- if Path(local_name).exists():
54
- return local_name
55
-
56
- repo_id = os.environ.get("SAFETY_LIST_REPO")
57
- token = os.environ.get("HF_TOKEN")
58
- if repo_id and token:
59
- from huggingface_hub import hf_hub_download
60
- filename = os.environ.get("SAFETY_LIST_REPO_FILE", local_name)
61
- return hf_hub_download(
62
- repo_id=repo_id, filename=filename, repo_type="dataset", token=token
63
- )
64
-
65
- raise FileNotFoundError(
66
- "Safety list not found.\n"
67
- f" - Commit '{local_name}' into this Space (keep the Space private), OR\n"
68
- " - Set SAFETY_LIST_REPO + HF_TOKEN to pull it from a private dataset."
69
- )
70
-
71
-
72
- _SAFETY_CACHE: dict | None = None
73
-
74
-
75
- def build_whitelist_from_path(path):
76
- """Parse a safety-list file (xlsx or csv) into {pairs, cities, rows}."""
77
- if str(path).lower().endswith(".csv"):
78
- safety_df = pd.read_csv(path)
79
- else:
80
- safety_df = pd.read_excel(path)
81
- acceptable_cols = [c for c in safety_df.columns if str(c).startswith("Acceptable FIPS")]
82
-
83
- valid_pairs, valid_cities = set(), set()
84
- for _, r in safety_df.iterrows():
85
- c = _norm_city(r["City Name"])
86
- if c is None:
87
- continue
88
- valid_cities.add(c)
89
- for col in acceptable_cols:
90
- f = _norm_fips(r[col])
91
- if f:
92
- valid_pairs.add(c + "||" + f)
93
-
94
- return {"pairs": valid_pairs, "cities": valid_cities, "rows": len(safety_df)}
95
-
96
-
97
- def load_whitelist():
98
- """Server-side safety list (repo file or private dataset). Cached per process."""
99
- global _SAFETY_CACHE
100
- if _SAFETY_CACHE is None:
101
- _SAFETY_CACHE = build_whitelist_from_path(get_safety_list_path())
102
- return _SAFETY_CACHE
103
-
104
-
105
- # =====================================================================
106
- # NORMALIZATION HELPERS (identical to the notebook)
107
- # =====================================================================
108
- PLACEHOLDERS = {"UNDETERMINED CITY", "UNDETERMINED ZIP", "NULL", "NAN", "NONE", ""}
109
-
110
-
111
- def _norm_city(x):
112
- if pd.isna(x):
113
- return None
114
- return str(x).strip().upper()
115
-
116
-
117
- def _norm_fips(x):
118
- """5-digit FIPS string, or None. Handles 28071, 28071.0, '01067', '28071 - LAFAYETTE'."""
119
- if pd.isna(x):
120
- return None
121
- m = re.search(r"\d+", str(x))
122
- return str(int(m.group(0))).zfill(5) if m else None
123
-
124
-
125
- # ---------------------------------------------------------------------
126
- # FIPS-COUNTY COLUMN DETECTION (by CONTENT, not header name)
127
- # ---------------------------------------------------------------------
128
- # The source header has changed before ("** REC FIPS-COUNTY" ->
129
- # "** REC FIPS-COUNTY NAME"), so we identify the column by what's IN it:
130
- # values shaped like "05045-FAULKNER" (FIPS digits, a dash, a county name).
131
- # Header names are only used as a last-resort fallback.
132
- FIPS_COUNTY_PATTERN = r"^\s*\d{1,5}\s*-\s*[A-Za-z]"
133
- FALLBACK_FIPS_HEADERS = ["** REC FIPS-COUNTY NAME", "** REC FIPS-COUNTY", "County"]
134
-
135
-
136
- def detect_fips_county_column(df, sample_size=1000, min_score=0.5):
137
- """Return (column_name, reason). Picks the column whose values best match
138
- the 'FIPS-COUNTYNAME' shape; falls back to known headers, then to any
139
- header mentioning FIPS."""
140
- best_col, best_score = None, 0.0
141
- for col in df.columns:
142
- s = df[col].dropna()
143
- if s.empty:
144
- continue
145
- sample = s.head(sample_size).astype(str)
146
- try:
147
- score = float(sample.str.match(FIPS_COUNTY_PATTERN).mean())
148
- except Exception:
149
- continue
150
- if score > best_score:
151
- best_col, best_score = col, score
152
-
153
- if best_col is not None and best_score >= min_score:
154
- return best_col, f"content match {best_score:.0%} of sampled rows"
155
-
156
- for name in FALLBACK_FIPS_HEADERS:
157
- if name in df.columns:
158
- return name, "fallback: known header name"
159
-
160
- for col in df.columns:
161
- if "FIPS" in str(col).upper():
162
- return col, "fallback: header mentions FIPS"
163
-
164
- return None, "not found"
165
-
166
-
167
- # =====================================================================
168
- # SELF-TEST (startup health check — same logic as the notebook's cell)
169
- # =====================================================================
170
- def run_self_test() -> str:
171
- path = get_safety_list_path()
172
- st = pd.read_excel(path)
173
- fc = [c for c in st.columns if str(c).startswith("Acceptable FIPS")]
174
- pairs = set()
175
- for _, r in st.iterrows():
176
- c = _norm_city(r["City Name"])
177
- if c is None:
178
- continue
179
- for col in fc:
180
- f = _norm_fips(r[col])
181
- if f:
182
- pairs.add(c + "||" + f)
183
-
184
- good_city = st.iloc[0]["City Name"]
185
- good_fips = st.iloc[0]["Acceptable FIPS 1"]
186
- test = pd.DataFrame({
187
- "DDPPropertyCityFinal": [good_city, good_city],
188
- "** REC FIPS-COUNTY": [good_fips, 99999], # row 2 = wrong FIPS on purpose
189
- })
190
- cn = test["DDPPropertyCityFinal"].map(_norm_city)
191
- fn = test["** REC FIPS-COUNTY"].map(_norm_fips)
192
- key = cn.fillna("") + "||" + fn.fillna("")
193
- ok = key.isin(pairs)
194
- assert bool(ok.iloc[0]) and not bool(ok.iloc[1]), \
195
- "SELF-TEST FAILED -- matching logic is not working as expected."
196
- return f"SELF-TEST PASSED: known-good ({good_city}) kept, wrong FIPS flagged."
197
-
198
-
199
- # =====================================================================
200
- # STEP 1 — FIPS SAFETY-LIST VALIDATION
201
- # =====================================================================
202
- def fips_validate(df: pd.DataFrame, blank_cities_not_in_list: bool, log, wl=None,
203
- fips_col=None):
204
- if wl is None:
205
- wl = load_whitelist()
206
- valid_pairs, valid_cities = wl["pairs"], wl["cities"]
207
-
208
- if fips_col is None:
209
- fips_col, _reason = detect_fips_county_column(df)
210
- if fips_col is None:
211
- raise ValueError(
212
- "Could not find a FIPS-county column. Expected values like "
213
- "'05045-FAULKNER' in one of the columns."
214
- )
215
- FIPS_COL = fips_col
216
- CITY_COL, ZIP_COL, TRACT_COL = (
217
- "DDPPropertyCityFinal", "DDPPropertyZIPFinal", "DDPRevisedCensusTract",
218
- )
219
-
220
- city_norm = df[CITY_COL].map(_norm_city)
221
- fips_norm = df[FIPS_COL].map(_norm_fips)
222
- row_key = city_norm.fillna("") + "||" + fips_norm.fillna("")
223
-
224
- is_real_city = df[CITY_COL].notna() & (~city_norm.fillna("").isin(PLACEHOLDERS))
225
- city_in_list = city_norm.isin(valid_cities)
226
- pair_ok = row_key.isin(valid_pairs)
227
-
228
- if blank_cities_not_in_list:
229
- fails_mask = is_real_city & (~pair_ok)
230
- else:
231
- fails_mask = is_real_city & city_in_list & (~pair_ok)
232
-
233
- n_real = int(is_real_city.sum())
234
- n_inlist = int((is_real_city & city_in_list).sum())
235
- n_valid = int((is_real_city & pair_ok).sum())
236
- n_fail = int(fails_mask.sum())
237
- log.append(
238
- f"FIPS validation — list: {wl['rows']:,} rows | {len(valid_cities):,} cities | "
239
- f"{len(valid_pairs):,} pairs"
240
- )
241
- log.append(
242
- f" real:{n_real:,} in-list:{n_inlist:,} valid-kept:{n_valid:,} blanked:{n_fail:,}"
243
- )
244
- if n_real > 0 and n_inlist == 0:
245
- log.append(" !! 0 cities matched the list — likely a format/spelling mismatch.")
246
-
247
- # Audit built BEFORE overwrite (insert position matches the notebook).
248
- key_col = "RecordKey" if "RecordKey" in df.columns else None
249
- base_cols = [c for c in [key_col, CITY_COL, FIPS_COL, ZIP_COL, TRACT_COL] if c]
250
- audit = df.loc[fails_mask, base_cols].copy()
251
- audit.insert(
252
- len(base_cols) if key_col is None else 2,
253
- "NormalizedFIPS", fips_norm[fails_mask].values,
254
- )
255
- audit["Reason"] = np.where(
256
- city_in_list[fails_mask].values, "FIPS does not match city", "City not in safety list"
257
- )
258
- audit = audit.rename(
259
- columns={CITY_COL: "OLD_City", ZIP_COL: "OLD_ZIP", TRACT_COL: "OLD_CensusTract"}
260
- )
261
-
262
- # Recent pandas reads all-numeric Excel columns as float64 and refuses to
263
- # accept a string into them; Colab's older pandas silently upcast to object.
264
- # Cast first so the placeholder write reproduces the notebook's result.
265
- df[CITY_COL] = df[CITY_COL].astype(object)
266
- df[ZIP_COL] = df[ZIP_COL].astype(object)
267
- df.loc[fails_mask, CITY_COL] = "UNDETERMINED CITY"
268
- df.loc[fails_mask, ZIP_COL] = "UNDETERMINED ZIP"
269
- df.loc[fails_mask, TRACT_COL] = np.nan
270
- return df, audit
271
-
272
-
273
- # =====================================================================
274
- # STEP 2 — RAPIDFUZZ CLUSTERING (identical to the notebook)
275
- # =====================================================================
276
- def group_similar_strings_optimized(strings, threshold=95, prefix_len=3, max_len_diff=3):
277
- cleaned, seen = [], set()
278
- for s in strings:
279
- if pd.isna(s):
280
- continue
281
- s = str(s).strip()
282
- if s and s not in seen:
283
- cleaned.append(s)
284
- seen.add(s)
285
-
286
- visited, groups = set(), []
287
- buckets = defaultdict(list)
288
- for s in cleaned:
289
- buckets[s.upper()[:prefix_len]].append(s)
290
-
291
- for bucket in buckets.values():
292
- for i, base in enumerate(bucket):
293
- if base in visited:
294
- continue
295
- group = [base]
296
- visited.add(base)
297
- base_upper = base.upper()
298
- base_len = len(base_upper)
299
- for j in range(i + 1, len(bucket)):
300
- compare = bucket[j]
301
- if compare in visited:
302
- continue
303
- comp_upper = compare.upper()
304
- if abs(base_len - len(comp_upper)) > max_len_diff:
305
- continue
306
- if fuzz.ratio(base_upper, comp_upper) >= threshold:
307
- group.append(compare)
308
- visited.add(compare)
309
- groups.append(group)
310
- return groups
311
-
312
-
313
- def build_clusters(city_df, zip_df, census_tracts_df, cluster_threshold):
314
- all_legal = pd.concat(
315
- [
316
- city_df[["County", "LegalSubDivision"]],
317
- zip_df[["County", "LegalSubDivision"]],
318
- census_tracts_df[["County", "LegalSubDivision"]],
319
- ],
320
- ignore_index=True,
321
- ).dropna(subset=["County", "LegalSubDivision"])
322
- all_legal["County"] = all_legal["County"].astype(str).str.strip()
323
- all_legal["LegalSubDivision"] = all_legal["LegalSubDivision"].astype(str).str.strip()
324
- all_legal = all_legal[(all_legal["County"] != "") & (all_legal["LegalSubDivision"] != "")]
325
- all_legal = all_legal[["County", "LegalSubDivision"]].drop_duplicates()
326
-
327
- groups = []
328
- for county in all_legal["County"].unique():
329
- strings = all_legal[all_legal["County"] == county]["LegalSubDivision"].tolist()
330
- groups.extend(
331
- group_similar_strings_optimized(
332
- strings, threshold=cluster_threshold, prefix_len=3, max_len_diff=3
333
- )
334
- )
335
-
336
- rows = []
337
- for cid, group in enumerate(groups, start=1):
338
- for sub in group:
339
- rows.append({"Cluster_ID": cid, "LegalSubDivision": sub})
340
- return pd.DataFrame(rows, columns=["Cluster_ID", "LegalSubDivision"])
341
-
342
-
343
- # =====================================================================
344
- # STEP 3 — WIDE TABLES + MAJORITY-VOTE "REPLACE WITH"
345
- # =====================================================================
346
- def build_cluster_wide_table(source_df, cluster_df, value_col, prefix):
347
- lk = cluster_df[["LegalSubDivision", "Cluster_ID"]].drop_duplicates("LegalSubDivision")
348
- cluster_map = dict(zip(lk["LegalSubDivision"], lk["Cluster_ID"]))
349
-
350
- w = source_df[["LegalSubDivision", value_col]].copy()
351
- w["LegalSubDivision"] = w["LegalSubDivision"].astype(str).str.strip()
352
- w[value_col] = w[value_col].replace("", np.nan)
353
- w["Cluster_ID"] = w["LegalSubDivision"].map(cluster_map)
354
- w = w.dropna(subset=["Cluster_ID", value_col])
355
- if w.empty:
356
- return pd.DataFrame(columns=["Cluster_ID"])
357
- w["Cluster_ID"] = w["Cluster_ID"].astype(int)
358
- w[value_col] = w[value_col].astype(str).str.strip()
359
- w = w[w[value_col] != ""]
360
-
361
- counts = w.groupby(["Cluster_ID", value_col], observed=True).size().unstack(fill_value=0)
362
- totals = counts.sum(axis=1)
363
- pct = counts.div(totals.replace(0, np.nan), axis=0).mul(100).fillna(0)
364
- counts.columns = [f"{prefix}{c}_Count" for c in counts.columns]
365
- pct.columns = [f"{prefix}{c}_Percentage" for c in pct.columns]
366
- wide = pd.concat([counts, pct], axis=1).reset_index()
367
-
368
- # Match the notebook: Cluster_ID first, all other columns sorted.
369
- other = sorted([c for c in wide.columns if c != "Cluster_ID"])
370
- return wide[["Cluster_ID"] + other]
371
-
372
-
373
- def build_replace_with(wide_df, prefix_to_remove, threshold):
374
- pct_cols = [c for c in wide_df.columns if c.endswith("_Percentage")]
375
- if not pct_cols:
376
- res = wide_df[["Cluster_ID"]].copy()
377
- res["Replace with"] = None
378
- return res.drop_duplicates(subset=["Cluster_ID"])
379
- labels = np.array(
380
- [c.replace(prefix_to_remove, "").replace("_Percentage", "") for c in pct_cols],
381
- dtype=object,
382
- )
383
- values = wide_df[pct_cols].to_numpy(copy=False)
384
- out = []
385
- for row in values:
386
- matched = labels[row >= threshold]
387
- out.append(", ".join(matched) if len(matched) else None)
388
- res = wide_df[["Cluster_ID"]].copy()
389
- res["Replace with"] = out
390
- return res.drop_duplicates(subset=["Cluster_ID"])
391
-
392
-
393
- def clean_census_tract_suffix(value):
394
- if pd.isna(value):
395
- return np.nan
396
- try:
397
- f = float(value)
398
- return str(int(f)) if f == int(f) else str(f)
399
- except ValueError:
400
- return str(value)
401
-
402
-
403
- def clean_zip(value):
404
- """Normalize ZIPs read from Excel as numbers: drop the float '.0' artifact and
405
- zero-pad to 5 digits (recovering leading zeros lost on numeric import, e.g.
406
- 7221.0 -> 07221). Handles comma-joined majority-vote results. ZIP+4 left as-is."""
407
- if pd.isna(value):
408
- return np.nan
409
- out = []
410
- for part in str(value).split(","):
411
- p = part.strip()
412
- if p.endswith(".0"):
413
- p = p[:-2]
414
- digits = re.sub(r"\D", "", p)
415
- if not digits:
416
- continue
417
- out.append(digits.zfill(5) if len(digits) <= 5 else digits)
418
- return ", ".join(out) if out else np.nan
419
-
420
-
421
- # =====================================================================
422
- # CHUNKED CSV WRITER (faithful port of save_csv_in_parts)
423
- # =====================================================================
424
- def save_csv_in_parts(df, base_filename, out_dir, row_limit=1_000_000):
425
- base_name = base_filename.replace(".csv", "")
426
- created = []
427
- total = len(df)
428
- num_parts = math.ceil(total / row_limit)
429
- for i in range(num_parts):
430
- start = i * row_limit
431
- end = min((i + 1) * row_limit, total)
432
- part = f"{base_name}_part_{i + 1}.csv" if num_parts > 1 else base_filename
433
- p = out_dir / part
434
- df.iloc[start:end].to_csv(p, index=False)
435
- created.append(str(p))
436
- gc.collect()
437
- return created
438
-
439
-
440
- # =====================================================================
441
- # MAIN PIPELINE
442
- # =====================================================================
443
- def run_pipeline(uploaded_files, reference_file, blank_cities_not_in_list,
444
- filter_dominant_county, cluster_threshold, replace_threshold):
445
- log = []
446
- if not uploaded_files:
447
- return "Please upload at least one Excel file.", []
448
-
449
- out_dir = Path(tempfile.mkdtemp(prefix="subdiv_"))
450
- outputs = []
451
-
452
- # ---- Resolve the reference (safety) list -------------------------
453
- # Default: server-side list (repo file or private dataset). If the user
454
- # uploads an override on the sidebar, use it for THIS run only.
455
- try:
456
- if reference_file is not None:
457
- wl = build_whitelist_from_path(reference_file.name)
458
- log.append(f"Using UPLOADED reference list: {Path(reference_file.name).name}")
459
- else:
460
- wl = load_whitelist()
461
- log.append("Using server-side safety list.")
462
- except FileNotFoundError as e:
463
- return "\n".join(log) + f"\n\nREFERENCE FILE ERROR:\n{e}", []
464
- except Exception as e:
465
- return "\n".join(log) + f"\n\nCould not parse reference list: {e}", []
466
-
467
- # ---- Load & merge uploads ----------------------------------------
468
- dfs = []
469
- for f in uploaded_files:
470
- try:
471
- dfs.append(pd.read_excel(f.name))
472
- log.append(f"Loaded {Path(f.name).name}")
473
- except Exception as e:
474
- log.append(f"ERROR loading {Path(f.name).name}: {e}")
475
- if not dfs:
476
- return "\n".join(log) + "\nNo files could be read.", []
477
- df = pd.concat(dfs, ignore_index=True)
478
- log.append(f"Merged shape: {df.shape}")
479
-
480
- # ---- Locate the FIPS-county column by CONTENT --------------------
481
- fips_col, fips_reason = detect_fips_county_column(df)
482
- if fips_col is None:
483
- return ("\n".join(log) +
484
- "\n\nCOLUMN ERROR: could not find a FIPS-county column. Expected "
485
- "values like '05045-FAULKNER' in one of the columns."), []
486
- log.append(f"FIPS-county column: '{fips_col}' ({fips_reason})")
487
-
488
- # ---- FIPS validation (uses the reference list) -------------------
489
- try:
490
- df, audit = fips_validate(df, blank_cities_not_in_list, log, wl=wl, fips_col=fips_col)
491
- except FileNotFoundError as e:
492
- return "\n".join(log) + f"\n\nREFERENCE FILE ERROR:\n{e}", []
493
-
494
- # ---- Download verification files (audit + RawFile_FIPSValidated) -
495
- if len(audit) > 0:
496
- ap = out_dir / "FIPS_Validation_Audit_ChangedRecords.xlsx"
497
- audit.to_excel(ap, index=False)
498
- outputs.append(str(ap))
499
-
500
- if len(df) <= EXCEL_ROW_LIMIT:
501
- rp = out_dir / "RawFile_FIPSValidated.xlsx"
502
- df.to_excel(rp, index=False)
503
- else:
504
- rp = out_dir / "RawFile_FIPSValidated.csv"
505
- df.to_csv(rp, index=False)
506
- outputs.append(str(rp))
507
-
508
- # ---- UNDETERMINED/NULL -> NaN ------------------------------------
509
- for col in ["DDPPropertyCityFinal", "DDPPropertyZIPFinal",
510
- "DDPRevisedCensusTract", "LegalSubDivision"]:
511
- if col in df.columns:
512
- df[col] = df[col].replace(["UNDETERMINED CITY", "UNDETERMINED ZIP", "NULL"], np.nan)
513
-
514
- # ---- Rename detected FIPS col -> County --------------------------
515
- if fips_col != "County":
516
- if "County" in df.columns:
517
- df = df.drop(columns=["County"])
518
- df = df.rename(columns={fips_col: "County"})
519
-
520
- # ---- Optional: filter to single most-dominant county -------------
521
- if filter_dominant_county:
522
- dominant = df["County"].value_counts().idxmax()
523
- df = df[df["County"] == dominant].copy()
524
- log.append(f"Filtered to dominant county: {dominant} -> {df.shape}")
525
-
526
- # ---- Source frames -----------------------------------------------
527
- city_df = df.loc[df["DDPPropertyCityFinal"].notna()].copy()
528
- zip_df = df.loc[df["DDPPropertyZIPFinal"].notna()].copy()
529
- census_tracts_df = df.loc[df["DDPRevisedCensusTract"].notna()].copy()
530
- original = df.loc[df["LegalSubDivision"].notna()].copy()
531
-
532
- # ---- Cluster ------------------------------------------------------
533
- cluster_df = build_clusters(city_df, zip_df, census_tracts_df, cluster_threshold)
534
- log.append(f"Clusters: {cluster_df['Cluster_ID'].nunique():,}")
535
-
536
- # ---- Replace-with (majority vote) --------------------------------
537
- zip_w = build_replace_with(
538
- build_cluster_wide_table(zip_df, cluster_df, "DDPPropertyZIPFinal", "ZIP_"),
539
- "ZIP_", replace_threshold).rename(columns={"Replace with": "Replace with Zip Codes"})
540
- city_w = build_replace_with(
541
- build_cluster_wide_table(city_df, cluster_df, "DDPPropertyCityFinal", "City_"),
542
- "City_", replace_threshold).rename(columns={"Replace with": "Replace with Cities"})
543
- cen_w = build_replace_with(
544
- build_cluster_wide_table(census_tracts_df, cluster_df, "DDPRevisedCensusTract", "CensusTract_"),
545
- "CensusTract_", replace_threshold).rename(columns={"Replace with": "Replace with Census Tracts"})
546
-
547
- # ---- Map back onto original --------------------------------------
548
- lk = cluster_df[["LegalSubDivision", "Cluster_ID"]].drop_duplicates("LegalSubDivision")
549
- cmap = dict(zip(lk["LegalSubDivision"], lk["Cluster_ID"]))
550
- original_final = original.copy()
551
- original_final["Cluster_ID"] = original_final["LegalSubDivision"].map(cmap)
552
- original_final["Replace with Zip Codes"] = original_final["Cluster_ID"].map(
553
- dict(zip(zip_w["Cluster_ID"], zip_w["Replace with Zip Codes"])))
554
- original_final["Replace with Cities"] = original_final["Cluster_ID"].map(
555
- dict(zip(city_w["Cluster_ID"], city_w["Replace with Cities"])))
556
- original_final["Replace with Census Tracts"] = original_final["Cluster_ID"].map(
557
- dict(zip(cen_w["Cluster_ID"], cen_w["Replace with Census Tracts"])))
558
-
559
- # Normalize ZIPs (drop float '.0', restore 5-digit zero-padding).
560
- original_final["Replace with Zip Codes"] = (
561
- original_final["Replace with Zip Codes"].apply(clean_zip))
562
-
563
- # ---- Reorder columns (desired_leading_columns) -------------------
564
- desired_leading = [
565
- "RecordKey", "ComesFromMtg2", "IsFlagged", "Cluster_ID", "LegalSubDivision",
566
- "County", "Replace with Zip Codes", "Replace with Cities", "Replace with Census Tracts",
567
- "DDPPropertyZIPFinal", "DDPPropertyCityFinal", "DDPRevisedCensusTract",
568
- "LotBlockSubdivisionTract", "DDPRevisedLatitude", "DDPRevisedLongitude",
569
- ]
570
- existing = original_final.columns.tolist()
571
- lead = [c for c in desired_leading if c in existing]
572
- other = [c for c in existing if c not in lead]
573
- original_final = original_final[lead + other]
574
-
575
- original_final_output = original_final.copy() # full output snapshot
576
-
577
- # ---- Split into final cities / zips / census tracts --------------
578
- keep = ["RecordKey", "ComesFromMtg2", "IsFlagged",
579
- "Replace with Zip Codes", "Replace with Cities", "Replace with Census Tracts",
580
- "DDPPropertyZIPFinal", "DDPPropertyCityFinal", "DDPRevisedCensusTract"]
581
- keep = [c for c in keep if c in original_final.columns]
582
- of = original_final[keep].rename(columns={
583
- "DDPPropertyZIPFinal": "DDPPropertyZIPFinalToRemove",
584
- "DDPPropertyCityFinal": "DDPPropertyCityFinalToRemove",
585
- "DDPRevisedCensusTract": "DDPRevisedCensusTractToRemove",
586
- "Replace with Zip Codes": "DDPPropertyZIPFinal",
587
- "Replace with Cities": "DDPPropertyCityFinal",
588
- "Replace with Census Tracts": "DDPRevisedCensusTract",
589
- })
590
- of["DDPRevisedCensusTract"] = of["DDPRevisedCensusTract"].apply(clean_census_tract_suffix)
591
-
592
- idcols = [c for c in ["RecordKey", "ComesFromMtg2", "IsFlagged"] if c in of.columns]
593
- final_cities = of[idcols + ["DDPPropertyCityFinalToRemove", "DDPPropertyCityFinal"]].copy()
594
- final_zips = of[idcols + ["DDPPropertyZIPFinalToRemove", "DDPPropertyZIPFinal"]].copy()
595
- final_ct = of[idcols + ["DDPRevisedCensusTractToRemove", "DDPRevisedCensusTract"]].copy()
596
-
597
- # keep only rows whose ORIGINAL value was blank (the ones we filled)
598
- final_cities = final_cities[final_cities["DDPPropertyCityFinalToRemove"].isna()].copy()
599
- final_zips = final_zips[final_zips["DDPPropertyZIPFinalToRemove"].isna()].copy()
600
- final_ct = final_ct[final_ct["DDPRevisedCensusTractToRemove"].isna()].copy()
601
-
602
- final_cities = final_cities.drop(columns=["DDPPropertyCityFinalToRemove"])
603
- final_zips = final_zips.drop(columns=["DDPPropertyZIPFinalToRemove"])
604
- final_ct = final_ct.drop(columns=["DDPRevisedCensusTractToRemove"])
605
-
606
- # ---- OnlyBlanks warnings (column entirely null) ------------------
607
- def only_blanks_warning(d, col, prefix):
608
- if col in d.columns and d[col].isnull().all():
609
- p = out_dir / f"OnlyBlanks_{prefix}.xlsx"
610
- d.to_excel(p, index=False)
611
- outputs.append(str(p))
612
- log.append(f"WARNING: {prefix} has only blanks in '{col}' -> {p.name}")
613
-
614
- only_blanks_warning(final_cities, "DDPPropertyCityFinal", "final_cities")
615
- only_blanks_warning(final_zips, "DDPPropertyZIPFinal", "final_zips")
616
- only_blanks_warning(final_ct, "DDPRevisedCensusTract", "final_census_tracts")
617
-
618
- # ---- Drop NaNs so only program assignments remain ----------------
619
- final_cities = final_cities[final_cities["DDPPropertyCityFinal"].notna()].copy()
620
- final_zips = final_zips[final_zips["DDPPropertyZIPFinal"].notna()].copy()
621
- final_ct = final_ct[final_ct["DDPRevisedCensusTract"].notna()].copy()
622
-
623
- # ---- Write the per-category + cluster outputs --------------------
624
- for d, name in [(final_cities, "final_cities.xlsx"),
625
- (final_zips, "final_zips.xlsx"),
626
- (final_ct, "final_census_tracts.xlsx"),
627
- (cluster_df, "cluster_df.xlsx")]:
628
- p = out_dir / name
629
- d.to_excel(p, index=False)
630
- outputs.append(str(p))
631
-
632
- # ---- Full output CSV (chunked at 1,000,000 rows) -----------------
633
- csv_name = f"OutputFile_{replace_threshold}%Threshold_{cluster_threshold}%ClustersRapidFuzz.csv"
634
- outputs.extend(save_csv_in_parts(original_final_output, csv_name, out_dir, row_limit=1_000_000))
635
-
636
- # ---- Summary sheet (standalone file) -----------------------------
637
- output_df = original_final_output
638
- city_df, zips_df, census_df = final_cities, final_zips, final_ct
639
-
640
- # County label looks like "05119 - PULASKI" (FIPS - NAME). Parse defensively.
641
- county_name, county_fips = "", ""
642
- counties = output_df["County"].dropna().unique() if "County" in output_df.columns else []
643
- if len(counties):
644
- raw = str(counties[0])
645
- parts = raw.split("-")
646
- county_fips = parts[0].strip()
647
- county_name = parts[1].strip().title() if len(parts) > 1 else raw.strip().title()
648
-
649
- total_clusters = int(cluster_df["Cluster_ID"].max()) if len(cluster_df) else 0
650
-
651
- def _blank_count(col):
652
- return int(output_df[col].isna().sum()) if col in output_df.columns else 0
653
-
654
- summary_df = pd.DataFrame([{
655
- "County": county_name,
656
- "FIPS": county_fips,
657
- "Total Records in County": len(output_df),
658
- "Total Subdivisions": int(output_df["LegalSubDivision"].count())
659
- if "LegalSubDivision" in output_df.columns else 0,
660
- "Total Clusters Made": total_clusters,
661
- "Total Blank Cities": _blank_count("DDPPropertyCityFinal"),
662
- "Total Cities Assignment": len(city_df),
663
- "Total Blank Zips": _blank_count("DDPPropertyZIPFinal"),
664
- "Total Zips Assignment": len(zips_df),
665
- "Total Blank Census": _blank_count("DDPRevisedCensusTract"),
666
- "Total Census Assignment": len(census_df),
667
- }])
668
-
669
- summary_path = out_dir / "Summary.xlsx"
670
- summary_df.to_excel(summary_path, index=False)
671
- outputs.append(str(summary_path))
672
-
673
- # ---- Master workbook: every output as a sheet in one file --------
674
- master_path = out_dir / "Master_Dataset.xlsx"
675
- with pd.ExcelWriter(master_path, engine="openpyxl") as writer:
676
- output_df.to_excel(writer, sheet_name="Output_70_95_RapidFuzz", index=False)
677
- # full_validation_df.to_excel(writer, sheet_name="RawFile_FIPSValidated", index=False)
678
- audit.to_excel(writer, sheet_name="ValidationAuditResults", index=False)
679
- cluster_df.to_excel(writer, sheet_name="cluster_df", index=False)
680
- city_df.to_excel(writer, sheet_name="final_cities", index=False)
681
- zips_df.to_excel(writer, sheet_name="final_zips", index=False)
682
- census_df.to_excel(writer, sheet_name="final_census_tracts", index=False)
683
- summary_df.to_excel(writer, sheet_name="Summary", index=False)
684
- outputs.append(str(master_path))
685
-
686
- log.append(f"Done. {len(outputs)} files ready.")
687
- gc.collect()
688
- return "\n".join(log), outputs
689
-
690
-
691
- # =====================================================================
692
- # STARTUP HEALTH CHECK
693
- # =====================================================================
694
- def _startup_health_check():
695
- if os.environ.get("SELF_TEST_ON_STARTUP", "1") != "1":
696
- return
697
- try:
698
- print("[startup] " + run_self_test())
699
- except FileNotFoundError as e:
700
- # Reference file not configured yet — let the UI load so the user can fix it.
701
- print(f"[startup] Self-test skipped — safety list unavailable:\n{e}")
702
- # AssertionError intentionally propagates: a failed self-test fails the boot.
703
-
704
-
705
- _startup_health_check()
706
-
707
-
708
- # =====================================================================
709
- # GRADIO UI (simple vertical layout — previous design)
710
- # =====================================================================
711
- with gr.Blocks() as demo:
712
- gr.Markdown(
713
- "## Subdivision Cluster + FIPS Validation\n"
714
- "Upload the Excel files to process. Records are validated against the "
715
- "Subdivision Program Safety List, then `LegalSubDivision` values are "
716
- "fuzzy-clustered and city/ZIP/census-tract are filled by majority vote.\n\n"
717
- "*Runs with the notebook's fixed settings.*"
718
- )
719
- files_in = gr.File(file_count="multiple", file_types=[".xlsx", ".xls"],
720
- label="Excel files to process")
721
- dominant_flag = gr.Checkbox(
722
- value=True, label="Filter to single most-dominant county")
723
- run_btn = gr.Button("Run pipeline", variant="primary")
724
- status = gr.Textbox(label="Log", lines=14)
725
- files_out = gr.File(label="Output files", file_count="multiple")
726
-
727
- def _run_simple(files, dominant):
728
- # No reference upload in this layout -> always the server-side list.
729
- # Strict mode and both thresholds are fixed at the notebook's values.
730
- return run_pipeline(files, None, STRICT_BLANK_CITIES, dominant,
731
- CLUSTER_THRESHOLD, REPLACE_THRESHOLD)
732
-
733
- run_btn.click(
734
- _run_simple,
735
- inputs=[files_in, dominant_flag],
736
- outputs=[status, files_out],
737
- )
738
-
739
- if __name__ == "__main__":
740
- demo.launch()