Methodology
National Nonprofit Infrastructure & County Needs

1) Purpose & analytic scope

This project provides a county-level view of nonprofit organizational presence (registry-based), focus area concentration (taxonomy-based), and selected population need indicators (survey-based). The intended use is descriptive and exploratory: identifying geographic variation, comparing counties, and enabling follow-on validation and deeper inventory work.

Recommended workflow

Select State → Select County → interpret focus concentration → review organization directory → review needs indicators. All downstream views respond to county selection via a parameter-driven architecture in Tableau.

Geographic grain
U.S. County (5-digit FIPS)
Supply layer
IRS EO BMF registry extract
Needs layer
ACS 5-year county indicators

2) Dashboard embed

The Tableau Public dashboard is designed for a wide, tall desktop layout.

3) Data sources

IRS Exempt Organizations BMF (EO BMF)

Organization registry extract used for a transparent baseline of nonprofit administrative presence. Key fields include EIN, name, address, ZIP, and classification codes used for focus taxonomy.

Source: IRS EO BMF extract.
HUD ZIP Crosswalk

ZIP is not a stable analytic geography and can span multiple counties. This project assigns a deterministic “primary county per ZIP” to support reproducibility and performance.

Source: HUD USPS ZIP crosswalk.
ACS 5-year (Census API)

County-level population denominators and need indicators. ACS measures are survey-based estimates; interpret carefully for very small counties.

Source: U.S. Census ACS 5-year via API.

4) Data pipeline

The pipeline produces three core CSVs for Tableau: org_extract_with_county_fips.csv, county_focus_area.csv, county_summary.csv. (A separate needs_county_YYYY.csv is also used for KPI cards if included.)

Output Grain Purpose
org_extract_with_county_fips.csv EIN Directory + auditable source layer
county_focus_area.csv county_fips × focus_area Focus-area concentration bars
county_summary.csv county_fips Map totals + per-capita normalization
Focus-area inclusion policy

Organizations are included if they can be classified (via NTEE) into the project’s focus taxonomy. This is an explicit, versionable scope limitation.

5) Statistical defensibility & interpretation

What this can support
  • Descriptive comparisons of nonprofit registry presence across counties.
  • Transparent focus concentration patterns by county using a consistent taxonomy.
  • Per-capita normalization for exploratory comparisons (e.g., orgs per 100k residents).
What this does not claim
  • Service capacity, utilization, outcomes, or adequacy.
  • Causal relationships between nonprofit presence and county need indicators.
Uncertainty & edge cases
  • ACS is estimate-based: point estimates can be noisy for small counties.
  • ZIP→county assignment is approximate: this version uses deterministic “primary county per ZIP” for reproducibility/performance.
  • Missing NTEE: organizations lacking NTEE are excluded because focus cannot be classified.

6) Tableau setup (parameter-driven, no joins)

6.1 Add each CSV as a separate data source
  1. Add county_summary.csv (map + baseline).
  2. Add county_focus_area.csv (focus bars).
  3. Add org_extract_with_county_fips.csv (directory).
  4. (Optional) Add needs_county_YYYY.csv (KPI cards).
6.2 Parameter

Create a string parameter p_fips. Use a dashboard parameter action so map selection updates p_fips. In each downstream sheet, create a Boolean calc like:

// Example per-data-source filter
[county_fips] = [p_fips]

Use that calc as a filter = True.

6.3 Needs KPI cards (0–100 bars)
  • Use Measure Names/Values.
  • Fix axes to 0–100 so all bars share a consistent scale.
  • Dual-axis: one mark layer for text; one for bar.

7) Python scripts (embedded)

The code blocks below are “publication-safe”: no hard-coded local paths, no policy-program language, and explicit inputs/outputs. Default behavior assumes the scripts run in the same folder as the EO files and HUD crosswalk.

