Source code for siege_utilities.reference.naics_soc_crosswalk

"""NAICS and SOC code crosswalk and normalization.

Maps NLRB industry/occupation codes to Census classification systems:

- **NAICS** (North American Industry Classification System): 2-6 digit
  hierarchical industry codes with revision crosswalks (2012 → 2017 → 2022).
- **SOC** (Standard Occupational Classification): 2-6 digit occupation
  codes with revision crosswalks (2010 → 2018).

Used by the cross-tabulation engine (:mod:`siege_utilities.data.cross_tabulation`)
to join NLRB bargaining-unit data with Census industry/occupation tables.
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Optional


__all__ = [
    'NAICSCode',
    'NAICS_SECTORS',
    'parse_naics',
    'naics_ancestors',
    'naics_to_sector',
    'crosswalk_naics',
    'SOCCode',
    'SOC_MAJOR_GROUPS',
    'parse_soc',
    'soc_to_major_group',
    'fuzzy_match_naics',
    'NAICS_SUBSECTORS',
    'SOC_MINOR_GROUPS',
    'get_naics_lookup',
    'get_soc_lookup',
    'naics_title',
    'soc_title',
    'filter_by_naics',
    'filter_by_naics_sector',
    'group_by_naics_sector',
]

# ---------------------------------------------------------------------------
# NAICS hierarchy
# ---------------------------------------------------------------------------

[docs] @dataclass class NAICSCode: """A NAICS industry code with hierarchy metadata.""" code: str title: str level: int # 2=sector, 3=subsector, 4=industry group, 5=industry, 6=national parent_code: Optional[str] = None @property def sector(self) -> str: """2-digit sector code.""" return self.code[:2]
# NAICS sector definitions (2-digit) NAICS_SECTORS: dict[str, str] = { "11": "Agriculture, Forestry, Fishing and Hunting", "21": "Mining, Quarrying, and Oil and Gas Extraction", "22": "Utilities", "23": "Construction", "31": "Manufacturing", "32": "Manufacturing", "33": "Manufacturing", "42": "Wholesale Trade", "44": "Retail Trade", "45": "Retail Trade", "48": "Transportation and Warehousing", "49": "Transportation and Warehousing", "51": "Information", "52": "Finance and Insurance", "53": "Real Estate and Rental and Leasing", "54": "Professional, Scientific, and Technical Services", "55": "Management of Companies and Enterprises", "56": "Administrative and Support and Waste Management", "61": "Educational Services", "62": "Health Care and Social Assistance", "71": "Arts, Entertainment, and Recreation", "72": "Accommodation and Food Services", "81": "Other Services (except Public Administration)", "92": "Public Administration", }
[docs] def parse_naics(code: str) -> NAICSCode: """Parse a NAICS code string into a :class:`NAICSCode`. Parameters ---------- code : str 2-6 digit NAICS code. Returns ------- NAICSCode Raises ------ ValueError If the code is not 2-6 digits. """ code = code.strip() if not re.match(r"^\d{2,6}$", code): raise ValueError(f"Invalid NAICS code (must be 2-6 digits): {code!r}") level = len(code) parent = code[: level - 1] if level > 2 else None sector_code = code[:2] title = NAICS_SECTORS.get(sector_code, "Unknown Sector") return NAICSCode(code=code, title=title, level=level, parent_code=parent)
[docs] def naics_ancestors(code: str) -> list[str]: """Return ancestor codes from sector down to the given code. >>> naics_ancestors("541511") ['54', '541', '5415', '54151', '541511'] """ code = code.strip() return [code[: i] for i in range(2, len(code) + 1)]
[docs] def naics_to_sector(code: str) -> tuple[str, str]: """Return (sector_code, sector_title) for any NAICS code.""" sector = code.strip()[:2] return sector, NAICS_SECTORS.get(sector, "Unknown Sector")
# --------------------------------------------------------------------------- # NAICS revision crosswalks # --------------------------------------------------------------------------- # Major 2017→2022 changes (selected high-impact mappings) _NAICS_2017_TO_2022: dict[str, list[str]] = { "454110": ["455110"], # Electronic Shopping → Electronic Shopping and Mail-Order "519130": ["519290"], # Internet Publishing → Web Search Portals etc. "517311": ["517111"], # Wired Telecommunications → Wired and Wireless Telecom "517312": ["517111"], # Wireless Telecommunications → merged "423990": ["423990"], # Durable goods (unchanged) } # Major 2012→2017 changes _NAICS_2012_TO_2017: dict[str, list[str]] = { "519130": ["519130"], # Unchanged in this revision "517110": ["517311"], # Wired Telecom → split }
[docs] def crosswalk_naics( code: str, from_year: int = 2017, to_year: int = 2022, ) -> list[str]: """Map a NAICS code from one revision to another. Parameters ---------- code : str Source NAICS code. from_year : int Source revision year (2012 or 2017). to_year : int Target revision year (2017 or 2022). Returns ------- list of str Target code(s). May be >1 if the source was split. Returns ``[code]`` if no mapping is found (assumed unchanged). """ code = code.strip() if from_year == 2017 and to_year == 2022: return _NAICS_2017_TO_2022.get(code, [code]) if from_year == 2012 and to_year == 2017: return _NAICS_2012_TO_2017.get(code, [code]) if from_year == 2012 and to_year == 2022: intermediate = crosswalk_naics(code, 2012, 2017) result = [] for c in intermediate: result.extend(crosswalk_naics(c, 2017, 2022)) return result raise ValueError(f"Unsupported crosswalk: {from_year} → {to_year}")
# --------------------------------------------------------------------------- # SOC codes # ---------------------------------------------------------------------------
[docs] @dataclass class SOCCode: """A Standard Occupational Classification code.""" code: str title: str level: str # "major", "minor", "broad", "detailed" @property def major_group(self) -> str: """2-digit major group (e.g., "11" from "11-1011").""" return self.code.split("-")[0] if "-" in self.code else self.code[:2]
SOC_MAJOR_GROUPS: dict[str, str] = { "11": "Management", "13": "Business and Financial Operations", "15": "Computer and Mathematical", "17": "Architecture and Engineering", "19": "Life, Physical, and Social Science", "21": "Community and Social Service", "23": "Legal", "25": "Educational Instruction and Library", "27": "Arts, Design, Entertainment, Sports, and Media", "29": "Healthcare Practitioners and Technical", "31": "Healthcare Support", "33": "Protective Service", "35": "Food Preparation and Serving Related", "37": "Building and Grounds Cleaning and Maintenance", "39": "Personal Care and Service", "41": "Sales and Related", "43": "Office and Administrative Support", "45": "Farming, Fishing, and Forestry", "47": "Construction and Extraction", "49": "Installation, Maintenance, and Repair", "51": "Production", "53": "Transportation and Material Moving", "55": "Military Specific", }
[docs] def parse_soc(code: str) -> SOCCode: """Parse an SOC code string. Parameters ---------- code : str SOC code in ``"XX-XXXX"`` format or just the major group ``"XX"``. Returns ------- SOCCode """ code = code.strip() if re.match(r"^\d{2}$", code): return SOCCode(code=code, title=SOC_MAJOR_GROUPS.get(code, "Unknown"), level="major") if not re.match(r"^\d{2}-\d{4}$", code): raise ValueError(f"Invalid SOC code (expected XX-XXXX): {code!r}") major = code[:2] minor_digits = code.split("-")[1] if minor_digits.endswith("0"): level = "broad" else: level = "detailed" if minor_digits == "0000": level = "major" return SOCCode( code=code, title=SOC_MAJOR_GROUPS.get(major, "Unknown"), level=level, )
[docs] def soc_to_major_group(code: str) -> tuple[str, str]: """Return (major_code, title) for any SOC code.""" major = code.strip().split("-")[0][:2] return major, SOC_MAJOR_GROUPS.get(major, "Unknown")
# --------------------------------------------------------------------------- # Fuzzy matching # ---------------------------------------------------------------------------
[docs] def fuzzy_match_naics( text: str, candidates: Optional[dict[str, str]] = None, threshold: float = 0.5, ) -> list[tuple[str, str, float]]: """Simple token-overlap fuzzy match of *text* against NAICS sector titles. Parameters ---------- text : str Free-text industry description (e.g., from NLRB filings). candidates : dict, optional ``{code: title}`` mapping. Defaults to :data:`NAICS_SECTORS`. threshold : float Minimum similarity score (0-1) to include. Returns ------- list of (code, title, score) Sorted by score descending. """ if candidates is None: candidates = NAICS_SECTORS text_tokens = set(text.lower().split()) results = [] for code, title in candidates.items(): title_tokens = set(title.lower().split()) if not title_tokens: continue overlap = len(text_tokens & title_tokens) score = overlap / max(len(text_tokens), len(title_tokens)) if score >= threshold: results.append((code, title, round(score, 3))) results.sort(key=lambda x: x[2], reverse=True) return results
# --------------------------------------------------------------------------- # Bundled NAICS subsector / industry group codes (3-4 digit) # --------------------------------------------------------------------------- NAICS_SUBSECTORS: dict[str, str] = { "111": "Crop Production", "112": "Animal Production and Aquaculture", "113": "Forestry and Logging", "114": "Fishing, Hunting and Trapping", "115": "Support Activities for Agriculture and Forestry", "211": "Oil and Gas Extraction", "212": "Mining (except Oil and Gas)", "213": "Support Activities for Mining", "221": "Utilities", "236": "Construction of Buildings", "237": "Heavy and Civil Engineering Construction", "238": "Specialty Trade Contractors", "311": "Food Manufacturing", "312": "Beverage and Tobacco Product Manufacturing", "313": "Textile Mills", "314": "Textile Product Mills", "315": "Apparel Manufacturing", "316": "Leather and Allied Product Manufacturing", "321": "Wood Product Manufacturing", "322": "Paper Manufacturing", "323": "Printing and Related Support Activities", "324": "Petroleum and Coal Products Manufacturing", "325": "Chemical Manufacturing", "326": "Plastics and Rubber Products Manufacturing", "327": "Nonmetallic Mineral Product Manufacturing", "331": "Primary Metal Manufacturing", "332": "Fabricated Metal Product Manufacturing", "333": "Machinery Manufacturing", "334": "Computer and Electronic Product Manufacturing", "335": "Electrical Equipment, Appliance, and Component Manufacturing", "336": "Transportation Equipment Manufacturing", "337": "Furniture and Related Product Manufacturing", "339": "Miscellaneous Manufacturing", "423": "Merchant Wholesalers, Durable Goods", "424": "Merchant Wholesalers, Nondurable Goods", "425": "Wholesale Trade Agents and Brokers", "441": "Motor Vehicle and Parts Dealers", "442": "Furniture and Home Furnishings Retailers", "443": "Electronics and Appliance Retailers", "444": "Building Material and Garden Equipment Retailers", "445": "Food and Beverage Retailers", "446": "Health and Personal Care Retailers", "447": "Gasoline Stations", "448": "Clothing and Clothing Accessories Retailers", "449": "Furniture, Home Furnishings, Electronics, and Appliance Retailers", "451": "Sporting Goods, Hobby, Musical Instrument, and Book Retailers", "452": "General Merchandise Retailers", "453": "Miscellaneous Store Retailers", "454": "Nonstore Retailers", "455": "General Merchandise Retailers (2022)", "456": "Health and Personal Care Retailers (2022)", "481": "Air Transportation", "482": "Rail Transportation", "483": "Water Transportation", "484": "Truck Transportation", "485": "Transit and Ground Passenger Transportation", "486": "Pipeline Transportation", "487": "Scenic and Sightseeing Transportation", "488": "Support Activities for Transportation", "491": "Postal Service", "492": "Couriers and Messengers", "493": "Warehousing and Storage", "511": "Publishing Industries", "512": "Motion Picture and Sound Recording Industries", "515": "Broadcasting (except Internet)", "516": "Internet Publishing and Broadcasting", "517": "Telecommunications", "518": "Computing Infrastructure Providers, Data Processing, and Related Services", "519": "Web Search Portals, Libraries, Archives, and Other Information Services", "521": "Monetary Authorities—Central Bank", "522": "Credit Intermediation and Related Activities", "523": "Securities, Commodity Contracts, and Other Financial Investments", "524": "Insurance Carriers and Related Activities", "525": "Funds, Trusts, and Other Financial Vehicles", "531": "Real Estate", "532": "Rental and Leasing Services", "533": "Lessors of Nonfinancial Intangible Assets", "541": "Professional, Scientific, and Technical Services", "551": "Management of Companies and Enterprises", "561": "Administrative and Support Services", "562": "Waste Management and Remediation Services", "611": "Educational Services", "621": "Ambulatory Health Care Services", "622": "Hospitals", "623": "Nursing and Residential Care Facilities", "624": "Social Assistance", "711": "Performing Arts, Spectator Sports, and Related Industries", "712": "Museums, Historical Sites, and Similar Institutions", "713": "Amusement, Gambling, and Recreation Industries", "721": "Accommodation", "722": "Food Services and Drinking Places", "811": "Repair and Maintenance", "812": "Personal and Laundry Services", "813": "Religious, Grantmaking, Civic, Professional, and Similar Organizations", "814": "Private Households", "921": "Executive, Legislative, and Other General Government Support", "922": "Justice, Public Order, and Safety Activities", "923": "Administration of Human Resource Programs", "924": "Administration of Environmental Quality Programs", "925": "Administration of Housing, Urban Planning, and Community Development", "926": "Administration of Economic Programs", "927": "Space Research and Technology", "928": "National Security and International Affairs", } # --------------------------------------------------------------------------- # Bundled SOC minor / broad occupation codes # --------------------------------------------------------------------------- SOC_MINOR_GROUPS: dict[str, str] = { "11-1000": "Top Executives", "11-2000": "Advertising, Marketing, Promotions, Public Relations, and Sales Managers", "11-3000": "Operations Specialties Managers", "11-9000": "Other Management Occupations", "13-1000": "Business Operations Specialists", "13-2000": "Financial Specialists", "15-1200": "Computer Occupations", "15-2000": "Mathematical Science Occupations", "17-1000": "Architects, Surveyors, and Cartographers", "17-2000": "Engineers", "17-3000": "Drafters, Engineering Technicians, and Mapping Technicians", "19-1000": "Life Scientists", "19-2000": "Physical Scientists", "19-3000": "Social Scientists and Related Workers", "19-4000": "Life, Physical, and Social Science Technicians", "21-1000": "Counselors, Social Workers, and Other Community and Social Service Specialists", "23-1000": "Lawyers, Judges, and Related Workers", "23-2000": "Legal Support Workers", "25-1000": "Postsecondary Teachers", "25-2000": "Preschool, Elementary, Middle, Secondary, and Special Education Teachers", "25-3000": "Other Teachers and Instructors", "25-4000": "Librarians, Curators, and Archivists", "25-9000": "Other Educational Instruction and Library Occupations", "27-1000": "Art and Design Workers", "27-2000": "Entertainers and Performers, Sports and Related Workers", "27-3000": "Media and Communication Workers", "27-4000": "Media and Communication Equipment Workers", "29-1000": "Healthcare Diagnosing or Treating Practitioners", "29-2000": "Health Technologists and Technicians", "29-9000": "Other Healthcare Practitioners and Technical Occupations", "31-1100": "Home Health and Personal Care Aides; and Nursing Assistants", "31-2000": "Occupational Therapy and Physical Therapist Assistants and Aides", "31-9000": "Other Healthcare Support Occupations", "33-1000": "First-Line Supervisors of Protective Service Workers", "33-2000": "Firefighting and Prevention Workers", "33-3000": "Law Enforcement Workers", "33-9000": "Other Protective Service Workers", "35-1000": "Supervisors of Food Preparation and Serving Workers", "35-2000": "Cooks and Food Preparation Workers", "35-3000": "Food and Beverage Serving Workers", "35-9000": "Other Food Preparation and Serving Related Workers", "37-1000": "Supervisors of Building and Grounds Cleaning and Maintenance Workers", "37-2000": "Building Cleaning and Pest Control Workers", "37-3000": "Grounds Maintenance Workers", "39-1000": "Supervisors of Personal Care and Service Workers", "39-2000": "Animal Care and Service Workers", "39-3000": "Entertainment Attendants and Related Workers", "39-4000": "Funeral Service Workers", "39-5000": "Personal Appearance Workers", "39-9000": "Other Personal Care and Service Workers", "41-1000": "Supervisors of Sales Workers", "41-2000": "Retail Sales Workers", "41-3000": "Sales Representatives, Services", "41-4000": "Sales Representatives, Wholesale and Manufacturing", "41-9000": "Other Sales and Related Workers", "43-1000": "Supervisors of Office and Administrative Support Workers", "43-2000": "Communications Equipment Operators", "43-3000": "Financial Clerks", "43-4000": "Information and Record Clerks", "43-5000": "Material Recording, Scheduling, Dispatching, and Distributing Workers", "43-6000": "Secretaries and Administrative Assistants", "43-9000": "Other Office and Administrative Support Workers", "45-1000": "Supervisors of Farming, Fishing, and Forestry Workers", "45-2000": "Agricultural Workers", "45-3000": "Fishing and Hunting Workers", "45-4000": "Forest, Conservation, and Logging Workers", "47-1000": "Supervisors of Construction and Extraction Workers", "47-2000": "Construction Trades Workers", "47-3000": "Helpers, Construction Trades", "47-4000": "Other Construction and Related Workers", "47-5000": "Extraction Workers", "49-1000": "Supervisors of Installation, Maintenance, and Repair Workers", "49-2000": "Electrical and Electronic Equipment Mechanics, Installers, and Repairers", "49-3000": "Vehicle and Mobile Equipment Mechanics, Installers, and Repairers", "49-9000": "Other Installation, Maintenance, and Repair Occupations", "51-1000": "Supervisors of Production Workers", "51-2000": "Assemblers and Fabricators", "51-3000": "Food Processing Workers", "51-4000": "Metal Workers and Plastic Workers", "51-5100": "Printing Workers", "51-6000": "Textile, Apparel, and Furnishings Workers", "51-7000": "Woodworkers", "51-8000": "Plant and System Operators", "51-9000": "Other Production Occupations", "53-1000": "Supervisors of Transportation and Material Moving Workers", "53-2000": "Air Transportation Workers", "53-3000": "Motor Vehicle Operators", "53-4000": "Rail Transportation Workers", "53-5000": "Water Transportation Workers", "53-6000": "Other Transportation Workers", "53-7000": "Material Moving Workers", "55-1000": "Military Officer Special and Tactical Operations Leaders", "55-2000": "First-Line Enlisted Military Supervisors", "55-3000": "Military Enlisted Tactical Operations and Air/Weapons Specialists", } # --------------------------------------------------------------------------- # Combined lookup tables # ---------------------------------------------------------------------------
[docs] def get_naics_lookup() -> dict[str, str]: """Return a combined NAICS lookup table (sectors + subsectors). Returns: Dict mapping NAICS codes to titles at all available levels. """ combined = {} combined.update(NAICS_SECTORS) combined.update(NAICS_SUBSECTORS) return combined
[docs] def get_soc_lookup() -> dict[str, str]: """Return a combined SOC lookup table (major groups + minor groups). Returns: Dict mapping SOC codes to titles at all available levels. """ combined = {} combined.update(SOC_MAJOR_GROUPS) combined.update(SOC_MINOR_GROUPS) return combined
[docs] def naics_title(code: str) -> str: """Look up the title for a NAICS code at any level. Checks subsector-level first, then falls back to sector. Returns 'Unknown' if not found. """ code = code.strip() lookup = get_naics_lookup() if code in lookup: return lookup[code] sector = code[:2] if sector in NAICS_SECTORS: return NAICS_SECTORS[sector] return "Unknown"
[docs] def soc_title(code: str) -> str: """Look up the title for an SOC code at any level. Checks minor-group level first, then falls back to major group. Returns 'Unknown' if not found. """ code = code.strip() lookup = get_soc_lookup() if code in lookup: return lookup[code] major = code.split("-")[0][:2] if major in SOC_MAJOR_GROUPS: return SOC_MAJOR_GROUPS[major] return "Unknown"
# --------------------------------------------------------------------------- # Query interface — filter NLRB records by NAICS prefix # ---------------------------------------------------------------------------
[docs] def filter_by_naics(records, naics_prefix: str) -> list: """Filter NLRB case records by NAICS code prefix. Works with any iterable of objects that have a ``naics_code`` attribute (NLRBCaseRecord, NLRBCase model instances, or dicts with 'naics_code'). Args: records: Iterable of records to filter. naics_prefix: NAICS prefix to match (e.g., '622' for hospitals, '62' for all health care). Returns: List of matching records. Example:: # Show all elections in NAICS 622 (hospitals) hospital_cases = filter_by_naics(result.cases, "622") """ naics_prefix = naics_prefix.strip() if not naics_prefix: return [] matches = [] for record in records: code = _get_naics(record) if code and code.startswith(naics_prefix): matches.append(record) return matches
[docs] def filter_by_naics_sector(records, sector_code: str) -> list: """Filter records by 2-digit NAICS sector code.""" return filter_by_naics(records, sector_code.strip()[:2])
[docs] def group_by_naics_sector(records) -> dict[str, list]: """Group records by their 2-digit NAICS sector code. Returns: Dict mapping sector codes to lists of records. Records without a NAICS code are grouped under ''. """ groups: dict[str, list] = {} for record in records: code = _get_naics(record) sector = code[:2] if code else "" groups.setdefault(sector, []).append(record) return groups
def _get_naics(record) -> str: """Extract NAICS code from a record (dataclass, model, or dict).""" if isinstance(record, dict): return str(record.get("naics_code", "")).strip() return str(getattr(record, "naics_code", "")).strip()