Spaces:
Sleeping
Sleeping
Commit ·
bb973ac
1
Parent(s): 7806dff
Upload 2 files
Browse files- app.py +51 -10
- requirements.txt +2 -0
app.py
CHANGED
|
@@ -56,7 +56,30 @@ FILTER_DOMINANT_COUNTY = False # process ALL counties (multi-county / full-stat
|
|
| 56 |
# "csv" -> Master_Dataset.zip, every result bundled as a CSV (faster: no Excel
|
| 57 |
# writing -- use this for very large / state-sized runs)
|
| 58 |
# The individual per-category files are CSV in both modes.
|
| 59 |
-
MASTER_OUTPUT = "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
def get_safety_list_path() -> str:
|
|
@@ -141,7 +164,7 @@ def _norm_fips(x):
|
|
| 141 |
# values shaped like "05045-FAULKNER" (FIPS digits, a dash, a county name).
|
| 142 |
# Header names are only used as a last-resort fallback.
|
| 143 |
FIPS_COUNTY_PATTERN = r"^\s*\d{1,5}\s*-\s*[A-Za-z]"
|
| 144 |
-
FALLBACK_FIPS_HEADERS = ["** REC FIPS-COUNTY NAME", "** REC FIPS-COUNTY", "County"
|
| 145 |
|
| 146 |
|
| 147 |
def detect_fips_county_column(df, sample_size=1000, min_score=0.5):
|
|
@@ -562,16 +585,18 @@ def run_pipeline(uploaded_files, reference_file, blank_cities_not_in_list,
|
|
| 562 |
name = f.name
|
| 563 |
try:
|
| 564 |
if str(name).lower().endswith(".csv"):
|
| 565 |
-
dfs.append(pd.read_csv(name))
|
| 566 |
else:
|
| 567 |
dfs.append(pd.read_excel(name))
|
| 568 |
-
|
| 569 |
except Exception as e:
|
| 570 |
-
|
| 571 |
if not dfs:
|
| 572 |
return "\n".join(log) + "\nNo files could be read.", []
|
| 573 |
df = pd.concat(dfs, ignore_index=True)
|
| 574 |
-
|
|
|
|
|
|
|
| 575 |
|
| 576 |
# ---- Locate the FIPS-county column by CONTENT --------------------
|
| 577 |
fips_col, fips_reason = detect_fips_county_column(df)
|
|
@@ -579,13 +604,15 @@ def run_pipeline(uploaded_files, reference_file, blank_cities_not_in_list,
|
|
| 579 |
return ("\n".join(log) +
|
| 580 |
"\n\nCOLUMN ERROR: could not find a FIPS-county column. Expected "
|
| 581 |
"values like '05045-FAULKNER' in one of the columns."), []
|
| 582 |
-
|
| 583 |
|
| 584 |
# ---- FIPS validation (uses the reference list) -------------------
|
|
|
|
| 585 |
try:
|
| 586 |
df, audit = fips_validate(df, blank_cities_not_in_list, log, wl=wl, fips_col=fips_col)
|
| 587 |
except FileNotFoundError as e:
|
| 588 |
return "\n".join(log) + f"\n\nREFERENCE FILE ERROR:\n{e}", []
|
|
|
|
| 589 |
|
| 590 |
# ---- Verification files (audit + RawFile_FIPSValidated), CSV -----
|
| 591 |
if len(audit) > 0:
|
|
@@ -596,6 +623,7 @@ def run_pipeline(uploaded_files, reference_file, blank_cities_not_in_list,
|
|
| 596 |
rp = out_dir / "RawFile_FIPSValidated.csv"
|
| 597 |
df.to_csv(rp, index=False)
|
| 598 |
outputs.append(str(rp))
|
|
|
|
| 599 |
|
| 600 |
# ---- UNDETERMINED/NULL -> NaN ------------------------------------
|
| 601 |
for col in ["DDPPropertyCityFinal", "DDPPropertyZIPFinal",
|
|
@@ -623,12 +651,15 @@ def run_pipeline(uploaded_files, reference_file, blank_cities_not_in_list,
|
|
| 623 |
zip_df = df.loc[df["DDPPropertyZIPFinal"].notna()].copy()
|
| 624 |
census_tracts_df = df.loc[df["DDPRevisedCensusTract"].notna()].copy()
|
| 625 |
original = df.loc[df["LegalSubDivision"].notna()].copy()
|
|
|
|
| 626 |
|
| 627 |
# ---- Cluster ------------------------------------------------------
|
|
|
|
| 628 |
cluster_df = build_clusters(city_df, zip_df, census_tracts_df, cluster_threshold)
|
| 629 |
-
|
| 630 |
|
| 631 |
# ---- Replace-with (majority vote) --------------------------------
|
|
|
|
| 632 |
zip_w = build_replace_with(
|
| 633 |
build_cluster_wide_table(zip_df, cluster_df, "DDPPropertyZIPFinal", "ZIP_"),
|
| 634 |
"ZIP_", replace_threshold).rename(columns={"Replace with": "Replace with Zip Codes"})
|
|
@@ -638,8 +669,10 @@ def run_pipeline(uploaded_files, reference_file, blank_cities_not_in_list,
|
|
| 638 |
cen_w = build_replace_with(
|
| 639 |
build_cluster_wide_table(census_tracts_df, cluster_df, "DDPRevisedCensusTract", "CensusTract_"),
|
| 640 |
"CensusTract_", replace_threshold).rename(columns={"Replace with": "Replace with Census Tracts"})
|
|
|
|
| 641 |
|
| 642 |
# ---- Map back onto original --------------------------------------
|
|
|
|
| 643 |
# Same fix: assign each row its county's cluster, keyed on (County, name).
|
| 644 |
lk = cluster_df[["County", "LegalSubDivision", "Cluster_ID"]].drop_duplicates(
|
| 645 |
["County", "LegalSubDivision"])
|
|
@@ -673,7 +706,10 @@ def run_pipeline(uploaded_files, reference_file, blank_cities_not_in_list,
|
|
| 673 |
other = [c for c in existing if c not in lead]
|
| 674 |
original_final = original_final[lead + other]
|
| 675 |
|
| 676 |
-
|
|
|
|
|
|
|
|
|
|
| 677 |
|
| 678 |
# ---- Split into final cities / zips / census tracts --------------
|
| 679 |
keep = ["RecordKey", "ComesFromMtg2", "IsFlagged",
|
|
@@ -735,12 +771,15 @@ def run_pipeline(uploaded_files, reference_file, blank_cities_not_in_list,
|
|
| 735 |
p = out_dir / name
|
| 736 |
d.to_csv(p, index=False)
|
| 737 |
outputs.append(str(p))
|
|
|
|
| 738 |
|
| 739 |
# ---- Full output CSV (chunked at 1,000,000 rows) -----------------
|
| 740 |
# NB: no '%' in the filename -- '%' is the URL-encoding character and makes
|
| 741 |
# the browser download fail with "unknown server error".
|
|
|
|
| 742 |
csv_name = f"OutputFile_{replace_threshold}Threshold_{cluster_threshold}ClustersRapidFuzz.csv"
|
| 743 |
outputs.extend(save_csv_in_parts(original_final_output, csv_name, out_dir, row_limit=1_000_000))
|
|
|
|
| 744 |
|
| 745 |
# ---- Summary sheet: ONE ROW PER COUNTY ---------------------------
|
| 746 |
# Totals (records / subdivisions) come from the FULL post-validation frame
|
|
@@ -807,13 +846,14 @@ def run_pipeline(uploaded_files, reference_file, blank_cities_not_in_list,
|
|
| 807 |
"Total Blank Zips", "Total Zips Assignment", "Total Blank Census",
|
| 808 |
"Total Census Assignment"]
|
| 809 |
summary_df = pd.DataFrame(summary_rows, columns=summary_cols)
|
| 810 |
-
|
| 811 |
|
| 812 |
summary_path = out_dir / "Summary.csv"
|
| 813 |
summary_df.to_csv(summary_path, index=False)
|
| 814 |
outputs.append(str(summary_path))
|
| 815 |
|
| 816 |
# ---- Master bundle: Excel workbook OR zip of CSVs (see MASTER_OUTPUT) -
|
|
|
|
| 817 |
if MASTER_OUTPUT == "csv":
|
| 818 |
# Bundle the already-written CSVs into one zip. No Excel writing -> fast.
|
| 819 |
master_path = out_dir / "Master_Dataset.zip"
|
|
@@ -842,6 +882,7 @@ def run_pipeline(uploaded_files, reference_file, blank_cities_not_in_list,
|
|
| 842 |
|
| 843 |
elapsed_min = (time.time() - t_start) / 60
|
| 844 |
log.append(f"Done. {len(outputs)} files ready.")
|
|
|
|
| 845 |
log.append(f"Total processing time: {elapsed_min:.1f} minutes.")
|
| 846 |
gc.collect()
|
| 847 |
return "\n".join(log), outputs
|
|
|
|
| 56 |
# "csv" -> Master_Dataset.zip, every result bundled as a CSV (faster: no Excel
|
| 57 |
# writing -- use this for very large / state-sized runs)
|
| 58 |
# The individual per-category files are CSV in both modes.
|
| 59 |
+
MASTER_OUTPUT = "excel"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _mem_mb():
|
| 63 |
+
"""Current process memory (RSS) in MB. psutil if available, else peak RSS."""
|
| 64 |
+
try:
|
| 65 |
+
import psutil
|
| 66 |
+
return psutil.Process().memory_info().rss / (1024 * 1024)
|
| 67 |
+
except Exception:
|
| 68 |
+
try:
|
| 69 |
+
import resource
|
| 70 |
+
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 # KB -> MB
|
| 71 |
+
except Exception:
|
| 72 |
+
return float("nan")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _step(log, msg):
|
| 76 |
+
"""Record a phase line with a timestamp + current RAM, and PRINT it live
|
| 77 |
+
(flushed) so it reaches the Space's container log immediately. If the process
|
| 78 |
+
is killed (e.g. out of memory), the last printed line pinpoints the phase."""
|
| 79 |
+
line = f"[{time.strftime('%H:%M:%S')}] {msg} | RAM {_mem_mb():.0f} MB"
|
| 80 |
+
print(line, flush=True)
|
| 81 |
+
log.append(line)
|
| 82 |
+
return line
|
| 83 |
|
| 84 |
|
| 85 |
def get_safety_list_path() -> str:
|
|
|
|
| 164 |
# values shaped like "05045-FAULKNER" (FIPS digits, a dash, a county name).
|
| 165 |
# Header names are only used as a last-resort fallback.
|
| 166 |
FIPS_COUNTY_PATTERN = r"^\s*\d{1,5}\s*-\s*[A-Za-z]"
|
| 167 |
+
FALLBACK_FIPS_HEADERS = ["** REC FIPS-COUNTY NAME", "** REC FIPS-COUNTY", "County"]
|
| 168 |
|
| 169 |
|
| 170 |
def detect_fips_county_column(df, sample_size=1000, min_score=0.5):
|
|
|
|
| 585 |
name = f.name
|
| 586 |
try:
|
| 587 |
if str(name).lower().endswith(".csv"):
|
| 588 |
+
dfs.append(pd.read_csv(name, low_memory=False))
|
| 589 |
else:
|
| 590 |
dfs.append(pd.read_excel(name))
|
| 591 |
+
_step(log, f"Loaded {Path(name).name} ({len(dfs[-1]):,} rows)")
|
| 592 |
except Exception as e:
|
| 593 |
+
_step(log, f"ERROR loading {Path(name).name}: {e}")
|
| 594 |
if not dfs:
|
| 595 |
return "\n".join(log) + "\nNo files could be read.", []
|
| 596 |
df = pd.concat(dfs, ignore_index=True)
|
| 597 |
+
del dfs
|
| 598 |
+
gc.collect()
|
| 599 |
+
_step(log, f"Merged shape: {df.shape}")
|
| 600 |
|
| 601 |
# ---- Locate the FIPS-county column by CONTENT --------------------
|
| 602 |
fips_col, fips_reason = detect_fips_county_column(df)
|
|
|
|
| 604 |
return ("\n".join(log) +
|
| 605 |
"\n\nCOLUMN ERROR: could not find a FIPS-county column. Expected "
|
| 606 |
"values like '05045-FAULKNER' in one of the columns."), []
|
| 607 |
+
_step(log, f"FIPS-county column: '{fips_col}' ({fips_reason})")
|
| 608 |
|
| 609 |
# ---- FIPS validation (uses the reference list) -------------------
|
| 610 |
+
_step(log, "Validating FIPS/city pairs...")
|
| 611 |
try:
|
| 612 |
df, audit = fips_validate(df, blank_cities_not_in_list, log, wl=wl, fips_col=fips_col)
|
| 613 |
except FileNotFoundError as e:
|
| 614 |
return "\n".join(log) + f"\n\nREFERENCE FILE ERROR:\n{e}", []
|
| 615 |
+
_step(log, "Validation done")
|
| 616 |
|
| 617 |
# ---- Verification files (audit + RawFile_FIPSValidated), CSV -----
|
| 618 |
if len(audit) > 0:
|
|
|
|
| 623 |
rp = out_dir / "RawFile_FIPSValidated.csv"
|
| 624 |
df.to_csv(rp, index=False)
|
| 625 |
outputs.append(str(rp))
|
| 626 |
+
_step(log, "Wrote RawFile_FIPSValidated.csv")
|
| 627 |
|
| 628 |
# ---- UNDETERMINED/NULL -> NaN ------------------------------------
|
| 629 |
for col in ["DDPPropertyCityFinal", "DDPPropertyZIPFinal",
|
|
|
|
| 651 |
zip_df = df.loc[df["DDPPropertyZIPFinal"].notna()].copy()
|
| 652 |
census_tracts_df = df.loc[df["DDPRevisedCensusTract"].notna()].copy()
|
| 653 |
original = df.loc[df["LegalSubDivision"].notna()].copy()
|
| 654 |
+
_step(log, f"Source frames built (subdivisions: {len(original):,})")
|
| 655 |
|
| 656 |
# ---- Cluster ------------------------------------------------------
|
| 657 |
+
_step(log, "Clustering LegalSubDivision per county...")
|
| 658 |
cluster_df = build_clusters(city_df, zip_df, census_tracts_df, cluster_threshold)
|
| 659 |
+
_step(log, f"Clusters: {cluster_df['Cluster_ID'].nunique():,}")
|
| 660 |
|
| 661 |
# ---- Replace-with (majority vote) --------------------------------
|
| 662 |
+
_step(log, "Building majority-vote tables...")
|
| 663 |
zip_w = build_replace_with(
|
| 664 |
build_cluster_wide_table(zip_df, cluster_df, "DDPPropertyZIPFinal", "ZIP_"),
|
| 665 |
"ZIP_", replace_threshold).rename(columns={"Replace with": "Replace with Zip Codes"})
|
|
|
|
| 669 |
cen_w = build_replace_with(
|
| 670 |
build_cluster_wide_table(census_tracts_df, cluster_df, "DDPRevisedCensusTract", "CensusTract_"),
|
| 671 |
"CensusTract_", replace_threshold).rename(columns={"Replace with": "Replace with Census Tracts"})
|
| 672 |
+
_step(log, "Majority-vote tables done")
|
| 673 |
|
| 674 |
# ---- Map back onto original --------------------------------------
|
| 675 |
+
_step(log, "Mapping clusters + fills back onto records...")
|
| 676 |
# Same fix: assign each row its county's cluster, keyed on (County, name).
|
| 677 |
lk = cluster_df[["County", "LegalSubDivision", "Cluster_ID"]].drop_duplicates(
|
| 678 |
["County", "LegalSubDivision"])
|
|
|
|
| 706 |
other = [c for c in existing if c not in lead]
|
| 707 |
original_final = original_final[lead + other]
|
| 708 |
|
| 709 |
+
# original_final is only READ from here on (never mutated), so we can alias
|
| 710 |
+
# instead of copying -- this avoids a second full copy of the 7M-row frame.
|
| 711 |
+
original_final_output = original_final
|
| 712 |
+
_step(log, "Full output frame ready")
|
| 713 |
|
| 714 |
# ---- Split into final cities / zips / census tracts --------------
|
| 715 |
keep = ["RecordKey", "ComesFromMtg2", "IsFlagged",
|
|
|
|
| 771 |
p = out_dir / name
|
| 772 |
d.to_csv(p, index=False)
|
| 773 |
outputs.append(str(p))
|
| 774 |
+
_step(log, "Wrote final_cities / final_zips / final_census_tracts / cluster_df")
|
| 775 |
|
| 776 |
# ---- Full output CSV (chunked at 1,000,000 rows) -----------------
|
| 777 |
# NB: no '%' in the filename -- '%' is the URL-encoding character and makes
|
| 778 |
# the browser download fail with "unknown server error".
|
| 779 |
+
_step(log, "Writing full output CSV...")
|
| 780 |
csv_name = f"OutputFile_{replace_threshold}Threshold_{cluster_threshold}ClustersRapidFuzz.csv"
|
| 781 |
outputs.extend(save_csv_in_parts(original_final_output, csv_name, out_dir, row_limit=1_000_000))
|
| 782 |
+
_step(log, "Full output CSV written")
|
| 783 |
|
| 784 |
# ---- Summary sheet: ONE ROW PER COUNTY ---------------------------
|
| 785 |
# Totals (records / subdivisions) come from the FULL post-validation frame
|
|
|
|
| 846 |
"Total Blank Zips", "Total Zips Assignment", "Total Blank Census",
|
| 847 |
"Total Census Assignment"]
|
| 848 |
summary_df = pd.DataFrame(summary_rows, columns=summary_cols)
|
| 849 |
+
_step(log, f"Summary: {len(summary_df)} county row(s).")
|
| 850 |
|
| 851 |
summary_path = out_dir / "Summary.csv"
|
| 852 |
summary_df.to_csv(summary_path, index=False)
|
| 853 |
outputs.append(str(summary_path))
|
| 854 |
|
| 855 |
# ---- Master bundle: Excel workbook OR zip of CSVs (see MASTER_OUTPUT) -
|
| 856 |
+
_step(log, f"Building master ({MASTER_OUTPUT})...")
|
| 857 |
if MASTER_OUTPUT == "csv":
|
| 858 |
# Bundle the already-written CSVs into one zip. No Excel writing -> fast.
|
| 859 |
master_path = out_dir / "Master_Dataset.zip"
|
|
|
|
| 882 |
|
| 883 |
elapsed_min = (time.time() - t_start) / 60
|
| 884 |
log.append(f"Done. {len(outputs)} files ready.")
|
| 885 |
+
_step(log, f"DONE — {len(outputs)} files ready in {elapsed_min:.1f} min")
|
| 886 |
log.append(f"Total processing time: {elapsed_min:.1f} minutes.")
|
| 887 |
gc.collect()
|
| 888 |
return "\n".join(log), outputs
|
requirements.txt
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
pandas>=2.0
|
| 2 |
numpy>=1.24
|
| 3 |
openpyxl>=3.1
|
|
|
|
| 4 |
rapidfuzz>=3.0
|
|
|
|
| 5 |
huggingface_hub>=0.23
|
|
|
|
| 1 |
pandas>=2.0
|
| 2 |
numpy>=1.24
|
| 3 |
openpyxl>=3.1
|
| 4 |
+
xlsxwriter>=3.1
|
| 5 |
rapidfuzz>=3.0
|
| 6 |
+
psutil>=5.9
|
| 7 |
huggingface_hub>=0.23
|