Run order
python3 org_extract_with_county_fips.py --input-dir . --hud-file hud_zip_county_crosswalk.csv --out org_extract_with_county_fips.csv
python3 county_focus_area.py --year 2022 --org org_extract_with_county_fips.csv --out county_focus_area.csv
python3 county_summary.py --year 2022 --org org_extract_with_county_fips.csv --out county_summary.csv
org_extract_with_county_fips.py
EO BMF (eo1–eo4) + HUD ZIP crosswalk → org_extract_with_county_fips.csv
Expand
#!/usr/bin/env python3
"""
org_extract_with_county_fips.py

Build an org-level extract from IRS EO BMF regional files (eo1–eo4),
attach a 5-digit county_fips using a HUD ZIP crosswalk, and filter to
a defined set of focus areas used by this project.

Inputs (default: current directory):
- eo1*.csv, eo2*.csv, eo3*.csv, eo4*.csv
- hud_zip_county_crosswalk.csv  (ZIP->TRACT with ratios)

Output (default: current directory):
- org_extract_with_county_fips.csv
"""

import argparse
import glob
import os
import re
import sys
from typing import List, Optional

import pandas as pd

OUTPUT_COLUMNS = [
    "ein",
    "org_name",
    "city",
    "state",
    "zip",
    "ntee_code",
    "focus_area",
    "county_fips",
    "source_file",
]

FILTER_TO_501C3_IF_POSSIBLE = True
FILTER_TO_ACTIVE_IF_POSSIBLE = False

ALLOWED_FOCUS_AREAS = {
    "HEALTHCARE",
    "BEHAVIORAL_HEALTH",
    "HOUSING",
    "HUMAN_SERVICES",
    "FOOD_SECURITY",
    "TRANSPORTATION",
    "EMPLOYMENT",
    "EDUCATION",
    "LEGAL_AID",
    "YOUTH_SERVICES",
    "DISABILITY_SERVICES",
    "SUBSTANCE_USE",
    "FAMILY_SUPPORT",
}

NTEE_MAJOR_TO_FOCUS = {
    "E": "HEALTHCARE",
    "P": "HUMAN_SERVICES",
    "L": "HOUSING",
    "N": "EMPLOYMENT",
    "B": "EDUCATION",
    "O": "YOUTH_SERVICES",
    "R": "LEGAL_AID",
    "G": "DISABILITY_SERVICES",
    "F": "BEHAVIORAL_HEALTH",
}

NTEE_CODE_REGEX_TO_FOCUS = [
    (r"^F2\d", "SUBSTANCE_USE"),
]


def read_csv_flexible(path: str) -> pd.DataFrame:
    try:
        return pd.read_csv(path, dtype=str, low_memory=False)
    except Exception:
        return pd.read_csv(path, dtype=str, low_memory=False, sep=None, engine="python")


def clean_zip5(x: object) -> Optional[str]:
    if pd.isna(x):
        return None
    s = str(x).strip()
    if not s:
        return None
    digits = re.sub(r"\D", "", s)
    if len(digits) < 5:
        return None
    return digits[:5]


def normalize_state(x: object) -> Optional[str]:
    if pd.isna(x):
        return None
    s = str(x).strip().upper()
    return s if s else None


def pad_fips5(x: object) -> Optional[str]:
    if pd.isna(x):
        return None
    s = str(x).strip()
    if not s:
        return None
    digits = re.sub(r"\D", "", s)
    if not digits:
        return None
    return digits.zfill(5)[-5:]


def pick_first_col(df: pd.DataFrame, candidates: List[str]) -> Optional[str]:
    cols_upper = {c.upper(): c for c in df.columns}
    for cand in candidates:
        if cand.upper() in cols_upper:
            return cols_upper[cand.upper()]
    return None


def choose_ratio_col(hud: pd.DataFrame) -> Optional[str]:
    for p in ["RES_RATIO", "TOT_RATIO", "BUS_RATIO"]:
        col = pick_first_col(hud, [p])
        if col:
            return col
    ratio_like = [c for c in hud.columns if "RATIO" in c.upper()]
    return ratio_like[0] if ratio_like else None


def tract_to_county_fips(v: object) -> Optional[str]:
    s = re.sub(r"\D", "", str(v).strip()) if not pd.isna(v) else ""
    if len(s) < 5:
        return None
    return s[:5]


def load_hud_primary_zip_to_county(hud_path: str) -> pd.DataFrame:
    hud = read_csv_flexible(hud_path)
    zip_col = pick_first_col(hud, ["ZIP", "ZIPCODE", "ZCTA5", "ZCTA"])
    tract_col = pick_first_col(hud, ["TRACT", "GEOID", "TRACT_GEOID", "CENSUS_TRACT"])
    ratio_col = choose_ratio_col(hud)

    if not zip_col or not tract_col:
        raise ValueError(
            "HUD crosswalk must include ZIP and TRACT (or GEOID) columns.\n"
            f"Detected zip_col={zip_col}, tract_col={tract_col}\n"
            f"Columns={list(hud.columns)}"
        )

    keep_cols = [zip_col, tract_col] + ([ratio_col] if ratio_col else [])
    hud2 = hud[keep_cols].copy()
    hud2["zip5"] = hud2[zip_col].apply(clean_zip5)
    hud2["county_fips"] = hud2[tract_col].apply(tract_to_county_fips)

    if ratio_col:
        hud2["ratio"] = pd.to_numeric(hud2[ratio_col], errors="coerce")
    else:
        hud2["ratio"] = 1.0

    hud2 = hud2.dropna(subset=["zip5", "county_fips"])
    hud2 = hud2.sort_values(["zip5", "ratio"], ascending=[True, False])
    primary = hud2.drop_duplicates(subset=["zip5"], keep="first")[["zip5", "county_fips"]].copy()
    return primary


def map_focus_area(ntee_code: str) -> Optional[str]:
    if not ntee_code or pd.isna(ntee_code):
        return None
    code = str(ntee_code).strip().upper()
    if not code:
        return None

    for pattern, focus in NTEE_CODE_REGEX_TO_FOCUS:
        if re.match(pattern, code):
            return focus

    major = code[:1]
    return NTEE_MAJOR_TO_FOCUS.get(major)


def load_and_stack_eo_files(input_dir: str) -> pd.DataFrame:
    patterns = [
        os.path.join(input_dir, "eo1*.csv"),
        os.path.join(input_dir, "eo2*.csv"),
        os.path.join(input_dir, "eo3*.csv"),
        os.path.join(input_dir, "eo4*.csv"),
    ]
    files: List[str] = []
    for p in patterns:
        files.extend(glob.glob(p))

    if not files:
        raise FileNotFoundError(f"No EO files found in {input_dir} matching eo1*.csv ... eo4*.csv")

    frames = []
    for f in sorted(files):
        print(f"Reading: {os.path.basename(f)}")
        df = read_csv_flexible(f)
        df["_source_file"] = os.path.basename(f)
        frames.append(df)

    return pd.concat(frames, ignore_index=True)


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--input-dir", default=".", help="Directory containing eo1–eo4 and HUD crosswalk.")
    parser.add_argument("--hud-file", default="hud_zip_county_crosswalk.csv", help="HUD crosswalk filename (within input-dir) or path.")
    parser.add_argument("--out", default="org_extract_with_county_fips.csv", help="Output CSV path.")
    args = parser.parse_args()

    input_dir = args.input_dir
    hud_path = args.hud_file
    if not os.path.isabs(hud_path):
        hud_path = os.path.join(input_dir, hud_path)

    out_path = args.out

    if not os.path.exists(hud_path):
        print(f"\n❌ HUD crosswalk not found at: {hud_path}", file=sys.stderr)
        return 1

    print("\n[1/6] Loading HUD ZIP→(primary)County mapping…")
    zip_to_county = load_hud_primary_zip_to_county(hud_path)
    print(f"ZIP mappings loaded: {len(zip_to_county):,}")

    print("\n[2/6] Loading EO BMF regional files (eo1–eo4)…")
    eo = load_and_stack_eo_files(input_dir)
    print(f"EO rows loaded: {len(eo):,}")

    ein_col = pick_first_col(eo, ["EIN"])
    name_col = pick_first_col(eo, ["NAME", "ORG_NAME", "ORGANIZATION_NAME", "PRIMARY_NAME", "NAME1"])
    state_col = pick_first_col(eo, ["STATE", "STATECD", "STATE_CD"])
    city_col = pick_first_col(eo, ["CITY"])
    zip_col = pick_first_col(eo, ["ZIP", "ZIPCD", "ZIP_CODE", "ZIP5", "ZIP_CODE_5"])
    ntee_col = pick_first_col(eo, ["NTEE_CD", "NTEE_CODE", "NTEE"])
    subsection_col = pick_first_col(eo, ["SUBSECTION", "SUBSECTION_CD"])
    status_col = pick_first_col(eo, ["STATUS", "ORG_STATUS", "STATUS_CD"])

    if not zip_col:
        print("\n❌ Could not detect a ZIP column in EO files.", file=sys.stderr)
        return 1
    if not ntee_col:
        print("\n❌ Could not detect an NTEE code column in EO files.", file=sys.stderr)
        return 1

    print("\n[3/6] Cleaning EO data…")
    work = eo.copy()
    work["zip5"] = work[zip_col].apply(clean_zip5)
    work["state"] = work[state_col].apply(normalize_state) if state_col else ""
    work["ein_norm"] = work[ein_col].astype(str).str.replace(r"\D", "", regex=True) if ein_col else ""
    work["ntee_norm"] = work[ntee_col].astype(str).str.strip().str.upper()

    if FILTER_TO_501C3_IF_POSSIBLE and subsection_col:
        before = len(work)
        sub = work[subsection_col].astype(str).str.strip()
        work = work[sub.isin(["03", "3", "003"])]
        print(f"Filtered to subsection 03 (501(c)(3)) where available: {before:,} → {len(work):,}")

    if FILTER_TO_ACTIVE_IF_POSSIBLE and status_col:
        vals = work[status_col].astype(str).str.upper().str.strip()
        if (vals.str.contains("ACTIVE")).any():
            before = len(work)
            work = work[vals.str.contains("ACTIVE")]
            print(f"Filtered to ACTIVE (text match): {before:,} → {len(work):,}")

    before = len(work)
    work = work[work["ntee_norm"].str.len() > 0].copy()
    print(f"Removed rows missing NTEE: {before:,} → {len(work):,}")

    print("\n[4/6] Attaching county_fips via ZIP…")
    work = work.merge(zip_to_county, how="left", left_on="zip5", right_on="zip5")

    total = len(work)
    missing_county = work["county_fips"].isna().sum()
    print(f"Rows missing county_fips after HUD merge: {missing_county:,} ({missing_county/total:.2%})")

    before = len(work)
    work = work.dropna(subset=["county_fips"]).copy()
    print(f"Dropped rows without county_fips: {before:,} → {len(work):,}")

    print("\n[5/6] Classifying focus areas and filtering to project scope…")
    work["focus_area"] = work["ntee_norm"].apply(map_focus_area)

    before = len(work)
    work = work.dropna(subset=["focus_area"]).copy()
    print(f"Removed rows not classifiable to a focus area: {before:,} → {len(work):,}")

    before = len(work)
    work["focus_area"] = work["focus_area"].astype(str).str.upper().str.strip()
    allowed = {x.upper() for x in ALLOWED_FOCUS_AREAS}
    work = work[work["focus_area"].isin(allowed)].copy()
    print(f"Filtered to allowed focus areas: {before:,} → {len(work):,}")

    out = pd.DataFrame()
    out["ein"] = work["ein_norm"].astype(str).str.strip()
    out["org_name"] = work[name_col].astype(str).str.strip() if name_col else ""
    out["city"] = work[city_col].astype(str).str.strip() if city_col else ""
    out["state"] = work["state"].astype(str).str.strip()
    out["zip"] = work["zip5"].astype(str).str.strip()
    out["ntee_code"] = work["ntee_norm"].astype(str).str.strip()
    out["focus_area"] = work["focus_area"].astype(str).str.strip()
    out["county_fips"] = work["county_fips"].apply(pad_fips5)
    out["source_file"] = work["_source_file"].astype(str)

    if out["ein"].str.len().gt(0).any():
        before = len(out)
        out = out.sort_values(["ein", "org_name"]).drop_duplicates(subset=["ein"], keep="first")
        print(f"Deduped by EIN: {before:,} → {len(out):,}")

    out = out[OUTPUT_COLUMNS].copy()
    out = out.sort_values(["county_fips", "focus_area", "org_name"]).reset_index(drop=True)

    print("\n[6/6] Writing output…")
    out.to_csv(out_path, index=False)
    print(f"✅ Wrote: {out_path}")
    print(f"✅ Rows: {len(out):,}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
county_focus_area.py
org_extract_with_county_fips.csv + ACS population → county_focus_area.csv
Expand
#!/usr/bin/env python3
"""
county_focus_area.py

Build county_focus_area.csv (county × focus_area) using:
- org_extract_with_county_fips.csv (org-level output; filtered + classified)
- ACS 5-year county population from Census API

Output:
- county_focus_area.csv
"""

import argparse
import re
import sys

import pandas as pd
import requests


def pad_fips(x: object, width: int = 5):
    if pd.isna(x):
        return None
    s = str(x).strip()
    if not s:
        return None
    s = re.sub(r"\D", "", s)
    if not s:
        return None
    return s.zfill(width)[-width:]


def fetch_acs_population_by_county(year: int) -> pd.DataFrame:
    url = f"https://api.census.gov/data/{year}/acs/acs5"
    params = {"get": "B01003_001E", "for": "county:*", "in": "state:*"}
    resp = requests.get(url, params=params, timeout=45)
    resp.raise_for_status()

    data = resp.json()
    header = data[0]
    rows = data[1:]
    df = pd.DataFrame(rows, columns=header)

    df["state_fips"] = df["state"].apply(lambda v: pad_fips(v, 2))
    df["county_fips"] = (df["state_fips"] + df["county"].apply(lambda v: pad_fips(v, 3))).astype(str)
    df["population"] = pd.to_numeric(df["B01003_001E"], errors="coerce")

    out = df[["county_fips", "population"]].dropna(subset=["county_fips", "population"]).copy()
    out["population"] = out["population"].astype(int)
    return out


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--year", type=int, default=2022)
    parser.add_argument("--org", default="org_extract_with_county_fips.csv")
    parser.add_argument("--out", default="county_focus_area.csv")
    args = parser.parse_args()
    year = args.year

    if not pd.io.common.file_exists(args.org):
        print(f"❌ Missing input file: {args.org}", file=sys.stderr)
        print("Run org_extract_with_county_fips.py first.", file=sys.stderr)
        return 1

    print(f"[1/4] Reading org extract: {args.org}")
    org = pd.read_csv(args.org, dtype=str, low_memory=False)

    required = {"county_fips", "focus_area"}
    missing = required - set(org.columns)
    if missing:
        print(f"❌ org extract missing columns: {sorted(missing)}", file=sys.stderr)
        return 1

    org["county_fips"] = org["county_fips"].apply(lambda v: pad_fips(v, 5))
    org["focus_area"] = org["focus_area"].astype(str).str.strip().str.upper()

    org = org.dropna(subset=["county_fips"])
    org = org[org["focus_area"].str.len() > 0].copy()

    print("[2/4] Aggregating counts by county × focus_area…")
    counts = (
        org.groupby(["county_fips", "focus_area"], as_index=False)
           .size()
           .rename(columns={"size": "nonprofit_count"})
    )

    print(f"[3/4] Fetching ACS {year} county population…")
    pop = fetch_acs_population_by_county(year)

    print("[4/4] Joining + computing per-100k by focus…")
    out = counts.merge(pop, how="left", on="county_fips")
    out = out.dropna(subset=["population"]).copy()
    out = out[out["population"] > 0].copy()

    out["nonprofits_per_100k_focus"] = (out["nonprofit_count"] / out["population"]) * 100000.0
    out["year"] = year

    out = out[[
        "county_fips",
        "focus_area",
        "year",
        "nonprofit_count",
        "nonprofits_per_100k_focus",
    ]].sort_values(["county_fips", "focus_area"]).reset_index(drop=True)

    out.to_csv(args.out, index=False)
    print(f"✅ Wrote: {args.out} (rows={len(out):,})")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
county_summary.py
org_extract_with_county_fips.csv + ACS population → county_summary.csv
Expand
#!/usr/bin/env python3
"""
county_summary.py

Build county_summary.csv (1 row per county) using:
- org_extract_with_county_fips.csv (org-level output; focus-filtered)
- ACS 5-year county population from Census API

Output:
- county_summary.csv
"""

import argparse
import re
import sys

import pandas as pd
import requests


def pad_fips(x: object, width: int = 5):
    if pd.isna(x):
        return None
    s = str(x).strip()
    if not s:
        return None
    s = re.sub(r"\D", "", s)
    if not s:
        return None
    return s.zfill(width)[-width:]


def fetch_acs_population_by_county(year: int) -> pd.DataFrame:
    url = f"https://api.census.gov/data/{year}/acs/acs5"
    params = {"get": "NAME,B01003_001E", "for": "county:*", "in": "state:*"}
    resp = requests.get(url, params=params, timeout=45)
    resp.raise_for_status()

    data = resp.json()
    header = data[0]
    rows = data[1:]
    df = pd.DataFrame(rows, columns=header)

    df["state_fips"] = df["state"].apply(lambda v: pad_fips(v, 2))
    df["county_fips"] = (df["state_fips"] + df["county"].apply(lambda v: pad_fips(v, 3))).astype(str)

    name_split = df["NAME"].str.split(", ", n=1, expand=True)
    df["county_name"] = name_split[0].fillna(df["NAME"])

    df["population"] = pd.to_numeric(df["B01003_001E"], errors="coerce")

    out = df[["county_fips", "county_name", "population"]].copy()
    out["year"] = year
    out = out.dropna(subset=["county_fips", "population"])
    out["population"] = out["population"].astype(int)
    return out


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--year", type=int, default=2022)
    parser.add_argument("--org", default="org_extract_with_county_fips.csv")
    parser.add_argument("--out", default="county_summary.csv")
    args = parser.parse_args()
    year = args.year

    if not pd.io.common.file_exists(args.org):
        print(f"❌ Missing input file: {args.org}", file=sys.stderr)
        print("Run org_extract_with_county_fips.py first.", file=sys.stderr)
        return 1

    print(f"[1/4] Reading org extract: {args.org}")
    org = pd.read_csv(args.org, dtype=str, low_memory=False)

    if "county_fips" not in org.columns:
        print("❌ org extract missing county_fips column.", file=sys.stderr)
        return 1

    org["county_fips"] = org["county_fips"].apply(lambda v: pad_fips(v, 5))
    org = org.dropna(subset=["county_fips"])

    print("[2/4] Aggregating org counts by county…")
    counts = org.groupby("county_fips", as_index=False).size().rename(columns={"size": "total_nonprofits"})

    print(f"[3/4] Fetching ACS {year} county population…")
    pop = fetch_acs_population_by_county(year)

    print("[4/4] Joining + computing per-100k…")
    out = pop.merge(counts, how="left", on="county_fips")
    out["total_nonprofits"] = out["total_nonprofits"].fillna(0).astype(int)

    out = out[out["population"] > 0].copy()
    out["nonprofits_per_100k"] = (out["total_nonprofits"] / out["population"]) * 100000.0

    out = out[[
        "county_fips",
        "county_name",
        "year",
        "population",
        "total_nonprofits",
        "nonprofits_per_100k",
    ]].sort_values("county_fips").reset_index(drop=True)

    out.to_csv(args.out, index=False)
    print(f"✅ Wrote: {args.out} (rows={len(out):,})")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Notes for publication